diff --git a/.github/workflows/update-screenshots.yml b/.github/workflows/update-screenshots.yml new file mode 100644 index 00000000..c49ad1f1 --- /dev/null +++ b/.github/workflows/update-screenshots.yml @@ -0,0 +1,100 @@ +name: Update Screenshots + +on: + push: + branches: + - main + paths: + - 'web/**' + - 'src/**' + - 'data/examples.lino' + workflow_dispatch: + inputs: + update_readme: + description: 'Update README.md with new screenshots' + type: boolean + default: true + update_use_cases: + description: 'Update USE-CASES.md with new screenshots' + type: boolean + default: true + +jobs: + update-screenshots: + name: Update Screenshots + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install wasm-pack + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: Build WASM package + run: wasm-pack build --target web --out-dir web/public/pkg + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: web/package-lock.json + + - name: Install web dependencies + working-directory: web + run: npm ci || npm install + + - name: Install Playwright browsers + working-directory: web + run: npx playwright install chromium + + - name: Build web app + working-directory: web + run: npm run build + + - name: Start preview server + working-directory: web + run: | + npm run preview & + sleep 5 + + - name: Take screenshots + working-directory: web + run: | + npx playwright test e2e/screenshots.spec.ts --reporter=list || true + + - name: Copy screenshots to docs + run: | + mkdir -p docs/use-cases + mkdir -p docs/screenshots + # Copy use-cases screenshots (generated by Playwright) + if [ -d "docs/use-cases" ] && ls docs/use-cases/*.png 1>/dev/null 2>&1; then + # Copy relevant screenshots for README + cp docs/use-cases/01-initial-state.png docs/screenshots/calculator-main.png 2>/dev/null || true + cp docs/use-cases/02-simple-arithmetic.png docs/screenshots/calculator-arithmetic.png 2>/dev/null || true + cp docs/use-cases/03-currency-conversion.png docs/screenshots/calculator-currency.png 2>/dev/null || true + cp docs/use-cases/08-datetime.png docs/screenshots/calculator-datetime.png 2>/dev/null || true + cp docs/use-cases/09-parentheses.png docs/screenshots/calculator-parentheses.png 2>/dev/null || true + fi + + - name: Check for changes + id: changes + run: | + git add -A + if git diff --cached --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Commit and push changes + if: steps.changes.outputs.has_changes == 'true' + run: | + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git commit -m "chore: update screenshots [skip ci]" + git push diff --git a/README.md b/README.md index 400451f2..87720a8e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A grammar-based expression calculator with DateTime and Currency support, built ## Screenshots ### Main Interface -![Link Calculator Main Interface](docs/screenshots/calculator-main.png) +![Link.Calculator Main Interface](docs/screenshots/calculator-main.png) ### Arithmetic Operations ![Arithmetic calculation with step-by-step explanation](docs/screenshots/calculator-arithmetic.png) @@ -25,6 +25,8 @@ A grammar-based expression calculator with DateTime and Currency support, built ### Currency Conversions ![Currency arithmetic with automatic conversion](docs/screenshots/calculator-currency.png) +> **📖 See [USE-CASES.md](docs/USE-CASES.md) for more detailed examples with screenshots**, including symbolic integration, math functions, dark theme, and more. + ## Features ### Expression Parser diff --git a/changelog.d/20260127_110314_ui_improvements.md b/changelog.d/20260127_110314_ui_improvements.md new file mode 100644 index 00000000..4b4dcc6a --- /dev/null +++ b/changelog.d/20260127_110314_ui_improvements.md @@ -0,0 +1,17 @@ +### Changed + +- Rebranded to "Link.Calculator" with new SVG logo and updated tagline "Free open-source calculator dedicated to public domain" +- Renamed "System" theme option to "Auto" for clarity +- Added "Automatic" option as first choice in language selector for auto-detection +- Moved input interpretation section before the result section and renamed it to "Input" +- Removed "Expression" label from input field for cleaner UI +- Changed input field from reactive updates to calculate-on-command: now requires clicking equals button or pressing Enter +- Disabled manual resize indicator on textarea (auto-resize only) + +### Added + +- Calculate button (=) in the input field to trigger computation +- Enter key support to submit calculation +- Preferred currency setting with major fiat currencies and top 10 cryptocurrencies +- Computation time display showing how long calculations take +- Window resize handler for textarea auto-resize diff --git a/changelog.d/20260127_113447_examples_and_tests.md b/changelog.d/20260127_113447_examples_and_tests.md new file mode 100644 index 00000000..a84e0ad8 --- /dev/null +++ b/changelog.d/20260127_113447_examples_and_tests.md @@ -0,0 +1,11 @@ +### Added +- Examples section showing 6 random examples from a centralized examples.lino file +- React unit tests for App.tsx component (23 new tests covering branding, input, settings, examples, and footer) +- USE-CASES.md documentation with screenshots of calculator features +- CI/CD workflow for auto-updating screenshots when web code changes +- New data/examples.lino file containing categorized calculator examples (arithmetic, currency, datetime, functions, integration) +- E2E screenshot generation tests for documentation + +### Changed +- Updated E2E tests to use explicit calculation trigger (Enter key or button click) instead of reactive calculation +- Examples are now randomly selected from examples.lino on each page load for variety diff --git a/data/examples.lino b/data/examples.lino new file mode 100644 index 00000000..ae22ac61 --- /dev/null +++ b/data/examples.lino @@ -0,0 +1,45 @@ +(example (expression "2 + 3") (description "Simple addition") (category "arithmetic")) +(example (expression "10 - 4") (description "Simple subtraction") (category "arithmetic")) +(example (expression "3 * 4") (description "Simple multiplication") (category "arithmetic")) +(example (expression "15 / 3") (description "Simple division") (category "arithmetic")) +(example (expression "(2 + 3) * 4") (description "Parentheses for grouping") (category "arithmetic")) +(example (expression "2 + 3 * 4") (description "Operator precedence") (category "arithmetic")) +(example (expression "-5 + 3") (description "Negative numbers") (category "arithmetic")) +(example (expression "3.14 + 2.86") (description "Decimal numbers") (category "arithmetic")) +(example (expression "100 * 1.5") (description "Multiplication with decimals") (category "arithmetic")) +(example (expression "100 USD") (description "Currency value") (category "currency")) +(example (expression "100 USD + 50 USD") (description "Same currency addition") (category "currency")) +(example (expression "84 USD - 34 EUR") (description "Currency conversion") (category "currency")) +(example (expression "100 USD in EUR") (description "Currency conversion") (category "currency")) +(example (expression "$100 + €50") (description "Currency symbols") (category "currency")) +(example (expression "1 USD + 1 EUR + 1 GBP") (description "Multiple currencies") (category "currency")) +(example (expression "(Jan 27, 8:59am UTC) - (Jan 25, 12:51pm UTC)") (description "DateTime subtraction") (category "datetime")) +(example (expression "(Jan 27, 8:59am UTC) - (Jan 26, 10:20am UTC)") (description "Time difference calculation") (category "datetime")) +(example (expression "sin(0)") (description "Sine function") (category "functions")) +(example (expression "cos(0)") (description "Cosine function") (category "functions")) +(example (expression "sqrt(16)") (description "Square root") (category "functions")) +(example (expression "sqrt(2)") (description "Irrational square root") (category "functions")) +(example (expression "pow(2, 3)") (description "Power function") (category "functions")) +(example (expression "2^3") (description "Power operator") (category "functions")) +(example (expression "exp(0)") (description "Exponential") (category "functions")) +(example (expression "ln(1)") (description "Natural logarithm") (category "functions")) +(example (expression "log(100)") (description "Base-10 logarithm") (category "functions")) +(example (expression "abs(-5)") (description "Absolute value") (category "functions")) +(example (expression "floor(3.7)") (description "Floor function") (category "functions")) +(example (expression "ceil(3.2)") (description "Ceiling function") (category "functions")) +(example (expression "pi()") (description "Pi constant") (category "functions")) +(example (expression "e()") (description "Euler constant") (category "functions")) +(example (expression "sqrt(abs(-16))") (description "Nested functions") (category "functions")) +(example (expression "sqrt(9 + 7)") (description "Function with expression argument") (category "functions")) +(example (expression "min(5, 3)") (description "Minimum value") (category "functions")) +(example (expression "max(5, 3)") (description "Maximum value") (category "functions")) +(example (expression "factorial(5)") (description "Factorial") (category "functions")) +(example (expression "deg(3.14159265358979)") (description "Radians to degrees") (category "functions")) +(example (expression "rad(180)") (description "Degrees to radians") (category "functions")) +(example (expression "integrate(x^2, x, 0, 3)") (description "Definite integral") (category "integration")) +(example (expression "integrate(sin(x), x, 0, 3.14159265358979)") (description "Definite integral of sin(x)") (category "integration")) +(example (expression "integrate sin(x)/x dx") (description "Indefinite integral (natural notation)") (category "integration")) +(example (expression "integrate x^2 dx") (description "Indefinite integral of x squared") (category "integration")) +(example (expression "integrate sin(x) dx") (description "Indefinite integral of sin(x)") (category "integration")) +(example (expression "integrate cos(x) dx") (description "Indefinite integral of cos(x)") (category "integration")) +(example (expression "integrate exp(x) dx") (description "Indefinite integral of exp(x)") (category "integration")) diff --git a/docs/USE-CASES.md b/docs/USE-CASES.md new file mode 100644 index 00000000..0ee98035 --- /dev/null +++ b/docs/USE-CASES.md @@ -0,0 +1,203 @@ +# Link.Calculator Use Cases + +This document showcases the various capabilities of Link.Calculator with screenshots and direct links to try each example. + +## Table of Contents + +- [Initial State](#initial-state) +- [Simple Arithmetic](#simple-arithmetic) +- [Currency Conversion](#currency-conversion) +- [DateTime Calculations](#datetime-calculations) +- [Parentheses Grouping](#parentheses-grouping) +- [Symbolic Integration](#symbolic-integration) +- [Definite Integrals](#definite-integrals) +- [Math Functions](#math-functions) +- [Dark Theme](#dark-theme) + +--- + +## Initial State + +The calculator starts with a clean interface, ready to accept your expressions. Six example buttons are shown to help you get started. + +![Initial State](use-cases/01-initial-state.png) + +[Try it live](https://link-assistant.github.io/calculator/) + +--- + +## Simple Arithmetic + +Basic arithmetic operations with step-by-step explanation. + +**Expression:** `2 + 3` + +![Simple Arithmetic](use-cases/02-simple-arithmetic.png) + +[Try this example](https://link-assistant.github.io/calculator/?q=KGV4cHJlc3Npb24lMjAlMjIyJTIwJTJCJTIwMyUyMik%3D) + +**Features shown:** +- Basic addition +- Step-by-step calculation breakdown +- Links Notation input interpretation + +--- + +## Currency Conversion + +Convert between currencies with real-time exchange rates. + +**Expression:** `84 USD - 34 EUR` + +![Currency Conversion](use-cases/03-currency-conversion.png) + +[Try this example](https://link-assistant.github.io/calculator/?q=KGV4cHJlc3Npb24lMjAlMjI4NCUyMFVTRCUyMC0lMjAzNCUyMEVVUiUyMik%3D) + +**Features shown:** +- Multi-currency arithmetic +- Real-time exchange rate display +- Source and date of exchange rate data +- Automatic currency conversion + +--- + +## DateTime Calculations + +Calculate time differences between dates and times. + +**Expression:** `(Jan 27, 8:59am UTC) - (Jan 25, 12:51pm UTC)` + +![DateTime Calculations](use-cases/08-datetime.png) + +[Try this example](https://link-assistant.github.io/calculator/?q=KGV4cHJlc3Npb24lMjAlMjIoSmFuJTIwMjclMkMlMjA4JTNBNTlhbSUyMFVUQyklMjAtJTIwKEphbiUyMDI1JTJDJTIwMTIlM0E1MXBtJTIwVVRDKSUyMik%3D) + +**Features shown:** +- DateTime parsing with multiple formats +- Time difference calculation +- Duration display in human-readable format +- Step-by-step breakdown + +--- + +## Parentheses Grouping + +Control operator precedence with parentheses. + +**Expression:** `(2 + 3) * 4` + +![Parentheses Grouping](use-cases/09-parentheses.png) + +[Try this example](https://link-assistant.github.io/calculator/?q=KGV4cHJlc3Npb24lMjAlMjIoMiUyMCUyQiUyMDMpJTIwKiUyMDQlMjIp) + +**Features shown:** +- Parentheses for grouping operations +- Correct operator precedence +- Step-by-step breakdown + +--- + +## Symbolic Integration + +Compute indefinite integrals with mathematical notation output. + +**Expression:** `integrate sin(x)/x dx` + +![Symbolic Integration](use-cases/04-integration.png) + +[Try this example](https://link-assistant.github.io/calculator/?q=KGV4cHJlc3Npb24lMjAlMjJpbnRlZ3JhdGUlMjBzaW4oeCklMkZ4JTIwZHglMjIp) + +**Features shown:** +- Natural notation for integrals +- LaTeX-rendered mathematical output +- Function plot visualization +- Symbolic result (Si(x) + C) + +--- + +## Definite Integrals + +Compute definite integrals with numeric bounds. + +**Expression:** `integrate(x^2, x, 0, 3)` + +![Definite Integral](use-cases/06-definite-integral.png) + +[Try this example](https://link-assistant.github.io/calculator/?q=KGV4cHJlc3Npb24lMjAlMjJpbnRlZ3JhdGUoeCU1RTIlMkMlMjB4JTJDJTIwMCUyQyUyMDMpJTIyKQ%3D%3D) + +**Features shown:** +- Definite integral syntax: `integrate(expression, variable, lower, upper)` +- Numerical result +- Step-by-step breakdown + +--- + +## Math Functions + +Use built-in mathematical functions. + +**Expression:** `sqrt(16) + pow(2, 3)` + +![Math Functions](use-cases/07-math-functions.png) + +[Try this example](https://link-assistant.github.io/calculator/?q=KGV4cHJlc3Npb24lMjAlMjJzcXJ0KDE2KSUyMCUyQiUyMHBvdygyJTJDJTIwMyklMjIp) + +**Features shown:** +- Square root function: `sqrt()` +- Power function: `pow(base, exponent)` +- Function call breakdown in steps +- Combination of multiple functions + +**Available functions:** +- Trigonometric: `sin()`, `cos()`, `tan()`, `asin()`, `acos()`, `atan()` +- Exponential: `exp()`, `ln()`, `log()` +- Power: `sqrt()`, `pow()`, `abs()` +- Rounding: `floor()`, `ceil()`, `round()` +- Constants: `pi()`, `e()` +- Comparison: `min()`, `max()` +- Other: `factorial()` + +--- + +## Dark Theme + +Switch to dark mode for comfortable viewing in low-light environments. + +![Dark Theme](use-cases/05-dark-theme.png) + +**Features shown:** +- Dark theme toggle in settings +- Language selector +- Preferred currency selector +- Consistent styling across all components + +--- + +## More Examples + +Try these additional expressions: + +| Category | Expression | Description | +|----------|------------|-------------| +| Arithmetic | `(2 + 3) * 4` | Parentheses for grouping | +| Arithmetic | `2^3` | Power operator | +| Currency | `100 USD in EUR` | Currency conversion | +| Currency | `$100 + €50` | Currency symbols | +| DateTime | `(Jan 27, 8:59am UTC) - (Jan 25, 12:51pm UTC)` | Time difference | +| Functions | `sin(pi()/2)` | Trigonometric functions | +| Functions | `ln(e())` | Natural logarithm | +| Integration | `integrate cos(x) dx` | Indefinite integral | + +--- + +## Getting Started + +1. Visit [Link.Calculator](https://link-assistant.github.io/calculator/) +2. Type an expression in the input field +3. Press **Enter** or click the **=** button to calculate +4. View the result and step-by-step breakdown + +**Tips:** +- Click any example button to quickly try it +- Use the **Copy Link** button to share your calculation +- Switch themes and languages in the settings menu +- Report issues using the "Report Issue" link in the footer diff --git a/docs/screenshots/calculator-arithmetic.png b/docs/screenshots/calculator-arithmetic.png index fa68d268..e05858ad 100644 Binary files a/docs/screenshots/calculator-arithmetic.png and b/docs/screenshots/calculator-arithmetic.png differ diff --git a/docs/screenshots/calculator-currency.png b/docs/screenshots/calculator-currency.png index 1f38692c..6ccf6dc8 100644 Binary files a/docs/screenshots/calculator-currency.png and b/docs/screenshots/calculator-currency.png differ diff --git a/docs/screenshots/calculator-datetime.png b/docs/screenshots/calculator-datetime.png index 392ff401..dfa003e4 100644 Binary files a/docs/screenshots/calculator-datetime.png and b/docs/screenshots/calculator-datetime.png differ diff --git a/docs/screenshots/calculator-main.png b/docs/screenshots/calculator-main.png index 77196450..7a250f28 100644 Binary files a/docs/screenshots/calculator-main.png and b/docs/screenshots/calculator-main.png differ diff --git a/docs/screenshots/calculator-parentheses.png b/docs/screenshots/calculator-parentheses.png index 7b486b01..aabcae3d 100644 Binary files a/docs/screenshots/calculator-parentheses.png and b/docs/screenshots/calculator-parentheses.png differ diff --git a/docs/use-cases/01-initial-state.png b/docs/use-cases/01-initial-state.png new file mode 100644 index 00000000..51426b50 Binary files /dev/null and b/docs/use-cases/01-initial-state.png differ diff --git a/docs/use-cases/02-simple-arithmetic.png b/docs/use-cases/02-simple-arithmetic.png new file mode 100644 index 00000000..4bc272e3 Binary files /dev/null and b/docs/use-cases/02-simple-arithmetic.png differ diff --git a/docs/use-cases/03-currency-conversion.png b/docs/use-cases/03-currency-conversion.png new file mode 100644 index 00000000..8265aad5 Binary files /dev/null and b/docs/use-cases/03-currency-conversion.png differ diff --git a/docs/use-cases/04-integration.png b/docs/use-cases/04-integration.png new file mode 100644 index 00000000..6e9da561 Binary files /dev/null and b/docs/use-cases/04-integration.png differ diff --git a/docs/use-cases/05-dark-theme.png b/docs/use-cases/05-dark-theme.png new file mode 100644 index 00000000..5fb305b1 Binary files /dev/null and b/docs/use-cases/05-dark-theme.png differ diff --git a/docs/use-cases/06-definite-integral.png b/docs/use-cases/06-definite-integral.png new file mode 100644 index 00000000..8d4b5b3d Binary files /dev/null and b/docs/use-cases/06-definite-integral.png differ diff --git a/docs/use-cases/07-math-functions.png b/docs/use-cases/07-math-functions.png new file mode 100644 index 00000000..5608bdc9 Binary files /dev/null and b/docs/use-cases/07-math-functions.png differ diff --git a/docs/use-cases/08-datetime.png b/docs/use-cases/08-datetime.png new file mode 100644 index 00000000..98198f1b Binary files /dev/null and b/docs/use-cases/08-datetime.png differ diff --git a/docs/use-cases/09-parentheses.png b/docs/use-cases/09-parentheses.png new file mode 100644 index 00000000..aabcae3d Binary files /dev/null and b/docs/use-cases/09-parentheses.png differ diff --git a/web/e2e/calculator.spec.ts b/web/e2e/calculator.spec.ts index 07201f8a..af0bed40 100644 --- a/web/e2e/calculator.spec.ts +++ b/web/e2e/calculator.spec.ts @@ -1,4 +1,15 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, Page } from '@playwright/test'; + +/** + * Helper function to enter an expression and trigger calculation. + * The calculator now requires explicit trigger (Enter key or button click). + */ +async function calculateExpression(page: Page, expression: string) { + const input = page.locator('textarea'); + await input.fill(expression); + // Trigger calculation by pressing Enter + await page.keyboard.press('Enter'); +} test.describe('Calculator', () => { test.beforeEach(async ({ page }) => { @@ -8,21 +19,19 @@ test.describe('Calculator', () => { }); test('should display the calculator UI', async ({ page }) => { - await expect(page.locator('h1')).toContainText('Link Calculator'); + await expect(page.locator('h1')).toContainText('Link.Calculator'); await expect(page.locator('textarea')).toBeVisible(); }); test('should calculate simple expression', async ({ page }) => { - const input = page.locator('textarea'); - await input.fill('2 + 3'); + await calculateExpression(page, '2 + 3'); // Wait for result to appear (result is in .result-value) await expect(page.locator('.result-value')).toContainText('5', { timeout: 5000 }); }); test('should show calculation steps', async ({ page }) => { - const input = page.locator('textarea'); - await input.fill('2 + 3 * 4'); + await calculateExpression(page, '2 + 3 * 4'); // Wait for result await expect(page.locator('.result-value')).toBeVisible({ timeout: 5000 }); @@ -40,19 +49,17 @@ test.describe('Calculator', () => { }); test('should display error for invalid expression', async ({ page }) => { - const input = page.locator('textarea'); - await input.fill('invalid expression +++'); + await calculateExpression(page, 'invalid expression +++'); // Wait for error to appear (error is .result-value.error) await expect(page.locator('.result-value.error')).toBeVisible({ timeout: 5000 }); }); test('should handle multiline input', async ({ page }) => { - const input = page.locator('textarea'); - await input.fill('1 + 1'); + await calculateExpression(page, '1 + 1'); // Should process and show result - await expect(page.locator('.result-value')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.result-value')).toContainText('2', { timeout: 5000 }); }); }); @@ -104,8 +111,8 @@ test.describe('Language', () => { const settingsButton = page.locator('.settings-button'); await settingsButton.click(); - // Check for language selector (select element) - const langSelector = page.locator('.settings-dropdown select'); + // Check for language selector (first select element in dropdown is language) + const langSelector = page.locator('.settings-dropdown select').first(); await expect(langSelector).toBeVisible(); }); @@ -114,8 +121,8 @@ test.describe('Language', () => { const settingsButton = page.locator('.settings-button'); await settingsButton.click(); - // Select German - const langSelector = page.locator('.settings-dropdown select'); + // Select German (first select is language selector) + const langSelector = page.locator('.settings-dropdown select').first(); await langSelector.selectOption('de'); // UI should change to German - check result heading changes to "Ergebnis" @@ -152,6 +159,11 @@ test.describe('URL Sharing', () => { const input = page.locator('textarea'); await expect(input).toHaveValue(expression); + // Trigger calculation (calculation is on-demand, not automatic) + // First focus the input, then press Enter + await input.focus(); + await page.keyboard.press('Enter'); + // Result should show 25 await expect(page.locator('.result-value')).toContainText('25', { timeout: 5000 }); }); @@ -162,18 +174,21 @@ test.describe('URL Sharing', () => { const input = page.locator('textarea'); - // Type first expression + // Type and calculate first expression await input.fill('1 + 1'); + await page.keyboard.press('Enter'); await page.waitForTimeout(600); await expect(page.locator('.result-value')).toContainText('2', { timeout: 5000 }); - // Type second expression + // Type and calculate second expression await input.fill('2 + 2'); + await page.keyboard.press('Enter'); await page.waitForTimeout(600); await expect(page.locator('.result-value')).toContainText('4', { timeout: 5000 }); - // Type third expression + // Type and calculate third expression await input.fill('3 + 3'); + await page.keyboard.press('Enter'); await page.waitForTimeout(600); await expect(page.locator('.result-value')).toContainText('6', { timeout: 5000 }); @@ -181,24 +196,27 @@ test.describe('URL Sharing', () => { await page.goBack(); await page.waitForTimeout(200); - // Should show second expression + // Should show second expression, trigger calculation await expect(input).toHaveValue('2 + 2'); + await page.keyboard.press('Enter'); await expect(page.locator('.result-value')).toContainText('4', { timeout: 5000 }); // Go back again await page.goBack(); await page.waitForTimeout(200); - // Should show first expression + // Should show first expression, trigger calculation await expect(input).toHaveValue('1 + 1'); + await page.keyboard.press('Enter'); await expect(page.locator('.result-value')).toContainText('2', { timeout: 5000 }); // Go forward await page.goForward(); await page.waitForTimeout(200); - // Should show second expression again + // Should show second expression again, trigger calculation await expect(input).toHaveValue('2 + 2'); + await page.keyboard.press('Enter'); await expect(page.locator('.result-value')).toContainText('4', { timeout: 5000 }); }); }); @@ -279,9 +297,8 @@ test.describe('Busy Indicator', () => { await page.goto('/'); await expect(page.locator('textarea')).toBeEnabled({ timeout: 10000 }); - const input = page.locator('textarea'); - // Type a complex expression that might take longer - await input.fill('123456789 * 987654321'); + // Type a complex expression that might take longer and trigger calculation + await calculateExpression(page, '123456789 * 987654321'); // Eventually result should appear (loading indicator is .loading) await expect(page.locator('.result-value')).toBeVisible({ timeout: 10000 }); @@ -309,8 +326,13 @@ test.describe('Examples', () => { const input = page.locator('textarea'); await expect(input).not.toHaveValue(''); + // Trigger calculation (clicking example only fills input, doesn't calculate) + // First focus the input, then press Enter + await input.focus(); + await page.keyboard.press('Enter'); + // Result should appear - await expect(page.locator('.result-value')).toBeVisible({ timeout: 5000 }); + await expect(page.locator('.result-value')).not.toContainText('Enter an expression', { timeout: 5000 }); }); }); @@ -473,9 +495,8 @@ test.describe('Currency Conversion with Real Rates', () => { }); test('should calculate currency conversion', async ({ page }) => { - const input = page.locator('textarea'); // Use an expression that will trigger currency conversion - await input.fill('100 USD in EUR'); + await calculateExpression(page, '100 USD in EUR'); // Wait for result to appear (not the default placeholder) await expect(page.locator('.result-value')).not.toContainText('Enter an expression', { timeout: 10000 }); @@ -487,9 +508,8 @@ test.describe('Currency Conversion with Real Rates', () => { }); test('should show exchange rate info in calculation steps', async ({ page }) => { - const input = page.locator('textarea'); // This should trigger a currency conversion - await input.fill('0 RUB + 1 USD'); + await calculateExpression(page, '0 RUB + 1 USD'); // Wait for result await expect(page.locator('.result-value')).toBeVisible({ timeout: 10000 }); @@ -504,8 +524,7 @@ test.describe('Currency Conversion with Real Rates', () => { }); test('should handle multiple currency conversions', async ({ page }) => { - const input = page.locator('textarea'); - await input.fill('1 USD + 1 EUR + 1 GBP'); + await calculateExpression(page, '1 USD + 1 EUR + 1 GBP'); // Wait for result await expect(page.locator('.result-value')).toBeVisible({ timeout: 10000 }); @@ -516,8 +535,7 @@ test.describe('Currency Conversion with Real Rates', () => { }); test('should display rate source information', async ({ page }) => { - const input = page.locator('textarea'); - await input.fill('100 JPY in USD'); + await calculateExpression(page, '100 JPY in USD'); // Wait for result await expect(page.locator('.result-value')).toBeVisible({ timeout: 10000 }); @@ -533,8 +551,7 @@ test.describe('Currency Conversion with Real Rates', () => { }); test('should handle currency symbols', async ({ page }) => { - const input = page.locator('textarea'); - await input.fill('$100 + €50'); + await calculateExpression(page, '$100 + €50'); // Wait for result await expect(page.locator('.result-value')).toBeVisible({ timeout: 10000 }); @@ -544,11 +561,10 @@ test.describe('Currency Conversion with Real Rates', () => { expect(resultText).toMatch(/[$€]|\w{3}/); // Either symbol or currency code }); - test('should gracefully handle rates when offline', async ({ page, context }) => { + test('should gracefully handle rates when offline', async ({ page }) => { // This test checks that calculator still works even if rates fail // We can't easily simulate offline, but we can test that basic math works - const input = page.locator('textarea'); - await input.fill('2 + 2'); + await calculateExpression(page, '2 + 2'); await expect(page.locator('.result-value')).toContainText('4', { timeout: 5000 }); }); diff --git a/web/e2e/screenshots.spec.ts b/web/e2e/screenshots.spec.ts new file mode 100644 index 00000000..23b6f6a8 --- /dev/null +++ b/web/e2e/screenshots.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from '@playwright/test'; + +/** + * Screenshot generation tests for USE-CASES.md documentation. + * These tests generate screenshots of various calculator use cases. + */ + +const SCREENSHOT_DIR = '../docs/use-cases'; + +test.describe('Screenshot Generation', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Wait for WASM to be ready + await expect(page.locator('textarea')).toBeEnabled({ timeout: 15000 }); + // Wait for exchange rates to load + await page.waitForTimeout(2000); + }); + + test('01-initial-state', async ({ page }) => { + await page.screenshot({ + path: `${SCREENSHOT_DIR}/01-initial-state.png`, + fullPage: false, + }); + }); + + test('02-simple-arithmetic', async ({ page }) => { + const input = page.locator('textarea'); + await input.fill('2 + 3'); + await page.keyboard.press('Enter'); + await expect(page.locator('.result-value')).toContainText('5', { timeout: 5000 }); + + await page.screenshot({ + path: `${SCREENSHOT_DIR}/02-simple-arithmetic.png`, + fullPage: false, + }); + }); + + test('03-currency-conversion', async ({ page }) => { + const input = page.locator('textarea'); + await input.fill('84 USD - 34 EUR'); + await page.keyboard.press('Enter'); + await expect(page.locator('.result-value')).not.toContainText('Enter an expression', { timeout: 10000 }); + + await page.screenshot({ + path: `${SCREENSHOT_DIR}/03-currency-conversion.png`, + fullPage: false, + }); + }); + + test('04-integration', async ({ page }) => { + const input = page.locator('textarea'); + await input.fill('integrate sin(x)/x dx'); + await page.keyboard.press('Enter'); + await expect(page.locator('.result-value')).toBeVisible({ timeout: 10000 }); + // Wait for plot to render + await page.waitForTimeout(1000); + + await page.screenshot({ + path: `${SCREENSHOT_DIR}/04-integration.png`, + fullPage: true, + }); + }); + + test('05-dark-theme', async ({ page }) => { + // First enter an expression + const input = page.locator('textarea'); + await input.fill('integrate sin(x)/x dx'); + await page.keyboard.press('Enter'); + await expect(page.locator('.result-value')).toBeVisible({ timeout: 10000 }); + await page.waitForTimeout(500); + + // Switch to dark theme + const settingsButton = page.locator('.settings-button'); + await settingsButton.click(); + await expect(page.locator('.settings-dropdown')).toBeVisible(); + + const darkButton = page.locator('.settings-buttons button:has-text("Dark")'); + await darkButton.click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + + await page.screenshot({ + path: `${SCREENSHOT_DIR}/05-dark-theme.png`, + fullPage: true, + }); + }); + + test('06-definite-integral', async ({ page }) => { + // Switch to dark theme first for consistency + const settingsButton = page.locator('.settings-button'); + await settingsButton.click(); + const darkButton = page.locator('.settings-buttons button:has-text("Dark")'); + await darkButton.click(); + // Close dropdown + await page.keyboard.press('Escape'); + + const input = page.locator('textarea'); + await input.fill('integrate(x^2, x, 0, 3)'); + await page.keyboard.press('Enter'); + await expect(page.locator('.result-value')).toContainText('9', { timeout: 5000 }); + + await page.screenshot({ + path: `${SCREENSHOT_DIR}/06-definite-integral.png`, + fullPage: false, + }); + }); + + test('07-math-functions', async ({ page }) => { + // Switch to dark theme first for consistency + const settingsButton = page.locator('.settings-button'); + await settingsButton.click(); + const darkButton = page.locator('.settings-buttons button:has-text("Dark")'); + await darkButton.click(); + // Close dropdown + await page.keyboard.press('Escape'); + + const input = page.locator('textarea'); + await input.fill('sqrt(16) + pow(2, 3)'); + await page.keyboard.press('Enter'); + await expect(page.locator('.result-value')).toContainText('12', { timeout: 5000 }); + + await page.screenshot({ + path: `${SCREENSHOT_DIR}/07-math-functions.png`, + fullPage: false, + }); + }); + + test('08-datetime', async ({ page }) => { + const input = page.locator('textarea'); + await input.fill('(Jan 27, 8:59am UTC) - (Jan 25, 12:51pm UTC)'); + await page.keyboard.press('Enter'); + await expect(page.locator('.result-value')).toContainText('day', { timeout: 5000 }); + + await page.screenshot({ + path: `${SCREENSHOT_DIR}/08-datetime.png`, + fullPage: false, + }); + }); + + test('09-parentheses', async ({ page }) => { + const input = page.locator('textarea'); + await input.fill('(2 + 3) * 4'); + await page.keyboard.press('Enter'); + await expect(page.locator('.result-value')).toContainText('20', { timeout: 5000 }); + + await page.screenshot({ + path: `${SCREENSHOT_DIR}/09-parentheses.png`, + fullPage: false, + }); + }); +}); diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx new file mode 100644 index 00000000..92d27005 --- /dev/null +++ b/web/src/App.test.tsx @@ -0,0 +1,359 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import App from './App'; + +// Mock i18next +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + 'app.subtitle': 'Free open-source calculator dedicated to public domain.', + 'input.placeholder': 'Enter an expression', + 'input.calculate': 'Calculate', + 'result.title': 'Result', + 'result.input': 'Input', + 'result.calculating': 'Calculating...', + 'result.loading': 'Loading calculator engine...', + 'result.placeholder': 'Enter an expression above', + 'examples.title': 'Try these examples:', + 'settings.theme': 'Theme', + 'settings.themeLight': 'Light', + 'settings.themeDark': 'Dark', + 'settings.themeAuto': 'Auto', + 'settings.language': 'Language', + 'settings.languageAutomatic': 'Automatic', + 'settings.currency': 'Preferred Currency', + 'settings.fiatCurrencies': 'Fiat Currencies', + 'settings.cryptoCurrencies': 'Crypto Currencies', + 'share.copyLink': 'Copy Link', + 'footer.poweredBy': 'Powered by Rust + WebAssembly', + 'footer.viewOnGitHub': 'View on GitHub', + 'footer.reportIssue': 'Report Issue', + }; + let result = translations[key] || key; + if (params) { + Object.entries(params).forEach(([k, v]) => { + result = result.replace(`{{${k}}}`, String(v)); + }); + } + return result; + }, + i18n: { + language: 'en', + changeLanguage: vi.fn(), + }, + }), +})); + +// Mock hooks +vi.mock('./hooks', () => ({ + useTheme: () => ({ + theme: 'system', + resolvedTheme: 'light', + setTheme: vi.fn(), + }), + useUrlExpression: () => ({ + expression: '', + setExpression: vi.fn(), + copyShareLink: vi.fn().mockResolvedValue(true), + }), + useDelayedLoading: (loading: boolean) => loading, +})); + +// Mock i18n module +vi.mock('./i18n', () => ({ + SUPPORTED_LANGUAGES: [ + { code: 'en', name: 'English', nativeName: 'English' }, + { code: 'de', name: 'German', nativeName: 'Deutsch' }, + ], + loadPreferences: vi.fn(() => ({ theme: 'system', language: null, currency: null })), + savePreferences: vi.fn(), +})); + +// Mock ResizeObserver +class MockResizeObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} + +describe('App Component - Branding', () => { + let originalResizeObserver: typeof ResizeObserver; + + beforeEach(() => { + originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + vi.restoreAllMocks(); + }); + + it('should display Link.Calculator as brand title', () => { + render(); + expect(screen.getByText('Link.Calculator')).toBeInTheDocument(); + }); + + it('should display the SVG logo in the header', () => { + render(); + const logo = document.querySelector('.brand-title svg'); + expect(logo).toBeInTheDocument(); + }); + + it('should display the correct subtitle', () => { + render(); + expect(screen.getByText('Free open-source calculator dedicated to public domain.')).toBeInTheDocument(); + }); +}); + +describe('App Component - Input Section', () => { + let originalResizeObserver: typeof ResizeObserver; + + beforeEach(() => { + originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + vi.restoreAllMocks(); + }); + + it('should render the textarea input', () => { + render(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('should display calculate button with equals sign', () => { + render(); + const calculateButton = screen.getByTitle('Calculate'); + expect(calculateButton).toBeInTheDocument(); + expect(calculateButton).toHaveTextContent('='); + }); + + it('should have calculate button disabled when textarea is empty', () => { + render(); + const calculateButton = screen.getByTitle('Calculate'); + expect(calculateButton).toBeDisabled(); + }); + + it('should not have resize handle on textarea (auto-resize only)', () => { + render(); + const textarea = screen.getByRole('textbox'); + expect(textarea).toHaveStyle({ resize: 'none' }); + }); +}); + +describe('App Component - Settings', () => { + let originalResizeObserver: typeof ResizeObserver; + + beforeEach(() => { + originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + vi.restoreAllMocks(); + }); + + it('should have settings button', () => { + render(); + const settingsButton = screen.getByLabelText('Theme'); + expect(settingsButton).toBeInTheDocument(); + }); + + it('should open settings dropdown when clicking settings button', async () => { + render(); + const settingsButton = screen.getByLabelText('Theme'); + + await userEvent.click(settingsButton); + + const dropdown = document.querySelector('.settings-dropdown'); + expect(dropdown).toBeInTheDocument(); + }); + + it('should display Auto theme option instead of System', async () => { + render(); + const settingsButton = screen.getByLabelText('Theme'); + + await userEvent.click(settingsButton); + + expect(screen.getByText('Auto')).toBeInTheDocument(); + expect(screen.queryByText('System')).not.toBeInTheDocument(); + }); + + it('should have Automatic option in language selector', async () => { + render(); + const settingsButton = screen.getByLabelText('Theme'); + + await userEvent.click(settingsButton); + + const selectors = screen.getAllByRole('combobox'); + // Language selector is the first one (contains language options like 'en') + const langSelector = selectors.find(sel => sel.querySelector('option[value="en"]')); + expect(langSelector).toBeInTheDocument(); + + // Check for Automatic option + const options = langSelector!.querySelectorAll('option'); + const automaticOption = Array.from(options).find(opt => opt.textContent === 'Automatic'); + expect(automaticOption).toBeInTheDocument(); + }); + + it('should have currency preference selector', async () => { + render(); + const settingsButton = screen.getByLabelText('Theme'); + + await userEvent.click(settingsButton); + + expect(screen.getByText('Preferred Currency')).toBeInTheDocument(); + }); + + it('should have fiat currencies group in currency selector', async () => { + render(); + const settingsButton = screen.getByLabelText('Theme'); + + await userEvent.click(settingsButton); + + // Look for optgroup elements in the DOM + const optgroup = document.querySelector('optgroup[label="Fiat Currencies"]'); + expect(optgroup).toBeInTheDocument(); + }); + + it('should have crypto currencies group in currency selector', async () => { + render(); + const settingsButton = screen.getByLabelText('Theme'); + + await userEvent.click(settingsButton); + + // Look for optgroup elements in the DOM + const optgroup = document.querySelector('optgroup[label="Crypto Currencies"]'); + expect(optgroup).toBeInTheDocument(); + }); +}); + +describe('App Component - Examples Section', () => { + let originalResizeObserver: typeof ResizeObserver; + + beforeEach(() => { + originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + vi.restoreAllMocks(); + }); + + it('should display example buttons', () => { + render(); + const exampleButtons = screen.getAllByRole('button', { name: /^\(?\d|integrate|USD|EUR/i }); + expect(exampleButtons.length).toBeGreaterThan(0); + }); + + it('should have examples section with title', () => { + render(); + expect(screen.getByText('Try these examples:')).toBeInTheDocument(); + }); +}); + +describe('App Component - Result Section', () => { + let originalResizeObserver: typeof ResizeObserver; + + beforeEach(() => { + originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + vi.restoreAllMocks(); + }); + + it('should have Result section heading', () => { + render(); + expect(screen.getByText('Result')).toBeInTheDocument(); + }); +}); + +describe('App Component - Footer', () => { + let originalResizeObserver: typeof ResizeObserver; + + beforeEach(() => { + originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + vi.restoreAllMocks(); + }); + + it('should display Link.Calculator in footer', () => { + render(); + const footer = screen.getByRole('contentinfo'); + expect(footer).toHaveTextContent('Link.Calculator'); + }); + + it('should have GitHub link in footer', () => { + render(); + expect(screen.getByText('View on GitHub')).toBeInTheDocument(); + }); + + it('should have Report Issue button in footer', () => { + render(); + expect(screen.getByText('Report Issue')).toBeInTheDocument(); + }); +}); + +describe('LinkCalculatorLogo Component', () => { + let originalResizeObserver: typeof ResizeObserver; + + beforeEach(() => { + originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + vi.restoreAllMocks(); + }); + + it('should render SVG logo with correct size', () => { + render(); + const logo = document.querySelector('.brand-title svg'); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('width', '32'); + expect(logo).toHaveAttribute('height', '32'); + }); + + it('should have proper viewBox for scaling', () => { + render(); + const logo = document.querySelector('.brand-title svg'); + expect(logo).toHaveAttribute('viewBox', '0 0 100 100'); + }); +}); + +describe('Currency Detection', () => { + let originalResizeObserver: typeof ResizeObserver; + + beforeEach(() => { + originalResizeObserver = window.ResizeObserver; + window.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + window.ResizeObserver = originalResizeObserver; + vi.restoreAllMocks(); + }); + + it('should detect currency from browser locale (USD for en-US)', () => { + // The detectUserCurrency function should be called on render + // and use the browser's locale to determine currency + render(); + // Component should render without errors + expect(screen.getByText('Link.Calculator')).toBeInTheDocument(); + }); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 80b7fa8f..3ff58f5c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,25 +1,71 @@ -import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react'; +import { useState, useEffect, useCallback, useRef, lazy, Suspense, KeyboardEvent, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; import { useTheme, useUrlExpression, useDelayedLoading } from './hooks'; import { SUPPORTED_LANGUAGES, loadPreferences, savePreferences } from './i18n'; import { generateIssueUrl, type PageState } from './utils/reportIssue'; -import { AutoResizeTextarea, ColorCodedLino, RepeatingDecimalNotations } from './components'; +import { AutoResizeTextarea, ColorCodedLino, RepeatingDecimalNotations, type AutoResizeTextareaRef } from './components'; +import { getExamplesForDisplay } from './examples'; import type { CalculationResult, ErrorInfo } from './types'; +// SVG Logo component for Link.Calculator branding +const LinkCalculatorLogo = ({ size = 24 }: { size?: number }) => ( + + + + + + + + + + + + + + +); + +// Top 10 crypto currencies by market cap +const CRYPTO_CURRENCIES = [ + { code: 'BTC', name: 'Bitcoin' }, + { code: 'ETH', name: 'Ethereum' }, + { code: 'USDT', name: 'Tether' }, + { code: 'BNB', name: 'BNB' }, + { code: 'SOL', name: 'Solana' }, + { code: 'XRP', name: 'XRP' }, + { code: 'USDC', name: 'USD Coin' }, + { code: 'ADA', name: 'Cardano' }, + { code: 'DOGE', name: 'Dogecoin' }, + { code: 'AVAX', name: 'Avalanche' }, +]; + +// Major fiat currencies +const FIAT_CURRENCIES = [ + { code: 'USD', name: 'US Dollar' }, + { code: 'EUR', name: 'Euro' }, + { code: 'GBP', name: 'British Pound' }, + { code: 'JPY', name: 'Japanese Yen' }, + { code: 'CNY', name: 'Chinese Yuan' }, + { code: 'INR', name: 'Indian Rupee' }, + { code: 'RUB', name: 'Russian Ruble' }, + { code: 'BRL', name: 'Brazilian Real' }, + { code: 'CHF', name: 'Swiss Franc' }, + { code: 'CAD', name: 'Canadian Dollar' }, + { code: 'AUD', name: 'Australian Dollar' }, + { code: 'KRW', name: 'Korean Won' }, +]; + // Lazy load the math and plot components for better initial bundle size const MathRenderer = lazy(() => import('./components/MathRenderer')); const FunctionPlot = lazy(() => import('./components/FunctionPlot')); -const EXAMPLES = [ - '2 + 3', - '(2 + 3) * 4', - '84 USD - 34 EUR', - '100 * 1.5', - 'integrate sin(x)/x dx', - 'integrate(x^2, x, 0, 3)', -]; - /** * Translates an error using i18n error info. * Falls back to the raw error message if translation key doesn't exist. @@ -45,11 +91,56 @@ function translateError( return translated; } +/** + * Detect user's preferred currency from browser locale. + */ +function detectUserCurrency(): string { + try { + const locale = navigator.language || 'en-US'; + // Map common locales to their currencies + const localeToCurrency: Record = { + 'en-US': 'USD', + 'en-GB': 'GBP', + 'de-DE': 'EUR', + 'fr-FR': 'EUR', + 'es-ES': 'EUR', + 'it-IT': 'EUR', + 'ja-JP': 'JPY', + 'zh-CN': 'CNY', + 'zh-TW': 'TWD', + 'ko-KR': 'KRW', + 'ru-RU': 'RUB', + 'pt-BR': 'BRL', + 'hi-IN': 'INR', + 'ar-SA': 'SAR', + }; + + // Try exact match first + if (localeToCurrency[locale]) { + return localeToCurrency[locale]; + } + + // Try language-only match + const lang = locale.split('-')[0]; + const langMatch = Object.entries(localeToCurrency).find(([key]) => key.startsWith(lang + '-')); + if (langMatch) { + return langMatch[1]; + } + + return 'USD'; // Default to USD + } catch { + return 'USD'; + } +} + function App() { const { t, i18n } = useTranslation(); const { theme, resolvedTheme, setTheme } = useTheme(); const { expression: input, setExpression: setInput, copyShareLink } = useUrlExpression(''); + // Get random examples from the examples.lino file (memoized to stay stable during session) + const displayExamples = useMemo(() => getExamplesForDisplay(6), []); + const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [wasmReady, setWasmReady] = useState(false); @@ -58,9 +149,15 @@ function App() { const [settingsOpen, setSettingsOpen] = useState(false); const [ratesLoading, setRatesLoading] = useState(false); const [ratesInfo, setRatesInfo] = useState<{ date?: string; base?: string } | null>(null); + const [computationTime, setComputationTime] = useState(null); + const [preferredCurrency, setPreferredCurrency] = useState(() => { + const prefs = loadPreferences(); + return prefs.currency || detectUserCurrency(); + }); const workerRef = useRef(null); const settingsRef = useRef(null); + const textareaRef = useRef(null); // Delayed loading indicator (shows after 300ms) const showLoading = useDelayedLoading(loading, 300); @@ -80,6 +177,10 @@ function App() { setVersion(data.version); } else if (type === 'result') { setResult(data); + // Capture computation time from worker if provided + if (data.computation_time_ms !== undefined) { + setComputationTime(data.computation_time_ms); + } setLoading(false); } else if (type === 'error') { setResult({ @@ -89,6 +190,7 @@ function App() { success: false, error: data.error, }); + setComputationTime(null); setLoading(false); } else if (type === 'ratesLoading') { setRatesLoading(data.loading); @@ -116,26 +218,34 @@ function App() { }; }, [t]); - const calculate = useCallback((expression: string) => { - if (!expression.trim() || !wasmReady || !workerRef.current) { + const calculate = useCallback((expression?: string) => { + const expr = expression ?? input; + if (!expr.trim() || !wasmReady || !workerRef.current) { return; } setLoading(true); - workerRef.current.postMessage({ type: 'calculate', expression }); - }, [wasmReady]); + setComputationTime(null); + workerRef.current.postMessage({ type: 'calculate', expression: expr }); + }, [wasmReady, input]); + + // Handle Enter key press to trigger calculation + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + calculate(); + } + }, [calculate]); + // Handle window resize to auto-resize textarea useEffect(() => { - const debounce = setTimeout(() => { - if (input.trim()) { - calculate(input); - } else { - setResult(null); - } - }, 300); + const handleResize = () => { + textareaRef.current?.resize(); + }; - return () => clearTimeout(debounce); - }, [input, calculate]); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); const handleExampleClick = (example: string) => { setInput(example); @@ -155,6 +265,12 @@ function App() { savePreferences({ ...prefs, language: langCode }); }; + const handleCurrencyChange = (currencyCode: string) => { + setPreferredCurrency(currencyCode); + const prefs = loadPreferences(); + savePreferences({ ...prefs, currency: currencyCode }); + }; + const handleReportIssue = () => { const pageState: PageState = { expression: input, @@ -192,7 +308,10 @@ function App() {
-

{t('app.title')}

+

+ + Link.Calculator +

@@ -234,6 +353,7 @@ function App() { value={i18n.language} onChange={(e) => handleLanguageChange(e.target.value)} > + {SUPPORTED_LANGUAGES.map((lang) => (
+
+ + +
)} @@ -250,47 +392,75 @@ function App() {
-
setInput(e.target.value)} + onKeyDown={handleKeyDown} placeholder={t('input.placeholder')} disabled={!wasmReady} autoFocus - minRows={1} + minRows={2} maxRows={10} /> - {input && ( +
+ {input && ( + + )} - )} +
+ {/* Input interpretation section - before Result */} + {result && result.success && result.lino_interpretation && ( +
+

{t('result.input')}

+
+ +
+
+ )} +

{t('result.title')}

- {showLoading && ( -
-
- {t('result.calculating')} -
- )} +
+ {showLoading && ( +
+
+ {t('result.calculating')} +
+ )} + {!showLoading && computationTime !== null && ( + + {computationTime < 1 ? '<1' : computationTime.toFixed(0)} ms + + )} +
{!wasmReady ? ( @@ -302,17 +472,7 @@ function App() { <> {result.success ? ( <> - {/* Section 1: Interpretation (mandatory) - Links notation with color-coded parentheses */} - {result.lino_interpretation && ( -
-

{t('result.interpretation', 'Interpretation')}

-
- -
-
- )} - - {/* Section 2: Result (mandatory) */} + {/* Section 1: Result (mandatory) */} {result.is_symbolic && result.latex_input && result.latex_result ? (
{result.result}
}> @@ -391,7 +551,7 @@ function App() {

{t('examples.title')}

- {EXAMPLES.map((example) => ( + {displayExamples.map((example) => (