diff --git a/.changeset/focus-cash-register-reading.md b/.changeset/focus-cash-register-reading.md new file mode 100644 index 0000000..9999119 --- /dev/null +++ b/.changeset/focus-cash-register-reading.md @@ -0,0 +1,5 @@ +--- +'@hledger-reports/web': patch +--- + +Focus cash registers on recent activity and balance, with compact money columns and read-only transaction details for every entry. Preserve mixed-unit amounts instead of hiding the account. diff --git a/apps/web/e2e/account-navigation.e2e.ts b/apps/web/e2e/account-navigation.e2e.ts index 48b2226..0a66778 100644 --- a/apps/web/e2e/account-navigation.e2e.ts +++ b/apps/web/e2e/account-navigation.e2e.ts @@ -106,7 +106,10 @@ test('desktop account navigation preserves client routing and observes later sav await accounts.getByRole('link', { name: /^Everyday checking\b/ }).click(); await expect(main.getByRole('heading', { name: 'Everyday checking', exact: true })).toBeVisible(); - const registerStamp = main.locator('time'); + // Investment registers still display the preparation timestamp used by this refresh check. + await accounts.getByRole('link', { name: /^Brokerage\b/ }).click(); + await expect(main.getByRole('heading', { name: 'Brokerage', exact: true })).toBeVisible(); + const registerStamp = main.getByText(/^Prepared at /).locator('time'); await expect(registerStamp).toHaveAttribute('datetime', savedAt); // Explicit work happens in another authenticated page, not in the page left open. @@ -127,7 +130,7 @@ test('desktop account navigation preserves client routing and observes later sav Object.assign(window, { accountNavigationToken: value }); }, token); await main.getByRole('link', { name: 'Newest first', exact: true }).click(); - await expect(page).toHaveURL(/\/registers\/cash\/[a-z0-9]{8}\?order=desc$/); + await expect(page).toHaveURL(/\/registers\/investments\/[a-z0-9]{8}\?order=desc$/); await expect(registerStamp).toHaveAttribute('datetime', laterAt); expect(await page.evaluate(() => Reflect.get(window, 'accountNavigationToken'))).toBe(token); @@ -239,11 +242,11 @@ test('responsive Accounts supports phone, overlay drawer and desktop transitions // The account drawer must not leave the register's existing dialog unusable. const activity = page.getByRole('region', { name: 'Cash register activity', exact: true }); - const opener = activity.getByRole('button', { name: 'Breakdown', exact: true }).first(); + const opener = activity.getByRole('button', { name: /^View transaction details:/ }).first(); await opener.click(); - const breakdown = page.getByRole('dialog', { name: 'Transaction breakdown', exact: true }); - await expect(breakdown).toBeVisible(); - await breakdown.getByRole('button', { name: 'Close breakdown', exact: true }).click(); + const details = page.getByRole('dialog', { name: 'Transaction details', exact: true }); + await expect(details).toBeVisible(); + await details.getByRole('button', { name: 'Close transaction details', exact: true }).click(); await expect(opener).toBeFocused(); await trigger.click(); @@ -252,9 +255,9 @@ test('responsive Accounts supports phone, overlay drawer and desktop transitions await expect(drawer).not.toBeVisible(); await expect(page.getByRole('complementary', { name: 'Accounts', exact: true })).toBeVisible(); // Real focus and scrolling detect stale modal inertness/scroll locks, not CSS classes. - const newest = page.locator('main').getByRole('link', { name: 'Newest first', exact: true }); - await newest.focus(); - await expect(newest).toBeFocused(); + const dateOrder = page.locator('main').getByRole('link', { name: /^Date:/ }); + await dateOrder.focus(); + await expect(dateOrder).toBeFocused(); await page.setViewportSize({ width: 1440, height: 500 }); await page.evaluate(() => window.scrollTo(0, 0)); await page.mouse.move(200, 400); diff --git a/apps/web/e2e/cash-register.e2e.ts b/apps/web/e2e/cash-register.e2e.ts index 5d78fec..61c1102 100644 --- a/apps/web/e2e/cash-register.e2e.ts +++ b/apps/web/e2e/cash-register.e2e.ts @@ -1,31 +1,95 @@ -import { expect, test } from '@playwright/test'; +import { + expect, + test, + type Request as BrowserRequest, + type Response as BrowserResponse +} from '@playwright/test'; import { prepared } from './overview-fixture'; -test('Cash register opens saved activity and a transaction breakdown', async ({ page }) => { +async function replayWithoutSession(response: BrowserResponse, origin: string) { + const request = response.request(); + const headers = new Headers({ origin }); + const contentType = request.headers()['content-type']; + if (contentType) headers.set('content-type', contentType); + const options: RequestInit = { method: request.method(), headers, redirect: 'error' }; + if (request.method() !== 'GET' && request.method() !== 'HEAD') options.body = request.postData(); + return fetch(response.url(), options); +} + +test('Compact cash rows keep signed amounts and open details from the row', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await prepared(page); + await page.goto('/registers/cash'); + await page + .locator('main') + .getByRole('link', { name: /^Everyday checking\b/ }) + .click(); + const activity = page.getByRole('region', { name: 'Cash register activity', exact: true }); + const purchase = activity.locator('tbody tr').filter({ hasText: '2026-04-04' }); + await expect(purchase.locator('time')).toHaveText('04/04/26', { useInnerText: true }); + await expect(purchase.locator('time')).toHaveAttribute('datetime', '2026-04-04'); + const amount = purchase.locator('[data-column="payment"]'); + await expect(amount).toHaveText('-$1,400.00', { useInnerText: true }); + await amount.click(); + const dialog = page.getByRole('dialog', { name: 'Transaction details', exact: true }); + await expect(dialog.getByText('-1400.00 USD', { exact: true }).first()).toBeVisible(); + await expect(dialog.getByText('9500.00 USD', { exact: true })).toBeVisible(); + await dialog.getByRole('button', { name: 'Close transaction details', exact: true }).click(); + await expect(purchase.getByRole('button', { name: /^View transaction details:/ })).toBeFocused(); +}); + +test('Cash register opens recent saved activity and complete transaction details', async ({ + page +}) => { await prepared(page); await page .getByRole('complementary', { name: 'Accounts', exact: true }) .getByRole('link', { name: 'Cash', exact: true }) .click(); await expect(page).toHaveURL(/\/registers\/cash$/); - await page + const accountURL = await page .locator('main') .getByRole('link', { name: /^Everyday checking\b/ }) - .click(); + .getAttribute('href'); + if (!accountURL) throw new Error('Missing saved cash account link.'); + const reads: BrowserRequest[] = []; + page.on('request', (request) => { + if (request.resourceType() === 'fetch' || request.resourceType() === 'xhr') reads.push(request); + }); + await page.goto(accountURL); await expect(page.getByRole('heading', { name: /^Everyday checking\b/, level: 1 })).toBeVisible(); const activity = page.getByRole('region', { name: 'Cash register activity', exact: true }); - await expect(activity.locator('tbody tr').last().locator('td[data-column="balance"]')).toHaveText( - '8520.00 USD' + await expect(page.getByLabel('Ending recorded balance', { exact: true })).toContainText( + '$8,520.00' ); + await expect( + activity.locator('tbody tr').first().locator('td[data-column="balance"]') + ).toHaveText('$8,520.00'); - const opener = activity.getByRole('button', { name: 'Breakdown', exact: true }).first(); - await opener.click(); - const dialog = page.getByRole('dialog', { name: 'Transaction breakdown', exact: true }); + const purchase = activity.locator('tbody tr').filter({ hasText: '2026-04-04' }); + await expect(purchase.locator('[data-column="payment"]')).toHaveText('-$1,400.00', { + useInnerText: true + }); + const opener = purchase.getByRole('button', { name: /^View transaction details:/ }); + await expect(opener).toBeEnabled(); + expect(reads).toHaveLength(0); + const loaded = page.waitForResponse((response) => response.request().resourceType() === 'fetch'); + await purchase.locator('td.date').click(); + const response = await loaded; + expect(response.ok()).toBe(true); + expect(reads.map((request) => request.url())).toEqual([response.url()]); + const dialog = page.getByRole('dialog', { name: 'Transaction details', exact: true }); await expect(dialog).toBeVisible(); - const splits = dialog.getByRole('table', { name: 'Split details', exact: true }); - await expect(splits).toContainText('Brokerage'); - await expect(splits.getByRole('cell', { name: '10.00 VTI', exact: true })).toBeVisible(); - const close = dialog.getByRole('button', { name: 'Close breakdown', exact: true }); + const postings = dialog.getByRole('region', { name: 'Posting lines', exact: true }); + await expect(postings).toContainText('Brokerage'); + await expect(postings.getByText('10.00 VTI', { exact: true })).toBeVisible(); + await expect(dialog.getByText('-1400.00 USD', { exact: true }).first()).toBeVisible(); + + // Replay the actual framework-generated read without its session, not a guessed API URL. + const anonymous = await replayWithoutSession(response, new URL(page.url()).origin); + expect(anonymous.status).toBe(401); + expect(anonymous.headers.get('cache-control')).toBe('no-store'); + const close = dialog.getByRole('button', { name: 'Close transaction details', exact: true }); await expect(close).toBeFocused(); await page.mouse.click(1, 1); await expect(dialog).toBeVisible(); @@ -37,4 +101,12 @@ test('Cash register opens saved activity and a transaction breakdown', async ({ await close.click(); await expect(dialog).not.toBeVisible(); await expect(opener).toBeFocused(); + + const salary = activity.getByRole('button', { name: /^View transaction details: Employer on/ }); + await salary.click(); + await expect(dialog.getByRole('region', { name: 'Posting lines' })).toContainText( + 'income:salary' + ); + await close.click(); + await expect(salary).toBeFocused(); }); diff --git a/apps/web/src/lib/registers/cash/CashRegister.svelte b/apps/web/src/lib/registers/cash/CashRegister.svelte index 8a0c1d4..0ed269c 100644 --- a/apps/web/src/lib/registers/cash/CashRegister.svelte +++ b/apps/web/src/lib/registers/cash/CashRegister.svelte @@ -1,186 +1,222 @@ -{#snippet amount(value: ReportedAmount | null)} - {#if value} - {value.quantity} - {value.commodity === '' ? 'no commodity' : value.commodity} - {:else}Not reported{/if} +{#snippet amounts(values: ReportedAmount[])} + {#each values as value, index (index)} + {@const formatted = formatAmount(value)} + {@const unit = value.commodity || 'no commodity'} + {formatted}{#if showUnits && !formatted.includes(unit)} + {unit}{/if} + {/each} {/snippet} -{#if data.unsupported} -

This account cannot be shown in the cash register.

+{#if data.rowCount === 0} +

No recorded activity.

{:else} -

Ending recorded balance: {@render amount(data.endingBalance)}

-

{data.rowCount} rows · Page {data.page} of {data.pageCount}

-