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}
-