From 1512cae39e99c38369889e2e91a400288a347e6b Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 5 Nov 2026 12:00:32 -0800 Subject: [PATCH] Add responsive account navigation --- .../add-responsive-account-navigation.md | 11 + README.md | 6 +- apps/web/README.md | 62 +++- apps/web/e2e/account-navigation.e2e.ts | 157 ++++++++ apps/web/e2e/cash-register.e2e.ts | 9 +- apps/web/e2e/holdings.e2e.ts | 19 +- apps/web/e2e/investment-register.e2e.ts | 9 +- apps/web/e2e/manual-preparation.e2e.ts | 23 +- apps/web/src/app.d.ts | 2 + apps/web/src/lib/amounts.test.ts | 40 +++ apps/web/src/lib/amounts.ts | 35 ++ apps/web/src/lib/dates.test.ts | 10 + apps/web/src/lib/dates.ts | 11 + .../src/lib/navigation/AccountSidebar.svelte | 334 ++++++++++++++++++ .../src/lib/navigation/AccountSidebar.test.ts | 194 ++++++++++ .../src/lib/navigation/AccountsDrawer.svelte | 183 ++++++++++ apps/web/src/lib/navigation/data.ts | 27 ++ apps/web/src/lib/server/accounts.test.ts | 166 ++++++++- apps/web/src/lib/server/accounts.ts | 57 +++ .../lib/server/reports.integration.test.ts | 98 +++++ apps/web/src/lib/server/reports.test.ts | 64 +++- apps/web/src/lib/server/reports.ts | 40 ++- apps/web/src/lib/styles/theme.css | 1 + .../src/routes/(protected)/+layout.server.ts | 23 +- .../web/src/routes/(protected)/+layout.svelte | 118 ++++--- .../src/routes/(protected)/+page.server.ts | 3 +- .../(protected)/holdings/+page.server.ts | 11 +- .../(protected)/holdings/page.server.test.ts | 4 +- .../web/src/routes/(protected)/layout.test.ts | 65 +++- .../routes/(protected)/page.server.test.ts | 4 +- .../registers/cash/+page.server.ts | 4 +- .../cash/[accountId]/+page.server.ts | 4 +- .../cash/[accountId]/page.server.test.ts | 1 + .../registers/cash/page.server.test.ts | 1 + .../registers/investments/+page.server.ts | 4 +- .../investments/[accountId]/+page.server.ts | 4 +- .../[accountId]/page.server.test.ts | 1 + .../registers/investments/page.server.test.ts | 1 + .../(protected)/settings/+page.server.ts | 3 +- .../(protected)/settings/page.server.test.ts | 4 +- .../routes/(protected)/settings/page.test.ts | 12 +- docs/architecture.md | 120 ++++++- docs/development.md | 16 +- 43 files changed, 1810 insertions(+), 151 deletions(-) create mode 100644 .changeset/add-responsive-account-navigation.md create mode 100644 apps/web/e2e/account-navigation.e2e.ts create mode 100644 apps/web/src/lib/amounts.test.ts create mode 100644 apps/web/src/lib/amounts.ts create mode 100644 apps/web/src/lib/dates.test.ts create mode 100644 apps/web/src/lib/dates.ts create mode 100644 apps/web/src/lib/navigation/AccountSidebar.svelte create mode 100644 apps/web/src/lib/navigation/AccountSidebar.test.ts create mode 100644 apps/web/src/lib/navigation/AccountsDrawer.svelte create mode 100644 apps/web/src/lib/navigation/data.ts create mode 100644 apps/web/src/lib/server/reports.integration.test.ts diff --git a/.changeset/add-responsive-account-navigation.md b/.changeset/add-responsive-account-navigation.md new file mode 100644 index 0000000..5e29a9b --- /dev/null +++ b/.changeset/add-responsive-account-navigation.md @@ -0,0 +1,11 @@ +--- +'@hledger-reports/web': patch +--- + +Add signed-in Accounts navigation: a permanent 20rem right rail at 80rem+, a right overlay from 40rem to below 80rem, and a full-screen modal below 40rem. Navigation, backdrop, Escape and Close dismiss the modal; no-JavaScript users retain a native disclosure. + +Simplify the header to Overview, Settings, Sign out and narrow Accounts; move register directory links into Cash/Investments headings while keeping Holdings access within investment account registers. Protected header/main content fill the available responsive width without centered caps; public sign-in sizing is unchanged. + +Show configured Cash raw balances and Investment valuations including cash, with explicit unavailable/incomplete states and a visible balance cutoff, but no preparation timestamp. Net worth uses saved native whole-journal NET, never a configured-row sum; there are no Cash/Investment group totals. + +Share one private saved report selection between shell and page on path/query navigation. Browsing never requests work; open pages do not refresh automatically. Format 6, native/worker preparation, configuration and transaction history are unchanged; existing format-6 sets need no additional preparation. The homepage remains six months; new figures and period controls are deferred to PR4. diff --git a/README.md b/README.md index 6400634..ba4d2ee 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,18 @@ A local, read-only hledger reporter. hledger owns accounting; OpenWorkflow + SQL ## Overview, transactions and live queries -**Overview (`/`) is the prepared financial summary:** monthly income-vs-expenses bars, depth-2 expense-category bars and a native market-valued net-worth trend, with exact supporting tables, for the latest six calendar months of the journal: the month of its latest recorded transaction and the previous five. Each page reads the latest complete current-format saved report. Without one, it says “No prepared reports available.” and links to manual preparation. **Reload overview** only reads saved data; it never starts calculations or checks journal changes. Pending preparation keeps saved data available; a failed job does not necessarily mean no new set was committed. +**Overview (`/`) is the prepared financial summary:** monthly income-vs-expenses bars, depth-2 expense-category bars and a native market-valued net-worth trend, with exact supporting tables, for the latest six calendar months of the journal: the month of its latest recorded transaction and the previous five. The protected shell and saved page share one complete current-format report set per request. Without one, it says “No prepared reports available.” and links to manual preparation. **Reload overview** only reads saved data; it never starts calculations or checks journal changes. Pending preparation keeps saved data available; a failed job does not necessarily mean no new set was committed. The window is journal-anchored, not clock-anchored or a freshness guarantee. Exact native quantities and commodities are preserved. **Income/expense amounts are not available cash, cash flow or net worth**; the separate net-worth card uses native dated asset and liability values. See the [library contract](packages/hledger/README.md#overview-data) for report semantics and empty-journal behavior. A compact journal-coverage panel shows the recorded transaction count and inclusive native date range for the whole journal. Secondary and posting dates can extend that range; the financial window remains anchored to the latest primary transaction date. This is context, not proof of completeness or freshness. -The hledger library reads the journal and organizes neutral, table/chart-ready `OverviewData`. The worker publishes native 1/3/6/12-month income, expenses, NET and category totals, shared twelve-month income/expense and net-worth histories, latest assets/liabilities and raw/valued account summaries. Web uses type-only hledger imports and an explicit current-page projection: range-6 categories and the last six history points, with only rendered context. Other ranges, latest sides, account values/catalog and private provenance are not serialized. The current six-month UI is deliberate; period controls, a three-month default and an account sidebar are future work. Chart.js renders separate income/category commodity scales and one net-worth line in hledger's inferred base currency; the tables remain exact and server-rendered. Income/category totals and dated historical balance sheets share one native loaded journal and complete prepared-report set. Only complete current-format reports are supported; every overview includes all four panels. Unconverted holdings mark net-worth points incomplete rather than silently becoming zero or a partial consolidated total. +The hledger library reads the journal and organizes neutral, table/chart-ready `OverviewData`. The worker publishes native 1/3/6/12-month income, expenses, NET and category totals, shared twelve-month income/expense and net-worth histories, latest assets/liabilities and raw/valued account summaries. Web uses type-only hledger imports and an explicit current-page projection: range-6 categories and the last six history points, with only rendered context. The homepage omits other ranges, latest sides and the account catalog; the protected shell separately projects only configured account links and snapshot values. Neither exposes private provenance or full account histories. The current six-month homepage is deliberate; new figures, period controls and a three-month default remain future work. Chart.js renders separate income/category commodity scales and one net-worth line in hledger's inferred base currency; the tables remain exact and server-rendered. Income/category totals and dated historical balance sheets share one native loaded journal and complete prepared-report set. Only complete current-format reports are supported; every overview includes all four panels. Unconverted holdings mark net-worth points incomplete rather than silently becoming zero or a partial consolidated total. **Cash and Investment registers list their explicitly configured accounts.** Select one for prepared activity with ordinary pagination: 50 rows for Cash, or 50 whole transactions for Investments. Dates, exact changes and running balances come from hledger and do not reset on page boundaries. The web reads the native account directory in `overview.json`, opens the selected account's complete JSON file, and projects only the requested page. Friendly labels do not change native identity or report scope. Browsing never executes native work; Overview and all native account activity still publish together as one complete set. +**Accounts navigation is available throughout signed-in pages**, in a responsive drawer or desktop right rail. It shows configured Cash/Investment links and saved balances, plus native whole-journal net worth—not a sum of configured accounts. Browsing remains manual-only and existing format-6 sets need no additional preparation. See [Accounts usage](apps/web/README.md#accounts-navigation) and [ownership and lifecycle](docs/architecture.md#shared-saved-reads-and-account-navigation). + **Settings (`/settings`) owns inline report preparation and status.** Submit its ordinary **Prepare reports** form to prepare Overview and all account registers together. Reload Settings to check this deployment's queue status, then navigate to saved reports. `/prepare` has been removed, with no redirect or POST alias. Worker startup and journal edits do not prepare anything automatically. After editing your journal, prepare again when you want new results. There is no browser polling or Overview/Transactions API. Overview and Settings show the **last recorded strict validation outcome and time**, separately from when financial reports were prepared. Reading either never checks the journal; unavailable validation does not hide saved finances. See [validation and preparation](docs/architecture.md#last-recorded-validation) for their independent lifecycles. diff --git a/apps/web/README.md b/apps/web/README.md index f2c1029..e08be5c 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -18,8 +18,9 @@ See the [architecture](../../docs/architecture.md) and Overview and registers do not query job status, hash inputs, infer freshness or submit work. Without saved financial data, they say it has not been prepared and link to Settings. Saved files remain readable if the worker is stopped. After -editing the journal, explicitly prepare again. The six-month Overview and register -presentation are unchanged; there are no new period controls or account sidebar. +editing the journal, explicitly prepare again. The homepage still presents six +months; new figures, period controls and a three-month default remain future work. +Registers retain their existing presentation. Settings is the canonical preparation/status page; `/prepare` has been removed with no redirect or POST alias. Its inline Prepare reports form requests Overview and @@ -27,9 +28,9 @@ all account registers together. Concurrent requests join active preparation; a l request recalculates even if inputs are unchanged. The ordinary empty form works without JavaScript and redirects with 303 to `/settings`. Reload Settings to check this deployment's queue status, then navigate to saved reports. There is no polling, -fetch controller, automatic invalidation or Overview/Transactions API. Settings links +fetch controller, background refresh or Overview/Transactions API. Settings links to the existing `/queries` screen instead of a standalone Live queries utility-nav -entry; this adds no query history or new navigation feature. +entry; this adds no query history. Overview and Settings show the last recorded strict validation outcome/time separately from the saved financial preparation time. Unavailable validation never hides readable @@ -54,6 +55,25 @@ No investment members means no Holdings view. Preparation still covers all nativ accounts. Names/labels are literal, with no environment or prefix expansion. See [account configuration](../../packages/config/README.md#account-presentation). +## Accounts navigation + +On signed-in pages, open Accounts on narrow screens or use the permanent desktop +rail. Cash/Investments headings open configured register directories; select an +account to read its saved activity. Holdings is reached from an investment account +register, not Accounts or the header. Close, Escape or the backdrop dismisses the +drawer; navigation also closes it. Without JavaScript, Accounts is a native +disclosure with real links. Public/sign-in pages have no financial navigation. + +Accounts shows saved Cash raw balances, Investment valuations including cash and +native whole-journal NET, not configured-row or group sums. The balance cutoff is +visible; no preparation timestamp appears here. Unavailable and incomplete values +are explicit. Navigation/reload reads saved data for shell and page together; +open pages do not refresh automatically or request work. + +See [architecture](../../docs/architecture.md#shared-saved-reads-and-account-navigation) +for responsive widths, private projection, component and lifecycle ownership, and +[the file map](#run-and-check) for implementation locations. + ## Financial presentation The [native library](../../packages/hledger/README.md#overview-data) owns financial @@ -62,11 +82,12 @@ hledger imports; it does not interpret native tuples or reconstruct balances. Preparation now saves native 1/3/6/12-month range totals/categories, shared twelve-month income/expense and net-worth histories, latest assets/liabilities and account summaries. The homepage server explicitly projects only rendered context, range-6 categories -and the last six monthly/net-worth points into `OverviewPageData`. It omits all -range headline totals, other ranges, latest sides, account values/catalog, private -publication metadata and provenance. This is the current page model, not a legacy -producer adapter. Period controls, a three-month default and sidebar values remain -future work; routes, navigation, Settings, live queries and themes are unchanged. +and the last six monthly/net-worth points into `OverviewPageData`. That homepage +projection omits range headline totals, other ranges, latest sides, account +values/catalog and private provenance; the shell has its separate compact account +projection described above. This is the current page model, not a legacy producer +adapter. New homepage figures, period controls and a three-month default remain +future work. Settings, live queries and themes are unchanged. Overview uses the latest primary transaction's month and preceding five months, not the computer's month. Income/expenses are not available cash, cash flow or net @@ -119,8 +140,8 @@ The saved `overview.json` envelope contains the selected journal identity, finan overview and account directory, including required raw/valued summaries. Register directories explicitly project only identity/access/count fields, not summaries. One whole account file contains all native rows. The worker publishes a complete immutable -set; a request pins that set before reading. Private journal identities and set -paths never enter page data. Financial format **6** retains unchanged full exported +set; the shell and saved-data leaf share one pinned selection per request. Private +journal identities and set paths never enter page data. Financial format **6** retains unchanged full exported transaction/posting metadata and private source/assertion positions. Register loaders project only the selected page's public table/detail fields; stored records are never passed directly into PageData. @@ -130,7 +151,9 @@ web and worker revisions together, then explicitly **Prepare reports again** in Settings. Format-5 files remain untouched but unusable; account IDs may reset and old account URLs may no longer resolve. There is no automatic work, older-format fallback, partial-data adapter, migration or deletion. The v1 void-input preparation -workflow and independent validation-evidence lifecycle are unchanged. +workflow and independent validation-evidence lifecycle are unchanged. Accounts +navigation changes neither format 6 nor native/worker preparation, transaction +history or configuration; existing format-6 sets need no additional preparation. Reports for a different configured journal are withheld. Immutable validation records live separately in `data/snapshots/.validation/`, retained without cleanup or a history UI. Homepage browsing reads these files, never the queue or native journal. @@ -209,10 +232,15 @@ The library folders describe the current responsibilities: src/lib/ charts/ Chart canvas and configurations named by chart overview/ Financial summary panels + navigation/ AccountSidebar.svelte, AccountsDrawer.svelte and public data.ts registers/ Cash and investment presentation preparation/ Shared last recorded validation presentation queries/ Live-query result presentation server/ Server-only request and saved-report helpers + accounts.ts Configured account directory/sidebar projections + reports.ts Request-local saved report selection + amounts.ts Display-only native amount formatting + dates.ts Display-only balance-cutoff date formatting styles/ Document reset/theme, layout mechanics and native controls ``` @@ -231,13 +259,17 @@ exercise their colocated request helpers against real SDK queues in temporary directories, including cross-client races, failures and abortable result waits. Route tests stay beside their SvelteKit files, without the reserved `+` prefix. They cover a few app-owned boundaries: passive reads, explicit requests, membership -and public projection. Shared validation is tested once at its owner, not through +and public projection. Account navigation coverage includes `server/reports.test.ts`, +`server/accounts.test.ts`, `server/reports.integration.test.ts` and adjacent +navigation/formatter tests. Shared validation is tested once at its owner, not through every possible route input. Routine page markup is not a unit-test contract. Playwright starts a disposable copied journal/data deployment and signs in normally. Its test-only HTTP boundary supplies the actual loopback scheme and socket IP to the native compiled handler; browsers send normal headers, with no auth bypass. -A small smoke suite covers the main journeys, including explicit native preparation; -it does not publish mock financial snapshots or repeat chart edge-case matrices. +A small smoke suite covers the main journeys, including explicit native preparation. +Account navigation adds real client path/query refresh, drawer keyboard/dismiss/link/resize +and no-JS disclosure journeys. It does not publish mock financial snapshots or +repeat financial, CSS or framework matrices. Run targeted tests during iteration. See the [testing philosophy](../../docs/philosophy.md#tests-while-the-foundations-are-changing) and [development guide](../../docs/development.md#focused-local-checks) for scope and commands. diff --git a/apps/web/e2e/account-navigation.e2e.ts b/apps/web/e2e/account-navigation.e2e.ts new file mode 100644 index 0000000..7a6e308 --- /dev/null +++ b/apps/web/e2e/account-navigation.e2e.ts @@ -0,0 +1,157 @@ +import { expect, test } from '@playwright/test'; +import { prepared, prepareReports, readPreparationTimestamp } from './overview-fixture'; + +test('desktop account navigation preserves client routing and observes later saved registers', async ({ + page, + context +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.emulateMedia({ colorScheme: 'light' }); + await prepared(page); + const savedAt = await readPreparationTimestamp(page); + const accounts = page.getByRole('complementary', { name: 'Accounts', exact: true }); + const balanceDate = accounts.getByText(/^Balances as of/); + await expect(balanceDate).toBeVisible(); + await expect(balanceDate.locator('time')).toHaveAttribute('datetime', '2026-04-16'); + await expect(accounts.getByText('$8,520.00', { exact: true })).toBeVisible(); + await expect( + accounts + .getByRole('region', { name: 'Net worth', exact: true }) + .getByText('$11,825.00', { exact: true }) + ).toBeVisible(); + await expect(accounts.getByRole('link', { name: /^Brokerage\b/ })).toBeVisible(); + await expect( + accounts.getByRole('link', { name: /^assets:investments:retirement\b/ }) + ).toBeVisible(); + // Overview uses the whole space beside the rail, not a centered reading column. + const main = page.locator('main'); + const mainBox = await main.boundingBox(); + const railBox = await accounts.boundingBox(); + expect(mainBox).not.toBeNull(); + expect(railBox).not.toBeNull(); + expect(mainBox!.x).toBeCloseTo(0, 0); + expect(mainBox!.x + mainBox!.width).toBeCloseTo(railBox!.x, 0); + + 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'); + await expect(registerStamp).toHaveAttribute('datetime', savedAt); + + // Explicit work happens in another authenticated page, not in the page left open. + const preparationPage = await context.newPage(); + let laterAt: string; + try { + laterAt = await prepareReports(preparationPage); + } finally { + await preparationPage.close(); + } + expect(laterAt).not.toBe(savedAt); + await expect(registerStamp).toHaveAttribute('datetime', savedAt); + + // Query-only navigation retains component instances; the saved register must + // still refresh without a document reload. + const token = `account-navigation-${Date.now()}`; + await page.evaluate((value) => { + 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(registerStamp).toHaveAttribute('datetime', laterAt); + expect(await page.evaluate(() => Reflect.get(window, 'accountNavigationToken'))).toBe(token); + + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1440); +}); + +test('responsive Accounts supports phone, overlay drawer and desktop transitions in dark mode', async ({ + page +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ colorScheme: 'dark' }); + await prepared(page); + const trigger = page.getByRole('button', { name: 'Accounts', exact: true }); + const drawer = page.getByRole('dialog', { name: 'Accounts', exact: true }); + await trigger.focus(); + await page.keyboard.press('Enter'); + await expect(drawer).toBeVisible(); + const close = drawer.getByRole('button', { name: 'Close accounts', exact: true }); + await expect(close).toBeFocused(); + await expect(drawer).toBeInViewport({ ratio: 1 }); + const phoneBox = await drawer.boundingBox(); + expect(phoneBox!.x).toBeCloseTo(0, 0); + expect(phoneBox!.width).toBeCloseTo(390, 0); + await page.keyboard.press('Escape'); + await expect(drawer).not.toBeVisible(); + await expect(trigger).toBeFocused(); + await page.keyboard.press('Enter'); + await close.click(); + await expect(drawer).not.toBeVisible(); + await expect(trigger).toBeFocused(); + + await page.keyboard.press('Enter'); + await expect(close).toBeFocused(); + await page.setViewportSize({ width: 900, height: 844 }); + await expect(drawer).toBeInViewport({ ratio: 1 }); + const overlayBox = await drawer.boundingBox(); + expect(overlayBox!.x).toBeGreaterThan(0); + expect(overlayBox!.x + overlayBox!.width).toBeCloseTo(900, 0); + await page.mouse.click(24, 200); + await expect(drawer).not.toBeVisible(); + await expect(trigger).toBeFocused(); + + await trigger.click(); + await drawer.getByRole('link', { name: 'Investments', exact: true }).click(); + await expect(page).toHaveURL(/\/registers\/investments$/); + await expect( + page.locator('main').getByRole('heading', { name: 'Investment register', exact: true }) + ).toBeVisible(); + await expect(drawer).not.toBeVisible(); + + await trigger.click(); + await expect(close).toBeFocused(); + await page.goBack(); + await expect( + page.locator('main').getByRole('heading', { name: 'Overview', exact: true }) + ).toBeVisible(); + await expect(drawer).not.toBeVisible(); + await expect(trigger).not.toBeFocused(); + await page.setViewportSize({ width: 390, height: 844 }); + + await trigger.click(); + await expect(close).toBeFocused(); + const checking = drawer.getByRole('link', { name: /^Everyday checking\b/ }); + await checking.focus(); + await page.evaluate(() => Object.assign(window, { drawerNavigationToken: 'retained' })); + await page.keyboard.press('Enter'); + await expect( + page.locator('main').getByRole('heading', { name: 'Everyday checking', exact: true }) + ).toBeVisible(); + await expect(drawer).not.toBeVisible(); + await expect(trigger).not.toBeFocused(); + // Closing the portal must not turn its link into a full-document navigation. + expect(await page.evaluate(() => Reflect.get(window, 'drawerNavigationToken'))).toBe('retained'); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + + // 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(); + 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(); + await expect(opener).toBeFocused(); + + await trigger.click(); + await expect(drawer).toBeVisible(); + await page.setViewportSize({ width: 1440, height: 900 }); + 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(); + await page.setViewportSize({ width: 1440, height: 500 }); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.mouse.move(200, 400); + await page.mouse.wheel(0, 600); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); +}); diff --git a/apps/web/e2e/cash-register.e2e.ts b/apps/web/e2e/cash-register.e2e.ts index 4892f7e..5d78fec 100644 --- a/apps/web/e2e/cash-register.e2e.ts +++ b/apps/web/e2e/cash-register.e2e.ts @@ -4,11 +4,14 @@ import { prepared } from './overview-fixture'; test('Cash register opens saved activity and a transaction breakdown', async ({ page }) => { await prepared(page); await page - .getByRole('navigation', { name: 'Primary', exact: true }) - .getByRole('link', { name: 'Cash register', exact: true }) + .getByRole('complementary', { name: 'Accounts', exact: true }) + .getByRole('link', { name: 'Cash', exact: true }) .click(); await expect(page).toHaveURL(/\/registers\/cash$/); - await page.getByRole('link', { name: /^Everyday checking\b/ }).click(); + await page + .locator('main') + .getByRole('link', { name: /^Everyday checking\b/ }) + .click(); 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( diff --git a/apps/web/e2e/holdings.e2e.ts b/apps/web/e2e/holdings.e2e.ts index 77b5555..c1c9061 100644 --- a/apps/web/e2e/holdings.e2e.ts +++ b/apps/web/e2e/holdings.e2e.ts @@ -14,11 +14,17 @@ test('Holdings runs fresh investment reports without replacing saved Overview', }); await page - .getByRole('navigation', { name: 'Primary', exact: true }) - .getByRole('link', { name: 'Holdings', exact: true }) + .getByRole('complementary', { name: 'Accounts', exact: true }) + .getByRole('link', { name: 'Investments', exact: true }) .click(); + await page + .locator('main') + .getByRole('link', { name: /^Brokerage\b/ }) + .click(); + await page.locator('main').getByRole('link', { name: 'Holdings', exact: true }).click(); await expect(page.getByLabel('As of', { exact: true })).toHaveValue('2026-04-16'); const account = page.getByLabel('Account', { exact: true }); + await account.selectOption(''); await expect(account.locator('option:checked')).toHaveText('All investment accounts'); await expect(account.locator('option')).toHaveText([ 'All investment accounts', @@ -48,10 +54,13 @@ test('Holdings runs fresh investment reports without replacing saved Overview', expect(requests).toHaveLength(2); await page - .getByRole('navigation', { name: 'Primary', exact: true }) - .getByRole('link', { name: 'Investment register', exact: true }) + .getByRole('complementary', { name: 'Accounts', exact: true }) + .getByRole('link', { name: 'Investments', exact: true }) + .click(); + await page + .locator('main') + .getByRole('link', { name: /^Brokerage\b/ }) .click(); - await page.getByRole('link', { name: /^Brokerage\b/ }).click(); await page.locator('main').getByRole('link', { name: 'Holdings', exact: true }).click(); await expect(account.locator('option:checked')).toHaveText( 'Brokerage (assets:investments:brokerage)' diff --git a/apps/web/e2e/investment-register.e2e.ts b/apps/web/e2e/investment-register.e2e.ts index 1662ceb..91ca4b1 100644 --- a/apps/web/e2e/investment-register.e2e.ts +++ b/apps/web/e2e/investment-register.e2e.ts @@ -4,11 +4,14 @@ import { prepared } from './overview-fixture'; test('Investment register shows cash once per transaction and opens details', async ({ page }) => { await prepared(page); await page - .getByRole('navigation', { name: 'Primary', exact: true }) - .getByRole('link', { name: 'Investment register', exact: true }) + .getByRole('complementary', { name: 'Accounts', exact: true }) + .getByRole('link', { name: 'Investments', exact: true }) .click(); await expect(page).toHaveURL(/\/registers\/investments$/); - await page.getByRole('link', { name: /^Brokerage\b/ }).click(); + await page + .locator('main') + .getByRole('link', { name: /^Brokerage\b/ }) + .click(); await expect(page.getByRole('heading', { name: /^Brokerage\b/, level: 1 })).toBeVisible(); const activity = page.getByRole('region', { name: 'Investment activity', exact: true }); const groups = activity.locator('tbody.transaction-group'); diff --git a/apps/web/e2e/manual-preparation.e2e.ts b/apps/web/e2e/manual-preparation.e2e.ts index 6a1bf91..cc01b90 100644 --- a/apps/web/e2e/manual-preparation.e2e.ts +++ b/apps/web/e2e/manual-preparation.e2e.ts @@ -3,9 +3,10 @@ import { prepareReports, readPreparationTimestamp } from './overview-fixture'; test.use({ javaScriptEnabled: false }); -test('explicitly prepares reports and browses saved Overview without JavaScript', async ({ +test('explicitly prepares reports, reads Overview and follows Accounts without JavaScript', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); const posts: string[] = []; page.on('request', (request) => { if (request.method() === 'POST') posts.push(new URL(request.url()).pathname); @@ -23,5 +24,25 @@ test('explicitly prepares reports and browses saved Overview without JavaScript' await page.reload(); await expect(income.getByRole('table')).toContainText('1000.00'); expect(await readPreparationTimestamp(page)).toBe(preparedAt); + + // The server-rendered disclosure remains real navigation without hydration. + const disclosure = page + .locator('details') + .filter({ has: page.locator('summary', { hasText: /^Accounts$/ }) }); + const accountsToggle = disclosure.locator('summary').filter({ hasText: /^Accounts$/ }); + await accountsToggle.click(); + const balanceDate = disclosure.getByText(/^Balances as of/); + await expect(balanceDate).toBeVisible(); + await expect(balanceDate.locator('time')).toHaveAttribute('datetime', '2026-04-16'); + await disclosure.getByRole('link', { name: /^Everyday checking\b/ }).click(); + await expect( + page.locator('main').getByRole('heading', { name: 'Everyday checking', exact: true }) + ).toBeVisible(); + await expect(page.locator('main time')).toHaveAttribute('datetime', preparedAt); + await accountsToggle.click(); + await disclosure.getByRole('link', { name: /^Brokerage\b/ }).click(); + await expect( + page.locator('main').getByRole('heading', { name: 'Brokerage', exact: true }) + ).toBeVisible(); expect(posts).toEqual(['/settings']); }); diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index dcb29fc..f470663 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,5 +1,6 @@ import type { ApplicationContext } from '#lib/server/application.js'; import type { AuthSession } from '#lib/server/auth/index.js'; +import type { SavedReportsState } from '#lib/server/reports.js'; declare global { namespace App { @@ -7,6 +8,7 @@ declare global { application: ApplicationContext | null; session: AuthSession | null; auth: () => Promise; + savedReports?: Promise; } } } diff --git a/apps/web/src/lib/amounts.test.ts b/apps/web/src/lib/amounts.test.ts new file mode 100644 index 0000000..39faa5b --- /dev/null +++ b/apps/web/src/lib/amounts.test.ts @@ -0,0 +1,40 @@ +import { expect, it } from 'vitest'; +import { formatAmount } from './amounts.js'; + +it.each([ + ['123.090', '$123.09'], + ['123', '$123.00'], + ['-12345.600', '-$12,345.60'] +])('formats USD %s with grouping and a two-digit minimum', (quantity, expected) => { + expect(formatAmount({ commodity: 'USD', quantity })).toBe(expected); +}); + +it('uses unambiguous currency symbols and native minor-unit minimums without rounding', () => { + expect(formatAmount({ commodity: 'CAD', quantity: '123.090' })).toBe('CA$123.09'); + expect(formatAmount({ commodity: 'JPY', quantity: '1234.000' })).toBe('¥1,234'); + expect(formatAmount({ commodity: 'JPY', quantity: '1234.250' })).toBe('¥1,234.25'); +}); + +it('preserves unsafe integers and every meaningful fractional digit', () => { + expect(formatAmount({ commodity: 'USD', quantity: '9007199254740993.0102300' })).toBe( + '$9,007,199,254,740,993.01023' + ); + const zeroes = '0'.repeat(120); + expect(formatAmount({ commodity: 'USD', quantity: `-0.${zeroes}12300` })).toBe( + `-$0.${zeroes}123` + ); +}); + +it.each([ + ['ABC', '2.500', '2.500 ABC'], + ['AAPL', '10.000', '10.000 AAPL'], + ['BTC', '0.0001000', '0.0001000 BTC'], + ['usd', '123.090', '123.090 usd'], + ['', '0.000', '0.000 no commodity'] +])('keeps the native quantity and literal commodity for %s', (commodity, quantity, expected) => { + expect(formatAmount({ commodity, quantity })).toBe(expected); +}); + +it('styles the literal native dollar symbol as a display-only case', () => { + expect(formatAmount({ commodity: '$', quantity: '123.090' })).toBe('$123.09'); +}); diff --git a/apps/web/src/lib/amounts.ts b/apps/web/src/lib/amounts.ts new file mode 100644 index 0000000..f20a2ae --- /dev/null +++ b/apps/web/src/lib/amounts.ts @@ -0,0 +1,35 @@ +import type { ReportedAmount } from '@hledger-reports/hledger'; + +// Intl accepts invented three-letter codes too, so require exact known membership. +const currencies = new Set(Intl.supportedValuesOf('currency')); + +/** Display native quantities without conversion, arithmetic or lost fractional digits. */ +export function formatAmount({ commodity, quantity }: ReportedAmount): string { + if (commodity !== '$' && !currencies.has(commodity)) { + return `${quantity} ${commodity || 'no commodity'}`; + } + + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + // A literal native '$' gets dollar styling only; its commodity identity is unchanged. + currency: commodity === '$' ? 'USD' : commodity, + currencyDisplay: 'symbol' + }); + const [wholeText, fractionText = ''] = quantity.split('.'); + const fraction = fractionText + .replace(/0+$/, '') + .padEnd(formatter.resolvedOptions().minimumFractionDigits ?? 0, '0'); + const whole = BigInt(wholeText); + // BigInt loses the sign of -0, but negative sub-unit quantities must keep it. + const parts = formatter.formatToParts(whole === 0n && quantity.startsWith('-') ? -0 : whole); + const lastInteger = parts.findLastIndex((part) => part.type === 'integer'); + + // Intl supplies grouping, symbol and sign placement. Splice the exact fraction into + // that en-US pattern rather than round it or impose Intl's 100-fraction-digit limit. + return parts + .map((part, index) => { + if (part.type === 'decimal' || part.type === 'fraction') return ''; + return part.value + (index === lastInteger && fraction ? `.${fraction}` : ''); + }) + .join(''); +} diff --git a/apps/web/src/lib/dates.test.ts b/apps/web/src/lib/dates.test.ts new file mode 100644 index 0000000..3427f8e --- /dev/null +++ b/apps/web/src/lib/dates.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest'; +import { formatDate } from './dates.js'; + +it('uses readable calendar dates', () => { + expect(formatDate('2026-04-17')).toBe('Apr 17, 2026'); +}); + +it('leaves unreadable saved date text intact rather than blocking the page', () => { + expect(formatDate('unknown')).toBe('unknown'); +}); diff --git a/apps/web/src/lib/dates.ts b/apps/web/src/lib/dates.ts new file mode 100644 index 0000000..e5182d9 --- /dev/null +++ b/apps/web/src/lib/dates.ts @@ -0,0 +1,11 @@ +const dateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + year: 'numeric', + month: 'short', + day: 'numeric' +}); + +export function formatDate(value: string): string { + const date = new Date(`${value}T00:00:00Z`); + return Number.isNaN(date.getTime()) ? value : dateFormatter.format(date); +} diff --git a/apps/web/src/lib/navigation/AccountSidebar.svelte b/apps/web/src/lib/navigation/AccountSidebar.svelte new file mode 100644 index 0000000..64cf3a8 --- /dev/null +++ b/apps/web/src/lib/navigation/AccountSidebar.svelte @@ -0,0 +1,334 @@ + + +{#snippet accountContents(account: SidebarAccount)} + + +{/snippet} + + + + diff --git a/apps/web/src/lib/navigation/AccountSidebar.test.ts b/apps/web/src/lib/navigation/AccountSidebar.test.ts new file mode 100644 index 0000000..826913e --- /dev/null +++ b/apps/web/src/lib/navigation/AccountSidebar.test.ts @@ -0,0 +1,194 @@ +import { render } from 'svelte/server'; +import { expect, it } from 'vitest'; +import type { AccountSidebarData } from './data.js'; +import AccountSidebar from './AccountSidebar.svelte'; + +function text(body: string) { + return body + .replace(//g, '') + .replace(//g, '') + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .replace(/\s+([.,])/g, '$1'); +} + +function links(body: string) { + return Array.from( + body.matchAll(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g), + ([, href, content]) => ({ href, content: text(content).trim() }) + ); +} + +it('formats exact native bags and incomplete whole-journal net worth without restricting available register links', () => { + const sidebar: AccountSidebarData = { + asOf: '2026-04-30', + hasSavedReports: true, + readFailed: false, + netWorth: { + amounts: [ + { commodity: 'USD', quantity: '9007199254740993.000000000000000001' }, + { commodity: 'XYZ', quantity: '-3.25' } + ], + unconverted: ['XYZ', ''] + }, + groups: [ + { + kind: 'cash', + accounts: [ + { + id: 'bank0001', + name: 'assets:bank', + label: 'Checking', + access: 'ready', + value: { + status: 'ready', + amounts: [{ commodity: 'USD', quantity: '8520' }], + unconverted: [] + } + }, + { id: 'ambig001', name: 'ASSETS', label: 'Ambiguous', access: 'ambiguous', value: null }, + { id: null, name: 'missing', label: 'Missing', access: 'missing', value: null } + ] + }, + { + kind: 'investment', + accounts: [ + { + id: 'broker01', + name: 'assets:broker', + label: 'Brokerage', + access: 'ready', + value: { + status: 'ready', + amounts: [ + { commodity: 'USD', quantity: '42.000000000000000001' }, + { commodity: 'XYZ', quantity: '-3.25' } + ], + unconverted: ['XYZ', ''] + } + } + ] + } + ] + }; + const { body } = render(AccountSidebar, { + props: { sidebar, pathname: '/registers/cash/bank0001' } + }); + const content = text(body); + expect(links(body)).toEqual([ + { href: '/registers/cash', content: 'Cash' }, + { href: '/registers/cash/bank0001', content: 'Checking assets:bank $8,520.00' }, + { + href: '/registers/cash/ambig001', + content: 'Ambiguous ASSETS Ambiguous account; register access is limited.' + }, + { href: '/registers/investments', content: 'Investments' }, + { + href: '/registers/investments/broker01', + content: + 'Brokerage assets:broker Incomplete valuation; unconverted: XYZ, no commodity $42.000000000000000001 -3.25 XYZ' + } + ]); + expect(content).toContain('Missing missing Account unavailable in saved reports.'); + expect(content).toContain( + 'Net worth Incomplete valuation; unconverted: XYZ, no commodity $9,007,199,254,740,993.000000000000000001 -3.25 XYZ' + ); + expect(body).toContain('aria-label="Net worth"'); + expect(content).toContain('Accounts Balances as of Apr 30, 2026'); + expect(body).toContain('datetime="2026-04-30"'); +}); + +it('distinguishes known empty, undated and unavailable values without inventing zeroes or disabling IDs', () => { + const sidebar: AccountSidebarData = { + asOf: null, + hasSavedReports: true, + readFailed: false, + netWorth: null, + groups: [ + { + kind: 'cash', + accounts: [ + { + id: 'empty001', + name: 'assets:retirement', + label: 'assets:retirement', + access: 'ready', + value: { status: 'ready', amounts: [], unconverted: [] } + }, + { + id: 'undated1', + name: 'Undated', + label: 'Undated', + access: 'ready', + value: { status: 'undated' } + } + ] + } + ] + }; + const saved = render(AccountSidebar, { props: { sidebar, pathname: '/' } }); + expect(links(saved.body)).toEqual([ + { href: '/registers/cash', content: 'Cash' }, + { href: '/registers/cash/empty001', content: 'assets:retirement No balance' }, + { href: '/registers/cash/undated1', content: 'Undated No dated balance.' } + ]); + expect(text(saved.body)).toContain('Accounts No dated balances'); + expect(text(saved.body)).toContain('Net worth No dated balance.'); + expect(text(saved.body)).not.toMatch(/\b0\b/); + + const failed = render(AccountSidebar, { + props: { + sidebar: { + ...sidebar, + hasSavedReports: false, + readFailed: true, + groups: [ + { + kind: 'cash', + accounts: [ + { id: null, name: 'Unknown', label: 'Unknown', access: 'missing', value: null } + ] + } + ] + }, + pathname: '/' + } + }); + expect(text(failed.body)).toContain("Saved account values couldn't be read."); + expect(text(failed.body)).toContain('Net worth Balance unavailable.'); + expect(text(failed.body)).not.toMatch(/No balance|\b0\b/); + expect(links(failed.body)).toEqual([{ href: '/registers/cash', content: 'Cash' }]); +}); + +it('shows prepared whole-journal net worth independently of configured rows, preserving native zero and empty meanings', () => { + const sidebar: AccountSidebarData = { + asOf: '2026-04-30', + hasSavedReports: true, + readFailed: false, + netWorth: { amounts: [{ commodity: 'USD', quantity: '0' }], unconverted: [] }, + groups: [] + }; + const zero = render(AccountSidebar, { props: { sidebar, pathname: '/' } }); + expect(text(zero.body)).toContain('No Cash or Investment accounts configured.'); + expect(text(zero.body)).toContain('Balances as of Apr 30, 2026'); + expect(text(zero.body)).toContain('Net worth $0.00'); + expect(zero.body).not.toContain('aria-label="Account registers"'); + + const empty = render(AccountSidebar, { + props: { + sidebar: { ...sidebar, netWorth: { amounts: [], unconverted: [] } }, + pathname: '/' + } + }); + expect(text(empty.body)).toContain('Net worth No balance'); + expect(text(empty.body)).not.toContain('$0.00'); + + const unconfigured = render(AccountSidebar, { + props: { + sidebar: { ...sidebar, asOf: null, hasSavedReports: false, netWorth: null }, + pathname: '/' + } + }); + expect(text(unconfigured.body)).toContain('Accounts No prepared account values available.'); + expect(text(unconfigured.body)).toContain('No Cash or Investment accounts configured.'); +}); diff --git a/apps/web/src/lib/navigation/AccountsDrawer.svelte b/apps/web/src/lib/navigation/AccountsDrawer.svelte new file mode 100644 index 0000000..43f61ac --- /dev/null +++ b/apps/web/src/lib/navigation/AccountsDrawer.svelte @@ -0,0 +1,183 @@ + + +
+ {#if !ready} +
+ Accounts + +
+ {:else} + { + if (next) closeReason = 'dismiss'; + }} + > + {#if !desktop} + + {#snippet child({ props })} + + {/snippet} + + {/if} + + + {#snippet child({ props })} +
+ {/snippet} +
+ + {#snippet child({ props })} +
+ + {#snippet heading()} + + {#snippet child({ props: titleProps })} +

Accounts

+ {/snippet} +
+ {/snippet} + {#snippet actions()} + + {#snippet child({ props: closeProps })} + + {/snippet} + + {/snippet} +
+
+ {/snippet} +
+
+
+ {/if} +
+ + diff --git a/apps/web/src/lib/navigation/data.ts b/apps/web/src/lib/navigation/data.ts new file mode 100644 index 0000000..e96312c --- /dev/null +++ b/apps/web/src/lib/navigation/data.ts @@ -0,0 +1,27 @@ +import type { NativeValue, ReportedAmount } from '@hledger-reports/hledger'; + +export interface SidebarAccount { + id: string | null; + name: string; + label: string; + access: 'ready' | 'ambiguous' | 'missing'; + value: + | null + | { status: 'undated' } + | { + status: 'ready'; + amounts: ReportedAmount[]; + unconverted: string[]; + }; +} + +export interface AccountSidebarData { + asOf: string | null; + hasSavedReports: boolean; + netWorth: NativeValue | null; + readFailed: boolean; + groups: { + kind: 'cash' | 'investment'; + accounts: SidebarAccount[]; + }[]; +} diff --git a/apps/web/src/lib/server/accounts.test.ts b/apps/web/src/lib/server/accounts.test.ts index 417b4b4..c5deab9 100644 --- a/apps/web/src/lib/server/accounts.test.ts +++ b/apps/web/src/lib/server/accounts.test.ts @@ -1,6 +1,170 @@ import type { AccountConfig } from '@hledger-reports/config'; import { expect, it } from 'vitest'; -import { registerDirectory, registerMembers } from './accounts.js'; +import type { ReportPublication } from '@hledger-reports/snapshots'; +import { projectAccountSidebar, registerDirectory, registerMembers } from './accounts.js'; + +const publication: ReportPublication = { + journal: '/PRIVATE/journal', + directory: '/PRIVATE/set', + accounts: [], + overview: { + asOf: null, + preparedAt: '2026-05-01T12:00:00Z', + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + ranges: null, + balanceSheet: null, + incomeExpenses: { months: [] }, + netWorth: { currency: null, points: [] } + } +}; + +it('projects configured sidebar data in group order, selecting exact raw or valued bags without private fields', () => { + const raw = { commodity: '', quantity: '0', privateSource: '/PRIVATE/source' }; + const valued = { + commodity: 'USD', + quantity: '42.000000000000000001', + privateSource: '/PRIVATE/source' + }; + const net = { commodity: 'USD', quantity: '1234.56', privateSource: '/PRIVATE/net' }; + const summary = { + status: 'ready' as const, + balances: [raw], + valuation: { amounts: [valued], unconverted: ['XYZ', ''] } + }; + const accounts = [ + { id: 'broker01', name: 'assets:broker', access: 'ready' as const, rowCount: 2, summary }, + { id: 'bank0001', name: 'assets:bank', access: 'ready' as const, rowCount: 3, summary }, + { + id: 'ambig001', + name: 'ASSETS', + access: 'ambiguous' as const, + rowCount: null, + summary: { status: 'ambiguous' as const } + }, + { id: 'unused01', name: 'assets:other', access: 'ready' as const, rowCount: 0, summary } + ]; + const result = projectAccountSidebar( + [ + { account: 'assets:broker', register: 'investment', label: 'Brokerage' }, + { account: 'assets:bank', register: 'cash', label: 'Checking' }, + { account: 'assets:other', label: 'Label only' }, + { account: 'assets:missing', register: 'cash' }, + { account: 'ASSETS', register: 'cash' } + ], + { + publication: { + ...publication, + accounts, + overview: { + ...publication.overview, + asOf: '2026-04-30', + netWorth: { + currency: 'USD', + points: [ + { + date: '2026-03-31', + amounts: [{ commodity: 'USD', quantity: '9000.00' }], + unconverted: [] + }, + { + date: '2026-04-30', + amounts: [net], + unconverted: ['XYZ'] + } + ] + } + } + }, + readFailed: false + } + ); + expect(result).toEqual({ + asOf: '2026-04-30', + hasSavedReports: true, + netWorth: { amounts: [{ commodity: 'USD', quantity: '1234.56' }], unconverted: ['XYZ'] }, + readFailed: false, + groups: [ + { + kind: 'cash', + accounts: [ + { + id: 'bank0001', + name: 'assets:bank', + label: 'Checking', + access: 'ready', + value: { status: 'ready', amounts: [{ commodity: '', quantity: '0' }], unconverted: [] } + }, + { + id: null, + name: 'assets:missing', + label: 'assets:missing', + access: 'missing', + value: null + }, + { id: 'ambig001', name: 'ASSETS', label: 'ASSETS', access: 'ambiguous', value: null } + ] + }, + { + kind: 'investment', + accounts: [ + { + id: 'broker01', + name: 'assets:broker', + label: 'Brokerage', + access: 'ready', + value: { + status: 'ready', + amounts: [{ commodity: 'USD', quantity: '42.000000000000000001' }], + unconverted: ['XYZ', ''] + } + } + ] + } + ] + }); + expect(JSON.stringify(result)).not.toContain('PRIVATE'); +}); + +it('keeps native undated values distinct from an unavailable publication without inventing a zero', () => { + const config: AccountConfig[] = [{ account: 'assets:bank', register: 'cash' }]; + const saved = projectAccountSidebar(config, { + publication: { + ...publication, + accounts: [ + { + id: 'bank0001', + name: 'assets:bank', + access: 'ready', + rowCount: 0, + summary: { status: 'undated' } + } + ] + }, + readFailed: false + }); + expect(saved.asOf).toBeNull(); + expect(saved.hasSavedReports).toBe(true); + expect(saved.netWorth).toBeNull(); + expect(saved.groups[0].accounts[0]).toMatchObject({ + id: 'bank0001', + access: 'ready', + value: { status: 'undated' } + }); + expect(projectAccountSidebar(config, { publication: null, readFailed: true })).toEqual({ + asOf: null, + hasSavedReports: false, + netWorth: null, + readFailed: true, + groups: [ + { + kind: 'cash', + accounts: [ + { id: null, name: 'assets:bank', label: 'assets:bank', access: 'missing', value: null } + ] + } + ] + }); +}); it('selects only exact, explicit register members in configuration order', () => { const accounts: AccountConfig[] = [ diff --git a/apps/web/src/lib/server/accounts.ts b/apps/web/src/lib/server/accounts.ts index f951c2c..7070047 100644 --- a/apps/web/src/lib/server/accounts.ts +++ b/apps/web/src/lib/server/accounts.ts @@ -1,6 +1,63 @@ import type { AccountConfig } from '@hledger-reports/config'; import type { StoredAccount } from '@hledger-reports/snapshots'; import type { RegisterAccount } from '#lib/accounts.js'; +import type { AccountSidebarData, SidebarAccount } from '#lib/navigation/data.js'; +import type { SavedReportsState } from './reports.js'; + +export function projectAccountSidebar( + configuredAccounts: readonly AccountConfig[], + reports: SavedReportsState +): AccountSidebarData { + const { publication, readFailed } = reports; + const groups: AccountSidebarData['groups'] = []; + for (const kind of ['cash', 'investment'] as const) { + const members = configuredAccounts.filter((entry) => entry.register === kind); + if (members.length === 0) continue; + const accounts = members.map((entry): SidebarAccount => { + const saved = publication?.accounts.find((account) => account.name === entry.account); + return { + id: saved?.id ?? null, + name: entry.account, + label: entry.label ?? entry.account, + access: saved?.access ?? 'missing', + value: projectSidebarValue(saved, kind) + }; + }); + groups.push({ kind, accounts }); + } + // Use hledger's whole-journal NET, never a sum of potentially overlapping configured rows. + const latestNetWorth = publication?.overview.netWorth.points.at(-1); + const netWorth = latestNetWorth + ? { + amounts: latestNetWorth.amounts.map(({ commodity, quantity }) => ({ commodity, quantity })), + unconverted: [...latestNetWorth.unconverted] + } + : null; + return { + asOf: publication?.overview.asOf ?? null, + hasSavedReports: publication !== null, + netWorth, + readFailed, + groups + }; +} + +function projectSidebarValue( + saved: StoredAccount | undefined, + kind: 'cash' | 'investment' +): SidebarAccount['value'] { + if (saved?.access !== 'ready') return null; + const { summary } = saved; + if (summary.status === 'undated') return { status: 'undated' }; + if (summary.status !== 'ready') return null; + const amounts = kind === 'cash' ? summary.balances : summary.valuation.amounts; + const unconverted = kind === 'cash' ? [] : [...summary.valuation.unconverted]; + return { + status: 'ready', + amounts: amounts.map(({ commodity, quantity }) => ({ commodity, quantity })), + unconverted + }; +} export function registerMembers( accounts: readonly AccountConfig[], diff --git a/apps/web/src/lib/server/reports.integration.test.ts b/apps/web/src/lib/server/reports.integration.test.ts new file mode 100644 index 0000000..8193886 --- /dev/null +++ b/apps/web/src/lib/server/reports.integration.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, it } from 'vitest'; +import type { OverviewData, PreparedAccountSummary } from '@hledger-reports/hledger'; +import { createReportStage } from '@hledger-reports/snapshots'; +import { load as loadLayout } from '../../routes/(protected)/+layout.server.js'; +import { load as loadCashAccount } from '../../routes/(protected)/registers/cash/[accountId]/+page.server.js'; + +it('keeps the layout and saved account leaf on one real publication until a fresh request', async () => { + const root = await mkdtemp(join(tmpdir(), 'web-saved-reports-')); + try { + const snapshotDirectory = join(root, 'PRIVATE-snapshots'); + const account = 'assets:cash'; + const application = { + config: { + journal: '/PRIVATE/fictional.journal', + accounts: [{ account, label: 'Cash', register: 'cash' as const }] + }, + paths: { snapshotDirectory } + }; + // An undated, empty journal is a complete current-format report, not a partial fixture. + const overview: OverviewData = { + asOf: null, + preparedAt: '2026-05-01T12:00:00Z', + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + ranges: null, + balanceSheet: null, + incomeExpenses: { months: [] }, + netWorth: { currency: null, points: [] } + }; + const accounts: PreparedAccountSummary[] = [ + { name: account, access: 'ready', rowCount: 0, summary: { status: 'undated' } } + ]; + const stageA = await createReportStage(snapshotDirectory, { + id: 'A', + createdAt: new Date('2026-05-01T12:00:00Z') + }); + await stageA.publish(overview, accounts, application.config.journal); + + const locals = { application }; + const layoutA = await loadLayout({ + locals, + url: new URL('http://localhost/registers/cash') + } as unknown as Parameters[0]); + expect(layoutA.accountSidebar.hasSavedReports).toBe(true); + const idA = layoutA.accountSidebar.groups[0].accounts[0].id!; + + const preparedB = '2026-05-02T12:00:00Z'; + const stageB = await createReportStage(snapshotDirectory, { + id: 'B', + createdAt: new Date(preparedB) + }); + await stageB.publish( + { ...overview, preparedAt: preparedB }, + accounts, + application.config.journal + ); + + // This invokes the actual loaders, not Kit's router. Browser coverage owns client invalidation. + const leafA = await loadCashAccount({ + locals, + url: new URL(`http://localhost/registers/cash/${idA}`), + params: { accountId: idA }, + parent: async () => layoutA, + setHeaders: () => {} + } as unknown as Parameters[0]); + expect(leafA).toMatchObject({ + preparedAt: overview.preparedAt, + accountName: account, + access: 'ready', + rowCount: 0, + rows: [], + readFailed: false + }); + + const freshLocals = { application }; + const layoutB = await loadLayout({ + locals: freshLocals, + url: new URL('http://localhost/registers/cash') + } as unknown as Parameters[0]); + const idB = layoutB.accountSidebar.groups[0].accounts[0].id!; + const leafB = await loadCashAccount({ + locals: freshLocals, + url: new URL(`http://localhost/registers/cash/${idB}`), + params: { accountId: idB }, + parent: async () => layoutB, + setHeaders: () => {} + } as unknown as Parameters[0]); + expect(layoutB.accountSidebar.hasSavedReports).toBe(true); + expect(leafB.preparedAt).toBe(preparedB); + expect(JSON.stringify([layoutA, leafA, layoutB, leafB])).not.toMatch( + /PRIVATE|directory|journal|publication|savedReports/ + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/web/src/lib/server/reports.test.ts b/apps/web/src/lib/server/reports.test.ts index 002002e..50fd188 100644 --- a/apps/web/src/lib/server/reports.test.ts +++ b/apps/web/src/lib/server/reports.test.ts @@ -41,6 +41,15 @@ const publication: ReportPublication = { } }; +function locals(selectedJournal = journal): Parameters[0] { + return { + application: { + config: { journal: selectedJournal }, + paths: { snapshotDirectory: directory } + } + }; +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(readValidation).mockResolvedValue(record); @@ -85,7 +94,7 @@ it('withholds missing or unrelated evidence, comparing private journal identity it('keeps financial reports available when the independent validation read fails', async () => { vi.mocked(readValidation).mockRejectedValue(new Error('PRIVATE corrupt evidence')); const [reports, validation] = await Promise.all([ - readSavedReports(directory, journal), + readSavedReports(locals()), readSavedValidation(directory, journal) ]); expect(reports).toEqual({ publication, readFailed: false }); @@ -94,7 +103,7 @@ it('keeps financial reports available when the independent validation read fails it('keeps validation available when saved financial reports fail, without leaking errors', async () => { vi.mocked(readReportPublication).mockRejectedValue(new Error('PRIVATE corrupt reports')); - expect(await readSavedReports(directory, journal)).toEqual({ + expect(await readSavedReports(locals())).toEqual({ publication: null, readFailed: true }); @@ -104,11 +113,58 @@ it('keeps validation available when saved financial reports fail, without leakin }); }); -it('withholds a financial publication from another journal without changing its private read API', async () => { - expect(await readSavedReports(directory, '/PRIVATE/other')).toEqual({ +it('withholds a financial publication from another journal', async () => { + expect(await readSavedReports(locals('/PRIVATE/other'))).toEqual({ publication: null, readFailed: false }); expect(readReportPublication).toHaveBeenCalledExactlyOnceWith(directory); expect(readValidation).not.toHaveBeenCalled(); }); + +it('shares a pending financial selection within a request, not across requests', async () => { + let release!: (value: ReportPublication | null) => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const later = { + ...publication, + directory: '/PRIVATE/new-set', + overview: { ...publication.overview, preparedAt: '2026-05-01T12:00:00Z' } + }; + vi.mocked(readReportPublication).mockReturnValueOnce(pending).mockResolvedValueOnce(later); + const request = locals(); + const layout = readSavedReports(request); + const leaf = readSavedReports(request); + await Promise.resolve(); + expect(readReportPublication).toHaveBeenCalledTimes(1); + release(publication); + expect(await Promise.all([layout, leaf])).toEqual([ + { publication, readFailed: false }, + { publication, readFailed: false } + ]); + expect(await readSavedReports(locals())).toEqual({ publication: later, readFailed: false }); + expect(readReportPublication).toHaveBeenCalledTimes(2); +}); + +it.each(['missing', 'unreadable'])( + 'keeps a %s financial selection stable until the next request', + async (condition) => { + if (condition === 'missing') vi.mocked(readReportPublication).mockResolvedValueOnce(null); + else vi.mocked(readReportPublication).mockRejectedValueOnce(new Error('PRIVATE read error')); + const request = locals(); + const expected = { publication: null, readFailed: condition === 'unreadable' }; + expect(await readSavedReports(request)).toEqual(expected); + expect(await readSavedReports(request)).toEqual(expected); + expect(readReportPublication).toHaveBeenCalledTimes(1); + expect(await readSavedReports(locals())).toEqual({ publication, readFailed: false }); + } +); + +it('does not read financial files without an application', async () => { + expect(await readSavedReports({ application: null })).toEqual({ + publication: null, + readFailed: false + }); + expect(readReportPublication).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/lib/server/reports.ts b/apps/web/src/lib/server/reports.ts index ae2fa2b..11f3fdf 100644 --- a/apps/web/src/lib/server/reports.ts +++ b/apps/web/src/lib/server/reports.ts @@ -23,18 +23,38 @@ export async function readSavedValidation( } } -// Private publication context must never be included in page data. -export async function readSavedReports( - snapshotDirectory: string, - journal: string -): Promise<{ publication: ReportPublication | null; readFailed: boolean }> { +export interface SavedReportsState { + publication: ReportPublication | null; + readFailed: boolean; +} + +interface SavedReportsLocals { + application: { + config: { journal: string }; + paths: { snapshotDirectory: string }; + } | null; + savedReports?: Promise; +} + +async function readPublication( + application: SavedReportsLocals['application'] +): Promise { + if (!application) return { publication: null, readFailed: false }; try { - const publication = await readReportPublication(snapshotDirectory); - return { - publication: publication && publication.journal !== journal ? null : publication, - readFailed: false - }; + const publication = await readReportPublication(application.paths.snapshotDirectory); + if (!publication || publication.journal !== application.config.journal) { + return { publication: null, readFailed: false }; + } + return { publication, readFailed: false }; } catch { return { publication: null, readFailed: true }; } } + +export function readSavedReports(locals: SavedReportsLocals): Promise { + if (locals.savedReports === undefined) { + const application = locals.application; + locals.savedReports = Promise.resolve().then(() => readPublication(application)); + } + return locals.savedReports; +} diff --git a/apps/web/src/lib/styles/theme.css b/apps/web/src/lib/styles/theme.css index d096176..5b59c1d 100644 --- a/apps/web/src/lib/styles/theme.css +++ b/apps/web/src/lib/styles/theme.css @@ -23,6 +23,7 @@ --control-height: 2.75rem; --content-width: 52rem; --register-width: 88rem; + --accounts-width: 20rem; --dialog-width: 48rem; --color-backdrop: #00000080; --font-body: system-ui, sans-serif; diff --git a/apps/web/src/routes/(protected)/+layout.server.ts b/apps/web/src/routes/(protected)/+layout.server.ts index 4bb7323..80db593 100644 --- a/apps/web/src/routes/(protected)/+layout.server.ts +++ b/apps/web/src/routes/(protected)/+layout.server.ts @@ -1,17 +1,20 @@ import type { AccountLabel } from '#lib/accounts.js'; +import { projectAccountSidebar } from '#lib/server/accounts.js'; +import { readSavedReports } from '#lib/server/reports.js'; import type { LayoutServerLoad } from './$types'; -export const load = (({ locals }) => { - const accounts = locals.application?.config.accounts ?? []; - const accountLabels: AccountLabel[] = accounts.flatMap(({ account, label }) => +export const load = (async (event) => { + const { locals } = event; + // Track the full destination URL so query navigation refreshes the shared snapshot too. + const pathname = new URL(event.url.href).pathname; + const configuredAccounts = locals.application?.config.accounts ?? []; + const accountLabels: AccountLabel[] = configuredAccounts.flatMap(({ account, label }) => label === undefined ? [] : [{ account, label }] ); - const registers = { - cash: accounts.some(({ register }) => register === 'cash'), - investment: accounts.some(({ register }) => register === 'investment') + const reports = await readSavedReports(locals); + return { + accountLabels, + pathname, + accountSidebar: projectAccountSidebar(configuredAccounts, reports) }; - const session = locals.session - ? { user: { name: locals.session.user.name }, expires: locals.session.expires } - : null; - return { session, accountLabels, registers }; }) satisfies LayoutServerLoad; diff --git a/apps/web/src/routes/(protected)/+layout.svelte b/apps/web/src/routes/(protected)/+layout.svelte index fed9c6e..a085fdf 100644 --- a/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/web/src/routes/(protected)/+layout.svelte @@ -1,59 +1,59 @@ -
- hledger reports - - -
- {data.session?.user.name} -
- -
+
+
+
+ + +
+ +
+
+ {@render children()}
-
-{@render children()} + + diff --git a/apps/web/src/routes/(protected)/+page.server.ts b/apps/web/src/routes/(protected)/+page.server.ts index ce00c02..f881e54 100644 --- a/apps/web/src/routes/(protected)/+page.server.ts +++ b/apps/web/src/routes/(protected)/+page.server.ts @@ -4,6 +4,7 @@ import type { PageServerLoad } from './$types'; export const load = (async (event) => { event.setHeaders({ 'cache-control': 'no-store' }); + await event.parent(); const { application } = event.locals; if (!application) { return { @@ -16,7 +17,7 @@ export const load = (async (event) => { } const [{ publication, readFailed }, { validation, readFailed: validationReadFailed }] = await Promise.all([ - readSavedReports(application.paths.snapshotDirectory, application.config.journal), + readSavedReports(event.locals), readSavedValidation(application.paths.snapshotDirectory, application.config.journal) ]); const saved = publication?.overview; diff --git a/apps/web/src/routes/(protected)/holdings/+page.server.ts b/apps/web/src/routes/(protected)/holdings/+page.server.ts index 5761f3d..2086db2 100644 --- a/apps/web/src/routes/(protected)/holdings/+page.server.ts +++ b/apps/web/src/routes/(protected)/holdings/+page.server.ts @@ -42,10 +42,8 @@ export const load = (async (event): Promise => { if (application) { const members = registerMembers(application.config.accounts, 'investment'); if (members.length === 0) error(404, 'Holdings are unavailable.'); - const { publication, readFailed } = await readSavedReports( - application.paths.snapshotDirectory, - application.config.journal - ); + await event.parent(); + const { publication, readFailed } = await readSavedReports(event.locals); data.readFailed = readFailed; if (publication) { data.asOf = publication.overview.asOf; @@ -115,10 +113,7 @@ export const actions = { } let account: string | null = null; if (accountId) { - const { publication } = await readSavedReports( - application.paths.snapshotDirectory, - application.config.journal - ); + const { publication } = await readSavedReports(event.locals); const selected = publication?.accounts.find(({ id }) => id === accountId); if (!selected || !members.includes(selected.name)) { return fail(400, { ...context, message: 'Choose an available investment account or All.' }); diff --git a/apps/web/src/routes/(protected)/holdings/page.server.test.ts b/apps/web/src/routes/(protected)/holdings/page.server.test.ts index 384df9e..c3cb0ab 100644 --- a/apps/web/src/routes/(protected)/holdings/page.server.test.ts +++ b/apps/web/src/routes/(protected)/holdings/page.server.test.ts @@ -151,6 +151,7 @@ function event( ) { const url = new URL(`http://localhost:5173/holdings${options.query ?? ''}`); const method = options.method ?? 'GET'; + let locals: App.Locals | undefined; return { url, request: new Request(url, { @@ -163,9 +164,10 @@ function event( ...(method === 'POST' ? { body: options.body ?? 'asOf=2026-04-30&accountId=' } : {}) }), get locals() { - return readLocals(); + return (locals ??= { ...readLocals() }); }, getClientAddress: () => '127.0.0.1', + parent: vi.fn().mockResolvedValue({}), setHeaders: vi.fn() } as unknown as Parameters[0]; } diff --git a/apps/web/src/routes/(protected)/layout.test.ts b/apps/web/src/routes/(protected)/layout.test.ts index 1997b11..51a82c0 100644 --- a/apps/web/src/routes/(protected)/layout.test.ts +++ b/apps/web/src/routes/(protected)/layout.test.ts @@ -2,16 +2,43 @@ import { expect, it } from 'vitest'; import { load } from './+layout.server.js'; import { testConfig } from '#lib/server/application.fixture.js'; -it('projects only public session fields, labels and register membership without reading the queue', () => { - const session = { user: { name: 'Fictional reader' }, expires: '2026-05-08T12:00:00Z' }; +it('projects configured sidebar data and labels without queue access, session data or private context', async () => { const privateSession = { - ...session, - user: { ...session.user, email: 'PRIVATE', id: 'PRIVATE' }, + user: { name: 'PRIVATE reader', email: 'PRIVATE', id: 'PRIVATE' }, + expires: '2026-05-08T12:00:00Z', token: 'PRIVATE' }; - const result = load({ + const preparedAt = '2026-05-01T12:00:00Z'; + const result = await load({ + url: new URL('http://localhost/registers/cash?order=desc'), locals: { session: privateSession, + savedReports: Promise.resolve({ + readFailed: false, + publication: { + journal: '/PRIVATE/main.journal', + directory: '/PRIVATE/completed-set', + overview: { + asOf: null, + preparedAt, + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + ranges: null, + balanceSheet: null, + incomeExpenses: { months: [] }, + netWorth: { currency: null, points: [] } + }, + accounts: [ + { + id: 'abcd1234', + name: 'assets:bank', + access: 'ready', + rowCount: 0, + summary: { status: 'undated' }, + privateNote: 'PRIVATE' + } + ] + } + }), application: { config: { ...testConfig, @@ -25,20 +52,38 @@ it('projects only public session fields, labels and register membership without throw new Error('Layout must not read the queue'); }, get paths(): never { - throw new Error('Layout must not read saved reports'); + throw new Error('The selected request publication is already available'); } } } } as unknown as Parameters[0]); expect(result).toEqual({ - session, accountLabels: [ { account: 'assets:bank', label: 'Everyday' }, { account: 'expenses:living', label: 'Living' } ], - registers: { cash: true, investment: false } + pathname: '/registers/cash', + accountSidebar: { + asOf: null, + hasSavedReports: true, + netWorth: null, + readFailed: false, + groups: [ + { + kind: 'cash', + accounts: [ + { + id: 'abcd1234', + name: 'assets:bank', + label: 'Everyday', + access: 'ready', + value: { status: 'undated' } + } + ] + } + ] + } }); expect(JSON.stringify(result)).not.toContain(testConfig.auth.secret); - expect(result.session).not.toBe(privateSession); - expect(JSON.stringify(result)).not.toContain('PRIVATE'); + expect(JSON.stringify(result)).not.toMatch(/PRIVATE|directory|journal|publication|rowCount/); }); diff --git a/apps/web/src/routes/(protected)/page.server.test.ts b/apps/web/src/routes/(protected)/page.server.test.ts index f097ee3..0ec2f2a 100644 --- a/apps/web/src/routes/(protected)/page.server.test.ts +++ b/apps/web/src/routes/(protected)/page.server.test.ts @@ -131,13 +131,15 @@ function event( } } : options.application; + let locals: App.Locals | undefined; return { get locals() { - return readLocals(application); + return (locals ??= readLocals(application)); }, url, request: new Request(url, { headers: { host: url.host } }), getClientAddress: () => '127.0.0.1', + parent: vi.fn().mockResolvedValue({}), setHeaders: vi.fn() } as unknown as Parameters[0]; } diff --git a/apps/web/src/routes/(protected)/registers/cash/+page.server.ts b/apps/web/src/routes/(protected)/registers/cash/+page.server.ts index 049de4c..1873e98 100644 --- a/apps/web/src/routes/(protected)/registers/cash/+page.server.ts +++ b/apps/web/src/routes/(protected)/registers/cash/+page.server.ts @@ -16,8 +16,8 @@ export const load = (async (event): Promise => { if (!application) return data; const members = registerMembers(application.config.accounts, 'cash'); if (members.length === 0) error(404, 'Register not configured.'); - const directory = application.paths.snapshotDirectory; - const state = await readSavedReports(directory, application.config.journal); + await event.parent(); + const state = await readSavedReports(event.locals); data.configured = true; data.readFailed = state.readFailed; const publication = state.publication; diff --git a/apps/web/src/routes/(protected)/registers/cash/[accountId]/+page.server.ts b/apps/web/src/routes/(protected)/registers/cash/[accountId]/+page.server.ts index 3b75003..48237c4 100644 --- a/apps/web/src/routes/(protected)/registers/cash/[accountId]/+page.server.ts +++ b/apps/web/src/routes/(protected)/registers/cash/[accountId]/+page.server.ts @@ -50,8 +50,8 @@ export const load = (async (event): Promise => { if (!application) return data; const members = registerMembers(application.config.accounts, 'cash'); if (members.length === 0) error(404, 'Register not configured.'); - const directory = application.paths.snapshotDirectory; - const state = await readSavedReports(directory, application.config.journal); + await event.parent(); + const state = await readSavedReports(event.locals); data.configured = true; data.readFailed = state.readFailed; // Pin one immutable publication; its journal and directory remain server-only. diff --git a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts index 1c246b2..e5fb755 100644 --- a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts @@ -63,6 +63,7 @@ function event(query = '', accounts: AccountConfig[] = [{ account: name, registe params: { accountId: id }, request: new Request(url, { headers: { host: url.host } }), getClientAddress: () => '127.0.0.1', + parent: vi.fn().mockResolvedValue({}), setHeaders: vi.fn(), locals: { application, session: null, auth: async () => null } } as unknown as Parameters[0]; diff --git a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts index fef6e4d..769ea88 100644 --- a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts @@ -62,6 +62,7 @@ function event( url, request: new Request(url, { headers: { host: url.host } }), getClientAddress: () => '127.0.0.1', + parent: vi.fn().mockResolvedValue({}), setHeaders: vi.fn(), locals: { application, session: null, auth: async () => null } } as unknown as Parameters[0]; diff --git a/apps/web/src/routes/(protected)/registers/investments/+page.server.ts b/apps/web/src/routes/(protected)/registers/investments/+page.server.ts index cba0674..5462831 100644 --- a/apps/web/src/routes/(protected)/registers/investments/+page.server.ts +++ b/apps/web/src/routes/(protected)/registers/investments/+page.server.ts @@ -17,8 +17,8 @@ export const load = (async (event): Promise => { if (!application) return data; const members = registerMembers(application.config.accounts, 'investment'); if (members.length === 0) error(404, 'Register not configured.'); - const directory = application.paths.snapshotDirectory; - const state = await readSavedReports(directory, application.config.journal); + await event.parent(); + const state = await readSavedReports(event.locals); data.configured = true; data.readFailed = state.readFailed; const publication = state.publication; diff --git a/apps/web/src/routes/(protected)/registers/investments/[accountId]/+page.server.ts b/apps/web/src/routes/(protected)/registers/investments/[accountId]/+page.server.ts index 48b7a18..a1c19b5 100644 --- a/apps/web/src/routes/(protected)/registers/investments/[accountId]/+page.server.ts +++ b/apps/web/src/routes/(protected)/registers/investments/[accountId]/+page.server.ts @@ -49,8 +49,8 @@ export const load = (async (event): Promise => { if (!application) return data; const members = registerMembers(application.config.accounts, 'investment'); if (members.length === 0) error(404, 'Register not configured.'); - const directory = application.paths.snapshotDirectory; - const state = await readSavedReports(directory, application.config.journal); + await event.parent(); + const state = await readSavedReports(event.locals); data.configured = true; data.readFailed = state.readFailed; // Pin one immutable publication; its journal and directory remain server-only. diff --git a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts index 0aa6311..090e4e0 100644 --- a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts @@ -66,6 +66,7 @@ function event( params: { accountId: id }, request: new Request(url, { headers: { host: url.host } }), getClientAddress: () => '127.0.0.1', + parent: vi.fn().mockResolvedValue({}), setHeaders: vi.fn(), locals: { application, session: null, auth: async () => null } } as unknown as Parameters[0]; diff --git a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts index 33fe264..5652952 100644 --- a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts @@ -62,6 +62,7 @@ function event( url, request: new Request(url, { headers: { host: url.host } }), getClientAddress: () => '127.0.0.1', + parent: vi.fn().mockResolvedValue({}), setHeaders: vi.fn(), locals: { application, session: null, auth: async () => null } } as unknown as Parameters[0]; diff --git a/apps/web/src/routes/(protected)/settings/+page.server.ts b/apps/web/src/routes/(protected)/settings/+page.server.ts index bd0e5c2..618396a 100644 --- a/apps/web/src/routes/(protected)/settings/+page.server.ts +++ b/apps/web/src/routes/(protected)/settings/+page.server.ts @@ -12,6 +12,7 @@ import type { Actions, PageServerLoad } from './$types'; export const load = (async (event) => { // A failed ordinary POST renders this load with headers already set by the action. if (event.request.method !== 'POST') event.setHeaders({ 'cache-control': 'no-store' }); + await event.parent(); const { application } = event.locals; const configured = application !== null; let preparation: ReportsPreparationState | 'unavailable' = 'idle'; @@ -26,7 +27,7 @@ export const load = (async (event) => { // Queue diagnostics are private; status failure does not hide saved reports. } const [reports, savedValidation] = await Promise.all([ - readSavedReports(application.paths.snapshotDirectory, application.config.journal), + readSavedReports(event.locals), readSavedValidation(application.paths.snapshotDirectory, application.config.journal) ]); preparedAt = reports.publication?.overview.preparedAt ?? null; diff --git a/apps/web/src/routes/(protected)/settings/page.server.test.ts b/apps/web/src/routes/(protected)/settings/page.server.test.ts index a276a16..4c6a165 100644 --- a/apps/web/src/routes/(protected)/settings/page.server.test.ts +++ b/apps/web/src/routes/(protected)/settings/page.server.test.ts @@ -40,6 +40,7 @@ const application: ApplicationContext = { function event(options: { method?: 'GET' | 'POST'; origin?: string; body?: string } = {}) { const url = new URL('http://localhost:5173/settings'); const method = options.method ?? 'GET'; + let locals: App.Locals | undefined; return { url, request: new Request(url, { @@ -52,9 +53,10 @@ function event(options: { method?: 'GET' | 'POST'; origin?: string; body?: strin ...(method === 'POST' ? { body: options.body ?? '' } : {}) }), get locals() { - return readLocals(); + return (locals ??= { ...readLocals() }); }, getClientAddress: () => '127.0.0.1', + parent: vi.fn().mockResolvedValue({}), setHeaders: vi.fn() } as unknown as Parameters[0]; } diff --git a/apps/web/src/routes/(protected)/settings/page.test.ts b/apps/web/src/routes/(protected)/settings/page.test.ts index f445222..9523222 100644 --- a/apps/web/src/routes/(protected)/settings/page.test.ts +++ b/apps/web/src/routes/(protected)/settings/page.test.ts @@ -17,9 +17,15 @@ it('keeps failed execution, saved finances and recorded validation distinct', () preparedAt, validation: { outcome: 'passed', completedAt }, validationReadFailed: false, - session: null, - accountLabels: [], - registers: { cash: false, investment: false } + pathname: '/settings', + accountSidebar: { + asOf: null, + hasSavedReports: true, + netWorth: null, + readFailed: false, + groups: [] + }, + accountLabels: [] }, form: null } diff --git a/docs/architecture.md b/docs/architecture.md index f87ecdc..274ef2d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ The product has straightforward read, request and produce paths: ```text -Overview / registers → read saved financial files → render +Protected shell / saved pages → share saved financial set → render Overview / Settings → read saved validation outcome/time → render Settings form → request durable job → check + record → prepare complete set Live query Run → request durable job → hledger → return exact result @@ -41,8 +41,9 @@ interpret accounting. Report storage does not recalculate or regroup report rows After recording a passed standalone preflight, a preparation job uses one cached hledger financial journal view for Overview and all actual declared/used accounts. It streams account rows into private staging files, then publishes the complete directory. Storage commits files; the workflow returns the -Overview it calculated. Readers independently select the latest complete set by -request ordering, and pin it for the request. +Overview it calculated. Each server request selects the latest complete set by +request ordering and shares that pinned selection between the protected shell and +saved-data leaf. ```text data/snapshots/current/ @@ -88,12 +89,13 @@ prepared journal view, not durable cross-preparation IDs. The current six-month Overview and Cash/Investment register presentation remain unchanged. The homepage server builds `OverviewPageData` explicitly from range-6 categories, the last six monthly/net-worth points and rendered context only. It -serializes no unrendered ranges/headline totals, latest sides, account values/catalog, -private publication metadata or provenance. This is a current page model, not a -legacy producer adapter. Register directories project identity/access/count fields; -register details project only their selected page's public fields. Period controls, -a three-month default and account sidebar remain future work, not existing UI. -Routes, navigation, Settings, live queries and theme behavior are unchanged. +serializes no unrendered ranges/headline totals, latest sides, account values/catalog +or private provenance into the homepage payload. The protected shell separately +projects compact configured account navigation, not the full catalog. This is a +current page model, not a legacy producer adapter. Register directories project +identity/access/count fields; register details project only their selected page's +public fields. New homepage figures, period controls and a three-month default +remain future work. Settings, live queries and theme behavior are unchanged. Failure before the financial directory commit retains the previous complete set. After commit, an inspection-only `current` alias update can fail the job while the @@ -113,6 +115,83 @@ Reports for another configured journal are withheld. Journal identity is private operational context, not an input fingerprint or freshness assertion. Files are left untouched: there is no automatic cleanup, migration or regeneration. +## Shared saved reads and account navigation + +Web's `lib/server/reports.ts` owns `readSavedReports(locals)`, which assigns an +optional `locals.savedReports: Promise` before filesystem work. +Layout, saved-data leaf loads and account-selecting actions share that private +selection, including missing/error results. Layout output omits unused +session/register flags; private authentication gates are unchanged. Register detail +reads its selected account file from the pinned directory, not every history. +There is no global cache, public set pin or new financial data contract. + +The protected layout tracks the full destination URL, including query changes; +saved-data leaf loads await `parent()`. Ordinary SvelteKit changed-path/query +navigation refreshes the shell and participating leaf together in one request. +Request-local sharing alone would not refresh a retained browser layout. No custom +router, blanket full-document navigation, automatic prefetch, polling or staleness +inspection is added. Normal navigation or explicit reload observes a newly saved +set; a page left open stays unchanged. History may restore an older coherent view, +with no latest-on-tab-resume guarantee. Future shallow homepage period selection +will deliberately keep the loaded set; the current homepage is still six months. +New homepage figures, period controls and a three-month default are deferred to PR4. +Validation remains an independent passive read; Settings alone owns queue status +and the existing guarded explicit-work controls. + +At widths of at least 80rem, the protected shell reserves 20rem for a permanent +full-height right Accounts rail, independently scrollable, with header and content +to its left. Protected main/header content use the full available responsive width, +without centered 52rem/88rem caps; public sign-in sizing is unchanged. The header +has only Overview, Settings, Sign out and the narrow Accounts control, not brand, +username or register/Holdings links. Below 40rem, Accounts is a full-screen modal; +from 40rem to below 80rem, it is a right overlay drawer. Drawer and rail share the +20rem `--accounts-width` theme token. The protected layout owns header/main +composition, rail positioning and reserved content width. + +`navigation/AccountsDrawer.svelte` owns the hydration fallback, media-query +subscription and cleanup, responsive transitions, closing on navigation (including +browser history), and focus-restoration policy. Bits UI supplies the portal, +modal focus trapping, scroll locks and backdrop/Escape/Close dismissal mechanics. +AccountsDrawer restores trigger focus after dismissal, permits normal destination +focus on navigation, and closes the modal before switching to the desktop rail. +Safe-area padding and reduced motion use the existing theme. A native Accounts +disclosure supplies real links before/without JavaScript. Public and sign-in pages +expose no financial account navigation. + +Shared `navigation/AccountSidebar.svelte` renders configured Cash and Investment +members only, in group/configuration order with existing labels and native-ID +register links. Cash/Investments headings open directories; Holdings is reached +from an investment account register rather than the sidebar or header. The sidebar +owns its Accounts heading and balances-as-of subtitle, not modal state. Its +`heading`/`actions` snippets admit the drawer's real Bits UI +`Dialog.Title`/`Dialog.Close`, not a generic header framework. The same content +serves the permanent rail, modal and no-JavaScript disclosure. + +The pure `projectAccountSidebar(configuredAccounts, reports)` in +`lib/server/accounts.ts` explicitly projects allowed public fields from the private +catalog; it does not own navigation behavior. `navigation/data.ts` owns +`AccountSidebarData` and `SidebarAccount`, while root `lib/accounts.ts` retains +domain labels/register helpers. Cash displays exact raw native balance bags; +Investments displays native valuation including cash. Public navigation contains +only configured identities/labels/access, projected bags, latest native +whole-journal net worth and cutoff/read-state context, never full catalog/history +bodies, preparation time, journal identity or private paths. +Incomplete/unconverted, undated, empty, missing/unreadable and no-configuration +states remain explicit; incomplete value does not disable a valid register link. +Rows align names left and values right, with full-bleed hover/selected backgrounds, +inner gutters and a square flush selection marker. Dividers separate major +Cash/Investments/Net worth sections, not individual accounts. The balance cutoff +remains visible; Accounts has no preparation timestamp or Holdings shortcut. +Any new preparation-timestamp placement is deferred. +The Net worth section copies the latest saved native NET with its unconverted +flags; a saved point is available even without configured groups. This is never a +sum of configured accounts: parents may overlap, membership is a subset and +liabilities may be omitted. There are no Cash/Investment group totals, native calls +or whole-history scans on navigation reads. +Format 6, native/worker preparation, transaction history and configuration are +unchanged; existing format-6 sets need no additional preparation. The format-5 +activation instructions above still apply. + ## Last recorded validation The worker awaits standalone `checkJournal` before financial staging. This adds one @@ -177,8 +256,8 @@ justify exposing arbitrary hledger commands or building unused execution APIs. ## Code organization and boundaries -Feature presentation lives under `apps/web/src/lib/overview`, `registers`, `holdings` -and `queries`. Chart configurations and their canvas live together under `lib/charts`, +Feature presentation lives under `apps/web/src/lib/overview`, `registers`, `holdings`, +`navigation` and `queries`. Chart configurations and their canvas live together under `lib/charts`, named by chart, with adjacent matching tests. Overview panels compose those charts and exact tables; chart code does not own page or accounting logic. Shared server helpers stay in `lib/server` so SvelteKit enforces the server-only import boundary. @@ -197,6 +276,21 @@ keeps outside clicks non-dismissive and restores focus to the opener or main con Registers retain their payload-bound selections and clear them on navigation. The shell does not know about transactions, postings or financial values. +Web's central `lib/amounts.ts` owns display-only amount formatting, currently used +only in account navigation and its net-worth values; other report/register adoption +remains follow-up work. Known uppercase currencies and literal native `$` use en-US +symbols/grouping and standard minor-unit minimums. +Insignificant fractional zeros are trimmed without rounding or losing meaningful +precision, and quantities are never converted to `Number`. Unknown, lowercase, +noncurrency and unnamed commodities retain literal display (including explicit +“no commodity”). This changes neither native values/identity nor financial schemas; +it does not sum bags or convert currencies. + +Central `lib/dates.ts` provides display-only `formatDate`: readable English +balance-cutoff dates, deterministic across server/browser time zones. It is used +only by the sidebar in this pass, with ISO `datetime` attributes retained; wider +report-date adoption remains follow-up work. + CSS owns chart colors and font choices. `ChartCanvas` resolves them after mounting and on system-theme changes; pure chart builders receive that resolved theme while retaining their existing native-value and geometry rules. Server-rendered exact @@ -240,6 +334,10 @@ representative cases for financial meaning, complete publication, resource clean explicit execution, passive reads and access control—not repeated route matrices, HTML snapshots or the survival of an obsolete internal API. The browser suite is a small set of real user journeys, not a second financial/renderer test suite. +Focused saved-read/projection tests and one real-publication shell/leaf loader +integration protect request sharing and privacy; real navigation, drawer and no-JS +journeys protect the browser boundary without exhaustive financial, CSS or +framework matrices. Changes to persisted formats or queued workflow identities require an explicit compatibility decision; internal names do not require compatibility aliases. diff --git a/docs/development.md b/docs/development.md index dd3475b..c8597f5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,7 +34,13 @@ This uses the checked-in Compose files and your ignored live configuration. A mise/fnox Docker task is an owner-local convenience, not part of the checked-in project. The remaining commands below run from the repository root. -Open **http://127.0.0.1:5173/** and sign in. Overview and Transactions read saved reports; if none exist, they say so. Use the inline **Prepare reports** form in **Settings** (`/settings`) to request a complete calculation, then reload Settings to check status and navigate to saved results. `/prepare` is removed without a redirect or POST alias; Settings links to the existing `/queries` tool. Startup, journal edits and browsing never request preparation automatically. Saved reports remain available while a requested job runs or when the worker is unavailable. The separate automated-test `lots.journal` fixture anchors at **2026-04-16**, giving **November 2025–April 2026** regardless of today's date; no fixture edits are needed. Overview's bars, categories, net-worth trend and coverage now share a complete preparation with all account registers. Use a configured **Cash register** or **Investment register** in the primary navigation, then select an account; ordinary pagination reads saved account activity with native running balances. Account history is not restricted to the Overview window. Only complete financial format-6 reports are displayed; format 5 is no longer usable. Preparation saves native 1/3/6/12-month ranges, twelve monthly/net-worth history points, latest assets/liabilities and raw/valued account summaries. The current UI deliberately keeps six months: the server projects only range-6 categories, the last six history points and rendered context, not unrendered ranges, sides, account values/catalog or private provenance. Period controls, a three-month default and sidebar values are future work. Unsupported saved formats show “No prepared reports available.”; follow the activation steps below and explicitly prepare again. Existing saved files are not rewritten or deleted automatically. That fixture's net-worth point is 11825.00 USD at April 16, not the 12010.00 USD from later April 30 prices. Native whole-journal coverage includes 9 transactions dated April 1–16 in that fixture; its date span can include secondary/posting dates, while the financial window is anchored to the latest primary transaction date. See the [web contract](../apps/web/README.md) for interaction behavior and the [native library](../packages/hledger/README.md#overview-data) for dataset semantics. +Open **http://127.0.0.1:5173/** and sign in. Overview and Transactions read saved reports; if none exist, they say so. Use the inline **Prepare reports** form in **Settings** (`/settings`) to request a complete calculation, then reload Settings to check status and navigate to saved results. `/prepare` is removed without a redirect or POST alias; Settings links to the existing `/queries` tool. Startup, journal edits and browsing never request preparation automatically. Saved reports remain available while a requested job runs or when the worker is unavailable. The separate automated-test `lots.journal` fixture anchors at **2026-04-16**, giving **November 2025–April 2026** regardless of today's date; no fixture edits are needed. Overview's bars, categories, net-worth trend and coverage now share a complete preparation with all account registers. Use the **Cash** or **Investments** heading in Accounts to open a configured register directory, then select an account; ordinary pagination reads saved account activity with native running balances. Account history is not restricted to the Overview window. Only complete financial format-6 reports are displayed; format 5 is no longer usable. Preparation saves native 1/3/6/12-month ranges, twelve monthly/net-worth history points, latest assets/liabilities and raw/valued account summaries. The homepage deliberately keeps six months: its server projection includes only range-6 categories, the last six history points and rendered context, not unrendered ranges, sides, account values/catalog or private provenance. New homepage figures, period controls and a three-month default remain future work (PR4). Unsupported saved formats show “No prepared reports available.”; follow the activation steps below and explicitly prepare again. Existing saved files are not rewritten or deleted automatically. That fixture's net-worth point is 11825.00 USD at April 16, not the 12010.00 USD from later April 30 prices. Native whole-journal coverage includes 9 transactions dated April 1–16 in that fixture; its date span can include secondary/posting dates, while the financial window is anchored to the latest primary transaction date. See the [web contract](../apps/web/README.md) for interaction behavior and the [native library](../packages/hledger/README.md#overview-data) for dataset semantics. + +See [Accounts usage](../apps/web/README.md#accounts-navigation) and +[architecture](architecture.md#shared-saved-reads-and-account-navigation) for +responsive navigation, private shared reads and component lifecycle ownership. +Display-only amount/date formatting is documented under +[code organization](architecture.md#code-organization-and-boundaries). Follow **Live queries** from Settings to the existing `/queries` screen; submit `type:X` as of `2026-04-30` for the fixture expense total **100.00 USD**. Run waits for the worker through the same queue. Query reload still @@ -90,6 +96,8 @@ but unusable; account IDs may reset and old account URLs may no longer resolve. There is no automatic preparation, adapter, fallback, migration or deletion. The v1 void-input workflow identity and independent validation-evidence lifecycle do not change, nor do configuration, native account membership or transaction history. +The web-only Accounts navigation uses this existing format: already-format-6 sets +need no additional preparation. These activation steps remain required for format 5. For dated journals, the cached financial path now uses 4 income statements + 12 balance sheets + 2 bulk balances = **18 financial report commands**, still two financial processes @@ -281,6 +289,10 @@ preflight, browser journey and artifact probe after a markup or copy edit. The [testing philosophy](philosophy.md#tests-while-the-foundations-are-changing) explains what we retain and when to add coverage. +For account navigation test locations, see the [web file map and checks](../apps/web/README.md#run-and-check); +[architecture](architecture.md#code-organization-and-boundaries) describes coverage +responsibilities. These are not completed gate or browser-run evidence. + The full workspace gate remains available for cross-cutting changes and CI. In a disposable container: @@ -310,7 +322,7 @@ exported native server handler. Browsers send normal headers and sign in normall there is no authentication bypass. Existing Vite Compose E2E remains unchanged: use `docker-compose.dev.yml` alone for that fixture. The small smoke suite covers sign-in with and without JavaScript, explicit preparation and saved Overview, Cash/Investment browsing, -Holdings and a live query. It uses real services rather than mocked financial +Holdings, a live query and account rail/drawer/no-JS navigation. It uses real services rather than mocked financial publications or response matrices. Parser, financial, storage and failure details belong in their owning packages. Run this suite for changes to those user journeys or their integration—not for every presentation edit. Output appears in the terminal; -- 2.54.0