Add responsive Accounts navigation #40

Merged
eleith merged 1 commits from feat/account-navigation into main 2026-11-05 20:04:55 +00:00
43 changed files with 1810 additions and 151 deletions

No files matched your search

@@ -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.
+4 -2
View File
@@ -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.
+47 -15
View File
@@ -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.
+157
View File
@@ -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);
});
+6 -3
View File
@@ -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(
+14 -5
View File
@@ -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)'
+6 -3
View File
@@ -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');
+22 -1
View File
@@ -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']);
});
+2
View File
@@ -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<AuthSession | null>;
savedReports?: Promise<SavedReportsState>;
}
}
}
+40
View File
@@ -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');
});
+35
View File
@@ -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('');
}
+10
View File
@@ -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');
});
+11
View File
@@ -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);
}
@@ -0,0 +1,334 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { Snippet } from 'svelte';
import type { AccountSidebarData, SidebarAccount } from './data.js';
import { formatDate } from '#lib/dates.js';
import { formatAmount } from '#lib/amounts.js';
let {
sidebar,
pathname,
onNavigate,
heading,
actions
}: {
sidebar: AccountSidebarData;
pathname: string;
onNavigate?: () => void;
heading?: Snippet;
actions?: Snippet;
} = $props();
function handleNavigate(event: MouseEvent & { currentTarget: HTMLAnchorElement }) {
const link = event.currentTarget;
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
(link.target !== '' && link.target.toLowerCase() !== '_self') ||
link.hasAttribute('download')
)
return;
onNavigate?.();
}
</script>
{#snippet accountContents(account: SidebarAccount)}
<span class="account-name">
<span class="friendly-name">
{#if account.label === account.name}
{@const parts = account.name.split(':')}
{#each parts as part, index (index)}{part}{#if index < parts.length - 1}:<wbr />{/if}{/each}
{:else}
{account.label}
{/if}
</span>
{#if account.label !== account.name}
<span class="native-name" title={account.name}>{account.name}</span>
{/if}
</span>
<span class="account-value">
{#if account.access === 'ambiguous'}
<span class="value-note">Ambiguous account; register access is limited.</span>
{:else if account.access === 'missing'}
<span class="value-note">Account unavailable in saved reports.</span>
{:else if account.value === null}
<span class="value-note">Balance unavailable.</span>
{:else if account.value.status === 'undated'}
<span class="value-note">No dated balance.</span>
{:else}
{#if account.value.unconverted.length > 0}
<span class="value-note">
Incomplete valuation; unconverted:
{#each account.value.unconverted as commodity, index (index)}
{index > 0 ? ', ' : ''}<span class="commodity"
>{commodity === '' ? 'no commodity' : commodity}</span
>
{/each}
</span>
{/if}
{#each account.value.amounts as amount, index (index)}
<span class="amount">{formatAmount(amount)}</span>
{:else}
<span class="value-note">No balance</span>
{/each}
{/if}
</span>
{/snippet}
<div class="account-sidebar">
<header class="accounts-header">
<div class="accounts-heading">
{#if heading}
{@render heading()}
{:else}
<h2 class="section-title">Accounts</h2>
{/if}
<p class="balance-cutoff">
{#if sidebar.readFailed}
Saved account values couldn't be read.
{:else if !sidebar.hasSavedReports}
No prepared account values available.
{:else if sidebar.asOf !== null}
Balances as of <time datetime={sidebar.asOf}>{formatDate(sidebar.asOf)}</time>
{:else}
No dated balances
{/if}
</p>
</div>
{#if actions}
{@render actions()}
{/if}
</header>
{#if sidebar.groups.length > 0}
<nav aria-label="Account registers">
{#each sidebar.groups as group (group.kind)}
{@const directoryHref =
group.kind === 'cash'
? resolve('/(protected)/registers/cash')
: resolve('/(protected)/registers/investments')}
<section class="account-group">
<h3>
<a
class="group-link"
href={directoryHref}
aria-current={pathname === directoryHref ? 'page' : undefined}
onclick={handleNavigate}>{group.kind === 'cash' ? 'Cash' : 'Investments'}</a
>
</h3>
<ul>
{#each group.accounts as account (account.name)}
<li>
{#if account.id !== null}
{@const href =
group.kind === 'cash'
? resolve('/(protected)/registers/cash/[accountId]', {
accountId: account.id
})
: resolve('/(protected)/registers/investments/[accountId]', {
accountId: account.id
})}
<a
class="account-row"
{href}
aria-current={pathname === href ? 'page' : undefined}
onclick={handleNavigate}
>
{@render accountContents(account)}
</a>
{:else}
<div class="account-row">{@render accountContents(account)}</div>
{/if}
</li>
{/each}
</ul>
</section>
{/each}
</nav>
{:else}
<p class="empty-accounts">No Cash or Investment accounts configured.</p>
{/if}
{#if sidebar.groups.length > 0 || sidebar.netWorth !== null}
<section class="net-worth" aria-label="Net worth">
<div class="total-row">
<div class="total-label">
<h3>Net worth</h3>
</div>
<div class="account-value">
{#if sidebar.netWorth === null}
<p class="value-note">
{#if sidebar.hasSavedReports && sidebar.asOf === null && !sidebar.readFailed}
No dated balance.
{:else}
Balance unavailable.
{/if}
</p>
{:else}
{#if sidebar.netWorth.unconverted.length > 0}
<p class="value-note">
Incomplete valuation; unconverted:
{#each sidebar.netWorth.unconverted as commodity, index (index)}
{index > 0 ? ', ' : ''}<span class="commodity"
>{commodity === '' ? 'no commodity' : commodity}</span
>
{/each}
</p>
{/if}
{#each sidebar.netWorth.amounts as amount, index (index)}
<p class="amount">{formatAmount(amount)}</p>
{:else}
<p class="value-note">No balance</p>
{/each}
{/if}
</div>
</div>
</section>
{/if}
</div>
<style>
.account-sidebar {
min-width: 0;
overflow-wrap: anywhere;
}
.accounts-header,
.empty-accounts,
.group-link {
padding-inline: var(--accounts-inset-start, var(--space-medium))
var(--accounts-inset-end, var(--space-medium));
}
.accounts-header {
display: flex;
align-items: start;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-small);
padding-block-end: var(--space-medium);
}
.accounts-heading {
flex: 1;
min-width: 0;
}
.balance-cutoff {
color: var(--color-muted);
font-size: 0.75rem;
line-height: var(--line-section);
}
.balance-cutoff {
margin-block-start: var(--space-tight);
}
.account-group {
border-block-start: var(--border-width) solid var(--color-border);
}
.account-group h3 {
margin: 0;
color: var(--color-muted);
font-size: 0.75rem;
font-weight: var(--font-weight-strong);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.group-link {
display: block;
padding-block: var(--space-small) var(--space-tight);
color: inherit;
text-decoration: none;
}
.group-link:hover,
.group-link[aria-current='page'] {
background: var(--color-background);
}
.group-link[aria-current='page'] {
box-shadow: inset var(--focus-width) 0 var(--color-accent);
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
.account-row,
.total-row {
display: grid;
grid-template-columns: minmax(0, 1fr) fit-content(50%);
align-items: start;
gap: var(--space-small);
min-width: 0;
}
.account-row {
box-sizing: border-box;
min-height: 4rem;
align-items: center;
padding-block: var(--space-small);
padding-inline: var(--accounts-inset-start, var(--space-medium))
var(--accounts-inset-end, var(--space-medium));
color: var(--color-text);
font-size: var(--font-small);
line-height: var(--line-section);
text-decoration: none;
border-radius: 0;
}
a.account-row:hover,
a.account-row[aria-current='page'] {
background: var(--color-background);
}
a.account-row[aria-current='page'] {
box-shadow: inset var(--focus-width) 0 var(--color-accent);
}
a.account-row:focus-visible,
.group-link:focus-visible {
outline: var(--focus-width) solid var(--color-accent);
outline-offset: calc(0px - var(--focus-width));
}
.account-name,
.account-value {
display: flex;
flex-direction: column;
gap: var(--space-tiny);
min-width: 0;
}
.friendly-name {
font-weight: var(--font-weight-strong);
}
.native-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-muted);
font-family: ui-monospace, monospace;
font-size: 0.75rem;
}
.account-value {
text-align: right;
}
.value-note {
color: var(--color-muted);
font-size: 0.75rem;
font-weight: normal;
}
.amount {
color: var(--color-text);
font-variant-numeric: tabular-nums lining-nums;
}
.commodity {
white-space: pre-wrap;
}
p {
margin: 0;
}
.net-worth {
padding-block: var(--space-small);
padding-inline: var(--accounts-inset-start, var(--space-medium))
var(--accounts-inset-end, var(--space-medium));
border-block-start: var(--border-width) solid var(--color-border);
font-size: var(--font-small);
font-weight: var(--font-weight-strong);
line-height: var(--line-section);
}
.net-worth h3 {
margin: 0;
font-size: inherit;
}
</style>
@@ -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(/<!--[\s\S]*?-->/g, '')
.replace(/<wbr\s*\/?>/g, '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.replace(/\s+([.,])/g, '$1');
}
function links(body: string) {
return Array.from(
body.matchAll(/<a\b[^>]*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.');
});
@@ -0,0 +1,183 @@
<script lang="ts">
import { flushSync, onMount } from 'svelte';
import { Dialog } from 'bits-ui';
import { beforeNavigate } from '$app/navigation';
import type { AccountSidebarData } from './data.js';
import AccountSidebar from './AccountSidebar.svelte';
let { sidebar, pathname }: { sidebar: AccountSidebarData; pathname: string } = $props();
let ready = $state(false);
let desktop = $state(false);
let open = $state(false);
let trigger = $state<HTMLElement | null>(null);
let closeReason: 'dismiss' | 'navigate' | 'resize' = 'dismiss';
beforeNavigate(() => {
if (open) closeForNavigation();
});
onMount(() => {
// Keep this in step with the shell's content-width breakpoint.
const wide = window.matchMedia('(min-width: 80rem)');
desktop = wide.matches;
ready = true;
function updateLayout() {
if (wide.matches && open) {
closeReason = 'resize';
// Tear down the modal before removing its trigger for the desktop layout.
flushSync(() => (open = false));
}
desktop = wide.matches;
}
wide.addEventListener('change', updateLayout);
return () => wide.removeEventListener('change', updateLayout);
});
function closeForNavigation() {
closeReason = 'navigate';
// Finish focus/lock cleanup before the ordinary link reaches the router.
flushSync(() => (open = false));
}
function restoreFocus(event: Event) {
event.preventDefault();
if (closeReason === 'dismiss') {
trigger?.focus({ preventScroll: true });
} else {
// Same-URL links may not move focus themselves. Focus main now, never
// after navigation, so a changed destination keeps Kit's normal focus.
document.querySelector<HTMLElement>('#main')?.focus({ preventScroll: true });
}
}
</script>
<div class="accounts-controls">
{#if !ready}
<details class="accounts-fallback">
<summary>Accounts</summary>
<div class="fallback-links">
<AccountSidebar {sidebar} {pathname} />
</div>
</details>
{:else}
<Dialog.Root
bind:open
onOpenChange={(next) => {
if (next) closeReason = 'dismiss';
}}
>
{#if !desktop}
<Dialog.Trigger bind:ref={trigger}>
{#snippet child({ props })}
<button {...props} type="button" class="ui-button">Accounts</button>
{/snippet}
</Dialog.Trigger>
{/if}
<Dialog.Portal>
<Dialog.Overlay>
{#snippet child({ props })}
<div {...props} class="accounts-overlay"></div>
{/snippet}
</Dialog.Overlay>
<Dialog.Content onCloseAutoFocus={restoreFocus} restoreScrollDelay={0}>
{#snippet child({ props })}
<div {...props} class="accounts-drawer">
<AccountSidebar {sidebar} {pathname} onNavigate={closeForNavigation}>
{#snippet heading()}
<Dialog.Title level={2}>
{#snippet child({ props: titleProps })}
<h2 {...titleProps} class="section-title">Accounts</h2>
{/snippet}
</Dialog.Title>
{/snippet}
{#snippet actions()}
<Dialog.Close>
{#snippet child({ props: closeProps })}
<button {...closeProps} type="button" class="ui-button">Close accounts</button
>
{/snippet}
</Dialog.Close>
{/snippet}
</AccountSidebar>
</div>
{/snippet}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{/if}
</div>
<style>
.accounts-controls {
min-width: 0;
max-width: 100%;
}
.accounts-fallback {
overflow-wrap: anywhere;
}
summary {
min-height: var(--control-height);
padding: var(--space-small);
color: var(--color-accent);
cursor: pointer;
}
.fallback-links {
max-width: 28rem;
padding-block: var(--space-small);
}
.accounts-overlay {
position: fixed;
inset: 0;
z-index: 8;
background: var(--color-backdrop);
}
.accounts-drawer {
--accounts-inset-start: max(var(--space-medium), env(safe-area-inset-left));
--accounts-inset-end: max(var(--space-medium), env(safe-area-inset-right));
position: fixed;
inset: 0;
z-index: 9;
min-width: 0;
height: 100dvh;
padding-inline: 0;
padding-block-start: max(var(--space-medium), env(safe-area-inset-top));
padding-block-end: max(var(--space-medium), env(safe-area-inset-bottom));
background: var(--color-surface);
color: var(--color-text);
overflow: auto;
overscroll-behavior: contain;
overflow-wrap: anywhere;
}
.accounts-drawer[data-state='open'] {
animation: accounts-enter 160ms ease-out;
}
@keyframes accounts-enter {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.accounts-drawer[data-state='open'] {
animation: none;
}
}
@media (min-width: 40rem) {
.accounts-drawer {
inset-inline-start: auto;
inline-size: var(--accounts-width);
max-inline-size: 100%;
border-inline-start: var(--border-width) solid var(--color-border);
}
}
@media (min-width: 80rem) {
.accounts-controls {
display: none;
}
}
</style>
+27
View File
@@ -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[];
}[];
}
+165 -1
View File
@@ -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[] = [
+57
View File
@@ -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[],
@@ -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<typeof loadLayout>[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<typeof loadCashAccount>[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<typeof loadLayout>[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<typeof loadCashAccount>[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 });
}
});
+60 -4
View File
@@ -41,6 +41,15 @@ const publication: ReportPublication = {
}
};
function locals(selectedJournal = journal): Parameters<typeof readSavedReports>[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<ReportPublication | null>((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();
});
+30 -10
View File
@@ -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<SavedReportsState>;
}
async function readPublication(
application: SavedReportsLocals['application']
): Promise<SavedReportsState> {
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<SavedReportsState> {
if (locals.savedReports === undefined) {
const application = locals.application;
locals.savedReports = Promise.resolve().then(() => readPublication(application));
}
return locals.savedReports;
}
+1
View File
@@ -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;
@@ -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;
+68 -50
View File
@@ -1,59 +1,59 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import AccountSidebar from '#lib/navigation/AccountSidebar.svelte';
import AccountsDrawer from '#lib/navigation/AccountsDrawer.svelte';
import type { LayoutProps } from './$types';
let { children, data }: Pick<LayoutProps, 'children' | 'data'> = $props();
</script>
<header class="app-header">
<a class="brand" href={resolve('/(protected)')}>hledger reports</a>
<nav aria-label="Primary">
<a href={resolve('/(protected)')} aria-current={page.url.pathname === '/' ? 'page' : undefined}
>Overview</a
>
{#if data.registers.cash}
<a
href={resolve('/(protected)/registers/cash')}
aria-current={page.url.pathname === '/registers/cash' ||
page.url.pathname.startsWith('/registers/cash/')
? 'page'
: undefined}>Cash register</a
>
{/if}
{#if data.registers.investment}
<a
href={resolve('/(protected)/registers/investments')}
aria-current={page.url.pathname === '/registers/investments' ||
page.url.pathname.startsWith('/registers/investments/')
? 'page'
: undefined}>Investment register</a
>
<a
href={resolve('/(protected)/holdings')}
aria-current={page.url.pathname === '/holdings' ? 'page' : undefined}>Holdings</a
>
{/if}
</nav>
<nav class="utility" aria-label="Utilities">
<a
href={resolve('/(protected)/settings')}
aria-current={page.url.pathname === '/settings' ? 'page' : undefined}>Settings</a
>
</nav>
<div class="session">
<span>{data.session?.user.name}</span>
<form method="POST" action={resolve('/(public)/(auth)/signout')}>
<button class="ui-button" type="submit">Sign out</button>
</form>
<div class="app-shell">
<div class="app-body">
<header class="app-header">
<nav aria-label="Primary">
<a
href={resolve('/(protected)')}
aria-current={page.url.pathname === '/' ? 'page' : undefined}>Overview</a
>
<AccountsDrawer sidebar={data.accountSidebar} pathname={data.pathname} />
</nav>
<nav class="utility" aria-label="Utilities">
<a
href={resolve('/(protected)/settings')}
aria-current={page.url.pathname === '/settings' ? 'page' : undefined}>Settings</a
>
</nav>
<form method="POST" action={resolve('/(public)/(auth)/signout')}>
<button class="ui-button" type="submit">Sign out</button>
</form>
</header>
{@render children()}
</div>
</header>
{@render children()}
<aside class="accounts-rail" aria-label="Accounts">
<AccountSidebar sidebar={data.accountSidebar} pathname={data.pathname} />
</aside>
</div>
<style>
.app-shell {
min-width: 0;
}
.app-body {
min-width: 0;
}
.app-body :global(.page) {
max-width: none;
margin: 0;
}
.accounts-rail {
display: none;
}
.app-header {
max-width: var(--content-width);
margin: 0 auto;
min-width: 0;
max-width: none;
margin: 0;
padding: var(--space-medium);
display: flex;
align-items: center;
@@ -62,21 +62,39 @@
border-bottom: var(--border-width) solid var(--color-border);
}
nav {
min-width: 0;
max-width: 100%;
display: flex;
flex-wrap: wrap;
gap: var(--space-medium);
}
.brand,
[aria-current='page'] {
font-weight: var(--font-weight-strong);
}
.utility {
margin-inline-start: auto;
}
.session {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-small);
@media (min-width: 80rem) {
.app-shell {
padding-inline-end: var(--accounts-width);
}
.accounts-rail {
--accounts-inset-end: max(var(--space-medium), env(safe-area-inset-right));
display: flex;
flex-direction: column;
position: fixed;
inset-block: 0;
inset-inline-end: 0;
width: var(--accounts-width);
min-width: 0;
padding-inline: 0;
padding-block-start: max(var(--space-medium), env(safe-area-inset-top));
padding-block-end: max(var(--space-medium), env(safe-area-inset-bottom));
border-inline-start: var(--border-width) solid var(--color-border);
background: var(--color-surface);
overflow: auto;
overscroll-behavior: contain;
overflow-wrap: anywhere;
}
}
</style>
@@ -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;
@@ -42,10 +42,8 @@ export const load = (async (event): Promise<HoldingsPageData> => {
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.' });
@@ -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<typeof load>[0];
}
+55 -10
View File
@@ -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<typeof load>[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/);
});
@@ -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<typeof load>[0];
}
@@ -16,8 +16,8 @@ export const load = (async (event): Promise<CashAccountsData> => {
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;
@@ -50,8 +50,8 @@ export const load = (async (event): Promise<CashRegisterData> => {
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.
@@ -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<typeof load>[0];
@@ -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<typeof load>[0];
@@ -17,8 +17,8 @@ export const load = (async (event): Promise<InvestmentAccountsData> => {
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;
@@ -49,8 +49,8 @@ export const load = (async (event): Promise<InvestmentAccountPageData> => {
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.
@@ -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<typeof load>[0];
@@ -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<typeof load>[0];
@@ -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;
@@ -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<typeof load>[0];
}
@@ -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
}
+109 -11
View File
@@ -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<SavedReportsState>` 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.
+14 -2
View File
@@ -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;