From 56adb349ca4da4f33daaadc8afd4f445a028b9d7 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 6 Nov 2026 15:06:58 -0800 Subject: [PATCH 1/3] feat: prepare native period counts and checked report version --- .dockerignore | 6 +- README.md | 4 +- apps/web/README.md | 22 +-- apps/web/src/lib/server/accounts.test.ts | 1 + .../server/preparation.integration.test.ts | 1 + .../lib/server/reports.integration.test.ts | 1 + apps/web/src/lib/server/reports.test.ts | 1 + .../(protected)/holdings/page.server.test.ts | 2 + .../web/src/routes/(protected)/layout.test.ts | 1 + .../routes/(protected)/page.server.test.ts | 3 + .../cash/[accountId]/page.server.test.ts | 1 + .../registers/cash/page.server.test.ts | 1 + .../[accountId]/page.server.test.ts | 1 + .../registers/investments/page.server.test.ts | 1 + .../(protected)/settings/page.server.test.ts | 1 + apps/worker/README.md | 70 +++++---- .../src/workflows/prepare-reports/run.test.ts | 1 + docs/architecture.md | 61 +++++--- docs/development.md | 56 ++++--- packages/hledger/README.md | 74 ++++++---- .../fixtures/transaction-counts.journal | 55 +++++++ packages/hledger/src/journal-stats.ts | 70 ++++++--- packages/hledger/src/overview/batch.test.ts | 50 ++++++- packages/hledger/src/overview/batch.ts | 22 ++- .../hledger/src/overview/net-worth.test.ts | 11 +- packages/hledger/src/overview/ranges.test.ts | 7 +- packages/hledger/src/overview/ranges.ts | 8 +- packages/hledger/src/overview/report.test.ts | 31 ++-- packages/hledger/src/overview/report.ts | 7 +- .../transaction-counts.integration.test.ts | 24 +++ packages/hledger/src/prepare-reports.test.ts | 70 ++++++--- packages/hledger/src/prepare-reports.ts | 13 +- packages/hledger/test-fixtures.ts | 10 ++ packages/snapshots/README.md | 47 +++--- packages/snapshots/src/reports.test.ts | 139 ++++++++++++++---- packages/snapshots/src/reports.ts | 10 +- 36 files changed, 652 insertions(+), 231 deletions(-) create mode 100644 packages/hledger/fixtures/transaction-counts.journal create mode 100644 packages/hledger/src/overview/transaction-counts.integration.test.ts diff --git a/.dockerignore b/.dockerignore index 94ef7ab..bc9e840 100644 --- a/.dockerignore +++ b/.dockerignore @@ -55,7 +55,11 @@ apps/*/config/** **/*.tsv **/config/*.yaml **/config/*.yml -# Keep the existing correctness fixture and safe configuration example. +# Keep only explicit owned correctness fixtures and safe configuration examples. +!packages/hledger/fixtures/cash.journal +!packages/hledger/fixtures/checking.journal +!packages/hledger/fixtures/register.journal +!packages/hledger/fixtures/transaction-counts.journal !apps/worker/fixtures/lots.journal !apps/worker/fixtures/config/hledger-reports.yaml !apps/web/config/hledger-reports.example.yaml diff --git a/README.md b/README.md index ba4d2ee..f890c60 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The hledger library reads the journal and organizes neutral, table/chart-ready ` **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). +**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; existing sets require explicit format-8 preparation after a paired web/worker upgrade. 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. @@ -84,7 +84,7 @@ Configuration and `data/` remain siblings. Renaming the config in place does not - `data/snapshots/current/overview.json`: the selected journal identity, `OverviewData` and the account directory. Each native account has an eight-character lowercase-letter/digit ID, full name, access state, row count and required raw/valued summary. IDs are reused by exact name within supported same-journal publications, but may reset across the format transition. - `data/snapshots/current/transactions/.json`: one complete account register per file, with its identity, exact native amounts and exported transaction/posting metadata. Source and assertion positions remain private. This is processed report output, not original journal text or a complete investment cost-basis export. Account names remain JSON data, never paths. Pagination is a server-side array slice, not persisted page files. -Financial files are prepared together in a hidden staging set and published as a complete directory; `current` is the inspectable entry point. Readers require complete **format-6** publications for the selected journal, replacing format 5. To activate, stop old preparation execution, deploy compatible web and worker revisions together, then explicitly **Prepare reports again** in Settings. Format-5 files remain untouched but unusable; old account URLs may change because IDs may reset at this transition. Readers do not migrate, delete or automatically regenerate files. Former source-observation files are also left untouched and are not read or written. One directory rename commits the entire financial set. Before that commit, failure retains the previous complete set; a later `current` alias failure can fail the job while the new complete set remains selectable, without rollback or deletion. Reload saved reports to observe the result. The v1 void-input workflow and independent recorded-validation lifecycle are unchanged. See [publication and recovery](packages/snapshots/README.md#publication-and-reads) for commit behavior. +Financial files are prepared together in a hidden staging set and published as a complete directory; `current` is the inspectable entry point. Readers require complete **format-8** publications for the selected journal, including the checked hledger version and all four native period transaction counts. Formats 7, 6 and older are unsupported. To activate, stop old preparation execution, deploy compatible web and worker revisions with rebuilt shared packages together, then explicitly **Prepare reports again** in Settings. Unsupported files remain untouched but unusable; old account URLs may change because IDs may reset at this transition. Readers do not migrate, delete or automatically regenerate files. Former source-observation files are also left untouched and are not read or written. One directory rename commits the entire financial set. Before that commit, failure retains the previous complete set; a later `current` alias failure can fail the job while the new complete set remains selectable, without rollback or deletion. Reload saved reports to observe the result. The v1 void-input workflow and independent recorded-validation lifecycle are unchanged. See [publication and recovery](packages/snapshots/README.md#publication-and-reads) for commit behavior. In Compose, these paths are visible under **`apps/web/data/`** in the checkout. The previous named volume is left untouched; [copy it safely before the first bind-mounted start](docs/development.md#inspecting-data-and-preserving-the-previous-volume) to retain its queue and publications. Protect and back up the entire data directory; do not log financial data or native diagnostics. [Prepared-data lifetime](packages/snapshots/README.md#current-format-and-preservation) covers growth, last-good recovery and untouched unsupported files. diff --git a/apps/web/README.md b/apps/web/README.md index e08be5c..7eae27a 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -141,19 +141,21 @@ overview and account directory, including required raw/valued summaries. Registe 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; 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 +journal identities and set paths never enter page data. Financial format **8** requires +the checked hledger version and all four native period transaction counts, and 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. -Financial format **6** replaces 5. Stop old preparation execution, deploy compatible -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. Accounts -navigation changes neither format 6 nor native/worker preparation, transaction -history or configuration; existing format-6 sets need no additional preparation. +Only financial format **8** is supported; formats 7, 6 and older are unsupported. +Stop old preparation execution, deploy compatible web and worker revisions with +rebuilt shared packages together, then explicitly **Prepare reports again** in +Settings, including with an existing format-6 or format-7 set. Unsupported 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, independent +validation-evidence lifecycle, transaction history and configuration are unchanged. +See [paired activation](../../docs/development.md#financial-format-8-activation). 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. diff --git a/apps/web/src/lib/server/accounts.test.ts b/apps/web/src/lib/server/accounts.test.ts index c5deab9..c0bb107 100644 --- a/apps/web/src/lib/server/accounts.test.ts +++ b/apps/web/src/lib/server/accounts.test.ts @@ -11,6 +11,7 @@ const publication: ReportPublication = { asOf: null, preparedAt: '2026-05-01T12:00:00Z', journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, incomeExpenses: { months: [] }, diff --git a/apps/web/src/lib/server/preparation.integration.test.ts b/apps/web/src/lib/server/preparation.integration.test.ts index 0dcabef..f0a666d 100644 --- a/apps/web/src/lib/server/preparation.integration.test.ts +++ b/apps/web/src/lib/server/preparation.integration.test.ts @@ -24,6 +24,7 @@ const ready = { asOf: null, preparedAt: '2026-05-01T12:34:56Z', journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, incomeExpenses: { months: [] }, diff --git a/apps/web/src/lib/server/reports.integration.test.ts b/apps/web/src/lib/server/reports.integration.test.ts index 8193886..bd33b77 100644 --- a/apps/web/src/lib/server/reports.integration.test.ts +++ b/apps/web/src/lib/server/reports.integration.test.ts @@ -24,6 +24,7 @@ it('keeps the layout and saved account leaf on one real publication until a fres asOf: null, preparedAt: '2026-05-01T12:00:00Z', journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, incomeExpenses: { months: [] }, diff --git a/apps/web/src/lib/server/reports.test.ts b/apps/web/src/lib/server/reports.test.ts index 50fd188..63ce32a 100644 --- a/apps/web/src/lib/server/reports.test.ts +++ b/apps/web/src/lib/server/reports.test.ts @@ -34,6 +34,7 @@ const publication: ReportPublication = { asOf: null, preparedAt: '2026-04-01T12:00:00Z', journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', incomeExpenses: { months: [] }, ranges: null, balanceSheet: null, diff --git a/apps/web/src/routes/(protected)/holdings/page.server.test.ts b/apps/web/src/routes/(protected)/holdings/page.server.test.ts index c3cb0ab..464e4ba 100644 --- a/apps/web/src/routes/(protected)/holdings/page.server.test.ts +++ b/apps/web/src/routes/(protected)/holdings/page.server.test.ts @@ -49,6 +49,7 @@ const nonInvestmentSettings: ApplicationContext['config']['accounts'] = [ const anchorRange: OverviewRange = { begin: '2026-04-01', endExclusive: '2026-04-17', + transactionCount: 1, income: [], expenses: [], net: [], @@ -85,6 +86,7 @@ const publication: ReportPublication = { asOf: '2026-04-16', preparedAt: '2026-05-01T12:34:56Z', journalCoverage: { transactionCount: 3, firstDate: '2026-04-01', lastDate: '2026-04-16' }, + hledgerVersion: '1.99.5', ranges: { 1: anchorRange, 3: { ...anchorRange, begin: '2026-02-01' }, diff --git a/apps/web/src/routes/(protected)/layout.test.ts b/apps/web/src/routes/(protected)/layout.test.ts index 51a82c0..3c49650 100644 --- a/apps/web/src/routes/(protected)/layout.test.ts +++ b/apps/web/src/routes/(protected)/layout.test.ts @@ -22,6 +22,7 @@ it('projects configured sidebar data and labels without queue access, session da asOf: null, preparedAt, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, incomeExpenses: { months: [] }, diff --git a/apps/web/src/routes/(protected)/page.server.test.ts b/apps/web/src/routes/(protected)/page.server.test.ts index 0ec2f2a..24fcc60 100644 --- a/apps/web/src/routes/(protected)/page.server.test.ts +++ b/apps/web/src/routes/(protected)/page.server.test.ts @@ -21,6 +21,7 @@ vi.mock('yaml', () => { const journal = '/PRIVATE/main.journal'; const nonSixMonthRange: OverviewRange = { + transactionCount: 9, begin: '2025-05-01', endExclusive: '2026-04-17', income: [{ commodity: 'PRIVATE income', quantity: '1' }], @@ -63,6 +64,7 @@ const latestPoint = points[11]; latestPoint.amounts = [{ commodity: 'USD', quantity: '0.000000000000000001' }]; latestPoint.unconverted = ['STOCK']; const overview: OverviewData = { + hledgerVersion: '1.99.5', ranges: { 1: nonSixMonthRange, 3: nonSixMonthRange, @@ -225,6 +227,7 @@ it('keeps an undated publication empty without inventing categories or history', vi.mocked(readReportPublication).mockResolvedValue({ ...publication, overview: { + hledgerVersion: '1.99.5', asOf: null, preparedAt: overview.preparedAt, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, diff --git a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts index e5fb755..989da39 100644 --- a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts @@ -33,6 +33,7 @@ const publication: ReportPublication = { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, netWorth: { currency: null, points: [] }, diff --git a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts index 769ea88..487a08d 100644 --- a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts @@ -34,6 +34,7 @@ const publication: ReportPublication = { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, netWorth: { currency: null, points: [] }, diff --git a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts index 090e4e0..e6312c3 100644 --- a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts @@ -33,6 +33,7 @@ const publication: ReportPublication = { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, netWorth: { currency: null, points: [] }, diff --git a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts index 5652952..ff50027 100644 --- a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts @@ -34,6 +34,7 @@ const publication: ReportPublication = { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, netWorth: { currency: null, points: [] }, diff --git a/apps/web/src/routes/(protected)/settings/page.server.test.ts b/apps/web/src/routes/(protected)/settings/page.server.test.ts index 4c6a165..591ad2e 100644 --- a/apps/web/src/routes/(protected)/settings/page.server.test.ts +++ b/apps/web/src/routes/(protected)/settings/page.server.test.ts @@ -91,6 +91,7 @@ it('serializes only saved time, never financial data, journal or publication dir directory: '/PRIVATE/set', accounts: [], overview: { + hledgerVersion: '1.99.5', journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, ranges: null, balanceSheet: null, diff --git a/apps/worker/README.md b/apps/worker/README.md index 7b509e5..965e9d4 100644 --- a/apps/worker/README.md +++ b/apps/worker/README.md @@ -53,42 +53,58 @@ builds. The web requests these jobs; this process performs them. ## Explicit report preparation -Web displays saved reports or no reports. The inline `/settings` preparation form POST explicitly calls the web-owned `requestReportPreparation(client)`; reading reports or preparation status never creates work. The worker executes requested jobs only. Boot, idle time, journal edits and restart do not request financial jobs. There are no source hashes, filesystem watches, discovery/reconciliation loops, staleness checks or automatic follow-ups. Scheduled query retention below is independent operational maintenance. `/prepare` is removed without a redirect or POST alias; Settings also links to the existing `/queries` tool. +The worker executes requested jobs only. Startup, idle time, edits and restart do +not request financial work. There are no source hashes, filesystem watches, +staleness checks or automatic follow-ups. Scheduled query retention is independent +operational maintenance. Web owns requesting/joining work through Settings; see +[web behavior](../web/README.md#pages). -`src/workflows/index.ts` explicitly registers all five implementations. `src/workflows/prepare-reports/run.ts` owns the concrete handler for `prepareReportsSpec`: persisted SDK name `hledger.prepare-dashboard`, version **1**, **void input**, full **OverviewData output**, **maximumAttempts 1**, and no schedule. Explicit requests join active work; after completion or failure, another explicit request starts a new run even with unchanged inputs. A failed run remains failed until an explicit retry; editing the journal does not clear it. +`src/workflows/index.ts` explicitly registers all five implementations. +`prepare-reports/run.ts` implements `prepareReportsSpec`: persisted SDK name +`hledger.prepare-dashboard`, version **1**, **void input**, **OverviewData output**, +**maximumAttempts 1**, no schedule. Concurrent explicit requests join active work; +a later request starts a new run even with unchanged inputs. Failure remains failed +until another explicit request. The workflow identity/retry policy is unchanged. -The handler awaits standalone `checkJournal(journal)`, saves its completed outcome, -and proceeds to financial staging only after a recorded pass. An incomplete check -preserves earlier evidence; check or evidence-write failure stops financial work. -The extra preflight adds a third native process; the final financial batch remains -independently strict. It is not a replay-cached SDK step, and a recorded pass is -not a completed financial set or proof of unchanged inputs. +The handler awaits standalone `checkJournal(journal)`, records its completed outcome +and continues only after a recorded pass. Incomplete checking preserves earlier +evidence; check/evidence-write failure stops before financial staging. A recorded +pass is not proof of unchanged inputs or completed finances. -Snapshots owns immutable evidence and its selection rules; the web exposes only the -saved outcome/time. See [validation ownership](../../docs/architecture.md#last-recorded-validation) -for failure semantics and [recorded evidence](../../packages/snapshots/README.md#recorded-validation) -for storage/privacy/retention. Financial format **6** replaces 5; the web deliberately -keeps its six-month presentation. The workflow identity/version, void input, -OverviewData output contract and retry policy are unchanged. +Afterward it reads the prior publication, tolerating missing, corrupt or unsupported +data so an explicit job can replace it. Only supported same-journal data supplies +previous account IDs. It creates a private stage and awaits +`prepareReports(journal, { writeRows: stage.writeRows })`, then publishes Overview, +account summaries and staged rows together. The handler returns **its own calculated +OverviewData**, not a readback of whichever publication is selected, and awaits +stage discard in `finally`. -After recording a pass, the handler reads the previous saved publication, allowing missing, corrupt or unsupported data to be replaced by an explicit job. Only a usable current-format publication with a matching journal path allows reuse of prior account IDs. It creates a stage and calls `prepareReports(journal, { writeRows: stage.writeRows })`. The library uses one cached hledger journal view for native 1/3/6/12-month income/expenses/NET/category totals, twelve shared monthly columns, twelve net-worth points, latest assets/liabilities, two bulk raw/valued account reports and every full-history account register, delivering exact rows in awaited, ordered batches of at most 50. For dated journals this is 4 income statements + 12 balance sheets + 2 bulk balances = **18 financial report commands**, still two financial processes including discovery, plus the separate check = **three processes for the whole job**. Hledger owns all financial arithmetic and the latest-primary-date cutoff, including partial months; no JS sums, per-configured-member reports or WorkerConfig changes are added. These are not persisted pages: the store writes **one whole JSON file per account** without pooling every register in Node. The library owns account selection, dates, quantities and native balances. Edits during a build do not trigger another job: the publication represents the view loaded for that execution. +There are no replay-cached SDK financial checkpoints. Older queued runs with legacy +steps can finish under this handler without changing those steps. SQLite persists +the handler's Overview result, not duplicate account-history arrays or private source +positions. Native diagnostics and source paths never enter job output. -After successful native production and awaited staged I/O, `stage.publish(reports.overview, reports.accounts, journal)` commits the complete Overview, catalog and account files. The handler returns **its own calculated OverviewData**, not a readback of whichever publication is currently selected, and always awaits stage discard. Failure before the financial directory commit retains the previous complete set. -An inspection-only `current` alias failure after commit may fail the job while the new -valid complete set remains selectable. No completed set is rolled back or deleted; -Settings tells users to reload saved reports, not that failure guarantees unchanged data. There are no SDK financial checkpoints; old queued runs with legacy steps can finish under this handler without changing those steps. - -Only publication format **6** is accepted. Required raw inclusive balances and native end-valued amount bags with `unconverted` evidence are stored in the account catalog; investment values include uninvested cash. Existing native canonical qualification, exact full names, declared/used membership and register rows are unchanged. Whole-account files retain the library-owned original transaction and every posting, comments, ordered tags, exact nested amounts, account context and private source/assertion positions. These records reflect hledger's account-register export, not a verbatim source archive or complete investment cost/basis record; the worker does not recover annotations stripped by hledger. Source paths remain private stored metadata, never browser payloads or job output. There is no application-imposed saved-file size cap. - -The store pins an immutable private `.sets/` directory for each read; `current/` is an atomically updated human-inspection alias. Unsupported formats (including format 5 and all older shapes) provide no current report data or reusable account IDs. To activate format 6, stop old preparation execution and deploy compatible web and worker revisions together before explicitly **Prepare reports again** in Settings. Their format-5 files remain untouched but unusable, and account IDs may reset across the transition. There is no automatic work, fallback, migration, compatibility reader or garbage collection. This report-file policy is separate from the queued SDK job policies described here. OpenWorkflow persists only the handler's own **OverviewData** financial result in its private SQLite database, not duplicate account transaction arrays or source positions. - -See the [snapshot store](../../packages/snapshots/README.md) for last-good recovery and storage growth, and [web requester](../web/README.md) for request coordination and [shared contracts](../../packages/jobs/README.md) for persisted identities. +- [Architecture](../../docs/architecture.md#prepared-reports) owns cross-layer phases. +- [Native library](../../packages/hledger/README.md#overview-planning-and-interpretation) + owns commands, version checking, dates/counts and execution cost. +- [Snapshots](../../packages/snapshots/README.md#publication-and-reads) owns commit, + validation records, read selection and preservation. Post-commit alias failure can + fail the job while leaving a new valid set selectable; there is no rollback. +- [Format-8 activation](../../docs/development.md#financial-format-8-activation) owns + the manual paired web/worker/shared-package upgrade. ## Register preparation resources -The hledger library has one private `process.ts` boundary for all actual calls; unused standalone Overview/register execution APIs are removed. Version checking and explicit inspection use that boundary outside financial jobs. The native producer has no application-imposed execution deadline, output/row/tuple cap or saved-file size limit. Batches are written sequentially with backpressure into whole-account files. Native JSON generation can still use substantial hledger memory; browser pagination does not reduce native preparation cost or whole-account reads. Health remains separate from preparation. +Native rows are written sequentially with backpressure into whole-account files; +browser pagination does not reduce native preparation cost or whole-file read cost. +The producer has no application-imposed native deadline, output/row/tuple cap or +saved-file size cap. Native/Node memory can still be exhausted; there is no universal +journal-capacity guarantee. See the [native resource contract](../../packages/hledger/README.md#one-hledger-process-boundary). -There is no universal journal-capacity guarantee: larger input may exhaust native or Node resources. A failure before financial commit leaves the previous complete dataset available. Native CSV is not a fallback: it rounds some nonzero quantities to zero. Row callbacks own their I/O; native cancellation kills/reaps the process, then waits for the current write to settle before cleanup. The worker always discards its own unfinished stage, but completed directories are not removed, since they may already be selected. As with the existing publications, successful/orphaned completed directories remain until a separately agreed retention policy. +Cancellation kills/reaps native work and waits for active writes before cleanup. +The handler discards only its own unfinished stage; completed sets may already be +selected and are retained. Prepared-data retention is separate from SQLite query +retention below. Health remains independent of report calculation. ## Current-journal queries diff --git a/apps/worker/src/workflows/prepare-reports/run.test.ts b/apps/worker/src/workflows/prepare-reports/run.test.ts index b528751..1921945 100644 --- a/apps/worker/src/workflows/prepare-reports/run.test.ts +++ b/apps/worker/src/workflows/prepare-reports/run.test.ts @@ -29,6 +29,7 @@ const overview: OverviewData = { asOf: null, preparedAt: '2026-04-16T00:00:00Z', journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, netWorth: { currency: null, points: [] }, incomeExpenses: { months: [] }, diff --git a/docs/architecture.md b/docs/architecture.md index 274ef2d..d716267 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,9 +30,9 @@ launch point: small text reads and streamed prepared output share cancellation and cleanup there. There are no application-imposed native execution deadlines, output/row/tuple limits or saved-file size caps; native and Node memory still matter. Financial calls happen in worker jobs; checking the -installed version and the explicit journal-inspection command are operational -exceptions. There are no standalone Overview/register execution APIs or generic -CLI endpoint. Chart coordinates are approximate presentation only; tables +installed version at startup and during preparation, and the explicit +journal-inspection command, are operational checks, not extra financial batches. +There are no standalone Overview/register execution APIs or generic CLI endpoint. Chart coordinates are approximate presentation only; tables and tooltips retain the exact quantities reported by hledger. Worker code does not interpret accounting. Report storage does not recalculate or regroup report rows. @@ -56,8 +56,10 @@ data, never paths. The server reads one account's complete JSON and slices 50 ro before returning page data. Account changes and balances are already calculated by hledger; pagination does not reconstruct them. No separate indexes or persisted page files. -Financial format **6** replaces 5. The overview requires native 1/3/6/12-month -income, expenses, NET and category range totals; twelve shared monthly columns; +Financial format **8** replaces unsupported formats 7, 6 and older. The overview +requires the checked `hledgerVersion` identifier (`1.99.5`, not the native banner), +native 1/3/6/12-month income, expenses, NET and category range totals with required +safe nonnegative `transactionCount` values; twelve shared monthly columns; twelve exact-date net-worth points; and latest native assets/liabilities sides. Range totals come from native `prTotals.prrTotal` and `cbrTotals.prrTotal`, never JS sums or subtraction. Side totals retain native signs. The cutoff is the latest primary @@ -71,14 +73,26 @@ unchanged; there is no fuzzy folding, per-configured-account work or WorkerConfi change. Unconverted evidence includes exact descendants even if parent amounts cancel; known unused accounts retain native `[]`, not fake zeros or missing data. Undated overviews have null ranges/balanceSheet, empty histories and null currency; -accounts are undated unless ambiguous. No financial arithmetic moves into JS. +accounts are undated unless ambiguous. The checked version remains required even +for undated reports. No financial arithmetic moves into JS. -For dated journals, the cached batch now runs four income statements, twelve balance sheets and two -bulk balances: **18 financial report commands**, still two financial processes -including discovery, plus the separate preflight = **three whole-job processes**. -Account membership, history and the SDK v1 void-input workflow contract are unchanged. +Four native `stats -b BEGIN -e EXACT_END_EXCLUSIVE` commands supply the range +counts from the same cached production view, without monthly interval expansion. +Counts use primary transaction dates, including transfers/opening balances; +posting and secondary dates do not move transactions across count boundaries. +They are not sums of register/chart rows. Empty dated periods have count zero; +undated reports retain null ranges, not invented zero-count ranges. -Format 6 retains original exported transaction/posting +For dated journals, the cached batch runs four income statements, twelve balance +sheets and two bulk balances: **18 financial report commands**, plus four filtered +stats commands inside that batch, not four extra processes. Discovery and cached +production remain two financial processes. Preparation also checks the engine in +one version-only process before discovery, without journal flags or reads. With +the separate strict preflight, the whole job uses **four processes**, still **three +journal parses**. Account membership, history and the SDK v1 void-input workflow +contract are unchanged. + +Format 8 retains original exported transaction/posting metadata, recursively exact amounts and account-relative facts in `PreparedAccountRow`: `accountDate` is distinct from the original `transaction.date`. The hledger library owns payee parsing, status inheritance and canonical account/subtree posting membership. The current unfiltered @@ -101,15 +115,16 @@ 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 new valid complete set remains selectable. Completed sets are never rolled back or deleted, and a job failure must not be presented as proof that reports are unchanged. -Readers support only the complete format-6 report shape with required ranges, -histories, latest sides and account summaries; the private journal identity, account -directory and pinned set location are always present. If the latest +Readers support only the complete format-8 report shape with required checked +version, range counts, ranges, histories, latest sides and account summaries; the +private journal identity, account directory and pinned set location are always present. If the latest saved set uses an unsupported format, no report is returned; the user explicitly prepares again. Older files are neither interpreted nor used as a fallback. Activation requires stopping old preparation execution, deploying compatible web and worker revisions -together, then explicitly **Prepare reports again** in Settings. Format-5 files stay -untouched but unusable. Account IDs may reset across this format transition; -exact-name reuse continues within supported same-journal publications. +together with rebuilt shared packages, then explicitly **Prepare reports again** +in Settings. Formats 7, 6 and older stay untouched but unusable. See +[paired activation](development.md#financial-format-8-activation). Account IDs may +reset across this format transition; exact-name reuse continues within supported same-journal publications. Reports for another configured journal are withheld. Journal identity is private operational context, not an input fingerprint or freshness assertion. Files are left @@ -188,16 +203,16 @@ flags; a saved point is available even without configured groups. This is never 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. +Accounts navigation does not change native account membership, transaction history +or configuration. Existing sets require explicit format-8 preparation after the +paired upgrade described above. ## Last recorded validation The worker awaits standalone `checkJournal` before financial staging. This adds one -strict process/parse to the existing two-process discovery/financial preparation: -three in total. A completed exit 0 records **Passed**; a completed normal nonzero -records **Could not validate** and stops preparation. Nonzero can mean rejected, +strict process/parse to the two-process discovery/financial path; preparation's +version-only check makes four processes in total, still three journal parses. +A completed exit 0 records **Passed**; a completed normal nonzero records **Could not validate** and stops preparation. Nonzero can mean rejected, malformed, missing or unreadable input, not necessarily invalid accounting. Launch, abort, signal, stream or unexpected incomplete failures preserve prior evidence. No diagnostics are parsed or stored. Evidence-write failure stops financial work. diff --git a/docs/development.md b/docs/development.md index c8597f5..d47e3c9 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,7 +34,7 @@ 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 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. +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-8 reports are displayed; formats 7, 6 and older are unsupported. 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 @@ -87,23 +87,36 @@ The current set has one `overview.json` with journal/report details and the acco **SQLite query retention is enabled**. See [retention scope, scheduling and backup precautions](../apps/worker/README.md#query-job-retention); it is not prepared-file cleanup. -## Financial format-6 activation +## Financial format-8 activation -Stop old preparation execution before upgrading. Deploy compatible web and worker -revisions together, then explicitly **Prepare reports again** in Settings. Independent -image publication is not a coordinated activation. Format-5 files remain untouched -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. +The current financial format is **8**, with required checked hledger version and +all four native period transaction counts alongside existing coverage/financial +data. PR3 used format **6**; format **7** was an unlanded intermediate. Both and all +older formats are unsupported. See the +[storage contract](../packages/snapshots/README.md#current-format-and-preservation) +for validation and preservation, and the +[native contract](../packages/hledger/README.md#overview-data) for producer semantics. -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 -including discovery, plus the separate strict check = **three whole-job processes**. -This command count is not a benchmark or capacity guarantee. Hledger still owns all -financial arithmetic and the latest-primary-date cutoff, including partial months. +1. Stop old preparation execution before upgrading. +2. Deploy compatible web **and** worker revisions, including rebuilt shared packages, + together. Independent image publication is not coordinated activation. For a + local compiled pair, after stopping the old stack: + + ```sh + docker compose -f apps/web/docker-compose.yml up --build --wait web worker + ``` + + Compiled browsing still requires the trusted proxy described below. For local + Vite development, use the development Compose file instead. + +3. Explicitly **Prepare reports again** in Settings, including with an existing + format-6 or format-7 set, then reload/navigate to saved reports. + +Unsupported files remain untouched but unread. Account IDs may reset, so old account +URLs may no longer resolve. There is no automatic preparation, migration or deletion. +Do not clear data or edit envelopes to complete activation. Authentication, +configuration, the v1 void-input workflow and independent validation evidence do +not change. ## Register configuration @@ -219,8 +232,9 @@ hide readable finances. Journal/request identities, storage paths and native dia remain private. Records are retained without cleanup or a history UI; no freshness proof is inferred from their presence or timestamps. -Explicit preparation now runs standalone `checkJournal` before staging: a third native -process/strict parse before the existing two-process financial path. Completed exit 0 +Explicit preparation runs standalone `checkJournal` before staging: an additional +strict parse before the two-process financial path. Preparation's version-only +check makes four native processes in total, still three journal parses. Completed exit 0 records Passed; completed normal nonzero records Could not validate and stops financial work. Missing/unreadable input can also produce nonzero; it is not an invalid-accounting diagnosis. Launch/abort/signal/stream/unexpected incomplete failures preserve earlier @@ -304,6 +318,12 @@ This runs type checks, lint, unit/integration tests, builds, and native prefligh with Node **26.5.0**, pnpm **11.9.0**, and checksum-verified hledger **1.99.5**. Tests have their own loopback network and do not mount your source or data volume. +Docker context exceptions remain explicit: the four owned native fixtures +`cash.journal`, `checking.journal`, `register.journal` and +`transaction-counts.journal` under `packages/hledger/fixtures/`, the worker's owned +`lots.journal` and safe YAML examples. This is not an allowlist for arbitrary local +financial data; private journal/data directories remain excluded from images. + For the compiled **fictional-fixture** deployment, Chromium uses the test-only override: ```sh diff --git a/packages/hledger/README.md b/packages/hledger/README.md index f44b7cc..37a7eb3 100644 --- a/packages/hledger/README.md +++ b/packages/hledger/README.md @@ -3,7 +3,9 @@ Hledger library, pinned to **1.99.5**. It reads journal data and organizes neutral, chart/table-ready datasets rather than exposing CLI-shaped results or Svelte chart props. Hledger owns accounting; this library is not a second accounting engine. Process execution and exact decoding stay inside the library; web imports hledger types only. Only operations used by the apps are exported; hledger JSON types, codecs and process helpers stay private. - `prepareReports(path, { writeRows, signal? })` calculates Overview and all account activity by running hledger for the manual preparation job. Unlike storage's `readReportPublication`, it prepares new data rather than reading a saved report. -- `getHledgerVersion()` verifies the inspected engine version. +- `getHledgerVersion()` verifies the inspected engine version; its return is the + native banner. Preparation calls it once in a version-only process and saves the + validated numeric identifier `1.99.5`, not that banner. - `checkJournal(path): Promise` runs read-only strict checking with ambient hledger config disabled. It returns only after complete consumption, normal exit 0 and cleanup; sanitized `JournalCheckError.kind` distinguishes `nonzero-exit` from `not-completed`. A normal nonzero supports **Could not validate**, not an invalid-accounting diagnosis: input may be missing, unreadable or malformed. Launch/abort/signal/stream/unexpected errors mean incomplete execution. - `runCostBalance(path, expression, asOf)` runs one native `balance --strict --cost --flat` expression against the supplied journal path and its current includes. Native strict validation happens on every execution, not only at worker startup. It freezes relative-date context, uses an inclusive as-of cutoff, validates the returned structure, and preserves reported quantities as strings. It does not perform market valuation or implicit FX conversion. There are no application-imposed native runtime/output/row caps; this is not a journal-capacity or native-memory guarantee. - Exact JSON ingestion reads integer mantissas as BigInt from original JSON tokens, @@ -39,7 +41,7 @@ src/ │ └── cost-balance.ts # Recorded-cost query ├── amounts.ts # Shared exact quantities and amount projection ├── report-stream.ts # Incremental report framing -├── journal-stats.ts # Journal metadata interpretation +├── journal-stats.ts # Coverage, primary anchor, private currency and counts └── report-date.ts # Shared ISO report-date formatting ``` @@ -56,6 +58,7 @@ separate Overview executor. Storage and publication remain caller-owned. type OverviewMonths = 1 | 3 | 6 | 12; type NativeValue = { amounts: ReportedAmount[]; unconverted: string[] }; interface OverviewRange { + transactionCount: number; begin: string; endExclusive: string; income: ReportedAmount[]; @@ -69,6 +72,7 @@ interface OverviewRange { interface OverviewData { asOf: string | null; preparedAt: string; + hledgerVersion: string; // validated '1.99.5', not the native banner ranges: Record | null; balanceSheet: { assets: NativeValue; liabilities: NativeValue } | null; netWorth: { @@ -92,9 +96,26 @@ interface OverviewData { // ReportedAmount = { commodity: string; quantity: string } ``` -`journalCoverage` is the whole-journal transaction count and inclusive native date span. It comes from the existing **cached batch's unfiltered stats**, alongside the financial report—not the earlier discovery process. No additional native command is needed. The native span includes secondary and posting dates, so it can extend beyond the latest primary transaction (`asOf`) or any prepared reporting range. The library converts the native exclusive span end to an inclusive `lastDate`; web does not interpret native statistics or perform that conversion. +`journalCoverage` is the whole-journal transaction count and inclusive native date span. It comes from the existing **cached batch's unfiltered stats**, alongside the financial report—not the earlier discovery process. Whole-journal coverage itself needs no additional native command; filtered period counts below use four. The native span includes secondary and posting dates, so it can extend beyond the latest primary transaction (`asOf`) or any prepared reporting range. The library converts the native exclusive span end to an inclusive `lastDate`; web does not interpret native statistics or perform that conversion. -Empty journals have count `0` and null dates. Price directives and unexpanded periodic rules do not create recorded transactions. Rates, recent-days counts, runtime statistics and source paths are not exposed. Coverage does not prove completeness or freshness. +Empty journals have coverage count `0` and null dates, with required +`hledgerVersion`. Price directives and unexpanded periodic rules do not create +recorded transactions. Runtime statistics and source paths are not exposed. +Coverage does not prove completeness or freshness. The parsed `baseCurrency` +remains private planning data for financial valuation, not a stored statistics +object. + +Each 1/3/6/12-month range requires `transactionCount: number`, a safe nonnegative +integer from native `stats -b BEGIN -e EXACT_END_EXCLUSIVE` in the existing cached +second financial batch. Bounds are the range's `begin` (inclusive) and +`endExclusive` (`asOf` plus one day), without monthly interval expansion. It counts +actual transactions by **primary transaction date**, including transfers and opening +balances, independently of income/expense classification. Posting-date and secondary +(`date2`) overrides do not move a transaction into or out of the count. It is not a +posting count, sum of register/chart rows or TypeScript reconstruction. A dated +period with no matching transactions has count `0`; undated reports keep +`ranges: null`, not four invented zero-count ranges. Native preparation computes all +four counts once; browser selection only reads the selected prepared count. Each range's `expenseCategories` contains native expense-account totals over that selected range, at maximum absolute account depth 2. For example, `expenses:food:groceries` and `expenses:food:restaurants` roll into `expenses:food`; depth is counted from the beginning of the full account name. Native flat reporting keeps shallower direct postings separate rather than displaying inclusive parent totals alongside their children. Full native account names and order are retained, including expense roots not literally named `expenses`. @@ -114,13 +135,15 @@ Each range begins on the first day of its earliest month and ends exclusively at `asOf` is the latest recorded **primary transaction date**, not today or SDK request time. Assets, transfers, gains and recorded future-dated transactions can anchor the window. Price directives and unexpanded periodic rules do not count. Gain accounts (`type:G`) are excluded from income/expense values, not date discovery. Net worth uses native asset/liability classification; it does not inherit the income report's gain filter. Each 1/3/6/12-month range covers the as-of month and its preceding calendar months. Its trailing month runs through the latest primary transaction date without claiming completeness. For example, `2026-04-16` anchors the shared history at May 2025–April 2026 and the current six-month view at November 2025–April 2026, regardless of today's date. -No dated transactions returns the standard dataset with `asOf: null`, a current `preparedAt`, `journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }`, `ranges: null`, `balanceSheet: null`, `incomeExpenses: { months: [] }` and `netWorth: { currency: null, points: [] }`, not fabricated zero months. The income/category report requests no cost, market valuation or FX conversion. Its amounts are not available cash, cash flow or net worth: transfers, loans and asset-sale proceeds differ. This neutral dataset supports the exact table and grouped bars without chart-library configuration in this package. +No dated transactions returns the standard dataset with `asOf: null`, a current `preparedAt`, required `hledgerVersion`, `journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }`, `ranges: null`, `balanceSheet: null`, `incomeExpenses: { months: [] }` and `netWorth: { currency: null, points: [] }`, not fabricated zero months. The income/category report requests no cost, market valuation or FX conversion. Its amounts are not available cash, cash flow or net worth: transfers, loans and asset-sale proceeds differ. This neutral dataset supports the exact table and grouped bars without chart-library configuration in this package. ### Overview planning and interpretation `overview/batch.ts` plans commands and decodes the framed Overview batch; it does not execute hledger. `journal-stats.ts` decodes metadata; `overview/income-statement.ts` and `overview/balance-sheet.ts` describe/decode their hledger wire formats. `overview/report.ts` owns `OverviewData` and organizes financial fields, delegating range/history organization to `overview/ranges.ts` and net-worth/side interpretation to `overview/net-worth.ts`. The combined preparation operation below owns when these steps happen; there is no separate Overview executor. -Unfiltered strict native statistics discover the primary anchor and inferred target. The actual prepared batch reads statistics, four income statements, twelve date-specific historical balance sheets, two bulk account balance reports and all account registers from the same loaded journal. For dated journals that is **18 financial report commands**, up from 7, still within two financial processes (discovery plus cached production). The worker's separate strict preflight makes **three processes for the whole job**, not three financial batches. Known static markers delimit the reports; decoding from the end avoids mistaking metadata-path text or escaped JSON strings for a report boundary. +Unfiltered strict native statistics discover the primary anchor and inferred target. The actual prepared batch reads unfiltered statistics plus four bounded period `stats` commands, four income statements, twelve date-specific historical balance sheets, two bulk account balance reports and all account registers from the same loaded journal. For dated journals the existing **18 financial report commands** are unchanged; **four filtered stats commands** run inside the cached production batch, not four processes. There are still two financial processes (discovery plus cached production). The worker's separate strict preflight plus preparation's one version-only +`getHledgerVersion()` call make **four processes for the whole job**, still **three +journal parses**; version checking supplies no journal flags or reads. Known static markers delimit the reports; decoding from the end avoids mistaking metadata-path text or escaped JSON strings for a report boundary. Each income command retains `--flat --depth=2 --change --monthly --empty`, its 1/3/6/12-month bounds, `not:type:G` and `not:date:AS_OF_PLUS_1..`. Each balance sheet uses `--historical --flat --empty --depth=1`, its month start, the day after its valuation date, `--value=EXACT_DATE,INFERRED_TARGET`, JSON output and the matching negative date cutoff. It does **not** use monthly interval expansion or the income gain filter. Native historical accumulation retains opening balances from before the displayed window. @@ -168,27 +191,21 @@ type PreparedBalance = ``` Native strict discovery runs `stats` and `accounts --flat --directives` in one -cached `run`. A second, single cached `run` supplies the unfiltered stats, the -four-income/twelve-balance-sheet/two-bulk-balance financial plan, the actual account list, +cached `run`. A second, single cached `run` supplies the unfiltered stats, four +bounded period stats, the four-income/twelve-balance-sheet/two-bulk-balance financial +plan, the actual account list, all native `accounts --find --directives` identity qualifications, and then all full-history `aregister --historical --empty -Ojson` reports. Every published financial value comes from this second loaded journal. Its anchor, inferred currency and actual account list must match discovery. Both processes disable ambient config. The financial batch remains independently `--strict`. This is not -an atomic filesystem snapshot. The worker now calls standalone `checkJournal` before -this two-process financial path: **three native processes/parses** for an -explicit preparation. The check can observe different edits from financial production; -a passed check is not proof of identical inputs, freshness or a completed financial set. -The worker/storage, not this library, record completed outcomes; incomplete checks -preserve prior evidence. Read-only `worker:inspect` also uses `checkJournal` but never -writes evidence. The caller owns final publication; browsing saved reports does not -invoke preparation. Storage requires financial format **6**; the current six-month -Overview presentation and full-history register rows remain unchanged. Activation -requires stopping old preparation execution, deploying compatible web and worker -revisions together, then explicitly **Prepare reports again** in Settings. Format-5 -files remain untouched but unusable, and account IDs may reset; there is no adapter, -fallback, migration, deletion or automatic work. See the -[storage contract](../snapshots/README.md#current-format-and-preservation). +an atomic filesystem snapshot. The standalone strict check precedes this financial path; command/process +accounting is specified under [planning and interpretation](#overview-planning-and-interpretation). +The check can observe different edits from financial production; a pass proves +neither identical inputs, freshness nor completed publication. Worker/storage record +outcomes; read-only inspection does not. The caller owns publication and cleanup; +see [storage](../snapshots/README.md#current-format-and-preservation) and +[activation](../../docs/development.md#financial-format-8-activation). The account summaries contain only native used/declared accounts, sorted by literal name; the directory does not expand implicit parents. An explicitly declared parent has native @@ -247,8 +264,8 @@ have one authoritative location in `transaction`, not duplicate legacy table fie `PreparedTransaction`, `PreparedPosting`, `DetailedAmount`, `AmountStyle`, and `SourcePosition` are public types alongside `PreparedAccountRow`. All fields in the prepared contract are required; nullable fields contain explicit nulls. -Format 6 leaves these transaction records unchanged; the required new financial -fields are overview ranges/history/sides and catalog summaries, not a transaction adapter. +The financial report format and required-container validation are owned by +[snapshots](../snapshots/README.md#current-format-and-preservation). ### Mapping and privacy @@ -325,8 +342,9 @@ EOF or successful process exit. Cancellation kills native work immediately but w for active caller-owned writes before returning, so cleanup cannot race a late write. Financial operations are called by worker jobs, never web handlers or page loaders. -Version checking at worker startup and the explicit `worker:inspect` command are the -operational exceptions. Overview and account history currently remain coordinated by +Version checking at worker startup, the version-only call during preparation and +the explicit `worker:inspect` command are operational checks, not extra financial +batches. Overview and account history currently remain coordinated by one preparation job. The existing live cost-balance query remains separate; deciding which curated or user-authored exploratory reports to offer is future product work, not a reason to expose a generic CLI API now. @@ -340,8 +358,8 @@ and canonical posting selection; `transactions/transaction.ts` owns full transaction/posting metadata and recursive exact amount projection. Tests cover the pure financial plan/decoding/organization and the actual combined -producer. Independent native fixture oracles remain test-only; removed standalone -Overview/register execution APIs are not kept alive just to support tests. Process +producer. Independent native fixture oracles remain test-only; exports serve actual app +consumers, not test-only execution APIs. Process safety tests live next to `process.ts`. The finite range/side/balance outputs extend the framed financial prefix; register exports and callback backpressure remain unchanged. diff --git a/packages/hledger/fixtures/transaction-counts.journal b/packages/hledger/fixtures/transaction-counts.journal new file mode 100644 index 0000000..73cceeb --- /dev/null +++ b/packages/hledger/fixtures/transaction-counts.journal @@ -0,0 +1,55 @@ +; Fictional primary-date transaction-count oracle, cutoff 2026-04-16. +; 1/3/6/12 calendar-month windows contain 2/4/6/8 actual transactions. +commodity USD 1.00 +account assets:wallet +account assets:reserve +account equity:opening +account income:salary +account expenses:food + +; Outside twelve months, even though posting and secondary dates are inside April. +2025-04-30=2026-04-05 Outside history + assets:wallet 1.00 USD ; date:2026-04-05, date2:2026-04-06 + equity:opening + +2025-05-01 Twelve-month lower boundary opening + assets:wallet 100.00 USD + equity:opening + +2025-10-31 Before six-month boundary + assets:wallet 10.00 USD + income:salary + +2025-11-01 Six-month lower boundary transfer + assets:reserve 5.00 USD + assets:wallet + +2026-01-31 Before three-month boundary + expenses:food 2.00 USD + assets:wallet + +2026-02-01 Three-month lower boundary + assets:wallet 10.00 USD + income:salary + +2026-03-31 Before one-month boundary + expenses:food 2.00 USD + assets:wallet + +; Included by primary date despite all posting/date2 dates outside the period. +; Three postings still count as one transaction. +2026-04-01=2027-01-01 One-month lower boundary split + expenses:food 1.00 USD ; date:2026-04-17, date2:2027-01-01 + expenses:food 2.00 USD ; date:2026-04-17, date2:2027-01-01 + assets:wallet -3.00 USD ; date:2026-04-17, date2:2027-01-01 + +; Included even though all posting/date2 dates precede April. +2026-04-16=2025-01-01 Cutoff transfer + assets:reserve 1.00 USD ; date:2025-01-01, date2:2025-01-01 + assets:wallet -1.00 USD ; date:2025-01-01, date2:2025-01-01 + +; Forecasts and prices are not actual transactions. +P 2099-01-01 USD 1.00 USD +~ monthly from 2026-04-01 + expenses:food 50.00 USD + assets:wallet diff --git a/packages/hledger/src/journal-stats.ts b/packages/hledger/src/journal-stats.ts index 134c288..f3026df 100644 --- a/packages/hledger/src/journal-stats.ts +++ b/packages/hledger/src/journal-stats.ts @@ -15,42 +15,60 @@ function validDate(text: string): string { return text; } -// Consume only the four native fields needed here, never filtered transaction counts. -export function journalMetadata(stats: string): { - asOf: string | null; - journalCoverage: JournalCoverage; - baseCurrency: string; -} { +// Read exact native fields, not delimiter markers that can occur in a Main file path. +function statsFields(stats: string, expected: number) { const lines = stats.split(/\r?\n/); - const field = (name: string, preserveWhitespace = false): string => { + const field = (name: string, preserveWhitespace = false): string[] => { const pattern = new RegExp( preserveWhitespace ? `^${name}[ \t]*: (.*)$` : `^${name}[ \t]*:[ \t]*(.*)$` ); const matches = lines.flatMap((line) => { const match = pattern.exec(line); if (!match) return []; - let value = match[1]; - if (!preserveWhitespace) value = value.trim(); - return [value]; + if (/\p{Cc}/u.test(match[1])) throw new Error(failureMessage); + return [preserveWhitespace ? match[1] : match[1].trim()]; }); - if (matches.length !== 1) throw new Error(failureMessage); - return matches[0]; + if (matches.length !== expected) throw new Error(failureMessage); + return matches; }; + return { + baseCurrency: field('Base currency', true), + last: field('Last txn'), + counts: field('Txns').map(transactionCount), + spans: field('Txns span') + }; +} + +function nonnegativeInteger(text: string): number { + if (!/^(0|[1-9]\d*)$/.test(text)) throw new Error(failureMessage); + const count = Number(text); + if (!Number.isSafeInteger(count)) throw new Error(failureMessage); + return count; +} + +function transactionCount(text: string): number { + const match = /^(0|[1-9]\d*)(?:[ \t]+\([^\r\n]*\))?$/.exec(text); + if (!match) throw new Error(failureMessage); + return nonnegativeInteger(match[1]); +} + +function wholeJournalMetadata(fields: ReturnType): { + asOf: string | null; + journalCoverage: JournalCoverage; + baseCurrency: string; +} { // Native prints one separator space after the colon. The rest is the literal ID, // including empty, 'none', commas, case and leading/trailing spaces. - const baseCurrency = field('Base currency', true); - const last = field('Last txn'); + const baseCurrency = fields.baseCurrency[0]; + const last = fields.last[0]; let asOf: string | null = null; if (last !== 'none') { const lastMatch = /^(\d{4}-\d{2}-\d{2})(?:[ \t]+\([^\r\n]*\))?$/.exec(last); if (!lastMatch) throw new Error(failureMessage); asOf = validDate(lastMatch[1]); } - const countMatch = /^(0|[1-9]\d*)(?:[ \t]+\([^\r\n]*\))?$/.exec(field('Txns')); - if (!countMatch) throw new Error(failureMessage); - const transactionCount = Number(countMatch[1]); - if (!Number.isSafeInteger(transactionCount)) throw new Error(failureMessage); - const span = field('Txns span'); + const transactionCount = fields.counts[0]; + const span = fields.spans[0]; if (transactionCount === 0) { if (asOf !== null || !/^to(?:[ \t]+\([^\r\n]*\))?$/.test(span)) { throw new Error(failureMessage); @@ -77,3 +95,17 @@ export function journalMetadata(stats: string): { journalCoverage: { transactionCount, firstDate, lastDate: isoDate(lastDate) } }; } + +export function journalMetadata(stats: string) { + return wholeJournalMetadata(statsFields(stats, 1)); +} + +// Whole stats followed by the finite period stats, before any JSON report frames. +// Filtered empty periods have explicit spans, unlike an empty whole journal. +export function journalMetadataWithCounts(stats: string, periodCount: number) { + const fields = statsFields(stats, periodCount + 1); + return { + metadata: wholeJournalMetadata(fields), + transactionCounts: fields.counts.slice(1) + }; +} diff --git a/packages/hledger/src/overview/batch.test.ts b/packages/hledger/src/overview/batch.test.ts index 9d7b63e..c072cc3 100644 --- a/packages/hledger/src/overview/batch.test.ts +++ b/packages/hledger/src/overview/batch.test.ts @@ -1,18 +1,20 @@ import { expect, it } from 'vitest'; import { decodeOverviewReports, planOverviewReports } from './batch.js'; -import { journalMetadata } from '../journal-stats.js'; -import { balanceFrames, incomeFrames } from '../../test-fixtures.js'; +import { planIncomeRanges } from './ranges.js'; +import { journalMetadata, journalMetadataWithCounts } from '../journal-stats.js'; +import { balanceFrames, incomeFrames, periodStats } from '../../test-fixtures.js'; const stats = (asOf = '2026-04-16', currency = 'USD') => asOf === 'none' ? `Base currency : ${currency}\nLast txn : none\nTxns : 0\nTxns span : to \n` - : `Base currency : ${currency}\nLast txn : ${asOf}\nTxns : 1\nTxns span : ${asOf} to ${nextDay(asOf)}\n`; + : `Base currency : ${currency}\nLast txn : ${asOf}\nTxns : 12\nTxns span : ${planIncomeRanges(asOf)[3].begin} to ${nextDay(asOf)}\n`; function nextDay(asOf: string) { const date = new Date(`${asOf}T00:00:00Z`); date.setUTCDate(date.getUTCDate() + 1); return date.toISOString().slice(0, 10); } -const batch = (asOf = '2026-04-16') => stats(asOf) + incomeFrames(asOf) + balanceFrames(asOf); +const batch = (asOf = '2026-04-16') => + stats(asOf) + periodStats(asOf) + incomeFrames(asOf) + balanceFrames(asOf); function decode(text = batch(), discovery = stats()) { const metadata = journalMetadata(discovery); return decodeOverviewReports(text, metadata, planOverviewReports(metadata)); @@ -28,6 +30,17 @@ it('plans four statements and twelve exact-date sheets in one finite cached fina { months: 6, begin: '2025-11-01', endExclusive: '2026-04-17' }, { months: 12, begin: '2025-05-01', endExclusive: '2026-04-17' } ]); + expect(plan.commands.slice(0, 25)).toEqual([ + 'stats', + ...plan.incomeRanges.flatMap(({ begin, endExclusive }) => [ + '--', + 'stats', + '-b', + begin, + '-e', + endExclusive + ]) + ]); const commands = plan.commands.join(' ').split(' -- echo ').slice(1); expect(commands).toHaveLength(16); for (const [index, range] of plan.incomeRanges.entries()) { @@ -57,6 +70,9 @@ it('plans four statements and twelve exact-date sheets in one finite cached fina const decoded = decode(); expect(decoded.incomeStatements.map(({ months }) => months)).toEqual([1, 3, 6, 12]); expect(decoded.snapshots).toHaveLength(12); + expect(decoded.incomeStatements.map(({ transactionCount }) => transactionCount)).toEqual([ + 1, 3, 6, 12 + ]); }); it.each([ @@ -98,14 +114,32 @@ it.each(['INCOME_3', 'NET_WORTH_6'])('requires financial framing for %s', (name) it('splits backwards despite literal markers in paths and escaped JSON strings', () => { const markers = '\n__HLEDGER_INCOME_12__\n\n__HLEDGER_NET_WORTH_12__\n'; const text = batch().replace('"cbrTotals":', `"ignored":${JSON.stringify(markers)},"cbrTotals":`); - expect(decode(`Main file : /private/${markers}filename\n${text}`).snapshots).toHaveLength(12); + const withPaths = text.replaceAll( + 'Base currency', + `Main file : /private/${markers}filename\nBase currency` + ); + expect(decode(withPaths).snapshots).toHaveLength(12); }); it.each([ batch().replace('__HLEDGER_INCOME_3__\n{', '__HLEDGER_INCOME_3__\nSECRET {'), batch() + 'private tail', batch().replace('Last txn : 2026-04-16', 'Last txn : 2026-04-15'), - batch().replace('Base currency : USD', 'Base currency : usd') -])('rejects malformed financial framing or changed discovery metadata (case %#)', (text) => { - expect(() => decode(text)).toThrow(); + batch().replace('Base currency : USD', 'Base currency : usd'), + batch().replace('Txns : 1 (0.0 per day)\n', ''), + batch().replace('Txns : 3 (0.0 per day)', 'Txns : 9007199254740992 (0.0 per day)'), + batch().replace('Base currency : USD', 'Base currency : US\u0000D'), + `Main file : /private/\nTxns : 999\nfilename\n${batch()}` +])( + 'rejects malformed financial framing, counts or changed discovery metadata (case %#)', + (text) => { + expect(() => decode(text)).toThrow(); + } +); + +it('accepts bounded empty filtered stats without accepting them as an empty whole journal', () => { + const filtered = + 'Base currency : USD\nLast txn : none\nTxns : 0\nTxns span : 2026-05-01 to 2026-06-01\n'; + expect(journalMetadataWithCounts(stats() + filtered, 1).transactionCounts).toEqual([0]); + expect(() => journalMetadata(filtered)).toThrow(); }); diff --git a/packages/hledger/src/overview/batch.ts b/packages/hledger/src/overview/batch.ts index 87ee1f8..2425590 100644 --- a/packages/hledger/src/overview/batch.ts +++ b/packages/hledger/src/overview/batch.ts @@ -1,6 +1,6 @@ import { parseBalanceSheet, type HledgerBalanceSheet } from './balance-sheet.js'; import { parseIncomeStatement, type HledgerIncomeStatement } from './income-statement.js'; -import { journalMetadata, type JournalCoverage } from '../journal-stats.js'; +import { journalMetadataWithCounts, type JournalCoverage } from '../journal-stats.js'; import { isoDate } from '../report-date.js'; import { planIncomeRanges, type IncomeRangePlan, type Period } from './ranges.js'; @@ -8,7 +8,10 @@ export interface OverviewReports { asOf: string | null; baseCurrency: string; journalCoverage: JournalCoverage; - incomeStatements: (IncomeRangePlan & { report: HledgerIncomeStatement })[]; + incomeStatements: (IncomeRangePlan & { + transactionCount: number; + report: HledgerIncomeStatement; + })[]; snapshots: (Period & { date: string; report: HledgerBalanceSheet })[]; } @@ -33,6 +36,11 @@ export function planOverviewReports({ const snapshotPeriods: (Period & { date: string })[] = []; const commands = ['stats']; if (asOf === null) return { commands, incomeRanges, snapshotPeriods }; + // Keep all text stats before JSON so backwards JSON framing remains unambiguous, + // even when Main file contains literal newlines and report markers. + for (const range of incomeRanges) { + commands.push('--', 'stats', '-b', range.begin, '-e', range.endExclusive); + } const history = incomeRanges[3]; for (const [index, range] of incomeRanges.entries()) { commands.push( @@ -94,7 +102,7 @@ export function decodeOverviewReports( { incomeRanges, snapshotPeriods }: ReturnType ): OverviewReports { const snapshots: OverviewReports['snapshots'] = []; - const incomeStatements: OverviewReports['incomeStatements'] = []; + const statements: (IncomeRangePlan & { report: HledgerIncomeStatement })[] = []; if (asOf !== null) { // Decode backwards: paths may contain earlier literal markers; JSON strings escape them. for (let index = 11; index >= 0; index--) { @@ -111,14 +119,18 @@ export function decodeOverviewReports( const marker = incomeMarkers[index]; const split = batch.lastIndexOf(marker); if (split < 0) throw new Error(failureMessage); - incomeStatements.unshift({ + statements.unshift({ ...incomeRanges[index], report: parseIncomeStatement(batch.slice(split + marker.length)) }); batch = batch.slice(0, split); } } - const metadata = journalMetadata(batch); + const { metadata, transactionCounts } = journalMetadataWithCounts(batch, asOf === null ? 0 : 4); + const incomeStatements = statements.map((statement, index) => ({ + ...statement, + transactionCount: transactionCounts[index] + })); if (metadata.asOf !== asOf || metadata.baseCurrency !== baseCurrency) throw new Error(failureMessage); return { ...metadata, incomeStatements, snapshots }; diff --git a/packages/hledger/src/overview/net-worth.test.ts b/packages/hledger/src/overview/net-worth.test.ts index 7889865..b6eb1a4 100644 --- a/packages/hledger/src/overview/net-worth.test.ts +++ b/packages/hledger/src/overview/net-worth.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { prepareReports, getHledgerVersion } from '../index.js'; -import { balanceFrames, decodeOverviewFixture, incomeFrames } from '../../test-fixtures.js'; +import { + balanceFrames, + decodeOverviewFixture, + incomeFrames, + periodStats +} from '../../test-fixtures.js'; import { planOverviewReports } from './batch.js'; import { journalMetadata } from '../journal-stats.js'; @@ -36,7 +41,7 @@ const nativeAmount = (commodity: string, mantissa: number) => ({ aquantity: { decimalMantissa: mantissa, decimalPlaces: 2 } }); const stats = (currency = 'USD') => - `Base currency : ${currency}\nLast txn : 2026-04-16\nTxns : 1\nTxns span : 2026-04-16 to 2026-04-17\n`; + `Base currency : ${currency}\nLast txn : 2026-04-16\nTxns : 12\nTxns span : 2025-05-01 to 2026-04-17\n`; const frames = () => balanceFrames() .split(/\n__HLEDGER_NET_WORTH_\d+__\n/) @@ -54,7 +59,7 @@ const frames = () => } ); function batch(reports = frames(), currency = 'USD') { - return `${stats(currency)}${incomeFrames()}${reports.map((report, index) => `\n__HLEDGER_NET_WORTH_${index + 1}__\n${JSON.stringify(report)}`).join('')}`; + return `${stats(currency)}${periodStats()}${incomeFrames()}${reports.map((report, index) => `\n__HLEDGER_NET_WORTH_${index + 1}__\n${JSON.stringify(report)}`).join('')}`; } it('uses the exact native net bag, retaining mixed amounts, order and explicit zero entries', () => { diff --git a/packages/hledger/src/overview/ranges.test.ts b/packages/hledger/src/overview/ranges.test.ts index 905f274..6c7c860 100644 --- a/packages/hledger/src/overview/ranges.test.ts +++ b/packages/hledger/src/overview/ranges.test.ts @@ -48,8 +48,9 @@ it('copies native range totals, NET, signs and cost-distinguished bags, never mo { prrName: 'Fees', prrTotal: [] } ]; const before = structuredClone(parsed); - const data = organizeIncomeRange(parsed, plan); + const data = organizeIncomeRange(parsed, plan, 17); expect(data.range).toEqual({ + transactionCount: 17, begin: '2025-05-01', endExclusive: '2026-04-17', income: [ @@ -103,14 +104,14 @@ it.each<[string, (report: HledgerIncomeStatement) => void]>([ ])('rejects %s before publishing a range', (_name, mutate) => { const report = statement(); mutate(report); - expect(() => organizeIncomeRange(report, plan)).toThrow(); + expect(() => organizeIncomeRange(report, plan, 0)).toThrow(); }); it('validates shorter statements even though their monthly columns are not published', () => { const shorter = planIncomeRanges('2026-04-16')[0]; const report = statement(shorter); report.cbrSubreports[0][1].prTotals.prrAmounts.pop(); - expect(() => organizeIncomeRange(report, shorter)).toThrow(); + expect(() => organizeIncomeRange(report, shorter, 0)).toThrow(); }); const declarations = `commodity USD 1.00 diff --git a/packages/hledger/src/overview/ranges.ts b/packages/hledger/src/overview/ranges.ts index 6b8b975..0305b4a 100644 --- a/packages/hledger/src/overview/ranges.ts +++ b/packages/hledger/src/overview/ranges.ts @@ -4,6 +4,7 @@ import type { HledgerIncomeStatement } from './income-statement.js'; export type OverviewMonths = 1 | 3 | 6 | 12; export interface OverviewRange { + transactionCount: number; begin: string; endExclusive: string; income: ReportedAmount[]; @@ -93,13 +94,18 @@ function organizeMonths(report: HledgerIncomeStatement, plan: IncomeRangePlan) { }); } -export function organizeIncomeRange(report: HledgerIncomeStatement, plan: IncomeRangePlan) { +export function organizeIncomeRange( + report: HledgerIncomeStatement, + plan: IncomeRangePlan, + transactionCount: number +) { // Validate every native column, even though only the twelve-month history is published. const months = organizeMonths(report, plan); const { income, expenses } = incomeSections(report); const rows = expenses?.prRows; if (!Array.isArray(rows)) throw new Error(failureMessage); const range: OverviewRange = { + transactionCount, begin: plan.begin, endExclusive: plan.endExclusive, income: reportedAmounts(income?.prTotals?.prrTotal), diff --git a/packages/hledger/src/overview/report.test.ts b/packages/hledger/src/overview/report.test.ts index fd80ee3..c1ad7b5 100644 --- a/packages/hledger/src/overview/report.test.ts +++ b/packages/hledger/src/overview/report.test.ts @@ -3,12 +3,17 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { prepareReports, getHledgerVersion } from '../index.js'; -import { balanceFrames, decodeOverviewFixture, incomeFrames } from '../../test-fixtures.js'; +import { + balanceFrames, + decodeOverviewFixture, + incomeFrames, + periodStats +} from '../../test-fixtures.js'; const stats = - 'Base currency : USD\nLast txn : 2026-04-16\nTxns : 9\nTxns span : 2026-04-01 to 2026-04-17\n'; + 'Base currency : USD\nLast txn : 2026-04-16\nTxns : 12\nTxns span : 2025-05-01 to 2026-04-17\n'; const emptyStats = 'Base currency : none\nLast txn : none\nTxns : 0\nTxns span : to \n'; -const batch = (metadata = stats) => metadata + incomeFrames() + balanceFrames(); +const batch = (metadata = stats) => metadata + periodStats() + incomeFrames() + balanceFrames(); afterEach(() => { vi.useRealTimers(); }); @@ -17,12 +22,12 @@ it('publishes all four ranges, a shared twelve-month history and latest native s const data = decodeOverviewFixture(stats, batch()); expect(data).toMatchObject({ asOf: '2026-04-16', - journalCoverage: { transactionCount: 9, firstDate: '2026-04-01', lastDate: '2026-04-16' }, + journalCoverage: { transactionCount: 12, firstDate: '2025-05-01', lastDate: '2026-04-16' }, ranges: { - 1: { begin: '2026-04-01', endExclusive: '2026-04-17' }, - 3: { begin: '2026-02-01', endExclusive: '2026-04-17' }, - 6: { begin: '2025-11-01', endExclusive: '2026-04-17' }, - 12: { begin: '2025-05-01', endExclusive: '2026-04-17' } + 1: { transactionCount: 1, begin: '2026-04-01', endExclusive: '2026-04-17' }, + 3: { transactionCount: 3, begin: '2026-02-01', endExclusive: '2026-04-17' }, + 6: { transactionCount: 6, begin: '2025-11-01', endExclusive: '2026-04-17' }, + 12: { transactionCount: 12, begin: '2025-05-01', endExclusive: '2026-04-17' } }, balanceSheet: { assets: { amounts: [], unconverted: [] }, @@ -48,7 +53,7 @@ it('publishes monthly history only from the twelve-month statement', () => { shorter.replace('"prrAmounts":[[]]', `"prrAmounts":[[${amount(111)}]]`) + '\n__HLEDGER_INCOME_12__\n' + history.replace('"prrAmounts":[[]', `"prrAmounts":[[${amount(222)}]`); - const data = decodeOverviewFixture(stats, stats + statements + balanceFrames()); + const data = decodeOverviewFixture(stats, stats + periodStats() + statements + balanceFrames()); expect(data.incomeExpenses.months[0].income).toEqual([{ commodity: 'USD', quantity: '2.22' }]); expect(data.incomeExpenses.months[11].income).toEqual([]); expect(data.ranges![12].income).toEqual([]); @@ -66,11 +71,13 @@ it('projects undated input without invented ranges, sides, currency, dates or a }); it('uses second cached metadata and publishes no private paths or unused native fields', () => { - const metadata = `Main file : /private/SECRET.journal\n${stats.replace('Txns : 9', 'Txns : 10').replace('2026-04-01 to', '2025-01-01 to')}`; + const metadata = `Main file : /private/SECRET.journal\n${stats + .replace('Txns : 12', 'Txns : 13') + .replace('2025-05-01 to', '2025-01-01 to')}`; const text = batch(metadata).replace('"cbrTotals":', '"sourcePath":"SECRET","cbrTotals":'); const data = decodeOverviewFixture(stats, text); expect(data.journalCoverage).toEqual({ - transactionCount: 10, + transactionCount: 13, firstDate: '2025-01-01', lastDate: '2026-04-16' }); @@ -83,6 +90,7 @@ it('does not consult the clock or assign the producer-owned completion timestamp const data = decodeOverviewFixture(stats, batch()); expect(data.asOf).toBe('2026-04-16'); expect(data).not.toHaveProperty('preparedAt'); + expect(data).not.toHaveProperty('hledgerVersion'); }); const failure = 'Prepared transactions could not be read. Check the journal.'; @@ -145,6 +153,7 @@ describe('native hledger 1.99.5 journal-anchored income statement', () => { expect(data).toEqual({ asOf: null, journalCoverage: emptyCoverage, + hledgerVersion: '1.99.5', preparedAt: expect.any(String), incomeExpenses: { months: [] }, ranges: null, diff --git a/packages/hledger/src/overview/report.ts b/packages/hledger/src/overview/report.ts index 8a25946..bb21206 100644 --- a/packages/hledger/src/overview/report.ts +++ b/packages/hledger/src/overview/report.ts @@ -14,6 +14,7 @@ export type NativeValue = { export interface OverviewData { asOf: string | null; preparedAt: string; + hledgerVersion: string; journalCoverage: JournalCoverage; ranges: Record | null; incomeExpenses: { @@ -31,7 +32,9 @@ export interface OverviewData { balanceSheet: { assets: NativeValue; liabilities: NativeValue } | null; } -export function organizeOverviewData(reports: OverviewReports): Omit { +export function organizeOverviewData( + reports: OverviewReports +): Omit { const { asOf, journalCoverage, incomeStatements } = reports; const { netWorth, balanceSheet } = organizeNetWorth(reports); if (asOf === null) { @@ -48,7 +51,7 @@ export function organizeOverviewData(reports: OverviewReports): Omit { if (statement.months !== ([1, 3, 6, 12] as const)[index]) throw new Error('Invalid hledger Overview ranges.'); - return organizeIncomeRange(statement.report, statement); + return organizeIncomeRange(statement.report, statement, statement.transactionCount); }); return { asOf, diff --git a/packages/hledger/src/overview/transaction-counts.integration.test.ts b/packages/hledger/src/overview/transaction-counts.integration.test.ts new file mode 100644 index 0000000..7630c1b --- /dev/null +++ b/packages/hledger/src/overview/transaction-counts.integration.test.ts @@ -0,0 +1,24 @@ +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +import { prepareReports } from '../prepare-reports.js'; + +const journal = fileURLToPath( + new URL('../../fixtures/transaction-counts.journal', import.meta.url) +); +const expected = { 1: 2, 3: 4, 6: 6, 12: 8 }; + +it('prepares all four native counts alongside whole coverage and the existing financial set', async () => { + const { overview } = await prepareReports(journal, { writeRows: async () => {} }); + expect( + Object.fromEntries( + Object.entries(overview.ranges!).map(([months, range]) => [months, range.transactionCount]) + ) + ).toEqual(expected); + expect(overview.journalCoverage).toEqual({ + transactionCount: 9, + firstDate: '2025-01-01', + lastDate: '2027-01-01' + }); + expect(overview.asOf).toBe('2026-04-16'); + expect(overview.hledgerVersion).toBe('1.99.5'); +}); diff --git a/packages/hledger/src/prepare-reports.test.ts b/packages/hledger/src/prepare-reports.test.ts index 8c60c2e..608e98e 100644 --- a/packages/hledger/src/prepare-reports.test.ts +++ b/packages/hledger/src/prepare-reports.test.ts @@ -101,11 +101,12 @@ async function oracle(path: string, name: string) { ); } -it('uses two processes for the combined financial view and native JSON rows for exactly seven actual accounts', async () => { +it('uses one version-only process and two journal reads for the combined financial view and native JSON rows for exactly seven actual accounts', async () => { const collected = collector(); const prepared = await prepareReports(checking, collected); - expect(physical.spawn).toHaveBeenCalledTimes(2); - const discoveryArgs = physical.spawn.mock.calls[0][1] as string[]; + expect(physical.spawn).toHaveBeenCalledTimes(3); + expect(physical.spawn.mock.calls[0][1]).toEqual(['--no-conf', '--lang=en', '--version']); + const discoveryArgs = physical.spawn.mock.calls[1][1] as string[]; expect(discoveryArgs.slice(0, 9)).toEqual([ '--no-conf', '--lang=en', @@ -118,7 +119,7 @@ it('uses two processes for the combined financial view and native JSON rows for '--' ]); expect(discoveryArgs).toContain('--directives'); - const args = physical.spawn.mock.calls[1][1] as string[]; + const args = physical.spawn.mock.calls[2][1] as string[]; expect(args.slice(0, 8)).toEqual([ '--no-conf', '--lang=en', @@ -129,6 +130,7 @@ it('uses two processes for the combined financial view and native JSON rows for '--', 'stats' ]); + expect(args.filter((a) => a === 'stats')).toHaveLength(5); expect(args.filter((a) => a === 'balancesheet')).toHaveLength(12); expect(args.filter((a) => a === 'incomestatement')).toHaveLength(4); expect(args.filter((a) => a === 'balance')).toHaveLength(2); @@ -156,6 +158,7 @@ it('uses two processes for the combined financial view and native JSON rows for } expect(args).not.toContain('--today'); expect(prepared.overview.asOf).toBe('2026-04-16'); + expect(prepared.overview.hledgerVersion).toBe('1.99.5'); expect(prepared.overview.journalCoverage).toEqual({ transactionCount: 9, firstDate: '2026-04-01', @@ -190,12 +193,12 @@ it('uses two processes for the combined financial view and native JSON rows for expect(JSON.parse(JSON.stringify(prepared))).toEqual(prepared); }); -it('delivers richer cash rows and both print exports through the same two physical calls', async () => { +it('delivers richer cash rows and both print exports through the same cached financial call', async () => { const cash = fileURLToPath(new URL('../fixtures/cash.journal', import.meta.url)); const collected = collector(); const prepared = await prepareReports(cash, collected); - expect(physical.spawn).toHaveBeenCalledTimes(2); - const args = physical.spawn.mock.calls[1][1] as string[]; + expect(physical.spawn).toHaveBeenCalledTimes(3); + const args = physical.spawn.mock.calls[2][1] as string[]; expect(args.filter((arg) => arg === 'print')).toHaveLength(2 * prepared.accounts.length); expect(args).not.toContain('--lots'); expect(args.some((arg) => arg.startsWith('--infer'))).toBe(false); @@ -308,7 +311,7 @@ ${Array.from( ); }); -it('keeps Overview and all registers on the cached native include view when the source changes during a row write', async () => { +it('publishes the cached financial include view despite source edits during row writes', async () => { const path = await journal( 'commodity USD 1.00\naccount assets:cash ; type: A\naccount equity:opening ; type: E\ninclude detail.journal\n' ); @@ -336,15 +339,17 @@ it('keeps Overview and all registers on the cached native include view when the } }); expect(changed).toBe(true); - expect({ ...result.overview, preparedAt: '' }).toEqual({ ...beforeOverview, preparedAt: '' }); + expect({ ...result.overview, preparedAt: '' }).toEqual({ + ...beforeOverview, + preparedAt: '' + }); for (const account of result.accounts) { expect(collected.rowsByName.get(account.name)!.map(financialRow)).toEqual( account.name === 'assets:cash' ? beforeCash : beforeEquity ); } - expect((await prepareReports(path, { writeRows: async () => {} })).overview.netWorth).not.toEqual( - result.overview.netWorth - ); + const { overview: afterOverview } = await prepareReports(path, { writeRows: async () => {} }); + expect(afterOverview.netWorth).not.toEqual(result.overview.netWorth); }); it('preserves marker-named and Unicode account identities without treating names as paths', async () => { @@ -391,6 +396,7 @@ it.each(['', 'account assets:unused\naccount equity:unused\n'])( const prepared = await prepareReports(path, collected); expect(prepared.overview).toMatchObject({ asOf: null, + hledgerVersion: '1.99.5', incomeExpenses: { months: [] }, netWorth: { currency: null, points: [] } }); @@ -399,7 +405,7 @@ it.each(['', 'account assets:unused\naccount equity:unused\n'])( expect(account.rowCount).toBe(0); expect(collected.rowsByName.get(account.name)).toEqual([]); } - const args = physical.spawn.mock.calls[1][1] as string[]; + const args = physical.spawn.mock.calls[2][1] as string[]; expect(args).not.toContain('incomestatement'); expect(args).not.toContain('balancesheet'); } @@ -449,7 +455,11 @@ function rowsProtocol(count: number): string { ); return `${financialPrefix}${prints}${originalEnd}${prints}${transactedEnd}${rows}${end}`; } -function injectDiscovery(output = discovery, exit = 0) { +function injectVersion(output = 'hledger 1.99.5, full SECRET banner\n', exit = 0) { + injectDiscovery(output, exit, false); +} +function injectDiscovery(output = discovery, exit = 0, version = true) { + if (version) injectVersion(); physical.spawn.mockImplementationOnce(() => { const child = actual.spawn(process.execPath, [ '-e', @@ -499,6 +509,8 @@ describe('native protocol and lifecycle', () => { }); expect(result.overview.preparedAt).toBe('2026-01-01T00:02:30.000Z'); expect(result.overview.asOf).toBeNull(); + expect(result.overview.hledgerVersion).toBe('1.99.5'); + expect(JSON.stringify(result)).not.toContain('SECRET'); expect(result.accounts[0].rowCount).toBe(1); } finally { vi.useRealTimers(); @@ -628,7 +640,7 @@ describe('native protocol and lifecycle', () => { signal: controller.signal, writeRows: async () => {} }); - await vi.waitFor(() => expect(physical.spawn).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(physical.spawn).toHaveBeenCalledTimes(3)); controller.abort(); await expect(result).rejects.toThrow(failure); expect(child.signalCode).toBe('SIGKILL'); @@ -647,6 +659,28 @@ describe('native protocol and lifecycle', () => { expect(child!.exitCode).not.toBeNull(); }); + it('owns the version-only process through EOF and cancellation before reading any journal', async () => { + let child: ChildProcessWithoutNullStreams; + physical.spawn.mockImplementationOnce(() => { + child = actual.spawn(process.execPath, [ + '-e', + 'process.stdout.write("hledger 1.99.5, SECRET banner\\n");setInterval(()=>{},1000);' + ]); + return child; + }); + const controller = new AbortController(); + const result = prepareReports('/private', { + signal: controller.signal, + writeRows: async () => {} + }); + await new Promise((resolve) => child!.stdout.once('data', () => resolve())); + controller.abort(); + await expect(result).rejects.toThrow(failure); + expect(physical.spawn).toHaveBeenCalledTimes(1); + expect(physical.spawn.mock.calls[0][1]).toEqual(['--no-conf', '--lang=en', '--version']); + expect(child!.signalCode).toBe('SIGKILL'); + }); + it('does not start a pre-aborted call', async () => { await expect( prepareReports('/private', { signal: AbortSignal.abort(), writeRows: async () => {} }) @@ -664,7 +698,9 @@ describe('native protocol and lifecycle', () => { .replace( '\n__HLEDGER_PREPARED_LOT_COMMODITIES__\n', `\n__HLEDGER_PREPARED_LOT_COMMODITIES__\n${commodity}\n` - ) + ), + 0, + false ); const collected = collector(); const result = await prepareReports('/private', collected); @@ -699,7 +735,7 @@ describe('native protocol and lifecycle', () => { await expect(prepareReports('/private', { writeRows: async () => {} })).rejects.toThrow( failure ); - expect(physical.spawn).toHaveBeenCalledTimes(1); + expect(physical.spawn).toHaveBeenCalledTimes(2); injectDiscovery('SECRET /private 1.23 USD', 9); await expect(prepareReports('/private', { writeRows: async () => {} })).rejects.toThrow( failure diff --git a/packages/hledger/src/prepare-reports.ts b/packages/hledger/src/prepare-reports.ts index b0187ed..d24e4bc 100644 --- a/packages/hledger/src/prepare-reports.ts +++ b/packages/hledger/src/prepare-reports.ts @@ -7,6 +7,7 @@ import { type PreparedBalance } from './accounts/balances.js'; import { journalMetadata } from './journal-stats.js'; +import { getHledgerVersion, supportedHledgerVersion } from './checks.js'; import { ReportStreamReader } from './report-stream.js'; import { parseAccountRegisterRow, @@ -198,6 +199,9 @@ export async function prepareReports( } ): Promise<{ overview: OverviewData; accounts: PreparedAccountSummary[] }> { try { + options.signal?.throwIfAborted(); + // Operational version check only: no journal flags or extra journal read. + await getHledgerVersion((args) => readHledger(args, { signal: options.signal })); options.signal?.throwIfAborted(); const inputFlags = ['--no-conf', '--lang=en', '--strict', '-f', journal]; // A discovery process only plans commands. No values from it are published. @@ -286,7 +290,14 @@ export async function prepareReports( } ); // This timestamp describes completion of the whole set, including native exit. - return { overview: { ...overview, preparedAt: new Date().toISOString() }, accounts }; + return { + overview: { + ...overview, + hledgerVersion: supportedHledgerVersion, + preparedAt: new Date().toISOString() + }, + accounts + }; } catch { // No native diagnostics, paths, financial wire data or nested causes escape. throw new Error(failureMessage); diff --git a/packages/hledger/test-fixtures.ts b/packages/hledger/test-fixtures.ts index c468e92..9a95064 100644 --- a/packages/hledger/test-fixtures.ts +++ b/packages/hledger/test-fixtures.ts @@ -54,3 +54,13 @@ export function incomeFrames(asOf = '2026-04-16'): string { }) .join(''); } + +// Text stats stay together before every JSON frame. Empty filtered spans are bounded. +export function periodStats(asOf = '2026-04-16', counts = [1, 3, 6, 12]): string { + return planIncomeRanges(asOf) + .map( + (range, index) => + `Base currency : USD\nLast txn : ${counts[index] === 0 ? 'none' : asOf}\nTxns : ${counts[index]} (0.0 per day)\nTxns span : ${range.begin} to ${range.endExclusive}\n` + ) + .join(''); +} diff --git a/packages/snapshots/README.md b/packages/snapshots/README.md index 3354bfa..a9204d0 100644 --- a/packages/snapshots/README.md +++ b/packages/snapshots/README.md @@ -15,6 +15,10 @@ watch sources, hash inputs, reconcile state or assess freshness. - `writeValidation(snapshotDirectory, run, journal, result): Promise` - `readValidation(snapshotDirectory): Promise` +`currentReportFormat` is a private implementation constant (**8**) used by reads +and writes, not a package export or homepage display API. Tests use a local format-8 +literal rather than making that constant public. + Pass the snapshots directory. `run` contains `{ id, createdAt: Date }`. A stage streams contiguous account batches through `await stage.writeRows(fullNativeName, rows)`, then publishes with @@ -35,7 +39,7 @@ Each execution owns a unique hidden stage. One directory rename commits the **entire** immutable completed set under `.sets/`. Each set contains: ```text -overview.json # { format: 6, journal, overview, accounts } +overview.json # { format: 8, journal, overview, accounts } transactions/<8-character-id>.json # { id, name, transactions } ``` @@ -67,7 +71,7 @@ values accounts. Summaries live in this catalog, not separate value files; Account IDs are random eight-character lowercase alphanumeric identifiers, reused by **exact full native name** from the previous supported-format catalog. Pass only `previousPublication.accounts`, and only for the **same journal**. IDs may reset -across the format-5-to-6 transition; there is no legacy catalog reader to preserve them. +across the format-8 transition; there is no legacy catalog reader to preserve them. Collisions are checked against all previous and newly assigned IDs. Names remain unchanged data, never filesystem paths. There is no separate registry or account-page index. @@ -92,7 +96,7 @@ no capacity guarantee is implied. ## Recorded validation -Validation evidence is operational, independent of financial format **6**; its +Validation evidence is operational, independent of financial format **8**; its own format and workflow are unchanged. The worker writes after standalone strict checking completes, before financial staging. The store does not run checks or infer outcomes. `JournalValidationResult` contains @@ -130,27 +134,36 @@ The separate read-only native inspection command never writes these records. ## Current format and preservation -Only format-6 envelopes `{ format: 6, journal, overview, accounts }` are read. -Format 5 and older shapes are unsupported. To activate, stop old preparation -execution and deploy compatible web and worker revisions together before explicitly -**Prepare reports again** in Settings. Independent image publication does not -coordinate that upgrade. Old files remain untouched and unusable by the new reader; -no automatic work, adapter, fallback, migration or deletion is provided. Account IDs -may reset at this transition; exact-name reuse continues within the supported format. -No completed set, or an unsupported format in the latest completed set, returns -`null`: there is no fallback to an older supported set. Callers can show -“No prepared reports available.” with a Settings link for manual preparation. Store reads never -prepare automatically. +Only format-8 envelopes `{ format: 8, journal, overview, accounts }` are read. +The current contract requires saved `hledgerVersion: string`, all four native +period transaction counts and the existing coverage, financial data and account +summaries. The producer checks the engine version; storage validates its string +container, not a native banner. Native semantics belong to the +[hledger library](../hledger/README.md#overview-data). + +PR3's format 6, the unlanded intermediate format 7 and all older shapes are +unsupported, including otherwise complete sets. The latest unsupported set returns +`null`, without falling back to an older supported set. Old files remain untouched; +there is no automatic preparation, missing-field adapter, migration or deletion. +Users must explicitly prepare again after +[paired web/worker activation](../../docs/development.md#financial-format-8-activation). +Account IDs may reset; exact-name reuse continues within supported same-journal +publications. `currentReportFormat` remains private to this package. Malformed JSON or invalid current publications fail with a generic read error. The store checks required overview header primitives and containers: all four -`ranges` (1/3/6/12, including native totals and categories), `journalCoverage`, -twelve `incomeExpenses.months`, twelve `netWorth.points`, latest `balanceSheet` +`ranges` (1/3/6/12, including native totals, categories and a required numeric +`transactionCount` that passes `Number.isSafeInteger` and is at least zero), +`journalCoverage` (safe nonnegative count and nullable date strings), +string `hledgerVersion`, twelve `incomeExpenses.months`, twelve `netWorth.points`, latest `balanceSheet` sides, and required catalog summary states consistent with access/date. Undated results require `ranges: null`, `balanceSheet: null`, empty histories and `netWorth.currency: null`; ambiguous summaries remain ambiguous. It does not parse deep financial tuples or quantities, upgrade partial data, supply zeros or -recompute producer-owned values. +recompute producer-owned values. Missing, fractional, negative or unsafe counts in +format 8 are malformed current data, not empty periods. A missing/non-string version is also malformed, not optional metadata; there is +no fallback or legacy adapter. A genuine dated empty period stores native count `0`; an +undated report retains `ranges: null`. Old `dashboard/*.json` files are neither scanned nor read. Format-2 source metadata is not adapted. Old `source.json`, `dashboard/inputs.json`, and diff --git a/packages/snapshots/src/reports.test.ts b/packages/snapshots/src/reports.test.ts index 12b1b2a..f60638e 100644 --- a/packages/snapshots/src/reports.test.ts +++ b/packages/snapshots/src/reports.test.ts @@ -24,6 +24,7 @@ vi.mock('node:crypto', async (original) => ({ ...(await original()) })); +const currentReportFormat = 8; let root: string; let snapshots: string; const older = { id: 'older', createdAt: new Date('2026-04-30T23:59:59.999Z') }; @@ -35,6 +36,7 @@ const expenseAmounts = [ const range = (months: 1 | 3 | 6 | 12) => ({ begin: { 1: '2026-04-01', 3: '2026-02-01', 6: '2025-11-01', 12: '2025-05-01' }[months], endExclusive: '2026-04-17', + transactionCount: 1, income: [{ commodity: '$', quantity: '9007199254740993.123456789' }], expenses: expenseAmounts, net: [{ commodity: '$', quantity: '9007199254740992.123456789' }], @@ -47,6 +49,7 @@ const fixture = (): OverviewData => ({ asOf: '2026-04-16', preparedAt: '2026-05-02T12:34:56.789Z', journalCoverage: { transactionCount: 1, firstDate: '2026-04-16', lastDate: '2026-04-16' }, + hledgerVersion: '1.99.5', ranges: { 1: range(1), 3: range(3), 6: range(6), 12: range(12) }, netWorth: { currency: '$', @@ -230,7 +233,7 @@ it('publishes one readable overview and one whole-account JSON, with private mod expect( JSON.parse(await fs.readFile(join(publication.directory, 'overview.json'), 'utf8')) ).toEqual({ - format: 6, + format: currentReportFormat, overview: fixture(), journal, accounts: [account] @@ -953,29 +956,32 @@ it('ignores old dashboard files without scanning or reading them, preserving the expect(await fs.readFile(path, 'utf8')).toBe(raw); }); -it('leaves opaque format 5 data untouched without fallback until explicit preparation', async () => { - await publish(older); - const previous = await selected(); - await publish(newer); - const latest = await selected(); - const path = join(latest.directory, 'overview.json'); - const accountPath = join(latest.directory, 'transactions', `${latest.accounts[0].id}.json`); - const accountBytes = await fs.readFile(accountPath, 'utf8'); - const raw = JSON.stringify({ format: 5, overview: 'opaque old payload', accounts: null }); - await fs.writeFile(path, raw); - expect(await readReportPublication(snapshots)).toBeNull(); - expect(await readOverview(snapshots)).toBeNull(); - expect(await fs.readFile(path, 'utf8')).toBe(raw); - expect(await fs.readFile(accountPath, 'utf8')).toBe(accountBytes); - expect((await readAccountTransactions(previous, previous.accounts[0].id)).transactions).toEqual( - rows() - ); - await publish({ id: 'explicit', createdAt: new Date('2026-05-03T00:00:00Z') }); - expect(await readOverview(snapshots)).toEqual(fixture()); - expect(await fs.readFile(path, 'utf8')).toBe(raw); - expect(await fs.readFile(accountPath, 'utf8')).toBe(accountBytes); - expect(await fs.readdir(join(snapshots, '.sets'))).toHaveLength(3); -}); +it.each([7, 6])( + 'leaves opaque format %s data untouched without fallback until explicit preparation', + async (format) => { + await publish(older); + const previous = await selected(); + await publish(newer); + const latest = await selected(); + const path = join(latest.directory, 'overview.json'); + const accountPath = join(latest.directory, 'transactions', `${latest.accounts[0].id}.json`); + const accountBytes = await fs.readFile(accountPath, 'utf8'); + const raw = JSON.stringify({ format, overview: 'opaque old payload', accounts: null }); + await fs.writeFile(path, raw); + expect(await readReportPublication(snapshots)).toBeNull(); + expect(await readOverview(snapshots)).toBeNull(); + expect(await fs.readFile(path, 'utf8')).toBe(raw); + expect(await fs.readFile(accountPath, 'utf8')).toBe(accountBytes); + expect((await readAccountTransactions(previous, previous.accounts[0].id)).transactions).toEqual( + rows() + ); + await publish({ id: 'explicit', createdAt: new Date('2026-05-03T00:00:00Z') }); + expect(await readOverview(snapshots)).toEqual(fixture()); + expect(await fs.readFile(path, 'utf8')).toBe(raw); + expect(await fs.readFile(accountPath, 'utf8')).toBe(accountBytes); + expect(await fs.readdir(join(snapshots, '.sets'))).toHaveLength(3); + } +); it('leaves obsolete income-expenses and transactions namespaces untouched', async () => { const directories = ['income-expenses', 'transactions']; @@ -1004,13 +1010,18 @@ it('rejects current overview file symlinks and non-files', async () => { }); it.each([ - { format: 6, overview: fixture(), accounts: [] }, - { format: 6, journal: null, overview: fixture(), accounts: [] }, - { format: 6, journal: ' ', overview: fixture(), accounts: [] }, - { format: 6, journal, overview: fixture(), accounts: null }, - { format: 6, journal, overview: fixture(), accounts: [{ ...ready(), id: '../bad' }] }, + { format: currentReportFormat, overview: fixture(), accounts: [] }, + { format: currentReportFormat, journal: null, overview: fixture(), accounts: [] }, + { format: currentReportFormat, journal: ' ', overview: fixture(), accounts: [] }, + { format: currentReportFormat, journal, overview: fixture(), accounts: null }, { - format: 6, + format: currentReportFormat, + journal, + overview: fixture(), + accounts: [{ ...ready(), id: '../bad' }] + }, + { + format: currentReportFormat, journal, overview: fixture(), accounts: [ @@ -1030,7 +1041,7 @@ it('rejects a current publication missing its required account summary', async ( const publication = await selected(); const path = join(publication.directory, 'overview.json'); const raw = JSON.stringify({ - format: 6, + format: currentReportFormat, journal, overview: fixture(), accounts: [{ ...publication.accounts[0], summary: undefined }] @@ -1078,7 +1089,12 @@ it('rejects a selected current overview missing range 12 without fallback or cha const publication = await selected(); const overview = { ...fixture(), ranges: { 1: range(1), 3: range(3), 6: range(6) } }; const path = join(publication.directory, 'overview.json'); - const raw = JSON.stringify({ format: 6, journal, overview, accounts: publication.accounts }); + const raw = JSON.stringify({ + format: currentReportFormat, + journal, + overview, + accounts: publication.accounts + }); await fs.writeFile(path, raw); await expect(readOverview(snapshots)).rejects.toSatisfy(safeError('Unable to read overview')); expect(await fs.readFile(path, 'utf8')).toBe(raw); @@ -1087,6 +1103,65 @@ it('rejects a selected current overview missing range 12 without fallback or cha ); }); +it.each([ + { label: 'missing', transactionCount: undefined }, + { label: 'negative', transactionCount: -1 }, + { label: 'unsafe', transactionCount: Number.MAX_SAFE_INTEGER + 1 } +])( + 'rejects a $label period transaction count on writes and reads without repair', + async ({ transactionCount }) => { + await publish(); + const pinned = await selected(); + const overview = fixture(); + overview.ranges![3].transactionCount = transactionCount as number; + const stage = await createReportStage(snapshots, newer, pinned.accounts); + await stage.writeRows('assets:cash', rows()); + await expect(stage.publish(overview, [ready()], journal)).rejects.toSatisfy( + safeError('Unable to prepare reports') + ); + expect(await selected()).toEqual(pinned); + expect(await fs.readdir(join(snapshots, '.sets'))).toEqual([basename(pinned.directory)]); + + const path = join(pinned.directory, 'overview.json'); + const raw = JSON.stringify({ + format: currentReportFormat, + journal, + overview, + accounts: pinned.accounts + }); + await fs.writeFile(path, raw); + await expect(readOverview(snapshots)).rejects.toSatisfy(safeError('Unable to read overview')); + expect(await fs.readFile(path, 'utf8')).toBe(raw); + expect((await readAccountTransactions(pinned, pinned.accounts[0].id)).transactions).toEqual( + rows() + ); + } +); + +it('rejects a non-string producer version on writes and reads without repair', async () => { + await publish(); + const pinned = await selected(); + const overview = { ...fixture(), hledgerVersion: 19995 } as unknown as OverviewData; + const stage = await createReportStage(snapshots, newer, pinned.accounts); + await stage.writeRows('assets:cash', rows()); + await expect(stage.publish(overview, [ready()], journal)).rejects.toSatisfy( + safeError('Unable to prepare reports') + ); + expect(await selected()).toEqual(pinned); + expect(await fs.readdir(join(snapshots, '.sets'))).toEqual([basename(pinned.directory)]); + + const path = join(pinned.directory, 'overview.json'); + const raw = JSON.stringify({ + format: currentReportFormat, + journal, + overview, + accounts: pinned.accounts + }); + await fs.writeFile(path, raw); + await expect(readOverview(snapshots)).rejects.toSatisfy(safeError('Unable to read overview')); + expect(await fs.readFile(path, 'utf8')).toBe(raw); +}); + it('rejects publication without range 12, preserving the previous readable pinned set', async () => { await publish(); const pinned = await selected(); diff --git a/packages/snapshots/src/reports.ts b/packages/snapshots/src/reports.ts index e03ba30..c30fd8f 100644 --- a/packages/snapshots/src/reports.ts +++ b/packages/snapshots/src/reports.ts @@ -15,7 +15,7 @@ const runIdPattern = /^[A-Za-z0-9_-]{1,128}(?![\s\S])/; const setPattern = /^(\d{16}-[A-Za-z0-9_-]{1,128})-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?![\s\S])/; const idPattern = /^[a-z0-9]{8}(?![\s\S])/; -const currentFormat = 6; +const currentReportFormat = 8; export interface StoredAccount { id: string; @@ -62,6 +62,9 @@ function hasRangeContainers(value: unknown): boolean { return ( typeof value.begin === 'string' && typeof value.endExclusive === 'string' && + typeof value.transactionCount === 'number' && + Number.isSafeInteger(value.transactionCount) && + value.transactionCount >= 0 && Array.isArray(value.income) && Array.isArray(value.expenses) && Array.isArray(value.net) && @@ -82,6 +85,7 @@ function hasCurrentOverviewContainers(value: unknown): value is OverviewData { value.journalCoverage.transactionCount < 0 || !isNullableString(value.journalCoverage.firstDate) || !isNullableString(value.journalCoverage.lastDate) || + typeof value.hledgerVersion !== 'string' || !isObject(value.incomeExpenses) || !Array.isArray(value.incomeExpenses.months) || !isObject(value.netWorth) || @@ -218,7 +222,7 @@ export async function readReportPublication( await privateDirectory(directory); const record = await readJson(directory, 'overview.json'); if (!isObject(record)) throw new Error(); - if (record.format !== currentFormat) return null; + if (record.format !== currentReportFormat) return null; if ( !hasCurrentOverviewContainers(record.overview) || !isCatalog(record.accounts) || @@ -441,7 +445,7 @@ export async function createReportStage( } if (remaining.size) throw new Error(); await writeJson(temporary, 'overview.json', { - format: currentFormat, + format: currentReportFormat, journal, overview, accounts: catalog -- 2.54.0 From 94c1aa95b9a02e247567d42b1040c49f5e97f416 Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 6 Nov 2026 15:24:37 -0800 Subject: [PATCH 2/3] feat(web): add period-aware financial pages and branded navigation --- .changeset/present-responsive-homepage.md | 32 ++ .github/workflows/web.yml | 6 +- README.md | 175 ++----- apps/web/README.md | 422 +++++++-------- apps/web/e2e/account-navigation.e2e.ts | 108 +++- .../e2e/fixtures/category-allocation.journal | 23 + apps/web/e2e/holdings.e2e.ts | 8 +- apps/web/e2e/homepage.e2e.ts | 284 ++++++++++ apps/web/e2e/manual-preparation.e2e.ts | 145 +++++- apps/web/e2e/overview-fixture.ts | 25 +- apps/web/e2e/server.mjs | 4 + apps/web/package.json | 2 + apps/web/src/app.html | 1 + apps/web/src/lib/charts/ChartCanvas.svelte | 21 +- .../src/lib/charts/expense-categories.test.ts | 197 ------- apps/web/src/lib/charts/expense-categories.ts | 84 --- .../lib/charts/rendering.integration.test.ts | 15 +- apps/web/src/lib/dates.test.ts | 7 +- apps/web/src/lib/dates.ts | 16 + .../src/lib/navigation/AccountSidebar.svelte | 52 +- .../src/lib/navigation/AccountSidebar.test.ts | 133 ++++- .../src/lib/navigation/AccountsDrawer.svelte | 60 ++- apps/web/src/lib/navigation/AppHeader.svelte | 101 ++++ .../src/lib/overview/ExpenseCategories.svelte | 87 ---- .../src/lib/overview/FinancialValue.svelte | 54 ++ .../src/lib/overview/IncomeExpenses.svelte | 108 ---- .../src/lib/overview/JournalCoverage.svelte | 52 -- apps/web/src/lib/overview/KeyFigures.svelte | 84 +++ apps/web/src/lib/overview/KeyFigures.test.ts | 117 +++++ apps/web/src/lib/overview/NetWorth.svelte | 102 ---- apps/web/src/lib/overview/data.ts | 23 +- .../income-spending/IncomeSpending.svelte | 84 +++ .../income-spending/chart.test.ts} | 41 +- .../income-spending/chart.ts} | 8 +- .../src/lib/overview/select-period.test.ts | 83 +++ apps/web/src/lib/overview/select-period.ts | 13 + .../SpendingCategories.svelte | 190 +++++++ .../spending-categories/chart.test.ts | 76 +++ .../lib/overview/spending-categories/chart.ts | 59 +++ .../spending-categories/presentation.test.ts | 213 ++++++++ .../spending-categories/presentation.ts | 89 ++++ .../lib/preparation/ValidationStatus.svelte | 26 - .../lib/preparation/ValidationStatus.test.ts | 38 -- .../web/src/lib/report-card/ReportCard.svelte | 68 +++ .../web/src/lib/report-card/ScrollArea.svelte | 99 ++++ .../lib/report-period/PeriodControls.svelte | 127 +++++ .../lib/report-period/period-query.test.ts | 16 + .../web/src/lib/report-period/period-query.ts | 13 + .../src/lib/reports/networth/NetWorth.svelte | 103 ++++ .../networth/chart.test.ts} | 29 +- .../networth/chart.ts} | 6 +- .../src/lib/settings/JournalSummary.svelte | 155 ++++++ .../src/lib/settings/ValidationStatus.svelte | 32 ++ .../src/lib/settings/ValidationStatus.test.ts | 44 ++ apps/web/src/lib/settings/data.ts | 10 + apps/web/src/lib/styles/controls.css | 21 + apps/web/src/lib/styles/theme.css | 1 + .../web/src/routes/(protected)/+layout.svelte | 50 +- .../src/routes/(protected)/+page.server.ts | 115 +++-- apps/web/src/routes/(protected)/+page.svelte | 83 ++- .../routes/(protected)/page.server.test.ts | 207 ++++---- .../reports/networth/+page.server.ts | 32 ++ .../(protected)/reports/networth/+page.svelte | 79 +++ .../reports/networth/page.server.test.ts | 244 +++++++++ .../(protected)/settings/+page.server.ts | 19 +- .../routes/(protected)/settings/+page.svelte | 16 +- .../(protected)/settings/page.server.test.ts | 95 +++- .../routes/(protected)/settings/page.test.ts | 70 ++- apps/web/static/branding/app-icon.png | Bin 0 -> 1706 bytes apps/web/static/favicon.ico | Bin 0 -> 15086 bytes apps/web/tsconfig.json | 2 +- apps/web/vite.config.ts | 4 +- apps/worker/README.md | 2 +- docs/architecture.md | 486 +++++++----------- docs/development.md | 142 ++--- docs/philosophy.md | 63 +-- packages/hledger/README.md | 5 +- pnpm-lock.yaml | 149 ++++++ 78 files changed, 4161 insertions(+), 1894 deletions(-) create mode 100644 .changeset/present-responsive-homepage.md create mode 100644 apps/web/e2e/fixtures/category-allocation.journal create mode 100644 apps/web/e2e/homepage.e2e.ts delete mode 100644 apps/web/src/lib/charts/expense-categories.test.ts delete mode 100644 apps/web/src/lib/charts/expense-categories.ts create mode 100644 apps/web/src/lib/navigation/AppHeader.svelte delete mode 100644 apps/web/src/lib/overview/ExpenseCategories.svelte create mode 100644 apps/web/src/lib/overview/FinancialValue.svelte delete mode 100644 apps/web/src/lib/overview/IncomeExpenses.svelte delete mode 100644 apps/web/src/lib/overview/JournalCoverage.svelte create mode 100644 apps/web/src/lib/overview/KeyFigures.svelte create mode 100644 apps/web/src/lib/overview/KeyFigures.test.ts delete mode 100644 apps/web/src/lib/overview/NetWorth.svelte create mode 100644 apps/web/src/lib/overview/income-spending/IncomeSpending.svelte rename apps/web/src/lib/{charts/income-expenses.test.ts => overview/income-spending/chart.test.ts} (79%) rename apps/web/src/lib/{charts/income-expenses.ts => overview/income-spending/chart.ts} (92%) create mode 100644 apps/web/src/lib/overview/select-period.test.ts create mode 100644 apps/web/src/lib/overview/select-period.ts create mode 100644 apps/web/src/lib/overview/spending-categories/SpendingCategories.svelte create mode 100644 apps/web/src/lib/overview/spending-categories/chart.test.ts create mode 100644 apps/web/src/lib/overview/spending-categories/chart.ts create mode 100644 apps/web/src/lib/overview/spending-categories/presentation.test.ts create mode 100644 apps/web/src/lib/overview/spending-categories/presentation.ts delete mode 100644 apps/web/src/lib/preparation/ValidationStatus.svelte delete mode 100644 apps/web/src/lib/preparation/ValidationStatus.test.ts create mode 100644 apps/web/src/lib/report-card/ReportCard.svelte create mode 100644 apps/web/src/lib/report-card/ScrollArea.svelte create mode 100644 apps/web/src/lib/report-period/PeriodControls.svelte create mode 100644 apps/web/src/lib/report-period/period-query.test.ts create mode 100644 apps/web/src/lib/report-period/period-query.ts create mode 100644 apps/web/src/lib/reports/networth/NetWorth.svelte rename apps/web/src/lib/{charts/net-worth.test.ts => reports/networth/chart.test.ts} (86%) rename apps/web/src/lib/{charts/net-worth.ts => reports/networth/chart.ts} (94%) create mode 100644 apps/web/src/lib/settings/JournalSummary.svelte create mode 100644 apps/web/src/lib/settings/ValidationStatus.svelte create mode 100644 apps/web/src/lib/settings/ValidationStatus.test.ts create mode 100644 apps/web/src/lib/settings/data.ts create mode 100644 apps/web/src/routes/(protected)/reports/networth/+page.server.ts create mode 100644 apps/web/src/routes/(protected)/reports/networth/+page.svelte create mode 100644 apps/web/src/routes/(protected)/reports/networth/page.server.test.ts create mode 100644 apps/web/static/branding/app-icon.png create mode 100644 apps/web/static/favicon.ico diff --git a/.changeset/present-responsive-homepage.md b/.changeset/present-responsive-homepage.md new file mode 100644 index 0000000..1efc753 --- /dev/null +++ b/.changeset/present-responsive-homepage.md @@ -0,0 +1,32 @@ +--- +'@hledger-reports/web': patch +'@hledger-reports/worker': patch +--- + +Make Overview a responsive saved financial summary with four headlines: +selected-period Income, Spending and Transactions, plus latest whole-journal +Net worth. Two equal cards show income/spending and categories with visible exact +tables. Choose 1, 3, 6 or 12 months; bare links default to 3. With JavaScript, +selection and browser history reuse loaded data without requests. Direct links, +reload and the native GET form work without JavaScript. Latest net worth and +Accounts balances stay at the saved cutoff, independent of period. + +Spending by category uses a horizontal Top 5 + Other chart. Its chart-only currency +selector appears after hydration when needed, with automatic fallback when a period +removes a currency. Exact grouping stays within one commodity, without conversion +or changes to native totals. The complete raw category table remains unfiltered. +A Chart values disclosure after that table provides exact values in chart order, +including grouped Other, as the canvas's text alternative. + +Open the saved Net worth trend at `/reports/networth` through the entire Net worth +row in Accounts, even without configured accounts or readable balances. Settings +shows the six-fact Journal summary below preparation controls: date span, transaction +count, recorded strict check, preparation time, saved hledger version and currency. +Exact precision and incomplete valuations remain explicit throughout. + +This release requires financial format 8. Follow the +[manual paired activation steps](../docs/development.md#financial-format-8-activation) +for compatible web, worker and rebuilt shared packages, then explicitly prepare +reports again. Unsupported formats 7, 6 and older remain untouched; account IDs may +reset. There is no automatic preparation, migration or deletion. Authentication and +explicit-work behavior are unchanged. diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index e26449c..e93582f 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -85,7 +85,11 @@ jobs: run: | umask 077 mkdir -p apps/web/journals - cp apps/worker/fixtures/lots.journal apps/web/journals/main.journal + { + cat apps/worker/fixtures/lots.journal + printf '\n' + cat apps/web/e2e/fixtures/category-allocation.journal + } > apps/web/journals/main.journal cp apps/web/config/hledger-reports.example.yaml apps/web/config/hledger-reports.yaml - name: Validate Compose run: docker compose config --quiet diff --git a/README.md b/README.md index f890c60..c4a921a 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,75 @@ # hledger web reports -A local, read-only hledger reporter. hledger owns accounting; OpenWorkflow + SQLite coordinate work, not a second accounting engine. The web app never opens journals or executes hledger. +A local, read-only financial app for an hledger journal. Hledger owns accounting; +the web reads saved reports and never opens journals or executes hledger. -## Overview, transactions and live queries +## Product -**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. +- **Overview** shows selected-period Income, Spending and Transactions, plus latest + whole-journal Net worth, with income/spending and category charts and exact tables. +- **Accounts** links to configured Cash/Investment registers and the saved + **Net worth** trend at `/reports/networth`. +- **Settings** owns explicit report preparation, status and a six-fact Journal + summary. Browsing, startup and journal edits never prepare reports automatically. +- **Live queries**, linked from Settings, run a separate recorded-cost report on + explicit request. -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. +Exact quantities, separate commodities and incomplete valuations stay visible. +Saved timestamps describe preparation, not freshness. Saved reports remain readable +when the worker is unavailable. See [web usage and routes](apps/web/README.md). -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. +## Start with Docker Compose -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. +Create private `apps/web/config/hledger-reports.yaml` from the +[safe example](apps/web/config/hledger-reports.example.yaml), without overwriting +an existing configuration. Select your read-only journal tree under +`apps/web/journals/` (`main.journal`), or set `JOURNAL_DIR` to another directory. +Configure exactly one sign-in strategy and supply its secrets and `AUTH_SECRET` +through your secret tooling; see [authentication setup](docs/authentication.md). +No host Node, pnpm or hledger installation is needed for Compose. -**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; existing sets require explicit format-8 preparation after a paired web/worker upgrade. 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. - -**`/queries`, linked from Settings, remains the existing separate live report:** each Run executes native balance at recorded cost against the journal current at execution. Exact results persist in SQLite as operational workflow output, but the page displays only its latest response; reload clears it. Neither page offers user-facing run history. - -Sign-in is required. Local development stays loopback-bound; an existing trusted HTTPS proxy may front the compiled app under the documented Host, origin and client-address contract. Do not expose its upstream directly. See [shared access and authentication](docs/authentication.md). Curated report catalogs and search remain deferred. - -## Setup and run - -Before first startup, create private `apps/web/config/hledger-reports.yaml` from the [safe example](apps/web/config/hledger-reports.example.yaml), without overwriting an existing configuration. Place your journal tree in `apps/web/journals/` with `main.journal` as its entry point, or select another read-only directory with `JOURNAL_DIR`. Compose refuses a missing config file instead of creating a directory at that path. - -Configure exactly one sign-in strategy and supply `AUTH_SECRET` plus the chosen credential secret through your secret tooling before startup. See [authentication setup](docs/authentication.md#configuration). For live development with Docker Compose (no host Node/pnpm/hledger required): - -```bash +```sh cd apps/web mkdir -m 700 -p data && docker compose -f docker-compose.dev.yml up --build --wait ``` -Runtime data is bind-mounted from **`apps/web/data/`**, so it is directly inspectable and ignored by Git/build tools. To stop without removing the data folder, run `docker compose -f docker-compose.dev.yml down` from the same directory. +Open **http://127.0.0.1:5173/**, sign in, then use **Prepare reports** in **Settings**. +Reload Settings for status and navigate to saved results. Stop with +`docker compose -f docker-compose.dev.yml down`; keep the private data folder. -Open **http://127.0.0.1:5173/**, sign in, then use **Prepare reports** in **Settings** to create the first saved set. Web readiness does not wait for financial calculations, and saved data remains readable if the worker is unavailable. Automated tests use the separate fictional `lots.journal` fixture, never your live journal. See [container development and CI](docs/development.md) for setup, configuration migration, compiled proxy setup and browser tests. +Both services share ignored **`apps/web/data/`**, containing sensitive SQLite jobs, +financial results and saved reports. Protect and back up the whole folder. Existing +named-volume users should follow the [safe copy guide](docs/development.md#inspecting-data-and-preserving-the-previous-volume). -The worker runs requested jobs; it does not watch or hash journals, detect staleness, or schedule report preparation. Saved timestamps tell you when reports were prepared, not whether they match current inputs. Explicit query Runs still read current input. Closing the page aborts its HTTP request, not durable work. Without `CONFIG_PATH`, protected requests fail closed; public health and schema endpoints remain available. No personal configuration is discovered. +Upgrading from PR3's format 6 requires [manual paired format-8 activation](docs/development.md#financial-format-8-activation). +Unsupported files are preserved, not migrated, deleted or automatically regenerated. -## Configuration and private data +Sign-in remains mandatory. Development is loopback-bound; compiled preview needs +an existing [trusted HTTPS proxy](docs/authentication.md#trusted-proxy-contract). +This is not a public deployment recipe. -The canonical file is `config/hledger-reports.yaml`; Compose uses the ignored `apps/web/config/hledger-reports.yaml`. Only the safe `hledger-reports.example.yaml` is tracked. For host-native execution, for example: +## Documentation owners -```yaml -# yaml-language-server: $schema=http://127.0.0.1:5173/schema.json -version: 1 -journal: ../journals/main.journal -url: - app: http://127.0.0.1:5173 -auth: - secret: '${AUTH_SECRET}' - credentials: - username: household - password: '${AUTH_PASSWORD}' -``` - -`version` defaults to `1`; other versions fail validation. `url.app` and exactly one credentials/OIDC strategy are required. Sessions last a fixed seven days, without renewal or an auth database. Auth secrets remain web-private; worker validates structure without resolving them. Deployment/auth strings support `${NAME}` environment references, `${NAME:-fallback}` for unset variables, and `$$` for a literal dollar. Expansion is single-pass and missing variables fail safely. Relative paths resolve from the selected YAML file, not the working directory. Compose's example instead selects `/journal/main.journal`, the unchanged container journal path. Set the schema URL to your browser-facing origin. - -Configure register membership explicitly; omission means that register is hidden and unavailable, not an all-account fallback: - -```yaml -accounts: - - account: assets:bank:checking - label: Everyday checking - register: cash - - account: assets:investments:brokerage - label: Brokerage - register: investment - - account: expenses:living - label: Living expenses -``` - -Account identities and labels are literal strings, with exact matching and no environment expansion or prefix aliases. Labels apply throughout presentation while canonical names remain available. Label-only entries do not enable a register. Missing saved accounts are shown as unavailable. Holdings uses the same investment membership for its selector and displayed native account groups; without investment members it is also unavailable. Labels and membership do not alter native identity, financial values or preparation scope. See the [account configuration contract](packages/config/README.md#account-presentation). - -Configuration and `data/` remain siblings. Renaming the config in place does not move data or change the selected journal. Both processes must select the same file: - -- `data/jobs/`: private SQLite workflow queue, query parameters/results and preparation history. -- `data/snapshots/.validation/`: immutable completed-check records, separate from financial sets. Records are retained without automatic cleanup or a history UI; private journal/request metadata is never sent to the browser. -- `data/snapshots/current/overview.json`: the selected journal identity, `OverviewData` and the account directory. Each native account has an eight-character lowercase-letter/digit ID, full name, access state, row count and required raw/valued summary. IDs are reused by exact name within supported same-journal publications, but may reset across the format transition. -- `data/snapshots/current/transactions/.json`: one complete account register per file, with its identity, exact native amounts and exported transaction/posting metadata. Source and assertion positions remain private. This is processed report output, not original journal text or a complete investment cost-basis export. Account names remain JSON data, never paths. Pagination is a server-side array slice, not persisted page files. - -Financial files are prepared together in a hidden staging set and published as a complete directory; `current` is the inspectable entry point. Readers require complete **format-8** publications for the selected journal, including the checked hledger version and all four native period transaction counts. Formats 7, 6 and older are unsupported. To activate, stop old preparation execution, deploy compatible web and worker revisions with rebuilt shared packages together, then explicitly **Prepare reports again** in Settings. Unsupported files remain untouched but unusable; old account URLs may change because IDs may reset at this transition. Readers do not migrate, delete or automatically regenerate files. Former source-observation files are also left untouched and are not read or written. One directory rename commits the entire financial set. Before that commit, failure retains the previous complete set; a later `current` alias failure can fail the job while the new complete set remains selectable, without rollback or deletion. Reload saved reports to observe the result. The v1 void-input workflow and independent recorded-validation lifecycle are unchanged. See [publication and recovery](packages/snapshots/README.md#publication-and-reads) for commit behavior. - -In Compose, these paths are visible under **`apps/web/data/`** in the checkout. The previous named volume is left untouched; [copy it safely before the first bind-mounted start](docs/development.md#inspecting-data-and-preserving-the-previous-volume) to retain its queue and publications. Protect and back up the entire data directory; do not log financial data or native diagnostics. [Prepared-data lifetime](packages/snapshots/README.md#current-format-and-preservation) covers growth, last-good recovery and untouched unsupported files. - -Config loading neither opens journals nor creates directories. Each process loads its explicit selection at startup; restart after YAML edits. Blank/invalid selections fail startup. Web initializes one jobs client and reuses it for requests. A shared hook checks both framework request URL origins against normalized YAML `url.app`; POSTs additionally require an exact same-origin request and a 2-KiB streamed body limit: form encoding for preparation, JSON for live queries. These transport guards remain in addition to the central authentication gate. Responses are no-store. Vite denies raw journals, SQLite/sidecars and data/input/result folders. - -The [native library](packages/hledger/README.md) documents read-only journal access, execution safeguards and exact-codec limitations. The worker enables scheduled SQLite query retention; see [scope, scheduling and backup precautions](apps/worker/README.md#query-job-retention). - -## Validation and compiled runs - -During development, run the affected tests and relevant static checks rather than the entire gate after every edit. The [testing philosophy](docs/philosophy.md#tests-while-the-foundations-are-changing) keeps unit coverage on core contracts and browser coverage on a few real journeys. The full workspace commands below remain available for cross-cutting changes and CI. - -Run host checks from the repository root. They require Node **26.5.0**, pnpm **11.9.0** (`mise.toml`), and hledger **1.99.5** on PATH. Development resolves workspace source; production uses compiled packages. If needed, install public dependencies with `NPM_CONFIG_USERCONFIG=/dev/null pnpm install --store-dir .pnpm-store`. - -```bash -pnpm check -pnpm lint -pnpm test -pnpm build -pnpm worker:inspect # Read-only fixture preflight; does not prepare reports -``` - -The [compiled Compose setup](docs/development.md#compiled-preview) requires a trusted proxy and uses the same host data folder; direct HTTP browsing remains a Vite development workflow. - -Worker health defaults to `http://127.0.0.1:9000/healthz` (`WORKER_PORT` overrides it); web also serves `/healthz`. Health is liveness, not financial freshness. Tests cover guarded routes, exact native results, durable preparation/reload, failure preservation, live-query reload clearing and compiled browser interactions. Native errors are deliberately generic; run hledger directly for diagnostics. - -Validate a selected configuration without opening its journal, running hledger or creating storage: - -```bash -pnpm config:validate --config apps/web/config/hledger-reports.yaml -``` - -This validates resolved values using the current environment; it does not print them. `CONFIG_PATH` is also accepted. The existing `worker:inspect` command still performs a separate native journal preflight. - -After config-schema changes, regenerate and check the bundled editor artifact: - -```bash -pnpm --filter @hledger-reports/config generate:schema -pnpm --filter @hledger-reports/config check:schema -``` - -CI rejects schema drift without rewriting files. `/schema.json` serves only this artifact, never live configuration; it is public, nonfinancial and cached for five minutes. - -## Layout +| Document | Owns | +| ---------------------------------------------- | --------------------------------------------------------- | +| [Web](apps/web/README.md) | User behavior, routes and presentation file map | +| [Architecture](docs/architecture.md) | Request, public-data and phase boundaries | +| [Native library](packages/hledger/README.md) | Hledger commands, exact output, dates, counts and version | +| [Snapshot store](packages/snapshots/README.md) | Format validation, commit and preservation | +| [Development](docs/development.md) | Activation, local/compiled runs, fixtures, checks and CI | +| [Worker](apps/worker/README.md) | Job execution, service resources and query retention | +| [Configuration](packages/config/README.md) | YAML, paths, labels and register membership | +| [Philosophy](docs/philosophy.md) | Narrow ownership and simplicity principles | ```text -apps/web SvelteKit Overview, account transactions and explicit live queries -apps/worker OpenWorkflow implementations, native execution, owned fixtures -packages/config Shared config schema, validation and path resolution -packages/hledger Native journal library and ready report data -packages/jobs Shared job definitions and caller-owned queue connection -packages/snapshots Complete current-format Overview/account snapshots +apps/web SvelteKit saved views, Settings and live queries +apps/worker OpenWorkflow implementations and owned native fixtures +packages/config Configuration schema, validation and path resolution +packages/hledger Native execution and ready report data +packages/jobs Shared job definitions and queue connection +packages/snapshots Complete report publications and validation evidence ``` -Packages own their tooling and co-located tests. Development uses the root Node loader; production uses compiled JavaScript. See [architecture](docs/architecture.md) for the read/request/produce boundaries and [philosophy](docs/philosophy.md) for the simplicity rules. - -- [Web](apps/web/README.md) -- [Worker](apps/worker/README.md) and [fixture](apps/worker/fixtures/README.md) +All workspace packages are internal. Source and tests are co-located; development +uses source exports and production uses compiled JavaScript. diff --git a/apps/web/README.md b/apps/web/README.md index 7eae27a..4db11f3 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,134 +1,177 @@ # Web app -Svelte 5/SvelteKit with adapter-node. The web app displays saved reports and accepts -explicit requests for background work. It never opens journals or executes hledger. -See the [architecture](../../docs/architecture.md) and -[philosophy](../../docs/philosophy.md) for the ownership rules. +Svelte 5/SvelteKit with adapter-node. The web displays saved reports and accepts +explicit background-work requests. It never opens journals or executes hledger. +See [architecture](../../docs/architecture.md) for request and public-data boundaries. ## Pages -| Page | Behavior | -| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `/` | Read saved six-month Overview and independent last recorded validation outcome/time | -| `/registers/cash`, `/registers/investments` | Read configured members from the saved account directory | -| `/registers/cash/`, `/registers/investments/` | Read saved account activity with ordinary pagination | -| `/settings` | Inline preparation/status and saved validation; ordinary POST requests work; links to Live queries | -| `/queries` | Existing explicit recorded-cost balance tool; reload clears the displayed result | +| Page | Behavior | +| ----------------------------------------------------------- | ----------------------------------------------------------------------- | +| `/`, `/?months=1\|3\|6\|12` | Saved Overview; default 3 months | +| `/reports/networth`, `/reports/networth?months=1\|3\|6\|12` | Saved native net-worth history; default 3 months | +| `/registers/cash`, `/registers/investments` | Configured members from the saved account directory | +| `/registers/cash/`, `/registers/investments/` | Saved account activity with ordinary pagination | +| `/holdings` | Explicit investment holdings report for configured members | +| `/settings` | Preparation/status, Journal summary and link to Live queries | +| `/queries` | Explicit recorded-cost balance tool; reload clears its displayed result | -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 homepage still presents six -months; new figures, period controls and a three-month default remain future work. -Registers retain their existing presentation. +`/reports` is a namespace only, with no index or redirect; there is no +`/reports/net-worth` alias. `/prepare` has no redirect or POST alias. -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 -all account registers together. Concurrent requests join active preparation; a later -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, 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. +Browsing does not check inputs, infer freshness, query job status or submit work. +Without a complete supported financial set, saved views say reports are unavailable; +use Settings to prepare them. Saved reports remain readable while preparing or +with the worker stopped. Open pages do not refresh automatically. -Overview and Settings show the last recorded strict validation outcome/time separately -from the saved financial preparation time. Unavailable validation never hides readable -finances; only outcome/time is public, not journal/request identity or diagnostics. -A failed job does not guarantee reports are unchanged: reload saved reports to see -the available result. See [validation ownership](../../docs/architecture.md#last-recorded-validation) -and [publication/recovery](../../packages/snapshots/README.md#publication-and-reads) -for the lifecycle details. +## Overview periods + +Bare `/` and the header's Overview link select **3 months**. Choose **1/3/6/12 +months**, or use a URL such as `/?months=12`; there is no remembered preference. +The route accepts exactly one literal `months` value or no query. Repeated, empty, +unsupported values and unknown keys return 400. + +With JavaScript, selection and Back/Forward use already-loaded data without data, +API, job or native requests, preserving the snapshot, focus and scroll. Before or +without JavaScript, the native select and Apply button submit the same GET route. +Direct links and reloads can observe a newer saved set. + +Four headlines show selected-period **Income**, **Spending** and **Transactions**, +plus **latest whole-journal Net worth**. Transactions counts actual primary-date +transactions, including transfers and opening balances, not postings or chart rows. +The window includes the latest recorded primary transaction's month and preceding +calendar months, not the computer's month. Latest net worth and Accounts remain at +the saved cutoff regardless of selection. There is no income-less-expenses headline. + +Two equal **40rem** cards show monthly income/spending and depth-2 expense categories, +with exact tables visible by default. On wide screens each spans two headline tiles +plus their gap. Pinned headings and keyboard-accessible scrolling keep all table rows +available. The category plot stays in a bounded **19rem** slot without a chart scroll +wrapper; full accessible captions remain available when visual captions are clamped. + +### Spending by category chart + +One horizontal **Top 5 + Other** chart ranks signed net spending within a single +commodity, with native-order ties. Other appears last only when more than five +eligible categories exist and sums the remainder exactly; a real category named +Other remains distinct. Native zeros and refunds retain their signs. Missing +commodities are omitted, not turned into zero bars. Ambiguous duplicate amounts +for the same commodity in any row make that commodity's chart unavailable rather +than silently combining them. + +After hydration, a native **Spending chart currency** selector appears when several +commodities are available. If a period removes the chosen commodity, it falls back +to an available choice. Period/currency changes reuse saved data without requests. +The chart selector is absent without JavaScript; the complete raw category table +still shows every commodity and category in native order, never filtered or truncated. + +After that complete table, a native **Chart values** disclosure provides an ordered +text alternative to the chart, including exact grouped Other values and commodity. +The canvas is described by that list. The disclosure is available when the client +can produce the chart; it does not replace or modify the raw table. + +This chart-only exact grouping does not convert currencies, change native totals +or authorize browser accounting elsewhere. See the narrow +[arithmetic principle](../../docs/philosophy.md#ownership-before-abstraction). + +## Net worth report + +The entire Net worth footer row in Accounts links to **`/reports/networth`** and +marks the active page. It stays available without configured groups or saved +balances, with honest unavailable values. + +The report shows the saved twelve-point native trend and a visible exact table. +Its own 1/3/6/12-month selector defaults to 3 and has the same URL, history and GET +behavior as Overview. It selects loaded points without recalculation. The chosen +native currency and incomplete valuations remain explicit; selection does not +change latest NET or Accounts balances. + +## Settings journal summary + +Settings' ordinary **Prepare reports** form requests Overview and all account +registers together. Concurrent requests join active work; a later request prepares +again even with unchanged inputs. The empty form works without JavaScript and +redirects with 303 to `/settings`. Reload for this deployment's queue status, then +navigate to saved reports. There is no polling or automatic follow-up after edits. +A failed job does not guarantee unchanged reports; reload to inspect the available +result. See [publication/recovery](../../packages/snapshots/README.md#publication-and-reads). + +Below execution status and controls, **Journal summary** shows six facts: + +- **Journal**: full recorded date span (including posting/secondary dates), total + whole-journal transactions and last recorded strict-check outcome/time. +- **Saved report**: preparation time, saved hledger version and report currency. + +The summary owns the single preparation/check timestamp display; status above is +execution-only. Validation is independent of saved finances: unavailable validation +does not hide readable reports, and a recorded pass is not a freshness guarantee. +No journal/request identity or native diagnostic reaches the browser. + +Follow **Live queries** to `/queries` for a separate current-input calculation. +Each Run returns exact recorded-cost results; reload clears the display. Closing +HTTP does not cancel durable work, and there is no user-facing run history. ## Account labels and register membership -`accounts` in the shared YAML selects exact Cash/Investment register members and -optional app-wide labels. A register without members is hidden and returns 404, -including direct account URLs. Label-only entries do not enable registers. A member -missing from saved data is shown as unavailable, without a fabricated link or count. +Configure exact Cash/Investment members and optional app-wide labels in shared YAML. +A register without members is hidden and returns 404, including direct account URLs. +Label-only entries do not enable registers. Missing saved members appear unavailable +without fabricated links or counts. Holdings uses investment membership; without +investment members it is unavailable too. -Labels also appear in Holdings, category charts/tables, live query results and -transaction details. Canonical identities remain available; labels never enter -native job inputs or replace saved account IDs. Holdings uses exact investment -membership for its selector, direct submissions and displayed native account groups. -No investment members means no Holdings view. Preparation still covers all native -accounts. Names/labels are literal, with no environment or prefix expansion. -See [account configuration](../../packages/config/README.md#account-presentation). +Labels appear throughout presentation while canonical identities remain available. +They do not alter native job inputs, report scope or saved IDs. Preparation still +covers all native accounts. 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. +Signed-in pages have a permanent right rail on desktop, a right overlay drawer on +medium screens and a full-screen drawer on narrow screens. The compact branded +header keeps Overview and Settings left, with accessible Accounts/sign-out icon +actions right. Sign-out is a native POST, including without JavaScript. -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. +Cash/Investments headings open configured register directories; accounts link to +saved activity. Holdings is reached from an investment account register, not the +header or Accounts. Close, Escape, backdrop and navigation dismiss the drawer. +Without JavaScript, Accounts is a native disclosure with real links. Public/sign-in +pages have no financial navigation. -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. +Accounts shows Cash raw balances, Investment valuations including cash, and native +whole-journal net worth—not sums of configured accounts. The balance cutoff is +visible, without a preparation timestamp. Unavailable and incomplete values stay +explicit. Shell and page observe the same saved set on navigation/reload. ## Financial presentation -The [native library](../../packages/hledger/README.md#overview-data) owns financial -meaning, periods and exact quantities. Web renders its report data, with type-only -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`. 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. +Exact quantity strings and separate commodities remain visible. Display formatting +never rounds meaningful precision or converts quantities to floating point. Charts +use approximate geometry; exact tables and individual native tooltips retain the +producer values. Category Other is the sole chart-derived tooltip amount. Values +outside numeric range or ambiguous duplicate-commodity bags retain the exact table +instead of a misleading chart. Mixed/unconverted valuations are visibly incomplete, +not consolidated scalars or invented zeros. Empty and undated results are explicit. +See the [native contract](../../packages/hledger/README.md#overview-data) for financial +meaning, dates and valuation evidence. -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 -worth. Expense categories use native absolute depth2 totals. Net worth uses native -historical assets minus liabilities at dated market values in the inferred base -currency; unconverted units mark incomplete points, never invented zeros. Coverage -uses the whole native journal span, including secondary/posting dates, not just the -Overview window. A prepared Overview always contains all four sections; a genuinely -empty journal still has explicit empty results, not fabricated financial values. +Cash pages show 50 rows; Investment pages show 50 whole transactions. Full recorded +account activity, including declared-parent subtree history, is not filtered by +Overview's period. Native running balances do not reset at page boundaries. +First/Previous/Next/Last are ordinary links. Account names remain exact data, never +paths or slugs; IDs may reset across an unsupported-format upgrade. -Chart.js4.5.1 draws approximate geometry. Exact tables/tooltips preserve producer -strings, separate commodities and every reported amount. Duplicate-commodity bags -that cannot fit a single bar/point and values outside the numeric range retain the -exact table instead. There is no web financial summation or currency conversion. +### Transaction dialogs -Transactions show full recorded account activity, including subaccounts for real -parents. Implied ancestors are not extra reports. Each native account has a stable -eight-character lowercase-letter/digit ID within supported same-journal publications; -IDs may reset across the format transition. Names remain exact data, never paths or -slugs. Multiple securities can belong to one account. Native dates, related-account -labels, split flags, changes and attached running balances survive pagination. -Empty accounts have no invented transaction; ambiguous native selectors show no -account amounts. First/Previous/Next/Last are ordinary links, including without JS. - -## Transaction dialogs - -Cash breakdowns and Investment details share a small Bits UI shell. Their financial -content and selection stay in the owning feature. Escape and the close button dismiss; -backdrop clicks do not. Closing restores focus to the opener when available, otherwise -to main content. Navigation or replacement page data invalidates the selected record. - -The portal inherits document theme/typography and keeps the desktop dialog and narrow -bottom-sheet layout. Bits UI supplies focus trapping and body scroll lock. Detail -buttons remain disabled before hydration; the underlying financial tables work -without JavaScript. There are no forced SSR dialog contents or unrelated Bits controls. +Cash breakdowns and Investment details use a shared Bits UI shell. Escape/Close +dismiss; backdrop clicks do not. Focus returns to the opener or main content. +Navigation/replacement data clears the selection. The portal inherits theme and +supports desktop dialogs/narrow bottom sheets; Bits UI owns focus trapping and +scroll lock. Detail buttons are disabled before hydration; financial tables work +without JavaScript. ## Files and state -Compose shares ignored **`apps/web/data/`** at `/app/data` in both processes; no -placeholder is tracked. Vite denies raw journal/data/SQLite serving. Files are -private financial information, not public static assets. +Compose shares private ignored **`apps/web/data/`** with the worker. Vite denies +raw journal/data/SQLite serving. Nothing here is a public static asset. ```text data/snapshots/current/ @@ -136,142 +179,51 @@ data/snapshots/current/ transactions/.json ``` -The saved `overview.json` envelope contains the selected journal identity, financial -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; the shell and saved-data leaf share one pinned selection per request. Private -journal identities and set paths never enter page data. Financial format **8** requires -the checked hledger version and all four native period transaction counts, and 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. +The worker publishes complete immutable sets. Server loaders project safe page data, +never entire stored envelopes or source/assertion positions. See +[storage and preservation](../../packages/snapshots/README.md#current-format-and-preservation) +and [manual paired activation](../../docs/development.md#financial-format-8-activation). +Do not delete volumes to simulate an empty app or copy into populated data folders; +use the [safe copy guide](../../docs/development.md#inspecting-data-and-preserving-the-previous-volume). -Only financial format **8** is supported; formats 7, 6 and older are unsupported. -Stop old preparation execution, deploy compatible web and worker revisions with -rebuilt shared packages together, then explicitly **Prepare reports again** in -Settings, including with an existing format-6 or format-7 set. Unsupported 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, independent -validation-evidence lifecycle, transaction history and configuration are unchanged. -See [paired activation](../../docs/development.md#financial-format-8-activation). -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. - -See [prepared storage](../../packages/snapshots/README.md#current-format-and-preservation) and the -[old-volume copy guide](../../docs/development.md#inspecting-data-and-preserving-the-previous-volume). -Do not copy into populated data folders or delete volumes to simulate an empty app. - -## Request and resource boundaries - -This app requires shared sign-in. Both `event.url.origin` and the origin of -`request.url` must match normalized YAML `url.app`; there is no custom raw Host -parser. Local Vite development stays loopback-bound. Compiled instances use the -[trusted-proxy contract](../../docs/authentication.md#trusted-proxy-contract): -framework-owned scheme, Host and client-address handling, with no build-time public URL. -Unsafe methods require exact Origin; preparation/query POSTs also require bounded -fatal-UTF8 bodies (form encoding/JSON respectively). Responses are no-store. - -Hooks open one complete private application context per configured process and -place it in `locals.application`: resolved configuration, deployment paths, caller-owned authentication and a -`JobsClient`. Shutdown closes the client after adapter-node drains -requests. Restart after YAML changes; handlers do not reload configuration or -discover personal journals. YAML requires `journal`, `url.app` and exactly one -auth strategy, with `version` defaulting to `1` and fixed sibling config/data. Live `config/hledger-reports.yaml` is ignored; -only its safe example is tracked. See the [configuration contract](../../packages/config/README.md). - -Public/protected SvelteKit route groups declare access; a hook enforces it for -pages, data loads, actions and APIs. The protected layout owns financial navigation. -Sign-in is a progressively enhanced SvelteKit action and sign-out a native POST; -both work without JavaScript. Sessions last a fixed seven days with -no renewal or authentication database. See [authentication](../../docs/authentication.md) -for setup, OIDC admission, secret rotation and the trusted-proxy boundary. - -`/schema.json` is a public nonfinancial endpoint serving only the bundled editor -artifact. It does not access request locals, live configuration, journals or the -queue. Its explicit five-minute public cache policy is separate from financial -responses' no-store policy. - -The shared jobs package owns workflow definitions, query contracts and queue -resources, not web request coordination or worker execution. Web's -`lib/server/preparation.ts` owns `requestReportPreparation`, `readReportsPreparation` -and `ReportsPreparationState`: explicit requests join active work or atomically -start the next wave after terminal work; status reads never submit work. -`lib/server/queries.ts` owns `runQuery`: validate, enqueue a fresh live query without -an idempotency key, then await its result for at most 35 seconds. Neither helper -opens or closes the shared client. The worker owns execution and publication. - -`POST /api/queries` accepts `{query, asOf}` and calls the web's `runQuery` helper. -Safe errors omit native diagnostics. Closing HTTP does not cancel durable execution. Query defaults are fictional fixture examples, not personal -financial settings. `/healthz` is process liveness, not report readiness. +Sign-in and no-store financial responses remain mandatory. Configuration is selected +explicitly at startup; restart after YAML edits. Authentication, origins, POST guards +and compiled proxy requirements are owned by the +[authentication guide](../../docs/authentication.md); YAML by the +[config package](../../packages/config/README.md). `/healthz` is process liveness, +not financial readiness. Public `/schema.json` serves only the bundled editor schema. ## Run and check -First create ignored `apps/web/config/hledger-reports.yaml` without overwriting -an existing configuration, and select the read-only journal directory. See -[development setup and migration](../../docs/development.md#live-development). - -```sh -cd apps/web -mkdir -m 700 -p data && -docker compose -f docker-compose.dev.yml up --build --wait -``` - -Open **http://127.0.0.1:5173/** and use Prepare reports in Settings. From the repository root: - -```sh -pnpm build:packages -pnpm --filter @hledger-reports/web check -pnpm --filter @hledger-reports/web lint -pnpm --filter @hledger-reports/web test -``` - -The library folders describe the current responsibilities: +Use the [development guide](../../docs/development.md#live-development) for startup, +[focused checks](../../docs/development.md#focused-local-checks) for test/build/browser +commands and disposable fixture setup. Tests are co-located with implementations; +route tests omit the reserved `+` prefix. Test each contract at its owning layer. ```text 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 + charts/ ChartCanvas.svelte, theme.ts, theme.fixture.ts, + rendering.integration.test.ts + overview/ data.ts, select-period.ts, KeyFigures, FinancialValue + income-spending/ IncomeSpending.svelte and chart.ts + spending-categories/ SpendingCategories.svelte, presentation.ts and chart.ts + reports/ + networth/ NetWorth.svelte and chart.ts + report-card/ ReportCard.svelte and private ScrollArea.svelte + report-period/ PeriodControls.svelte and period-query.ts (URL choices/parsing) + navigation/ AppHeader, AccountSidebar, AccountsDrawer and public data.ts + registers/ Cash and Investment presentation, transaction dialogs + holdings/ Investment holdings presentation + settings/ data.ts, JournalSummary and single-presentation ValidationStatus + queries/ Live-query presentation + server/ Request coordination (including preparation.ts), pinned saved reads + and public projections + amounts.ts Exact display-only amount formatting + dates.ts Calendar dates and explicit-UTC timestamps + styles/ Reset/theme, layout and native controls ``` -The root imports shared CSS once. Native elements use page/panel/control classes; -feature styles retain financial table structure and alignment. Body-level typography -also applies outside the page wrapper. Chart colors/fonts resolve from CSS after -mounting, not from duplicate palettes in each report builder. There is no appearance -configuration or component registry. - -Tests sit beside their implementation: `charts/net-worth.ts` and -`charts/net-worth.test.ts`, for example. The cross-chart rendering regression is -`charts/rendering.integration.test.ts`; it exercises actual Chart.js, not a single -component. -`server/preparation.integration.test.ts` and `server/queries.integration.test.ts` -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. 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. -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. +Shared CSS is imported once. Feature styles retain financial table structure and +alignment. Chart colors/fonts resolve from CSS after mounting. Icons compile from +`virtual:icons/ph/*`; artwork lives in `static/branding/` and `static/favicon.ico`. +There is no runtime icon service or appearance configuration. diff --git a/apps/web/e2e/account-navigation.e2e.ts b/apps/web/e2e/account-navigation.e2e.ts index 7a6e308..48b2226 100644 --- a/apps/web/e2e/account-navigation.e2e.ts +++ b/apps/web/e2e/account-navigation.e2e.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Request } from '@playwright/test'; import { prepared, prepareReports, readPreparationTimestamp } from './overview-fixture'; test('desktop account navigation preserves client routing and observes later saved registers', async ({ @@ -8,7 +8,9 @@ test('desktop account navigation preserves client routing and observes later sav await page.setViewportSize({ width: 1440, height: 900 }); await page.emulateMedia({ colorScheme: 'light' }); await prepared(page); + await page.goto('/settings'); const savedAt = await readPreparationTimestamp(page); + await page.goto('/'); const accounts = page.getByRole('complementary', { name: 'Accounts', exact: true }); const balanceDate = accounts.getByText(/^Balances as of/); await expect(balanceDate).toBeVisible(); @@ -31,6 +33,76 @@ test('desktop account navigation preserves client routing and observes later sav expect(railBox).not.toBeNull(); expect(mainBox!.x).toBeCloseTo(0, 0); expect(mainBox!.x + mainBox!.width).toBeCloseTo(railBox!.x, 0); + const header = page.getByRole('banner'); + const headerBox = await header.boundingBox(); + const accountsHeaderBox = await accounts.locator('header').boundingBox(); + expect(headerBox!.height).toBeCloseTo(accountsHeaderBox!.height, 0); + expect(headerBox!.y + headerBox!.height).toBeCloseTo( + accountsHeaderBox!.y + accountsHeaderBox!.height, + 0 + ); + await expect( + header.getByRole('link', { name: 'Overview home', exact: true }).locator('img') + ).toBeVisible(); + + // The Accounts total opens the saved trend rather than another homepage card. + const netWorthLink = accounts.getByRole('link', { name: /^Net worth/ }); + await expect(netWorthLink).toHaveAttribute('href', '/reports/networth'); + await netWorthLink.click(); + await expect(page).toHaveURL(/\/reports\/networth$/); + await expect(main.getByRole('heading', { name: 'Net worth', exact: true })).toBeVisible(); + await expect(netWorthLink).toHaveAttribute('aria-current', 'page'); + const period = page.getByRole('button', { name: 'Net worth period', exact: true }); + const rows = page + .getByRole('table', { name: 'Net worth by valuation date', exact: true }) + .locator('tbody tr'); + await expect(period).toHaveText('3 months'); + await expect(rows).toHaveCount(3); + await expect(rows.getByRole('rowheader')).toHaveText(['2026-02-28', '2026-03-31', '2026-04-16']); + for (const index of [0, 1]) { + await expect(rows.nth(index).getByRole('cell')).toHaveText([ + 'No reported amounts', + 'No reported valuation' + ]); + } + await expect(rows.last().getByRole('cell')).toHaveText([ + '11825.00', + 'USD', + 'No unconverted units' + ]); + const accountsText = await accounts.innerText(); + const requests: string[] = []; + const observe = (request: Request) => { + if ( + ['fetch', 'xhr', 'document'].includes(request.resourceType()) || + request.method() === 'POST' + ) { + requests.push(`${request.method()} ${new URL(request.url()).pathname}`); + } + }; + page.on('request', observe); + await page.evaluate(() => Object.assign(window, { netWorthNavigationToken: 'retained' })); + await period.click(); + await page.getByRole('option', { name: '1 month', exact: true }).click(); + await expect(page).toHaveURL(/\/reports\/networth\?months=1$/); + await expect(rows).toHaveCount(1); + await expect(netWorthLink).toHaveAttribute('aria-current', 'page'); + await page.goBack(); + await expect(period).toHaveText('3 months'); + await expect(rows).toHaveCount(3); + expect(await accounts.innerText()).toBe(accountsText); + expect(await page.evaluate(() => Reflect.get(window, 'netWorthNavigationToken'))).toBe( + 'retained' + ); + expect(requests).toEqual([]); + page.off('request', observe); + + await page.goto('/reports/networth?months=12'); + await expect(period).toHaveText('12 months'); + await expect(rows).toHaveCount(12); + await expect(rows.first().getByRole('rowheader')).toHaveText('2025-05-31'); + await expect(rows.last().getByRole('rowheader')).toHaveText('2026-04-16'); + await expect(netWorthLink).toHaveAttribute('aria-current', 'page'); await accounts.getByRole('link', { name: /^Everyday checking\b/ }).click(); await expect(main.getByRole('heading', { name: 'Everyday checking', exact: true })).toBeVisible(); @@ -98,6 +170,40 @@ test('responsive Accounts supports phone, overlay drawer and desktop transitions await expect(drawer).not.toBeVisible(); await expect(trigger).toBeFocused(); + await trigger.click(); + await drawer.getByRole('link', { name: /^Net worth/ }).click(); + await expect(page).toHaveURL(/\/reports\/networth$/); + await expect( + page.locator('main').getByRole('heading', { name: 'Net worth', exact: true }) + ).toBeVisible(); + await expect(drawer).not.toBeVisible(); + await trigger.click(); + await expect(drawer.getByRole('link', { name: /^Net worth/ })).toHaveAttribute( + 'aria-current', + 'page' + ); + // The current destination still refreshes saved reports; closing must not cancel it. + const refreshed = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + new URL(response.url()).pathname === '/reports/networth/__data.json' + ); + await drawer.getByRole('link', { name: /^Net worth/ }).focus(); + await page.keyboard.press('Enter'); + const refresh = await refreshed; + expect(refresh.status()).toBe(200); + expect(await refresh.finished()).toBeNull(); + await expect(page).toHaveURL(/\/reports\/networth$/); + await expect(drawer).not.toBeVisible(); + await expect(page.locator('main')).toBeFocused(); + await page + .getByRole('navigation', { name: 'Primary', exact: true }) + .getByRole('link', { name: 'Overview', exact: true }) + .click(); + await expect( + page.locator('main').getByRole('heading', { name: 'Overview', exact: true }) + ).toBeVisible(); + await trigger.click(); await drawer.getByRole('link', { name: 'Investments', exact: true }).click(); await expect(page).toHaveURL(/\/registers\/investments$/); diff --git a/apps/web/e2e/fixtures/category-allocation.journal b/apps/web/e2e/fixtures/category-allocation.journal new file mode 100644 index 0000000..470870c --- /dev/null +++ b/apps/web/e2e/fixtures/category-allocation.journal @@ -0,0 +1,23 @@ +; Reclassify the fictional $100 expense without changing native financial totals. +account expenses:travel ; type: X +account expenses:food ; type: X +account expenses:utilities ; type: X +account expenses:books ; type: X +account expenses:health ; type: X +account expenses:clothing ; type: X +account expenses:foreign ; type: X +account expenses:foreign-refund ; type: X +commodity EUR + +2026-04-03 Category allocation + expenses:living -98.00 USD + expenses:travel 50.00 USD + expenses:food 20.00 USD + expenses:utilities 10.00 USD + expenses:books 8.00 USD + expenses:health 6.00 USD + expenses:clothing 4.00 USD + +2026-01-15 Currency category allocation + expenses:foreign 5.00 EUR + expenses:foreign-refund -5.00 EUR diff --git a/apps/web/e2e/holdings.e2e.ts b/apps/web/e2e/holdings.e2e.ts index c1c9061..947382f 100644 --- a/apps/web/e2e/holdings.e2e.ts +++ b/apps/web/e2e/holdings.e2e.ts @@ -1,11 +1,11 @@ import { expect, test } from '@playwright/test'; import { prepared, readPreparationTimestamp } from './overview-fixture'; -test('Holdings runs fresh investment reports without replacing saved Overview', async ({ - page -}) => { +test('Holdings runs fresh investment reports without replacing saved reports', async ({ page }) => { await prepared(page); + await page.goto('/settings'); const savedAt = await readPreparationTimestamp(page); + await page.goto('/'); const requests: string[] = []; page.on('request', (request) => { if (request.method() === 'POST' && new URL(request.url()).pathname === '/holdings') { @@ -68,6 +68,6 @@ test('Holdings runs fresh investment reports without replacing saved Overview', await expect(page.getByRole('region', { name: 'Holdings result', exact: true })).toHaveCount(0); expect(requests).toHaveLength(2); - await page.goto('/'); + await page.goto('/settings'); expect(await readPreparationTimestamp(page)).toBe(savedAt); }); diff --git a/apps/web/e2e/homepage.e2e.ts b/apps/web/e2e/homepage.e2e.ts new file mode 100644 index 0000000..d188469 --- /dev/null +++ b/apps/web/e2e/homepage.e2e.ts @@ -0,0 +1,284 @@ +import { expect, test, type Request } from '@playwright/test'; +import { prepared } from './overview-fixture'; + +test('homepage periods select one loaded snapshot and history without data requests', async ({ + page +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await prepared(page); + const controls = page.getByRole('button', { name: 'Overview period', exact: true }); + const transactions = page + .getByText('Transactions', { exact: true }) + .locator('..') + .getByRole('definition'); + await expect(transactions).toHaveText('10'); + const incomeRows = page + .getByRole('table', { name: 'Monthly income and spending', exact: true }) + .locator('tbody tr'); + await expect(controls).toHaveText('3 months'); + await expect(incomeRows).toHaveCount(6); + await expect(incomeRows.nth(4).getByRole('rowheader')).toHaveText('2026-04'); + await expect(incomeRows.nth(4).getByRole('cell')).toHaveText(['Income', '1000.00', 'USD']); + await expect(incomeRows.nth(5).getByRole('rowheader')).toHaveText('2026-04'); + await expect(incomeRows.nth(5).getByRole('cell')).toHaveText(['Spending', '100.00', 'USD']); + await expect( + page.getByRole('img', { name: 'Income and spending by month in USD', exact: true }) + ).toBeVisible(); + const latest = page + .getByRole('region', { name: 'Key figures', exact: true }) + .getByText('Latest net worth', { exact: true }) + .locator('..') + .getByRole('definition'); + await expect(latest).toHaveText('$11,825.00'); + const latestText = await latest.innerText(); + await expect(page.getByRole('region', { name: 'Journal summary', exact: true })).toHaveCount(0); + await expect(page.getByRole('region', { name: 'Net worth trend', exact: true })).toHaveCount(0); + const mainCards = [ + page.getByRole('region', { name: 'Income and spending', exact: true }), + page.getByRole('region', { name: 'Spending by category', exact: true }) + ]; + async function expectCardSizes() { + const boxes = await Promise.all(mainCards.map((card) => card.boundingBox())); + const first = boxes[0]!; + const figures = page.getByRole('region', { name: 'Key figures', exact: true }); + const income = (await figures + .getByText('Income', { exact: true }) + .locator('..') + .boundingBox())!; + const spending = (await figures + .getByText('Spending', { exact: true }) + .locator('..') + .boundingBox())!; + const titleSpan = + Math.abs(income.y - spending.y) < 1 ? spending.x + spending.width - income.x : income.width; + expect(first.width).toBeCloseTo(titleSpan, 0); + for (const box of boxes) expect(box!.width).toBeCloseTo(first.width, 0); + for (const box of boxes) expect(box!.height).toBeCloseTo(first.height, 0); + } + const categoryCard = mainCards[1]; + const categoryCharts = categoryCard.getByRole('region', { + name: 'Spending by category charts', + exact: true + }); + const categoryRows = categoryCard + .getByRole('table', { name: 'Spending by account', exact: true }) + .locator('tbody tr'); + const clothing = categoryRows.filter({ + has: page.getByRole('rowheader', { name: 'expenses:clothing', exact: true }) + }); + const foreign = categoryRows.filter({ + has: page.getByRole('rowheader', { name: 'expenses:foreign', exact: true }) + }); + const refund = categoryRows.filter({ + has: page.getByRole('rowheader', { name: 'expenses:foreign-refund', exact: true }) + }); + const currency = categoryCard.getByRole('combobox', { + name: 'Spending chart currency', + exact: true + }); + const chartValues = categoryCard.locator('#expense-categories-chart-values'); + const usdChartValues = [ + 'expenses:travel: 50.00 USD', + 'expenses:food: 20.00 USD', + 'expenses:utilities: 10.00 USD', + 'expenses:books: 8.00 USD', + 'expenses:health: 6.00 USD', + 'Other (grouped): 6.00 USD' + ]; + async function expectContainedCategoryChart(commodity = 'USD') { + await expect( + categoryCharts.getByRole('img', { + name: `Spending by category in ${commodity}`, + exact: true + }) + ).toBeVisible(); + await expect(categoryCharts.getByRole('img')).toHaveCount(1); + await expect + .poll(() => + categoryCharts.evaluate( + (node) => node.scrollHeight <= node.clientHeight && node.scrollWidth <= node.clientWidth + ) + ) + .toBe(true); + } + await expectCardSizes(); + await expect(categoryRows).toHaveCount(7); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(foreign).toHaveCount(0); + await expect(currency).toHaveCount(0); + await expectContainedCategoryChart(); + const usdCanvas = categoryCharts.getByRole('img', { + name: 'Spending by category in USD', + exact: true + }); + await expect(usdCanvas).toHaveAttribute('aria-describedby', 'expense-categories-chart-values'); + await expect(chartValues).not.toBeVisible(); + // The closed native disclosure still describes the plotted result for assistive technology. + await expect(usdCanvas).toHaveAccessibleDescription(usdChartValues.join(' ')); + const summary = categoryCard.locator('summary', { hasText: /^Chart values$/ }); + await summary.focus(); + await summary.press('Enter'); + await expect(chartValues).toBeVisible(); + await expect(chartValues.getByRole('listitem')).toHaveText(usdChartValues); + const spendingAmounts = page + .getByRole('region', { name: 'Key figures', exact: true }) + .getByText('Spending', { exact: true }) + .locator('..') + .getByRole('listitem'); + await expect(spendingAmounts).toHaveText(['$100.00']); + const accounts = page.getByRole('complementary', { name: 'Accounts', exact: true }); + const accountsText = await accounts.innerText(); + const requests: string[] = []; + const observe = (request: Request) => { + if ( + ['fetch', 'xhr', 'document'].includes(request.resourceType()) || + request.method() === 'POST' + ) { + requests.push(`${request.method()} ${new URL(request.url()).pathname}`); + } + }; + page.on('request', observe); + await page.evaluate(() => Object.assign(window, { periodNavigationToken: 'retained' })); + + await controls.focus(); + await controls.press('Enter'); + await expect(page.getByRole('listbox')).toBeVisible(); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\?months=12$/); + await expect(controls).toHaveText('12 months'); + await expect(page.getByRole('listbox')).not.toBeVisible(); + await expect(incomeRows).toHaveCount(24); + await expect(transactions).toHaveText('11'); + await expect(controls).toBeFocused(); + await expectCardSizes(); + await expectContainedCategoryChart(); + await expect(spendingAmounts).toHaveText(['€0.00', '$100.00']); + const januarySpending = incomeRows.filter({ hasText: '2026-01' }).filter({ hasText: 'Spending' }); + await expect(januarySpending.getByRole('cell')).toHaveText(['Spending', '0.00', 'EUR']); + // One full native inventory: neither Top 5/Other nor chart currency filters this table. + await expect(categoryRows.getByRole('rowheader')).toHaveText([ + 'expenses:living', + 'expenses:travel', + 'expenses:food', + 'expenses:utilities', + 'expenses:books', + 'expenses:health', + 'expenses:clothing', + 'expenses:foreign', + 'expenses:foreign-refund' + ]); + await expect(categoryRows.getByRole('cell')).toHaveText([ + '2.00', + 'USD', + '50.00', + 'USD', + '20.00', + 'USD', + '10.00', + 'USD', + '8.00', + 'USD', + '6.00', + 'USD', + '4.00', + 'USD', + '5.00', + 'EUR', + '-5.00', + 'EUR' + ]); + await expect(currency).toHaveValue('USD'); + await expect(currency.locator('option')).toHaveText(['USD', 'EUR']); + await currency.selectOption('EUR'); + await expectContainedCategoryChart('EUR'); + await expect(usdCanvas).toHaveCount(0); + await expect(chartValues.getByRole('listitem')).toHaveText([ + 'expenses:foreign: 5.00 EUR', + 'expenses:foreign-refund: -5.00 EUR' + ]); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(refund.getByRole('cell')).toHaveText(['-5.00', 'EUR']); + const details = page.getByRole('region', { name: 'Income and spending details', exact: true }); + await page.mouse.move(0, 0); + await expect( + details.locator('..').locator('[data-scroll-area-scrollbar][data-orientation="vertical"]') + ).toBeVisible(); + const tableHeading = details.locator('thead'); + await details.focus(); + const headingBefore = await tableHeading.boundingBox(); + await page.keyboard.press('PageDown'); + await expect.poll(() => details.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + const headingAfter = await tableHeading.boundingBox(); + expect(headingAfter!.y).toBeCloseTo(headingBefore!.y, 0); + + // Leaving January removes EUR amounts altogether, not just their geometry. + await page.goBack(); + await expect(controls).toHaveText('3 months'); + await expect(incomeRows).toHaveCount(6); + await expect(transactions).toHaveText('10'); + await expect(currency).toHaveCount(0); + await expectContainedCategoryChart(); + await expect(chartValues.getByRole('listitem')).toHaveText(usdChartValues); + await expect(categoryRows).toHaveCount(7); + await expect(foreign).toHaveCount(0); + await expect(refund).toHaveCount(0); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(spendingAmounts).toHaveText(['$100.00']); + await controls.press('Enter'); + await expect(page.getByRole('option', { name: '3 months', exact: true })).toHaveAttribute( + 'aria-selected', + 'true' + ); + await page.keyboard.press('Escape'); + await expect(page.getByRole('listbox')).not.toBeVisible(); + await expect(controls).toBeFocused(); + await page.goForward(); + await expect(controls).toHaveText('12 months'); + await expect(currency.locator('option')).toHaveText(['USD', 'EUR']); + await expect(foreign.getByRole('cell')).toHaveText(['5.00', 'EUR']); + await currency.selectOption('USD'); + await expectContainedCategoryChart(); + await expect(chartValues.getByRole('listitem')).toHaveText(usdChartValues); + await expect(refund.getByRole('cell')).toHaveText(['-5.00', 'EUR']); + expect(await latest.innerText()).toBe(latestText); + expect(await accounts.innerText()).toBe(accountsText); + expect(await page.evaluate(() => Reflect.get(window, 'periodNavigationToken'))).toBe('retained'); + expect(requests).toEqual([]); + page.off('request', observe); + + await page + .getByRole('navigation', { name: 'Primary', exact: true }) + .getByRole('link', { name: 'Overview', exact: true }) + .click(); + await expect(page).toHaveURL(/\/$/); + await expect(controls).toHaveText('3 months'); + await expect(incomeRows).toHaveCount(6); + + await page.goto('/?months=6'); + await expect(controls).toHaveText('6 months'); + await expect(incomeRows).toHaveCount(12); + await expect(transactions).toHaveText('11'); + await expectContainedCategoryChart(); + await expect(spendingAmounts).toHaveText(['€0.00', '$100.00']); + await expect(januarySpending.getByRole('cell')).toHaveText(['Spending', '0.00', 'EUR']); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(refund.getByRole('cell')).toHaveText(['-5.00', 'EUR']); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1440); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ colorScheme: 'dark' }); + await expectCardSizes(); + await expectContainedCategoryChart(); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(foreign.getByRole('cell')).toHaveText(['5.00', 'EUR']); + await controls.click(); + await expect(page.getByRole('option', { name: '12 months', exact: true })).toBeInViewport(); + await page.keyboard.press('Escape'); + await expect(controls).toBeFocused(); + await expect(page.getByRole('region', { name: 'Key figures', exact: true })).toBeVisible(); + await expect( + page.getByRole('table', { name: 'Monthly income and spending', exact: true }) + ).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); +}); diff --git a/apps/web/e2e/manual-preparation.e2e.ts b/apps/web/e2e/manual-preparation.e2e.ts index cc01b90..1996cbc 100644 --- a/apps/web/e2e/manual-preparation.e2e.ts +++ b/apps/web/e2e/manual-preparation.e2e.ts @@ -3,7 +3,7 @@ import { prepareReports, readPreparationTimestamp } from './overview-fixture'; test.use({ javaScriptEnabled: false }); -test('explicitly prepares reports, reads Overview and follows Accounts without JavaScript', async ({ +test('explicitly prepares reports and reads Overview, Net worth and Settings without JavaScript', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); @@ -13,36 +13,141 @@ test('explicitly prepares reports, reads Overview and follows Accounts without J }); const preparedAt = await prepareReports(page); - const income = page.getByRole('region', { name: 'Income vs expenses', exact: true }); - await expect(income.getByRole('table')).toContainText('1000.00'); - await expect(income.locator('tbody tr')).toHaveCount(12); // Two series for six months. - const netWorth = page.getByRole('table', { name: 'Net worth by valuation date', exact: true }); - await expect(netWorth.locator('tbody tr')).toHaveCount(6); - await expect(netWorth.locator('tbody tr').last()).toContainText('2026-04-16'); - expect(await readPreparationTimestamp(page)).toBe(preparedAt); + const income = page.getByRole('region', { name: 'Income and spending', exact: true }); + await expect(income.locator('tbody tr')).toHaveCount(6); + await expect(income.locator('tbody tr').nth(4).getByRole('cell')).toHaveText([ + 'Income', + '1000.00', + 'USD' + ]); + await expect(income.locator('tbody tr').nth(5).getByRole('cell')).toHaveText([ + 'Spending', + '100.00', + 'USD' + ]); + const categories = page.getByRole('region', { name: 'Spending by category', exact: true }); + const categoryRows = categories + .getByRole('table', { name: 'Spending by account', exact: true }) + .locator('tbody tr'); + const clothing = categoryRows.filter({ + has: page.getByRole('rowheader', { name: 'expenses:clothing', exact: true }) + }); + const transactions = page + .getByText('Transactions', { exact: true }) + .locator('..') + .getByRole('definition'); + const primary = page.getByRole('navigation', { name: 'Primary', exact: true }); + await expect(primary.getByRole('link', { name: 'Overview', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); + await expect(transactions).toHaveText('10'); + await expect( + categories.getByText('Chart loads with JavaScript; exact amounts below.', { exact: true }) + ).toBeVisible(); + await expect(categories.getByRole('img')).toHaveCount(0); + // The complete native table remains usable without a client-only chart selector. + await expect(categoryRows).toHaveCount(7); + await expect(categoryRows.first().getByRole('cell')).toHaveText(['2.00', 'USD']); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(page.getByRole('region', { name: 'Net worth trend', exact: true })).toHaveCount(0); + await expect(page.getByRole('region', { name: 'Journal summary', exact: true })).toHaveCount(0); + + await page.getByRole('combobox', { name: 'Overview period', exact: true }).selectOption('1'); + await page.getByRole('button', { name: 'Apply period', exact: true }).click(); + await expect(page).toHaveURL(/\/\?months=1$/); + await expect(income.locator('tbody tr')).toHaveCount(2); + await expect(income.locator('tbody tr').getByRole('rowheader')).toHaveText([ + '2026-04', + '2026-04' + ]); + await expect(income.locator('tbody tr').getByRole('cell')).toHaveText([ + 'Income', + '1000.00', + 'USD', + 'Spending', + '100.00', + 'USD' + ]); + await expect(categoryRows).toHaveCount(7); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(transactions).toHaveText('10'); await page.reload(); + await expect(page.getByRole('combobox', { name: 'Overview period', exact: true })).toHaveValue( + '1' + ); await expect(income.getByRole('table')).toContainText('1000.00'); - expect(await readPreparationTimestamp(page)).toBe(preparedAt); + await expect(income.locator('tbody tr')).toHaveCount(2); + await expect(categoryRows).toHaveCount(7); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(transactions).toHaveText('10'); - // The server-rendered disclosure remains real navigation without hydration. + // The server-rendered Accounts 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(); + await disclosure + .locator('summary') + .filter({ hasText: /^Accounts$/ }) + .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 disclosure.getByRole('link', { name: /^Net worth/ }).click(); + await expect(page).toHaveURL(/\/reports\/networth$/); + const period = page.getByRole('combobox', { name: 'Net worth period', exact: true }); + const netWorth = page.getByRole('table', { name: 'Net worth by valuation date', exact: true }); + await expect(period).toHaveValue('3'); + await expect(netWorth.locator('tbody tr')).toHaveCount(3); + await period.selectOption('1'); + await page.getByRole('button', { name: 'Apply period', exact: true }).click(); + await expect(page).toHaveURL(/\/reports\/networth\?months=1$/); + await expect(period).toHaveValue('1'); + await expect(netWorth.locator('tbody tr')).toHaveCount(1); + await expect(netWorth.locator('tbody tr').getByRole('rowheader')).toHaveText('2026-04-16'); + await expect(netWorth.locator('tbody tr').getByRole('cell')).toHaveText([ + '11825.00', + 'USD', + 'No unconverted units' + ]); + // Real SSR query navigation must retain the footer's current-page semantics. + await disclosure + .locator('summary') + .filter({ hasText: /^Accounts$/ }) + .click(); + await expect(disclosure.getByRole('link', { name: /^Net worth/ })).toHaveAttribute( + 'aria-current', + 'page' + ); + + await page + .getByRole('navigation', { name: 'Primary', exact: true }) + .getByRole('link', { name: 'Settings', exact: true }) + .click(); + await expect(primary.getByRole('link', { name: 'Settings', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); + const journal = page.getByRole('region', { name: 'Journal summary', exact: true }); + const span = journal.getByText('Full date span', { exact: true }).locator('..'); + await expect(span.locator('time').first()).toHaveAttribute('datetime', '2026-01-15'); + await expect(span.locator('time').last()).toHaveAttribute('datetime', '2026-04-16'); 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(); + journal.getByText('Total transactions', { exact: true }).locator('..').getByRole('definition') + ).toHaveText('11'); await expect( - page.locator('main').getByRole('heading', { name: 'Brokerage', exact: true }) - ).toBeVisible(); + journal.getByText('hledger version', { exact: true }).locator('..').getByRole('definition') + ).toHaveText('1.99.5'); + await expect( + journal.getByText('Report currency', { exact: true }).locator('..').getByRole('definition') + ).toHaveText('USD'); + await expect(journal.getByLabel('Saved journal validation', { exact: true })).toContainText( + 'Passed' + ); + await expect( + journal.getByLabel('Journal validation completed at', { exact: true }) + ).toHaveAttribute('datetime', /.+/); + expect(await readPreparationTimestamp(page)).toBe(preparedAt); expect(posts).toEqual(['/settings']); }); diff --git a/apps/web/e2e/overview-fixture.ts b/apps/web/e2e/overview-fixture.ts index 2fc0724..81e8bb5 100644 --- a/apps/web/e2e/overview-fixture.ts +++ b/apps/web/e2e/overview-fixture.ts @@ -1,17 +1,14 @@ import { expect, type Page } from '@playwright/test'; const preparationStamp = (page: Page) => - page - .getByRole('region', { name: 'Income vs expenses', exact: true }) - .getByLabel('Financial reports prepared at', { exact: true }); + page.getByLabel('Financial reports prepared at', { exact: true }); /** Explicit user action, including for external fictional-fixture smoke tests. */ export async function prepareReports(page: Page): Promise { - await page.goto('/'); - const stamp = preparationStamp(page); - const previous = (await stamp.count()) ? await stamp.getAttribute('datetime') : null; await page.goto('/settings'); await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); + const stamp = preparationStamp(page); + const previous = (await stamp.count()) ? await stamp.getAttribute('datetime') : null; await Promise.all([ page.waitForResponse( (response) => @@ -28,8 +25,7 @@ export async function prepareReports(page: Page): Promise { .poll( async () => { await page.reload(); - const time = status.getByLabel('Financial reports prepared at', { exact: true }); - const current = (await time.count()) ? await time.getAttribute('datetime') : null; + const current = (await stamp.count()) ? await stamp.getAttribute('datetime') : null; return ( current !== null && current !== previous && @@ -39,9 +35,7 @@ export async function prepareReports(page: Page): Promise { { timeout: 25_000, intervals: [100, 250, 500] } ) .toBe(true); - const preparedAt = (await status - .getByLabel('Financial reports prepared at', { exact: true }) - .getAttribute('datetime'))!; + const preparedAt = (await stamp.getAttribute('datetime'))!; await page.goto('/'); return preparedAt; } @@ -49,10 +43,11 @@ export async function prepareReports(page: Page): Promise { // Financial cases explicitly prepare only when there is no saved report. export async function prepared(page: Page) { await page.goto('/'); - if (!(await preparationStamp(page).count())) await prepareReports(page); - await expect( - page.getByRole('region', { name: 'Income vs expenses', exact: true }).getByRole('table') - ).toBeVisible(); + const table = page + .getByRole('region', { name: 'Income and spending', exact: true }) + .getByRole('table'); + if (!(await table.count())) await prepareReports(page); + await expect(table).toBeVisible(); } export async function readPreparationTimestamp(page: Page): Promise { diff --git a/apps/web/e2e/server.mjs b/apps/web/e2e/server.mjs index dff5457..39efbe0 100644 --- a/apps/web/e2e/server.mjs +++ b/apps/web/e2e/server.mjs @@ -20,6 +20,10 @@ await copyFile( fileURLToPath(new URL('../../worker/fixtures/lots.journal', import.meta.url)), join(deployment, 'journal', 'parts', 'base.journal') ); +await copyFile( + fileURLToPath(new URL('./fixtures/category-allocation.journal', import.meta.url)), + join(deployment, 'journal', 'parts', 'category-allocation.journal') +); await writeFile(journal, 'include parts/*.journal\n'); await writeFile( configPath, diff --git a/apps/web/package.json b/apps/web/package.json index d5965e5..0b6afe8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@eslint/compat": "2.1.0", "@eslint/js": "catalog:", + "@iconify-json/ph": "1.2.2", "@playwright/test": "1.61.1", "@sveltejs/adapter-node": "6.0.1", "@sveltejs/kit": "3.1.1", @@ -50,6 +51,7 @@ "svelte-check": "4.7.7", "typescript": "catalog:", "typescript-eslint": "catalog:", + "unplugin-icons": "23.0.1", "vite": "catalog:", "vitest": "catalog:" }, diff --git a/apps/web/src/app.html b/apps/web/src/app.html index b8ba469..3a6eff9 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -3,6 +3,7 @@ + %sveltekit.head% diff --git a/apps/web/src/lib/charts/ChartCanvas.svelte b/apps/web/src/lib/charts/ChartCanvas.svelte index df71fc7..91cee89 100644 --- a/apps/web/src/lib/charts/ChartCanvas.svelte +++ b/apps/web/src/lib/charts/ChartCanvas.svelte @@ -36,12 +36,14 @@ let { label, + describedBy, makeConfig, height = 280 }: { label: string; + describedBy?: string; makeConfig: (theme: ChartTheme) => CanvasConfiguration | null; - height?: number; + height?: number | 'fill'; } = $props(); let theme = $state(); const config = $derived(theme ? makeConfig(theme) : undefined); @@ -65,17 +67,17 @@ }); -
-
{label}
+
+
{label}
{#if config === null}

This chart cannot represent all reported amounts for this commodity; see the exact table below. Amounts have not been combined or replaced.

{:else if config} -
+
- + Exact amounts are in the table below.
@@ -92,7 +94,16 @@ min-width: 0; font-size: var(--font-small); } + .fill { + flex: 1; + min-height: 0; + } + .fill .chart { + flex: 1; + min-height: 0; + } figcaption { + flex-shrink: 0; white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/apps/web/src/lib/charts/expense-categories.test.ts b/apps/web/src/lib/charts/expense-categories.test.ts deleted file mode 100644 index fa3259d..0000000 --- a/apps/web/src/lib/charts/expense-categories.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { OverviewPageData } from '#lib/overview/data.js'; -import type { TooltipItem, TooltipModel } from 'chart.js'; -import { expect, it } from 'vitest'; -import { theme } from './theme.fixture.js'; -import { expenseCategoriesChart } from './expense-categories'; - -type Rows = OverviewPageData['expenseCategories']['rows']; - -function rows(): Rows { - return [ - { account: 'Outgoings:Travel', amounts: [{ commodity: 'USD', quantity: '120.00' }] }, - { account: 'Expenses', amounts: [] }, - { account: 'Living:Food', amounts: [{ commodity: 'USD', quantity: '0.00' }] }, - { account: 'Outgoings:Refunds', amounts: [{ commodity: 'USD', quantity: '-12.50' }] } - ]; -} - -it('uses full native account order in one horizontal Expenses series, without sorting or truncation', () => { - const data = rows(); - const config = expenseCategoriesChart(data, 'USD', theme)!; - expect(config.type).toBe('bar'); - expect(config.data.labels).toEqual(data.map((row) => row.account)); - expect(config.data.datasets).toEqual([ - { - label: 'Expenses', - backgroundColor: theme.expense, - maxBarThickness: 32, - data: [120, null, 0, -12.5] - } - ]); - expect(config.options).toMatchObject({ - indexAxis: 'y', - responsive: true, - maintainAspectRatio: false, - animation: false, - skipNull: false, - scales: { x: { bounds: 'data', beginAtZero: true } } - }); - // Refunds remain negative; beginAtZero does not clamp the domain to positive values. - expect(config.options!.scales!.x).not.toHaveProperty('min'); - expect(config.options!.scales!.y).not.toHaveProperty('beginAtZero'); -}); - -it('distinguishes an empty native bag and a missing commodity from native positive and negative zero', () => { - const data = rows(); - data[0].amounts = [{ commodity: 'EUR', quantity: '4' }]; - data[3].amounts[0].quantity = '-0.000'; - expect(expenseCategoriesChart(data, 'USD', theme)!.data.datasets[0].data).toEqual([ - null, - null, - 0, - -0 - ]); -}); - -it('keeps every account, including long names and shallow custom roots', () => { - const data: Rows = Array.from({ length: 100 }, (_, index) => ({ - account: index === 0 ? 'Custom root' : `Custom root:${'full account name '.repeat(20)}${index}`, - amounts: [{ commodity: 'USD', quantity: '1' }] - })); - const config = expenseCategoriesChart(data, 'USD', theme)!; - expect(config.data.labels).toEqual(data.map((row) => row.account)); - expect(config.data.datasets[0].data).toHaveLength(100); - // Chart.js's default title uses the full label; do not replace it with a shortened account. - expect(config.options!.plugins!.tooltip!.callbacks).not.toHaveProperty('title'); -}); - -it('keeps exact large and tiny native strings in tooltips instead of rounded geometry', () => { - const data = rows(); - data[0].amounts[0].quantity = '9007199254740993.000000000000000001'; - data[3].amounts[0].quantity = '-0.0000000000000000100'; - const config = expenseCategoriesChart(data, 'USD', theme)!; - const label = config.options!.plugins!.tooltip!.callbacks!.label!; - const tooltip = {} as TooltipModel<'bar'>; - expect(label.call(tooltip, { datasetIndex: 0, dataIndex: 0 } as TooltipItem<'bar'>)).toBe( - 'Expenses: 9007199254740993.000000000000000001 USD' - ); - expect(label.call(tooltip, { datasetIndex: 0, dataIndex: 3 } as TooltipItem<'bar'>)).toBe( - 'Expenses: -0.0000000000000000100 USD' - ); - expect(label.call(tooltip, { datasetIndex: 0, dataIndex: 1 } as TooltipItem<'bar'>)).toBe(''); -}); - -it('preserves case, whitespace and empty commodity identities in coordinates and tooltips', () => { - const data = rows(); - data[0].amounts = [ - { commodity: 'USD', quantity: '1' }, - { commodity: 'usd', quantity: '2' }, - { commodity: ' USD ', quantity: '3' }, - { commodity: '', quantity: '4.00' } - ]; - for (const [commodity, value, exact] of [ - ['USD', 1, 'Expenses: 1 USD'], - ['usd', 2, 'Expenses: 2 usd'], - [' USD ', 3, 'Expenses: 3 USD '], - ['', 4, 'Expenses: 4.00'] - ] as const) { - const config = expenseCategoriesChart(data, commodity, theme)!; - expect(config.data.datasets[0].data[0]).toBe(value); - expect( - config.options!.plugins!.tooltip!.callbacks!.label!.call( - {} as TooltipModel<'bar'>, - { datasetIndex: 0, dataIndex: 0 } as TooltipItem<'bar'> - ) - ).toBe(exact); - } - expect(expenseCategoriesChart(data, 'Usd', theme)!.data.datasets[0].data[0]).toBeNull(); -}); - -it('falls back only for the commodity with multiple native cost amounts; never sums or takes the first', () => { - const data = rows(); - data[1].amounts = [ - { commodity: 'STOCK', quantity: '2' }, - { commodity: 'STOCK', quantity: '3' }, - { commodity: 'EUR', quantity: '42.50' }, - { commodity: 'HUGE', quantity: '9'.repeat(400) } - ]; - expect(expenseCategoriesChart(data, 'STOCK', theme)).toBeNull(); - expect(expenseCategoriesChart(data, 'HUGE', theme)).toBeNull(); - expect(expenseCategoriesChart(data, 'USD', theme)!.data.datasets[0].data).toEqual([ - 120, - null, - 0, - -12.5 - ]); - expect(expenseCategoriesChart(data, 'EUR', theme)!.data.datasets[0].data).toEqual([ - null, - 42.5, - null, - null - ]); -}); - -it.each([ - '9'.repeat(400), - `-${'9'.repeat(400)}`, - `0.${'0'.repeat(400)}1`, - `-0.${'0'.repeat(400)}1` -])('falls back for a non-finite or underflowing coordinate: %s', (quantity) => { - const data = rows(); - data[0].amounts[0].quantity = quantity; - expect(expenseCategoriesChart(data, 'USD', theme)).toBeNull(); -}); - -it('falls back when individually finite coordinates overflow the numeric range', () => { - const data = rows(); - data[0].amounts[0].quantity = `1${'0'.repeat(308)}`; - data[3].amounts[0].quantity = `-1${'0'.repeat(308)}`; - expect(expenseCategoriesChart(data, 'USD', theme)).toBeNull(); -}); - -it('accepts finite extremes without an arbitrary financial magnitude limit', () => { - const data = rows(); - data[0].amounts[0].quantity = `17${'0'.repeat(307)}`; - data[3].amounts = []; - expect(expenseCategoriesChart(data, 'USD', theme)).not.toBeNull(); - data[0].amounts[0].quantity = `85${'0'.repeat(306)}`; - data[3].amounts = [{ commodity: 'USD', quantity: `-85${'0'.repeat(306)}` }]; - expect(expenseCategoriesChart(data, 'USD', theme)).not.toBeNull(); -}); - -it('does not mutate the native data for supported or unsupported charts', () => { - const data = rows(); - data[1].amounts = [ - { commodity: 'STOCK', quantity: '2' }, - { commodity: 'STOCK', quantity: '3' } - ]; - const before = structuredClone(data); - expenseCategoriesChart(data, 'USD', theme); - expenseCategoriesChart(data, 'STOCK', theme); - expect(data).toEqual(before); -}); - -it('changes only exact display labels and keeps duplicate-label categories independent', () => { - const data = rows(); - const original = structuredClone(data); - const native = expenseCategoriesChart(data, 'USD', theme)!; - const config = expenseCategoriesChart(data, 'USD', theme, [ - { account: 'Outgoings:Travel', label: '生活 ' }, - { account: 'Living:Food', label: '生活 ' }, - { account: 'Outgoings', label: 'Not a prefix' } - ])!; - expect(config.data.labels).toEqual([ - '生活 ', - 'Expenses', - '生活 ', - 'Outgoings:Refunds' - ]); - expect(config.data.datasets).toEqual(native.data.datasets); - expect( - config.options!.plugins!.tooltip!.callbacks!.label!.call( - {} as TooltipModel<'bar'>, - { datasetIndex: 0, dataIndex: 0 } as TooltipItem<'bar'> - ) - ).toBe('Expenses: 120.00 USD'); - expect(data).toEqual(original); -}); diff --git a/apps/web/src/lib/charts/expense-categories.ts b/apps/web/src/lib/charts/expense-categories.ts deleted file mode 100644 index 7275be6..0000000 --- a/apps/web/src/lib/charts/expense-categories.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { OverviewPageData } from '#lib/overview/data.js'; -import type { ChartConfiguration } from 'chart.js'; -import type { ChartTheme } from './theme.js'; -import { accountLabel, type AccountLabel } from '../accounts.js'; - -export function expenseCategoriesChart( - rows: OverviewPageData['expenseCategories']['rows'], - commodity: string, - theme: ChartTheme, - labels: readonly AccountLabel[] = [] -): ChartConfiguration<'bar', (number | null)[], string> | null { - const reported = rows.map((row) => - row.amounts.filter((amount) => amount.commodity === commodity) - ); - // Distinct native costs can produce several amounts for one commodity; never combine them. - if (reported.some((amounts) => amounts.length > 1)) return null; - - const coordinates: (number | null)[] = []; - let minimum = 0; - let maximum = 0; - for (const [amount] of reported) { - if (!amount) { - coordinates.push(null); - continue; - } - // Only the canvas uses approximate numbers. The table and tooltips keep native strings. - const value = Number(amount.quantity); - if (!Number.isFinite(value) || (value === 0 && /[1-9]/.test(amount.quantity))) return null; - minimum = Math.min(minimum, value); - maximum = Math.max(maximum, value); - coordinates.push(value); - } - if (!Number.isFinite(maximum - minimum)) return null; - - return { - type: 'bar', - data: { - labels: rows.map((row) => accountLabel(row.account, labels)), - datasets: [ - { - label: 'Expenses', - backgroundColor: theme.expense, - maxBarThickness: 32, - data: coordinates - } - ] - }, - options: { - indexAxis: 'y', - responsive: true, - maintainAspectRatio: false, - animation: false, - font: { family: theme.fontFamily, size: theme.fontSize }, - skipNull: false, - scales: { - // The numeric axis is horizontal. Outward tick rounding can overflow a finite range. - x: { - bounds: 'data', - beginAtZero: true, - ticks: { color: theme.text }, - grid: { color: theme.border } - }, - y: { ticks: { color: theme.text }, grid: { color: theme.border } } - }, - plugins: { - legend: { labels: { color: theme.text } }, - tooltip: { - backgroundColor: theme.tooltipBackground, - titleColor: theme.tooltipText, - bodyColor: theme.tooltipText, - callbacks: { - label(item) { - const amount = reported[item.dataIndex]?.[0]; - if (!amount) return ''; - let label = `Expenses: ${amount.quantity}`; - if (commodity !== '') label += ` ${commodity}`; - return label; - } - } - } - } - } - }; -} diff --git a/apps/web/src/lib/charts/rendering.integration.test.ts b/apps/web/src/lib/charts/rendering.integration.test.ts index a305212..0d4a52f 100644 --- a/apps/web/src/lib/charts/rendering.integration.test.ts +++ b/apps/web/src/lib/charts/rendering.integration.test.ts @@ -16,22 +16,25 @@ it('renders extreme bars and lines with gaps and constant values without hanging import assert from 'node:assert/strict'; import { theme } from ${JSON.stringify(new URL('./theme.fixture.ts', import.meta.url).href)}; import { Chart, BarController, BarElement, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend, BasicPlatform } from 'chart.js'; - import { incomeExpensesChart } from ${JSON.stringify(new URL('./income-expenses.ts', import.meta.url).href)}; - import { expenseCategoriesChart } from ${JSON.stringify(new URL('./expense-categories.ts', import.meta.url).href)}; - import { netWorthChart } from ${JSON.stringify(new URL('./net-worth.ts', import.meta.url).href)}; + import { incomeSpendingChart } from ${JSON.stringify(new URL('../overview/income-spending/chart.ts', import.meta.url).href)}; + import { spendingCategoriesChart } from ${JSON.stringify(new URL('../overview/spending-categories/chart.ts', import.meta.url).href)}; + import { spendingCategoriesPresentation } from ${JSON.stringify(new URL('../overview/spending-categories/presentation.ts', import.meta.url).href)}; + import { netWorthChart } from ${JSON.stringify(new URL('../reports/networth/chart.ts', import.meta.url).href)}; Chart.register(BarController, BarElement, LineController, LineElement, PointElement, CategoryScale, LinearScale, Tooltip, Legend); const extreme = '17' + '0'.repeat(307); const mixed = '85' + '0'.repeat(306); for (const [positive, negative] of [[extreme, null], [null, '-' + extreme], [mixed, '-' + mixed]]) { const amounts = quantity => quantity === null ? [] : [{ commodity: 'USD', quantity }]; - const vertical = incomeExpensesChart([{ + const vertical = incomeSpendingChart([{ begin: '2026-04-01', endExclusive: '2026-05-01', income: amounts(positive), expenses: amounts(negative) }], 'USD', theme); - const horizontal = expenseCategoriesChart([ + const presentation = spendingCategoriesPresentation([ { account: 'Outgoings:Travel', amounts: amounts(positive) }, { account: 'Outgoings:Refunds', amounts: amounts(negative) } - ], 'USD', theme); + ], 'USD'); + assert.ok(presentation); + const horizontal = spendingCategoriesChart(presentation, theme); const line = netWorthChart({ currency: 'USD', points: [positive, negative, positive].map((quantity, index) => ({ date: ['2026-02-28', '2026-03-31', '2026-04-16'][index], amounts: amounts(quantity), unconverted: [] })) }, theme); diff --git a/apps/web/src/lib/dates.test.ts b/apps/web/src/lib/dates.test.ts index 3427f8e..00ab949 100644 --- a/apps/web/src/lib/dates.test.ts +++ b/apps/web/src/lib/dates.test.ts @@ -1,10 +1,15 @@ import { expect, it } from 'vitest'; -import { formatDate } from './dates.js'; +import { formatDate, formatDateTime } from './dates.js'; it('uses readable calendar dates', () => { expect(formatDate('2026-04-17')).toBe('Apr 17, 2026'); }); +it('renders preparation timestamps in explicit UTC, independently of their source offset', () => { + expect(formatDateTime('2026-04-17T14:03:04+02:00')).toBe('Apr 17, 2026, 12:03:04 PM UTC'); +}); + it('leaves unreadable saved date text intact rather than blocking the page', () => { expect(formatDate('unknown')).toBe('unknown'); + expect(formatDateTime('unknown')).toBe('unknown'); }); diff --git a/apps/web/src/lib/dates.ts b/apps/web/src/lib/dates.ts index e5182d9..caea6f0 100644 --- a/apps/web/src/lib/dates.ts +++ b/apps/web/src/lib/dates.ts @@ -5,6 +5,22 @@ const dateFormatter = new Intl.DateTimeFormat('en-US', { day: 'numeric' }); +const dateTimeFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short' +}); + +export function formatDateTime(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : dateTimeFormatter.format(date); +} + export function formatDate(value: string): string { const date = new Date(`${value}T00:00:00Z`); return Number.isNaN(date.getTime()) ? value : dateFormatter.format(date); diff --git a/apps/web/src/lib/navigation/AccountSidebar.svelte b/apps/web/src/lib/navigation/AccountSidebar.svelte index 64cf3a8..0b70698 100644 --- a/apps/web/src/lib/navigation/AccountSidebar.svelte +++ b/apps/web/src/lib/navigation/AccountSidebar.svelte @@ -7,14 +7,14 @@ let { sidebar, - pathname, + url, onNavigate, heading, actions }: { sidebar: AccountSidebarData; - pathname: string; - onNavigate?: () => void; + url: URL; + onNavigate?: (event: MouseEvent) => void; heading?: Snippet; actions?: Snippet; } = $props(); @@ -32,7 +32,7 @@ link.hasAttribute('download') ) return; - onNavigate?.(); + onNavigate?.(event); } @@ -115,7 +115,9 @@ {group.kind === 'cash' ? 'Cash' : 'Investments'} @@ -134,7 +136,7 @@

Net worth

@@ -185,8 +194,8 @@ {/if}
-
- {/if} + +
diff --git a/apps/web/src/lib/overview/ExpenseCategories.svelte b/apps/web/src/lib/overview/ExpenseCategories.svelte deleted file mode 100644 index 81c7651..0000000 --- a/apps/web/src/lib/overview/ExpenseCategories.svelte +++ /dev/null @@ -1,87 +0,0 @@ - - -
-

Expenses by category

- {#if overview.asOf === null} -

No dated transactions.

- {:else} -

Six months ending in {overview.asOf.slice(0, 7)} · Account depth {categories.depth}

- {#if categories.rows.length === 0} -

No reported expenses in this window.

- {:else} - {#if commodities.length > 0} - {#each commodities as commodity (commodity)} - - expenseCategoriesChart(categories.rows, commodity, theme, labels)} - /> - {/each} - {/if} -
- - - - - - {#each categories.rows as row, rowIndex (rowIndex)} - {@const label = accountLabel(row.account, labels)} - {#each row.amounts as amount, amountIndex (amountIndex)} - - - - - {:else} - - - - {/each} - {/each} - -
AccountAmountCommodity
{amount.quantity}{amount.commodity}
No reported amounts
-
- {/if} - {/if} -
- - diff --git a/apps/web/src/lib/overview/FinancialValue.svelte b/apps/web/src/lib/overview/FinancialValue.svelte new file mode 100644 index 0000000..8e4d940 --- /dev/null +++ b/apps/web/src/lib/overview/FinancialValue.svelte @@ -0,0 +1,54 @@ + + +{#if value === null} +

No dated value available.

+{:else} + {#if value.unconverted.length > 0} +

+ Incomplete valuation; unconverted: + {#each value.unconverted as commodity, index (index)} + {index > 0 ? ', ' : ''}{commodity === '' ? 'no commodity' : commodity} + {/each} +

+ {/if} + {#if value.amounts.length > 0} +
    + {#each value.amounts as amount, index (index)} +
  • {formatAmount(amount)}
  • + {/each} +
+ {:else} +

No reported amounts.

+ {/if} +{/if} + + diff --git a/apps/web/src/lib/overview/IncomeExpenses.svelte b/apps/web/src/lib/overview/IncomeExpenses.svelte deleted file mode 100644 index d0567e1..0000000 --- a/apps/web/src/lib/overview/IncomeExpenses.svelte +++ /dev/null @@ -1,108 +0,0 @@ - - -
-

Income vs expenses

- {#if overview.asOf !== null} -

Six months ending in {overview.asOf.slice(0, 7)}.

-

Latest recorded transaction: .

- {/if} -

- Prepared at . -

- {#if overview.asOf === null} -

No dated transactions.

- {:else} - {#if commodities.length > 0} - {#each commodities as commodity (commodity)} - - incomeExpensesChart(overview.incomeExpenses.months, commodity, theme)} - /> - {/each} - {:else} -

No reported income or expenses to chart.

- {/if} -
- - - - {#each overview.incomeExpenses.months as month, monthIndex (monthIndex)} - {#each series as kind (kind)} - {#each month[kind] as amount, index (index)} - - - - - {:else} - - - - - {/each} - {/each} - {/each} - -
MonthSeriesAmountCommodity
{month.begin.slice(0, 7)}{monthIndex === - overview.incomeExpenses.months.length - 1 - ? ` (through ${overview.asOf})` - : ''}{labels[kind]}{amount.quantity}{amount.commodity}
{month.begin.slice(0, 7)}{monthIndex === - overview.incomeExpenses.months.length - 1 - ? ` (through ${overview.asOf})` - : ''}{labels[kind]}No reported amounts
-
- {/if} -
- - diff --git a/apps/web/src/lib/overview/JournalCoverage.svelte b/apps/web/src/lib/overview/JournalCoverage.svelte deleted file mode 100644 index 1a8147b..0000000 --- a/apps/web/src/lib/overview/JournalCoverage.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - -
-

Journal coverage

-
-
-
Recorded transactions
-
{coverage.transactionCount}
-
-
-
Journal date range (inclusive)
-
- {#if coverage.firstDate !== null && coverage.lastDate !== null} - – - {:else} - No dated transactions - {/if} -
-
-
-
- - diff --git a/apps/web/src/lib/overview/KeyFigures.svelte b/apps/web/src/lib/overview/KeyFigures.svelte new file mode 100644 index 0000000..455cb88 --- /dev/null +++ b/apps/web/src/lib/overview/KeyFigures.svelte @@ -0,0 +1,84 @@ + + +
+
+ {#each flows as flow (flow.key)} +
+
{flow.label}
+
+ +
+
+ {/each} +
+
Transactions
+
+ {#if period.range} + {counts.format(period.range.transactionCount)} + {:else} + No dated period available. + {/if} +
+
+
+
Latest net worth
+
+
+
+
+ + diff --git a/apps/web/src/lib/overview/KeyFigures.test.ts b/apps/web/src/lib/overview/KeyFigures.test.ts new file mode 100644 index 0000000..8612bef --- /dev/null +++ b/apps/web/src/lib/overview/KeyFigures.test.ts @@ -0,0 +1,117 @@ +import { render } from 'svelte/server'; +import { expect, it } from 'vitest'; +import type { OverviewPageData, OverviewPeriodData, OverviewRange } from './data.js'; +import KeyFigures from './KeyFigures.svelte'; + +function text(body: string): string { + return body + .replace(//g, '') + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .replace(/\s+([.,])/g, '$1') + .trim(); +} + +function figures(overview: OverviewPageData, period: OverviewPeriodData) { + const { body } = render(KeyFigures, { props: { overview, period } }); + return Object.fromEntries( + Array.from( + body.matchAll(/]*>([\s\S]*?)<\/dt>\s*]*>([\s\S]*?)<\/dd>/g), + ([, label, value]) => [text(label), text(value)] + ) + ); +} + +const range: OverviewRange = { + begin: '2026-04-01', + endExclusive: '2026-04-18', + transactionCount: 1234, + income: [{ commodity: 'USD', quantity: '20' }], + expenses: [{ commodity: 'USD', quantity: '5' }], + expenseCategories: { depth: 2, rows: [] } +}; +const overview: OverviewPageData = { + asOf: '2026-04-17', + ranges: { 1: range, 3: range, 6: range, 12: range }, + incomeExpenses: { months: [] }, + netWorth: { + currency: 'USD', + points: [ + { date: '2026-04-17', amounts: [{ commodity: 'USD', quantity: '70' }], unconverted: [] } + ] + } +}; +const period: OverviewPeriodData = { + months: 1, + asOf: overview.asOf, + range, + incomeExpenses: { months: [] } +}; + +it('presents selected income and spending, but latest snapshot net worth regardless of selected period', () => { + const shown = figures(overview, period); + expect(shown.Income).toBe('$20.00'); + expect(shown.Spending).toBe('$5.00'); + expect(shown.Transactions).toBe('1,234'); + expect(shown['Latest net worth']).toBe('$70.00'); +}); + +it('keeps exact commodity bags and explicit incomplete valuation evidence', () => { + const shown = figures( + { + ...overview, + netWorth: { + currency: 'USD', + points: [ + { + date: '2026-04-17', + amounts: [{ commodity: 'USD', quantity: '70' }], + unconverted: ['XYZ', ''] + } + ] + } + }, + { + ...period, + range: { + ...range, + income: [ + { commodity: 'XYZ', quantity: '-3.25' }, + { commodity: '', quantity: '2' } + ] + } + } + ); + expect(shown.Income).toBe('-3.25 XYZ 2 no commodity'); + expect(shown['Latest net worth']).toContain( + 'Incomplete valuation; unconverted: XYZ, no commodity' + ); + expect(shown['Latest net worth']).toContain('$70.00'); +}); + +it('distinguishes undated, empty and explicitly reported zero values without inventing money', () => { + const missing = figures( + { + ...overview, + asOf: null, + ranges: null, + netWorth: { currency: null, points: [] } + }, + { ...period, asOf: null, range: null } + ); + expect(missing.Income).toBe('No dated value available.'); + expect(missing['Latest net worth']).toBe('No dated value available.'); + expect(missing.Transactions).toBe('No dated period available.'); + const emptyAndZero = figures(overview, { + ...period, + range: { + ...range, + transactionCount: 0, + income: [], + expenses: [{ commodity: 'USD', quantity: '0' }] + } + }); + expect(emptyAndZero.Income).toBe('No reported amounts.'); + expect(emptyAndZero.Spending).toBe('$0.00'); + expect(emptyAndZero.Transactions).toBe('0'); +}); diff --git a/apps/web/src/lib/overview/NetWorth.svelte b/apps/web/src/lib/overview/NetWorth.svelte deleted file mode 100644 index 0b65417..0000000 --- a/apps/web/src/lib/overview/NetWorth.svelte +++ /dev/null @@ -1,102 +0,0 @@ - - -
-

Net worth

- {#if overview.asOf === null} -

No dated transactions.

- {:else if netWorth.points.length === 0} -

No reported net-worth valuations in this window.

- {:else} -

- Market value - {#if netWorth.currency !== null} - · {netWorth.currency === '' ? 'no commodity' : netWorth.currency} - {/if} - · As of -

- {#if incomplete} -

Incomplete valuations: unconverted commodities.

- {/if} - {#if netWorth.currency !== null} - netWorthChart(netWorth, theme)} - /> - {/if} -
- - - - - - - - - {#each netWorth.points as point, pointIndex (pointIndex)} - {#each point.amounts.length > 0 ? point.amounts : [null] as amount, amountIndex (amountIndex)} - - - {#if amount !== null} - - - {:else} - - {/if} - - - {/each} - {/each} - -
Valuation dateAmountCommodityStatus
{amount.quantity}{amount.commodity === '' ? 'no commodity' : amount.commodity}No reported amounts - {#if point.unconverted.length > 0} - Incomplete valuation; unconverted: - {#each point.unconverted as commodity, index (index)} - {index > 0 ? ', ' : ''}{commodity === '' ? 'no commodity' : commodity} - {/each} - {:else if point.amounts.length === 0} - No reported valuation - {:else} - No unconverted units - {/if} -
-
- {/if} -
- - diff --git a/apps/web/src/lib/overview/data.ts b/apps/web/src/lib/overview/data.ts index cc02ab0..688ef2b 100644 --- a/apps/web/src/lib/overview/data.ts +++ b/apps/web/src/lib/overview/data.ts @@ -1,8 +1,19 @@ -import type { OverviewData, OverviewRange } from '@hledger-reports/hledger'; +import type { + OverviewData, + OverviewMonths, + OverviewRange as PreparedOverviewRange +} from '@hledger-reports/hledger'; -/** Only the saved financial fields rendered by the current six-month homepage. */ -export type OverviewPageData = Pick & { - expenseCategories: OverviewRange['expenseCategories']; - incomeExpenses: OverviewData['incomeExpenses']; - netWorth: OverviewData['netWorth']; +export type { OverviewMonths } from '@hledger-reports/hledger'; +export type OverviewRange = Omit; + +export type OverviewPageData = Pick & { + ranges: Record | null; }; + +export interface OverviewPeriodData { + months: OverviewMonths; + asOf: string | null; + range: OverviewRange | null; + incomeExpenses: OverviewData['incomeExpenses']; +} diff --git a/apps/web/src/lib/overview/income-spending/IncomeSpending.svelte b/apps/web/src/lib/overview/income-spending/IncomeSpending.svelte new file mode 100644 index 0000000..0f0eb3c --- /dev/null +++ b/apps/web/src/lib/overview/income-spending/IncomeSpending.svelte @@ -0,0 +1,84 @@ + + + + {#snippet chart()} + {#if period.asOf === null} +

No dated transactions.

+ {:else if commodities.length > 0} + {#each commodities as commodity (commodity)} + + incomeSpendingChart(period.incomeExpenses.months, commodity, theme)} + /> + {/each} + {:else} +

No reported income or expenses to chart.

+ {/if} + {/snippet} + {#if period.asOf !== null} + + + + {#each period.incomeExpenses.months as month, monthIndex (monthIndex)} + {#each series as kind (kind)} + {#each month[kind] as amount, index (index)} + + + + + {:else} + + + + + {/each} + {/each} + {/each} + +
MonthSeriesAmountCommodity
{month.begin.slice(0, 7)}{labels[kind]}{amount.quantity}{amount.commodity === '' ? 'no commodity' : amount.commodity}
{month.begin.slice(0, 7)}{labels[kind]}No reported amounts
+ {/if} +
+ + diff --git a/apps/web/src/lib/charts/income-expenses.test.ts b/apps/web/src/lib/overview/income-spending/chart.test.ts similarity index 79% rename from apps/web/src/lib/charts/income-expenses.test.ts rename to apps/web/src/lib/overview/income-spending/chart.test.ts index 1469bb0..f1a78b3 100644 --- a/apps/web/src/lib/charts/income-expenses.test.ts +++ b/apps/web/src/lib/overview/income-spending/chart.test.ts @@ -1,10 +1,10 @@ -import type { OverviewPageData } from '#lib/overview/data.js'; +import type { OverviewData } from '@hledger-reports/hledger'; import type { TooltipItem, TooltipModel } from 'chart.js'; import { expect, it } from 'vitest'; -import { theme } from './theme.fixture.js'; -import { incomeExpensesChart } from './income-expenses'; +import { theme } from '#lib/charts/theme.fixture.js'; +import { incomeSpendingChart } from './chart.js'; -function months(): OverviewPageData['incomeExpenses']['months'] { +function months(): OverviewData['incomeExpenses']['months'] { return ['2025-11', '2025-12', '2026-01', '2026-02', '2026-03', '2026-04'].map( (begin, index, periods) => ({ begin: `${begin}-01`, @@ -16,7 +16,7 @@ function months(): OverviewPageData['incomeExpenses']['months'] { } it('configures two grouped series in native month order, without stacking', () => { - const config = incomeExpensesChart(months(), 'USD', theme)!; + const config = incomeSpendingChart(months(), 'USD', theme)!; expect(config.type).toBe('bar'); expect(config.data.labels).toEqual([ '2025-11', @@ -31,9 +31,6 @@ it('configures two grouped series in native month order, without stacking', () = { label: 'Expenses', data: [25, 25, 25, 25, 25, 25] } ]); expect(config.options).toMatchObject({ - responsive: true, - maintainAspectRatio: false, - animation: false, datasets: { bar: { grouped: true } }, skipNull: false, scales: { x: { stacked: false }, y: { bounds: 'data', stacked: false, beginAtZero: true } } @@ -46,7 +43,7 @@ it('distinguishes missing native amounts from reported zeros', () => { data[1].income[0].quantity = '0.00'; data[2].expenses = []; data[3].expenses[0].quantity = '-0.00'; - const config = incomeExpensesChart(data, 'USD', theme)!; + const config = incomeSpendingChart(data, 'USD', theme)!; expect(config.data.datasets[0].data).toEqual([null, 0, 100, 100, 100, 100]); expect(config.data.datasets[1].data).toEqual([25, 25, null, -0, 25, 25]); }); @@ -55,7 +52,7 @@ it('keeps negative native values without sign flips or a zero minimum clamp', () const data = months(); data[0].income[0].quantity = '-100.25'; data[0].expenses[0].quantity = '-12.50'; - const config = incomeExpensesChart(data, 'USD', theme)!; + const config = incomeSpendingChart(data, 'USD', theme)!; expect(config.data.datasets[0].data[0]).toBe(-100.25); expect(config.data.datasets[1].data[0]).toBe(-12.5); expect(config.options!.scales!.y).not.toHaveProperty('min'); @@ -65,7 +62,7 @@ it('uses original quantity strings in both tooltips, not rounded canvas coordina const data = months(); data[5].income[0].quantity = '9007199254740993.000000000000000001'; data[5].expenses[0].quantity = '-0.0000000000000000100'; - const config = incomeExpensesChart(data, 'USD', theme)!; + const config = incomeSpendingChart(data, 'USD', theme)!; const label = config.options!.plugins!.tooltip!.callbacks!.label!; const tooltip = {} as TooltipModel<'bar'>; expect(label.call(tooltip, { datasetIndex: 0, dataIndex: 5 } as TooltipItem<'bar'>)).toBe( @@ -90,9 +87,9 @@ it('preserves exact commodity identity, including case, whitespace and the empty [' USD ', 3], ['', 4] ] as const) { - expect(incomeExpensesChart(data, commodity, theme)!.data.datasets[0].data[0]).toBe(value); + expect(incomeSpendingChart(data, commodity, theme)!.data.datasets[0].data[0]).toBe(value); } - expect(incomeExpensesChart(data, 'Usd', theme)!.data.datasets[0].data[0]).toBeNull(); + expect(incomeSpendingChart(data, 'Usd', theme)!.data.datasets[0].data[0]).toBeNull(); }); it('plots commodities separately without conversion, ignoring unsupported amounts in other commodities', () => { @@ -103,9 +100,9 @@ it('plots commodities separately without conversion, ignoring unsupported amount { commodity: 'STOCK', quantity: '3' }, { commodity: 'HUGE', quantity: '9'.repeat(400) } ]; - expect(incomeExpensesChart(data, 'USD', theme)!.data.datasets[0].data[0]).toBe(100); - expect(incomeExpensesChart(data, 'USD', theme)!.data.datasets[1].data[0]).toBeNull(); - expect(incomeExpensesChart(data, 'EUR', theme)!.data.datasets[0].data[0]).toBe(42.5); + expect(incomeSpendingChart(data, 'USD', theme)!.data.datasets[0].data[0]).toBe(100); + expect(incomeSpendingChart(data, 'USD', theme)!.data.datasets[1].data[0]).toBeNull(); + expect(incomeSpendingChart(data, 'EUR', theme)!.data.datasets[0].data[0]).toBe(42.5); }); it.each(['income', 'expenses'] as const)( @@ -116,7 +113,7 @@ it.each(['income', 'expenses'] as const)( { commodity: 'STOCK', quantity: '2' }, { commodity: 'STOCK', quantity: '3' } ]; - expect(incomeExpensesChart(data, 'STOCK', theme)).toBeNull(); + expect(incomeSpendingChart(data, 'STOCK', theme)).toBeNull(); } ); @@ -130,7 +127,7 @@ it.each([ (quantity) => { const data = months(); data[4].expenses[0].quantity = quantity; - expect(incomeExpensesChart(data, 'USD', theme)).toBeNull(); + expect(incomeSpendingChart(data, 'USD', theme)).toBeNull(); } ); @@ -138,14 +135,14 @@ it('falls back when individually finite extremes overflow the numeric range', () const data = months(); data[0].income[0].quantity = `1${'0'.repeat(308)}`; data[1].expenses[0].quantity = `-1${'0'.repeat(308)}`; - expect(incomeExpensesChart(data, 'USD', theme)).toBeNull(); + expect(incomeSpendingChart(data, 'USD', theme)).toBeNull(); }); it('does not impose a magnitude budget on otherwise finite chart coordinates', () => { const data = months(); data[0].income[0].quantity = `1${'0'.repeat(307)}`; data[1].expenses[0].quantity = `-1${'0'.repeat(307)}`; - expect(incomeExpensesChart(data, 'USD', theme)).not.toBeNull(); + expect(incomeSpendingChart(data, 'USD', theme)).not.toBeNull(); }); it('does not mutate public native data for supported or unsupported charts', () => { @@ -153,7 +150,7 @@ it('does not mutate public native data for supported or unsupported charts', () data[0].income.push({ commodity: 'STOCK', quantity: '2' }); data[0].income.push({ commodity: 'STOCK', quantity: '3' }); const before = structuredClone(data); - incomeExpensesChart(data, 'USD', theme); - incomeExpensesChart(data, 'STOCK', theme); + incomeSpendingChart(data, 'USD', theme); + incomeSpendingChart(data, 'STOCK', theme); expect(data).toEqual(before); }); diff --git a/apps/web/src/lib/charts/income-expenses.ts b/apps/web/src/lib/overview/income-spending/chart.ts similarity index 92% rename from apps/web/src/lib/charts/income-expenses.ts rename to apps/web/src/lib/overview/income-spending/chart.ts index b8c3d7e..73a06ae 100644 --- a/apps/web/src/lib/charts/income-expenses.ts +++ b/apps/web/src/lib/overview/income-spending/chart.ts @@ -1,9 +1,9 @@ -import type { OverviewPageData } from '#lib/overview/data.js'; +import type { OverviewData } from '@hledger-reports/hledger'; import type { ChartConfiguration } from 'chart.js'; -import type { ChartTheme } from './theme.js'; +import type { ChartTheme } from '#lib/charts/theme.js'; -export function incomeExpensesChart( - months: OverviewPageData['incomeExpenses']['months'], +export function incomeSpendingChart( + months: OverviewData['incomeExpenses']['months'], commodity: string, theme: ChartTheme ): ChartConfiguration<'bar', (number | null)[], string> | null { diff --git a/apps/web/src/lib/overview/select-period.test.ts b/apps/web/src/lib/overview/select-period.test.ts new file mode 100644 index 0000000..cfee6bb --- /dev/null +++ b/apps/web/src/lib/overview/select-period.test.ts @@ -0,0 +1,83 @@ +import { expect, it } from 'vitest'; +import type { OverviewPageData, OverviewRange } from './data.js'; +import { selectOverviewPeriod } from './select-period.js'; +import { reportMonths } from '#lib/report-period/period-query.js'; + +function preparedRange(months: number): OverviewRange { + return { + begin: `2026-${String(13 - months).padStart(2, '0')}-01`, + endExclusive: '2026-12-17', + transactionCount: months, + income: [{ commodity: 'USD', quantity: `${months}.000000000000000001` }], + expenses: [{ commodity: 'USD', quantity: '-2' }], + expenseCategories: { + depth: 2, + rows: [{ account: `expenses:range-${months}`, amounts: [{ commodity: '', quantity: '2' }] }] + } + }; +} + +function overview(): OverviewPageData { + return { + asOf: '2026-12-16', + ranges: { + 1: preparedRange(1), + 3: preparedRange(3), + 6: preparedRange(6), + 12: preparedRange(12) + }, + incomeExpenses: { + months: Array.from({ length: 12 }, (_, index) => { + const begin = `2026-${String(index + 1).padStart(2, '0')}-01`; + return { + begin, + endExclusive: + index === 11 ? '2027-01-01' : `2026-${String(index + 2).padStart(2, '0')}-01`, + income: [{ commodity: 'USD', quantity: `${index}.000000000000000001` }], + expenses: [] + }; + }) + }, + netWorth: { + currency: 'USD', + points: [ + { + date: '2026-12-16', + amounts: [{ commodity: 'USD', quantity: '11.000000000000000001' }], + unconverted: ['STOCK'] + } + ] + } + }; +} + +it('selects native range results and trailing history without recalculating or mutating the snapshot', () => { + const data = overview(); + const before = structuredClone(data); + for (const months of reportMonths) { + const selected = selectOverviewPeriod(data, months); + expect(selected).toEqual({ + months, + asOf: '2026-12-16', + range: data.ranges![months], + incomeExpenses: { months: data.incomeExpenses.months.slice(12 - months) } + }); + expect(selected.range).toBe(data.ranges![months]); + expect(selected.range!.transactionCount).toBe(months); + } + expect(data).toEqual(before); +}); + +it('preserves an undated saved view without inventing a range or history', () => { + const data = overview(); + data.asOf = null; + data.ranges = null; + data.incomeExpenses.months = []; + data.netWorth = { currency: null, points: [] }; + expect(selectOverviewPeriod(data, 3)).toEqual({ + months: 3, + asOf: null, + range: null, + incomeExpenses: { months: [] } + }); +}); diff --git a/apps/web/src/lib/overview/select-period.ts b/apps/web/src/lib/overview/select-period.ts new file mode 100644 index 0000000..21248a9 --- /dev/null +++ b/apps/web/src/lib/overview/select-period.ts @@ -0,0 +1,13 @@ +import type { OverviewMonths, OverviewPageData, OverviewPeriodData } from './data.js'; + +export function selectOverviewPeriod( + overview: OverviewPageData, + months: OverviewMonths +): OverviewPeriodData { + return { + months, + asOf: overview.asOf, + range: overview.ranges?.[months] ?? null, + incomeExpenses: { months: overview.incomeExpenses.months.slice(-months) } + }; +} diff --git a/apps/web/src/lib/overview/spending-categories/SpendingCategories.svelte b/apps/web/src/lib/overview/spending-categories/SpendingCategories.svelte new file mode 100644 index 0000000..ec8a704 --- /dev/null +++ b/apps/web/src/lib/overview/spending-categories/SpendingCategories.svelte @@ -0,0 +1,190 @@ + + + + {#snippet chart()} +
+ {#if categories === undefined} +

No dated transactions.

+ {:else if categories.rows.length === 0} +

No reported expenses in this window.

+ {:else if selectedCommodity !== undefined} +

Top five; any remainder grouped as Other.

+ {#if ready && commodities.length > 1} + + {/if} + + presentation ? spendingCategoriesChart(presentation, theme) : null} + /> + {/if} +
+ {/snippet} + {#if categories !== undefined && categories.rows.length > 0} + + + + + + {#each categories.rows as row, rowIndex (rowIndex)} + {@const label = accountLabel(row.account, labels)} + {#each row.amounts as amount, amountIndex (amountIndex)} + + + + + {:else} + + + + {/each} + {/each} + +
AccountAmountCommodity
{amount.quantity}{amount.commodity === '' ? 'no commodity' : amount.commodity}
No reported amounts
+ {#if presentation} +
+ Chart values +
+

Spending by category, in chart order:

+
    + {#each presentation.bars as bar, index (index)} +
  1. + {bar.grouped ? 'Other (grouped)' : bar.label}: + {bar.quantity} + {presentation.commodity === '' ? 'no commodity' : presentation.commodity} +
  2. + {/each} +
+
+
+ {/if} + {/if} +
+ + diff --git a/apps/web/src/lib/overview/spending-categories/chart.test.ts b/apps/web/src/lib/overview/spending-categories/chart.test.ts new file mode 100644 index 0000000..0ed8a70 --- /dev/null +++ b/apps/web/src/lib/overview/spending-categories/chart.test.ts @@ -0,0 +1,76 @@ +import type { TooltipItem, TooltipModel } from 'chart.js'; +import { expect, it } from 'vitest'; +import { theme } from '#lib/charts/theme.fixture.js'; +import { spendingCategoriesChart } from './chart.js'; +import type { SpendingCategoriesPresentation } from './presentation.js'; + +type Config = ReturnType; + +function tooltip(config: Config, dataIndex: number): string | void | string[] { + return config.options!.plugins!.tooltip!.callbacks!.label!.call( + {} as TooltipModel<'bar'>, + { datasetIndex: 0, dataIndex } as TooltipItem<'bar'> + ); +} + +const presentation: SpendingCategoriesPresentation = { + commodity: 'USD', + bars: [ + { + label: 'Custom root:Full account', + quantity: '9007199254740993.000000000000000001', + grouped: false, + coordinate: 9007199254740994 + }, + { label: 'Other', quantity: '9.00', grouped: false, coordinate: 9 }, + { label: '生活 ', quantity: '0.00', grouped: false, coordinate: 0 }, + { label: '生活 ', quantity: '-0.000', grouped: false, coordinate: -0 }, + { + label: 'Outgoings:Refunds', + quantity: '-0.0000000000000000100', + grouped: false, + coordinate: -1e-17 + }, + { label: 'Other', quantity: '-0.30', grouped: true, coordinate: -0.3 } + ] +}; + +it('wires ordered labels and coordinates to one horizontal Expenses series without clipping refunds', () => { + const config = spendingCategoriesChart(presentation, theme); + expect(config.type).toBe('bar'); + expect(config.data.labels).toEqual(presentation.bars.map((bar) => bar.label)); + expect(config.data.datasets).toHaveLength(1); + expect(config.data.datasets[0]).toMatchObject({ + label: 'Expenses', + data: presentation.bars.map((bar) => bar.coordinate) + }); + expect(config.options).toMatchObject({ + indexAxis: 'y', + scales: { x: { bounds: 'data', beginAtZero: true }, y: { ticks: { autoSkip: false } } } + }); + // Refunds remain negative; beginAtZero does not clamp the domain to positive values. + expect(config.options!.scales!.x).not.toHaveProperty('min'); + expect(config.options!.scales!.y).not.toHaveProperty('beginAtZero'); +}); + +it('uses exact native quantities for tooltips rather than approximate canvas coordinates', () => { + const config = spendingCategoriesChart(presentation, theme); + expect(tooltip(config, 0)).toBe('Expenses: 9007199254740993.000000000000000001 USD'); + expect(tooltip(config, 3)).toBe('Expenses: -0.000 USD'); + expect(tooltip(config, 4)).toBe('Expenses: -0.0000000000000000100 USD'); +}); + +it('distinguishes a native Other label from the derived Other group', () => { + const config = spendingCategoriesChart(presentation, theme); + expect(tooltip(config, 1)).toBe('Expenses: 9.00 USD'); + expect(tooltip(config, 5)).toBe('Other (grouped): -0.30 USD'); +}); + +it('keeps literal commodity text and omits the suffix only for an empty commodity', () => { + expect(tooltip(spendingCategoriesChart({ ...presentation, commodity: ' USD ' }, theme), 1)).toBe( + 'Expenses: 9.00 USD ' + ); + expect(tooltip(spendingCategoriesChart({ ...presentation, commodity: '' }, theme), 1)).toBe( + 'Expenses: 9.00' + ); +}); diff --git a/apps/web/src/lib/overview/spending-categories/chart.ts b/apps/web/src/lib/overview/spending-categories/chart.ts new file mode 100644 index 0000000..94de74d --- /dev/null +++ b/apps/web/src/lib/overview/spending-categories/chart.ts @@ -0,0 +1,59 @@ +import type { ChartConfiguration } from 'chart.js'; +import type { ChartTheme } from '#lib/charts/theme.js'; +import type { SpendingCategoriesPresentation } from './presentation.js'; + +export function spendingCategoriesChart( + presentation: SpendingCategoriesPresentation, + theme: ChartTheme +): ChartConfiguration<'bar', (number | null)[], string> { + const { bars, commodity } = presentation; + return { + type: 'bar', + data: { + labels: bars.map((bar) => bar.label), + datasets: [ + { + label: 'Expenses', + backgroundColor: theme.expense, + maxBarThickness: 32, + data: bars.map((bar) => bar.coordinate) + } + ] + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + animation: false, + font: { family: theme.fontFamily, size: theme.fontSize }, + skipNull: false, + scales: { + // The numeric axis is horizontal. Outward tick rounding can overflow a finite range. + x: { + bounds: 'data', + beginAtZero: true, + ticks: { color: theme.text }, + grid: { color: theme.border } + }, + y: { ticks: { color: theme.text, autoSkip: false }, grid: { color: theme.border } } + }, + plugins: { + legend: { labels: { color: theme.text } }, + tooltip: { + backgroundColor: theme.tooltipBackground, + titleColor: theme.tooltipText, + bodyColor: theme.tooltipText, + callbacks: { + label(item) { + const bar = bars[item.dataIndex]; + if (!bar) return ''; + let label = `${bar.grouped ? 'Other (grouped)' : 'Expenses'}: ${bar.quantity}`; + if (commodity !== '') label += ` ${commodity}`; + return label; + } + } + } + } + } + }; +} diff --git a/apps/web/src/lib/overview/spending-categories/presentation.test.ts b/apps/web/src/lib/overview/spending-categories/presentation.test.ts new file mode 100644 index 0000000..ec80742 --- /dev/null +++ b/apps/web/src/lib/overview/spending-categories/presentation.test.ts @@ -0,0 +1,213 @@ +import type { OverviewRange } from '@hledger-reports/hledger'; +import { expect, it } from 'vitest'; +import { spendingCategoriesPresentation } from './presentation.js'; + +type Rows = OverviewRange['expenseCategories']['rows']; + +function categories(quantities: string[]): Rows { + return quantities.map((quantity, index) => ({ + account: `Custom root:Full account ${index}`, + amounts: [{ commodity: 'USD', quantity }] + })); +} + +it('distinguishes missing amounts from literal positive and negative zero, preserving native tie order', () => { + const rows: Rows = [ + { account: 'Outgoings:Travel', amounts: [{ commodity: 'EUR', quantity: '4' }] }, + { account: 'Expenses', amounts: [] }, + { account: 'Living:Food', amounts: [{ commodity: 'USD', quantity: '0.00' }] }, + { account: 'Outgoings:Refunds', amounts: [{ commodity: 'USD', quantity: '-0.000' }] } + ]; + expect(spendingCategoriesPresentation(rows, 'USD')).toEqual({ + commodity: 'USD', + bars: [ + { label: 'Living:Food', quantity: '0.00', grouped: false, coordinate: 0 }, + { label: 'Outgoings:Refunds', quantity: '-0.000', grouped: false, coordinate: -0 } + ] + }); + expect(spendingCategoriesPresentation([], 'USD')).toBeNull(); + expect(spendingCategoriesPresentation(rows, 'missing')).toBeNull(); +}); + +it('keeps the top five full titles and an exact scaled Other last, without mutating native rows', () => { + const rows = categories(['0.1', '8', '6', '9', '0.20', '7', '5']); + const before = structuredClone(rows); + const presentation = spendingCategoriesPresentation(rows, 'USD')!; + expect(presentation).toEqual({ + commodity: 'USD', + bars: [ + ...[3, 1, 5, 2, 6].map((index) => ({ + label: rows[index].account, + quantity: rows[index].amounts[0].quantity, + grouped: false, + coordinate: Number(rows[index].amounts[0].quantity) + })), + { label: 'Other', quantity: '0.30', grouped: true, coordinate: 0.3 } + ] + }); + expect(rows).toEqual(before); + + const nextPeriod = structuredClone(rows); + nextPeriod[0].amounts[0].quantity = '20'; + nextPeriod[3].amounts[0].quantity = '0.100'; + const next = spendingCategoriesPresentation(nextPeriod, 'USD')!; + expect(next.bars.map((bar) => bar.label)).toEqual( + [0, 1, 5, 2, 6].map((index) => rows[index].account).concat('Other') + ); + expect(next.bars[5].quantity).toBe('0.300'); +}); + +it('counts only eligible categories at the five-category boundary', () => { + const rows = categories(['5', '4', '3', '2', '1']); + rows.push({ account: 'Missing', amounts: [] }); + rows.push({ account: 'Other commodity', amounts: [{ commodity: 'EUR', quantity: '100' }] }); + const five = spendingCategoriesPresentation(rows, 'USD')!; + expect(five.bars.map((bar) => bar.label)).toEqual(rows.slice(0, 5).map((row) => row.account)); + expect(five.bars.every((bar) => !bar.grouped)).toBe(true); + rows.push({ account: 'Sixth', amounts: [{ commodity: 'USD', quantity: '-0.10' }] }); + expect(spendingCategoriesPresentation(rows, 'USD')!.bars[5]).toEqual({ + label: 'Other', + quantity: '-0.10', + grouped: true, + coordinate: -0.1 + }); +}); + +it('compares exact signed amounts beyond Number precision and keeps native ties stable at the cutoff', () => { + const rows = categories([ + '9007199254740992.000', + '9007199254740993', + '10.0', + '10.00', + '10', + '10.000', + '-10000000000000000' + ]); + const presentation = spendingCategoriesPresentation(rows, 'USD')!; + expect(presentation.bars.map((bar) => bar.label)).toEqual( + [1, 0, 2, 3, 4].map((index) => rows[index].account).concat('Other') + ); + expect(presentation.bars[0].quantity).toBe('9007199254740993'); + expect(presentation.bars[5].quantity).toBe('-9999999999999990.000'); +}); + +it('does not rank by absolute value or suppress a zero or negative Other', () => { + const negative = spendingCategoriesPresentation( + categories(['-100', '8', '7', '6', '5', '4', '0.2']), + 'USD' + )!; + expect(negative.bars.map((bar) => bar.coordinate)).toEqual([8, 7, 6, 5, 4, -99.8]); + expect(negative.bars[5].quantity).toBe('-99.8'); + const zero = spendingCategoriesPresentation( + categories(['8', '7', '6', '5', '4', '0.20', '-0.2']), + 'USD' + )!; + expect(zero.bars[5]).toEqual({ + label: 'Other', + quantity: '0.00', + grouped: true, + coordinate: 0 + }); +}); + +it('keeps large and tiny individual native strings instead of rounded geometry', () => { + const presentation = spendingCategoriesPresentation( + categories(['9007199254740993.000000000000000001', '-0.0000000000000000100']), + 'USD' + )!; + expect(presentation.bars.map((bar) => bar.quantity)).toEqual([ + '9007199254740993.000000000000000001', + '-0.0000000000000000100' + ]); +}); + +it('preserves case, whitespace and literal empty commodity identities without mixing native bags', () => { + const rows = categories(['1']); + rows[0].amounts = [ + { commodity: 'USD', quantity: '1' }, + { commodity: 'usd', quantity: '2' }, + { commodity: ' USD ', quantity: '3' }, + { commodity: '', quantity: '4.00' } + ]; + for (const amount of rows[0].amounts) { + expect(spendingCategoriesPresentation(rows, amount.commodity)).toMatchObject({ + commodity: amount.commodity, + bars: [{ quantity: amount.quantity, coordinate: Number(amount.quantity) }] + }); + } + expect(spendingCategoriesPresentation(rows, 'Usd')).toBeNull(); + const mixed = categories(['9', '8', '7', '6', '5', '0.1', '0.2']); + for (const row of mixed) row.amounts.push({ commodity: 'EUR', quantity: '999999' }); + expect(spendingCategoriesPresentation(mixed, 'USD')!.bars[5].quantity).toBe('0.3'); +}); + +it('rejects an entire commodity with duplicate cost amounts even outside its top five', () => { + const rows = categories(['9', '8', '7', '6', '5', '-1']); + rows[5].amounts.push( + { commodity: 'USD', quantity: '-2' }, + { commodity: 'EUR', quantity: '42.50' } + ); + const before = structuredClone(rows); + expect(spendingCategoriesPresentation(rows, 'USD')).toBeNull(); + expect(spendingCategoriesPresentation(rows, 'EUR')!.bars[0].quantity).toBe('42.50'); + expect(rows).toEqual(before); +}); + +it.each([ + '9'.repeat(400), + `-${'9'.repeat(400)}`, + `0.${'0'.repeat(400)}1`, + `-0.${'0'.repeat(400)}1` +])('falls back for a non-finite or underflowing coordinate: %s', (quantity) => { + expect(spendingCategoriesPresentation(categories([quantity]), 'USD')).toBeNull(); +}); + +it('also guards grouped coordinates and the resulting numeric axis range', () => { + const huge = `1${'0'.repeat(308)}`; + const rows = categories(['8', '7', '6', '5', '4', `-${huge}`, `-${huge}`]); + expect(spendingCategoriesPresentation(rows, 'USD')).toBeNull(); + const tiny = `-0.${'0'.repeat(400)}1`; + expect( + spendingCategoriesPresentation(categories(['8', '7', '6', '5', '4', tiny, tiny]), 'USD') + ).toBeNull(); + expect(spendingCategoriesPresentation(categories([huge, `-${huge}`]), 'USD')).toBeNull(); +}); + +it('accepts finite extremes without an arbitrary financial magnitude limit', () => { + expect( + spendingCategoriesPresentation(categories([`17${'0'.repeat(307)}`]), 'USD') + ).not.toBeNull(); + expect( + spendingCategoriesPresentation( + categories([`85${'0'.repeat(306)}`, `-85${'0'.repeat(306)}`]), + 'USD' + ) + ).not.toBeNull(); +}); + +it('uses aliases only for display and keeps duplicate labels and a real Other distinct from grouped Other', () => { + const rows = categories(['9', '8', '7', '6', '5', '0.1', '0.2']); + const original = structuredClone(rows); + const unaliased = spendingCategoriesPresentation(rows, 'USD')!; + const aliased = spendingCategoriesPresentation(rows, 'USD', [ + { account: rows[0].account, label: 'Other' }, + { account: rows[1].account, label: '生活 ' }, + { account: rows[2].account, label: '生活 ' }, + { account: 'Custom root', label: 'Not a prefix' }, + { account: 'Other', label: 'Not the group' } + ])!; + expect(aliased.bars.map((bar) => bar.label)).toEqual([ + 'Other', + '生活 ', + '生活 ', + rows[3].account, + rows[4].account, + 'Other' + ]); + expect( + aliased.bars.map(({ quantity, grouped, coordinate }) => ({ quantity, grouped, coordinate })) + ).toEqual( + unaliased.bars.map(({ quantity, grouped, coordinate }) => ({ quantity, grouped, coordinate })) + ); + expect(rows).toEqual(original); +}); diff --git a/apps/web/src/lib/overview/spending-categories/presentation.ts b/apps/web/src/lib/overview/spending-categories/presentation.ts new file mode 100644 index 0000000..59ebdad --- /dev/null +++ b/apps/web/src/lib/overview/spending-categories/presentation.ts @@ -0,0 +1,89 @@ +import type { OverviewRange } from '@hledger-reports/hledger'; +import { accountLabel, type AccountLabel } from '../../accounts.js'; + +export interface SpendingCategoriesPresentation { + commodity: string; + bars: { label: string; quantity: string; grouped: boolean; coordinate: number }[]; +} + +// Local chart arithmetic only: native amounts remain untouched and are never combined within a row. +function scaledDecimal(quantity: string): { coefficient: bigint; scale: number } { + const [whole, fraction = ''] = quantity.split('.'); + return { coefficient: BigInt(whole + fraction), scale: fraction.length }; +} + +function groupedQuantity(amounts: { coefficient: bigint; scale: number }[]): string { + const scale = amounts.reduce((maximum, amount) => Math.max(maximum, amount.scale), 0); + const coefficient = amounts.reduce( + (sum, amount) => sum + amount.coefficient * 10n ** BigInt(scale - amount.scale), + 0n + ); + const sign = coefficient < 0n ? '-' : ''; + const digits = (coefficient < 0n ? -coefficient : coefficient) + .toString() + .padStart(scale + 1, '0'); + return sign + (scale === 0 ? digits : `${digits.slice(0, -scale)}.${digits.slice(-scale)}`); +} + +function rankedCategories(rows: OverviewRange['expenseCategories']['rows'], commodity: string) { + const reported = rows.map((row) => + row.amounts.filter((amount) => amount.commodity === commodity) + ); + // Distinct native costs can produce several amounts for one commodity; never combine them. + if (reported.some((amounts) => amounts.length > 1)) return null; + + const eligible = rows.flatMap((row, index) => { + const amount = reported[index][0]; + return amount + ? [{ account: row.account, quantity: amount.quantity, ...scaledDecimal(amount.quantity) }] + : []; + }); + if (eligible.length === 0) return null; + + const scale = eligible.reduce((maximum, amount) => Math.max(maximum, amount.scale), 0); + const ranked = eligible.map((amount) => ({ + ...amount, + rank: amount.coefficient * 10n ** BigInt(scale - amount.scale) + })); + // Stable sort preserves native order for exact ties, including differently scaled zeros. + return ranked.sort((left, right) => { + if (left.rank > right.rank) return -1; + if (left.rank < right.rank) return 1; + return 0; + }); +} + +// One validated presentation for both the canvas and its exact text alternative. +export function spendingCategoriesPresentation( + rows: OverviewRange['expenseCategories']['rows'], + commodity: string, + labels: readonly AccountLabel[] = [] +): SpendingCategoriesPresentation | null { + const ranked = rankedCategories(rows, commodity); + if (!ranked) return null; + + const bars = ranked.slice(0, 5).map((amount) => ({ + label: accountLabel(amount.account, labels), + quantity: amount.quantity, + grouped: false + })); + if (ranked.length > 5) { + // Other is chart-derived, not an account identity or an alias lookup. + bars.push({ label: 'Other', quantity: groupedQuantity(ranked.slice(5)), grouped: true }); + } + + const plotted: SpendingCategoriesPresentation['bars'] = []; + let minimum = 0; + let maximum = 0; + for (const bar of bars) { + // Only final canvas geometry uses approximate numbers; tooltips keep exact decimal strings. + const value = Number(bar.quantity); + if (!Number.isFinite(value) || (value === 0 && /[1-9]/.test(bar.quantity))) return null; + minimum = Math.min(minimum, value); + maximum = Math.max(maximum, value); + plotted.push({ ...bar, coordinate: value }); + } + if (!Number.isFinite(maximum - minimum)) return null; + + return { commodity, bars: plotted }; +} diff --git a/apps/web/src/lib/preparation/ValidationStatus.svelte b/apps/web/src/lib/preparation/ValidationStatus.svelte deleted file mode 100644 index cb39dd0..0000000 --- a/apps/web/src/lib/preparation/ValidationStatus.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -
- {#if readFailed} -

Saved journal validation couldn't be read.

- {:else if validation} -

- Last recorded strict validation: {validation.outcome === 'passed' - ? 'Passed' - : 'Could not validate'}. Completed at - . -

-

Recorded separately from saved reports.

- {:else} -

No recorded journal check available.

- {/if} -
diff --git a/apps/web/src/lib/preparation/ValidationStatus.test.ts b/apps/web/src/lib/preparation/ValidationStatus.test.ts deleted file mode 100644 index 42a1a23..0000000 --- a/apps/web/src/lib/preparation/ValidationStatus.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { render } from 'svelte/server'; -import { expect, it } from 'vitest'; -import ValidationStatus from './ValidationStatus.svelte'; - -function text(body: string) { - return body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' '); -} - -it('presents the last completed pass with its own time, without claiming journal freshness', () => { - const completedAt = '2026-05-01T12:01:00Z'; - const { body } = render(ValidationStatus, { - props: { validation: { outcome: 'passed', completedAt }, readFailed: false } - }); - expect(text(body)).toContain('Last recorded strict validation: Passed.'); - expect(text(body)).toContain(`Completed at ${completedAt}`); - expect(body).toContain(`datetime="${completedAt}"`); - expect(text(body)).toContain('Recorded separately from saved reports.'); - expect(text(body)).not.toContain('reports prepared'); -}); - -it('describes a completed unsuccessful check without calling the accounting invalid', () => { - const { body } = render(ValidationStatus, { - props: { - validation: { outcome: 'could-not-validate', completedAt: '2026-05-02T12:01:00Z' }, - readFailed: false - } - }); - expect(text(body)).toContain('Last recorded strict validation: Could not validate.'); - expect(text(body)).not.toMatch(/Passed|Invalid/); -}); - -it('distinguishes missing evidence from a read failure and never shows a pass for either', () => { - const missing = render(ValidationStatus, { props: { validation: null, readFailed: false } }); - const failed = render(ValidationStatus, { props: { validation: null, readFailed: true } }); - expect(text(missing.body)).toContain('No recorded journal check available.'); - expect(text(failed.body)).toContain("Saved journal validation couldn't be read."); - expect(text(missing.body) + text(failed.body)).not.toMatch(/Passed|Completed at/); -}); diff --git a/apps/web/src/lib/report-card/ReportCard.svelte b/apps/web/src/lib/report-card/ReportCard.svelte new file mode 100644 index 0000000..7a11cc2 --- /dev/null +++ b/apps/web/src/lib/report-card/ReportCard.svelte @@ -0,0 +1,68 @@ + + +
+

{title}

+ {#if chart} +
+ {#if containedChart} + {@render chart()} + {:else} + {@render chart()} + {/if} +
+ {/if} +
+ {@render children()} +
+
+ + diff --git a/apps/web/src/lib/report-card/ScrollArea.svelte b/apps/web/src/lib/report-card/ScrollArea.svelte new file mode 100644 index 0000000..3212b4e --- /dev/null +++ b/apps/web/src/lib/report-card/ScrollArea.svelte @@ -0,0 +1,99 @@ + + +{#if ready} + + {#snippet child({ props })} +
+ + {@render children()} + + {#each axes as orientation (orientation)} + + {#snippet child({ props: barProps })} +
+ + {#snippet child({ props: thumbProps })} +
+ {/snippet} +
+
+ {/snippet} +
+ {/each} + +
+ {/snippet} +
+{:else} + +
+ {@render children()} +
+{/if} + + diff --git a/apps/web/src/lib/report-period/PeriodControls.svelte b/apps/web/src/lib/report-period/PeriodControls.svelte new file mode 100644 index 0000000..702d2e0 --- /dev/null +++ b/apps/web/src/lib/report-period/PeriodControls.svelte @@ -0,0 +1,127 @@ + + +{#if ready} + + + {#snippet child({ props })} + + {/snippet} + + + + {#snippet child({ props, wrapperProps })} +
+
+ + {#each options as option (option.value)} + + {#snippet child({ props: itemProps, selected })} +
+ {option.label} + {#if selected}
+ {/snippet} +
+ {/each} +
+
+
+ {/snippet} +
+
+
+{:else} +
+ + +
+{/if} + + diff --git a/apps/web/src/lib/report-period/period-query.test.ts b/apps/web/src/lib/report-period/period-query.test.ts new file mode 100644 index 0000000..204ac6a --- /dev/null +++ b/apps/web/src/lib/report-period/period-query.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from 'vitest'; +import { parseReportMonths, reportMonths } from './period-query.js'; + +it('defaults bare reports to three months and accepts only the four literal choices', () => { + expect(parseReportMonths(new URLSearchParams())).toBe(3); + for (const months of reportMonths) { + expect(parseReportMonths(new URLSearchParams({ months: String(months) }))).toBe(months); + } +}); + +it.each(['months=', 'months=2', 'months=03', 'months=3&months=3', 'months=3&other=1', 'other=3'])( + 'rejects nonliteral, repeated or unknown input: %s', + (query) => { + expect(() => parseReportMonths(new URLSearchParams(query))).toThrow(Error); + } +); diff --git a/apps/web/src/lib/report-period/period-query.ts b/apps/web/src/lib/report-period/period-query.ts new file mode 100644 index 0000000..952ae62 --- /dev/null +++ b/apps/web/src/lib/report-period/period-query.ts @@ -0,0 +1,13 @@ +import type { OverviewMonths } from '@hledger-reports/hledger'; + +export const reportMonths = [1, 3, 6, 12] as const; + +export function parseReportMonths( + params: Pick +): OverviewMonths { + if (params.size === 0) return 3; + if (params.size !== 1 || !params.has('months')) throw new Error('Invalid report period.'); + const months = reportMonths.find((value) => String(value) === params.get('months')); + if (months === undefined) throw new Error('Invalid report period.'); + return months; +} diff --git a/apps/web/src/lib/reports/networth/NetWorth.svelte b/apps/web/src/lib/reports/networth/NetWorth.svelte new file mode 100644 index 0000000..80c2d8b --- /dev/null +++ b/apps/web/src/lib/reports/networth/NetWorth.svelte @@ -0,0 +1,103 @@ + + + + {#snippet chart()} + {#if asOf === null} +

No dated transactions.

+ {:else if netWorth.points.length === 0} +

No reported net-worth valuations in this window.

+ {:else} +

+ Market value + {#if netWorth.currency !== null} + · {netWorth.currency === '' ? 'no commodity' : netWorth.currency} + {/if} +

+ {#if incomplete} +

Incomplete valuations: unconverted commodities.

+ {/if} + {#if netWorth.currency !== null} + netWorthChart(netWorth, theme)} + /> + {/if} + {/if} + {/snippet} + {#if asOf !== null && netWorth.points.length > 0} + + + + + + + + + {#each netWorth.points as point, pointIndex (pointIndex)} + {#each point.amounts.length > 0 ? point.amounts : [null] as amount, amountIndex (amountIndex)} + + + {#if amount !== null} + + + {:else} + + {/if} + + + {/each} + {/each} + +
Valuation dateAmountCommodityStatus
{amount.quantity}{amount.commodity === '' ? 'no commodity' : amount.commodity}No reported amounts + {#if point.unconverted.length > 0} + Incomplete valuation; unconverted: + {#each point.unconverted as commodity, index (index)} + {index > 0 ? ', ' : ''}{commodity === '' ? 'no commodity' : commodity} + {/each} + {:else if point.amounts.length === 0} + No reported valuation + {:else} + No unconverted units + {/if} +
+ {/if} +
+ + diff --git a/apps/web/src/lib/charts/net-worth.test.ts b/apps/web/src/lib/reports/networth/chart.test.ts similarity index 86% rename from apps/web/src/lib/charts/net-worth.test.ts rename to apps/web/src/lib/reports/networth/chart.test.ts index 9321d8a..cb12871 100644 --- a/apps/web/src/lib/charts/net-worth.test.ts +++ b/apps/web/src/lib/reports/networth/chart.test.ts @@ -1,16 +1,11 @@ -import type { OverviewPageData } from '#lib/overview/data.js'; +import type { OverviewData } from '@hledger-reports/hledger'; import type { TooltipItem, TooltipModel } from 'chart.js'; import { expect, it } from 'vitest'; -import { theme } from './theme.fixture.js'; -import { netWorthChart } from './net-worth'; +import { theme } from '#lib/charts/theme.fixture.js'; +import { netWorthChart } from './chart.js'; -function overview(): OverviewPageData { +function overview(): Pick { return { - journalCoverage: { transactionCount: 1, firstDate: '2026-04-16', lastDate: '2026-04-16' }, - expenseCategories: { depth: 2, rows: [] }, - asOf: '2026-04-16', - preparedAt: '2026-04-16T12:00:00Z', - incomeExpenses: { months: [] }, netWorth: { currency: 'USD', points: [ @@ -47,17 +42,11 @@ it('uses native valuation dates and net totals, not monthly sums or a later mont data: [null, null, null, null, null, 11825], spanGaps: false, tension: 0, - fill: false, - pointRadius: 3 + fill: false }); expect(config.options).toMatchObject({ - responsive: true, - maintainAspectRatio: false, - animation: false, scales: { y: { bounds: 'data', beginAtZero: true } } }); - // Chart.js's default tooltip title uses these full native date labels. - expect(config.options!.plugins!.tooltip!.callbacks).not.toHaveProperty('title'); }); it('distinguishes native zeros from absent bags and foreign-only bags without a target', () => { @@ -185,13 +174,7 @@ it('rejects overflowing ranges but accepts finite extremes and constant extremes }); it('does not invent a default currency for an empty prepared set', () => { - const data = overview(); - data.asOf = null; - data.journalCoverage = { transactionCount: 0, firstDate: null, lastDate: null }; - data.expenseCategories = { depth: 2, rows: [] }; - data.netWorth = { currency: null, points: [] }; - data.incomeExpenses.months = []; - expect(netWorthChart(data.netWorth, theme)).toBeNull(); + expect(netWorthChart({ currency: null, points: [] }, theme)).toBeNull(); }); it('keeps public data unchanged across supported and unsupported configurations', () => { diff --git a/apps/web/src/lib/charts/net-worth.ts b/apps/web/src/lib/reports/networth/chart.ts similarity index 94% rename from apps/web/src/lib/charts/net-worth.ts rename to apps/web/src/lib/reports/networth/chart.ts index 8487eb8..00e9dbd 100644 --- a/apps/web/src/lib/charts/net-worth.ts +++ b/apps/web/src/lib/reports/networth/chart.ts @@ -1,9 +1,9 @@ -import type { OverviewPageData } from '#lib/overview/data.js'; +import type { OverviewData } from '@hledger-reports/hledger'; import type { ChartConfiguration } from 'chart.js'; -import type { ChartTheme } from './theme.js'; +import type { ChartTheme } from '#lib/charts/theme.js'; export function netWorthChart( - netWorth: OverviewPageData['netWorth'], + netWorth: OverviewData['netWorth'], theme: ChartTheme ): ChartConfiguration<'line', (number | null)[], string> | null { const { currency, points } = netWorth; diff --git a/apps/web/src/lib/settings/JournalSummary.svelte b/apps/web/src/lib/settings/JournalSummary.svelte new file mode 100644 index 0000000..fcd353a --- /dev/null +++ b/apps/web/src/lib/settings/JournalSummary.svelte @@ -0,0 +1,155 @@ + + + +
+
+

Journal

+
+ {#if journalSummary} +
+
Full date span
+
+
+ {#if journalSummary.journalCoverage.firstDate !== null && journalSummary.journalCoverage.lastDate !== null} + + – + + {:else} + No recorded dates + {/if} +
+

Includes posting and secondary dates.

+
+
+
+
Total transactions
+
+ {journalSummary.journalCoverage.transactionCount.toLocaleString('en-US')} +
+
+ {:else} +
+
Coverage
+
No saved journal coverage available.
+
+ {/if} +
+
Last recorded strict check
+
+
+
+
+ +
+

Saved report

+ {#if journalSummary} +
+
+
Generated
+
+ +
+
+
+
hledger version
+
{journalSummary.hledgerVersion}
+
+
+
Report currency
+
+ {#if journalSummary.currency === null} + No dated valuation + {:else if journalSummary.currency === ''} + Unnamed commodity + {:else}{journalSummary.currency}{/if} +
+
+
+ {:else} +

No saved report available.

+ {/if} +
+
+
+ + diff --git a/apps/web/src/lib/settings/ValidationStatus.svelte b/apps/web/src/lib/settings/ValidationStatus.svelte new file mode 100644 index 0000000..54f871b --- /dev/null +++ b/apps/web/src/lib/settings/ValidationStatus.svelte @@ -0,0 +1,32 @@ + + +
+

+ {#if readFailed} + Unavailable + {:else if validation} + {#if validation.outcome === 'passed'} + Passed + {:else} + Could not validate + {/if} + · + + {:else} + Not recorded + {/if} +

+
diff --git a/apps/web/src/lib/settings/ValidationStatus.test.ts b/apps/web/src/lib/settings/ValidationStatus.test.ts new file mode 100644 index 0000000..da299ab --- /dev/null +++ b/apps/web/src/lib/settings/ValidationStatus.test.ts @@ -0,0 +1,44 @@ +import { render } from 'svelte/server'; +import { expect, it } from 'vitest'; +import ValidationStatus from './ValidationStatus.svelte'; + +function text(body: string) { + return body + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +it('presents the last completed pass with its own UTC time, without claiming journal freshness', () => { + const completedAt = '2026-05-01T12:01:00Z'; + const { body } = render(ValidationStatus, { + props: { validation: { outcome: 'passed', completedAt }, readFailed: false } + }); + expect(text(body)).toBe('Passed · May 1, 2026, 12:01:00 PM UTC'); + expect(body).toContain(`datetime="${completedAt}"`); +}); + +it('describes a completed unsuccessful check without calling the accounting invalid', () => { + const completedAt = '2026-05-02T12:01:00Z'; + const { body } = render(ValidationStatus, { + props: { + validation: { outcome: 'could-not-validate', completedAt }, + readFailed: false + } + }); + expect(text(body)).toBe('Could not validate · May 2, 2026, 12:01:00 PM UTC'); + expect(body).toContain(`datetime="${completedAt}"`); +}); + +it('distinguishes missing evidence from a read failure without a timestamp or stale pass', () => { + const missing = render(ValidationStatus, { props: { validation: null, readFailed: false } }); + const failed = render(ValidationStatus, { + props: { + validation: { outcome: 'passed', completedAt: '2026-05-01T12:01:00Z' }, + readFailed: true + } + }); + expect(text(missing.body)).toBe('Not recorded'); + expect(text(failed.body)).toBe('Unavailable'); + expect(missing.body + failed.body).not.toContain(' 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 AppHeader from '#lib/navigation/AppHeader.svelte'; import type { LayoutProps } from './$types'; let { children, data }: Pick = $props(); + const url = $derived(new URL(page.url.href));
-
- - -
- -
-
+ {@render children()}
@@ -50,30 +33,6 @@ display: none; } - .app-header { - min-width: 0; - max-width: none; - margin: 0; - padding: var(--space-medium); - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--space-medium); - 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); - } - [aria-current='page'] { - font-weight: var(--font-weight-strong); - } - .utility { - margin-inline-start: auto; - } @media (min-width: 80rem) { .app-shell { padding-inline-end: var(--accounts-width); @@ -88,7 +47,6 @@ 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); diff --git a/apps/web/src/routes/(protected)/+page.server.ts b/apps/web/src/routes/(protected)/+page.server.ts index f881e54..27a9352 100644 --- a/apps/web/src/routes/(protected)/+page.server.ts +++ b/apps/web/src/routes/(protected)/+page.server.ts @@ -1,67 +1,86 @@ -import { readSavedReports, readSavedValidation } from '#lib/server/reports.js'; -import type { OverviewPageData } from '#lib/overview/data.js'; +import { readSavedReports } from '#lib/server/reports.js'; +import type { OverviewMonths, OverviewPageData, OverviewRange } from '#lib/overview/data.js'; +import { parseReportMonths } from '#lib/report-period/period-query.js'; +import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; +function projectAmounts(amounts: OverviewRange['income']): OverviewRange['income'] { + return amounts.map(({ commodity, quantity }) => ({ commodity, quantity })); +} + +function projectRange(range: OverviewRange): OverviewRange { + return { + begin: range.begin, + endExclusive: range.endExclusive, + transactionCount: range.transactionCount, + income: projectAmounts(range.income), + expenses: projectAmounts(range.expenses), + expenseCategories: { + depth: range.expenseCategories.depth, + rows: range.expenseCategories.rows.map((row) => ({ + account: row.account, + amounts: projectAmounts(row.amounts) + })) + } + }; +} + export const load = (async (event) => { event.setHeaders({ 'cache-control': 'no-store' }); + let months: OverviewMonths; + try { + months = parseReportMonths(event.url.searchParams); + } catch { + error(400, 'Invalid report period.'); + } await event.parent(); const { application } = event.locals; if (!application) { return { configured: false, + months, overview: null, - readFailed: false, - validation: null, - validationReadFailed: false + readFailed: false }; } - const [{ publication, readFailed }, { validation, readFailed: validationReadFailed }] = - await Promise.all([ - readSavedReports(event.locals), - readSavedValidation(application.paths.snapshotDirectory, application.config.journal) - ]); + const { publication, readFailed } = await readSavedReports(event.locals); const saved = publication?.overview; - const overview: OverviewPageData | null = saved - ? { - asOf: saved.asOf, - preparedAt: saved.preparedAt, - journalCoverage: { - transactionCount: saved.journalCoverage.transactionCount, - firstDate: saved.journalCoverage.firstDate, - lastDate: saved.journalCoverage.lastDate - }, - expenseCategories: { - depth: saved.ranges === null ? 2 : saved.ranges[6].expenseCategories.depth, - rows: (saved.ranges === null ? [] : saved.ranges[6].expenseCategories.rows).map( - (row) => ({ - account: row.account, - amounts: row.amounts.map(({ commodity, quantity }) => ({ commodity, quantity })) - }) - ) - }, - incomeExpenses: { - months: saved.incomeExpenses.months.slice(-6).map((month) => ({ - begin: month.begin, - endExclusive: month.endExclusive, - income: month.income.map(({ commodity, quantity }) => ({ commodity, quantity })), - expenses: month.expenses.map(({ commodity, quantity }) => ({ commodity, quantity })) - })) - }, - netWorth: { - currency: saved.netWorth.currency, - points: saved.netWorth.points.slice(-6).map((point) => ({ - date: point.date, - amounts: point.amounts.map(({ commodity, quantity }) => ({ commodity, quantity })), - unconverted: point.unconverted - })) - } + let overview: OverviewPageData | null = null; + if (saved) { + let ranges: OverviewPageData['ranges'] = null; + if (saved.ranges !== null) { + ranges = { + 1: projectRange(saved.ranges[1]), + 3: projectRange(saved.ranges[3]), + 6: projectRange(saved.ranges[6]), + 12: projectRange(saved.ranges[12]) + }; + } + overview = { + asOf: saved.asOf, + ranges, + incomeExpenses: { + months: saved.incomeExpenses.months.map((month) => ({ + begin: month.begin, + endExclusive: month.endExclusive, + income: projectAmounts(month.income), + expenses: projectAmounts(month.expenses) + })) + }, + netWorth: { + currency: saved.netWorth.currency, + points: saved.netWorth.points.slice(-1).map((point) => ({ + date: point.date, + amounts: projectAmounts(point.amounts), + unconverted: [...point.unconverted] + })) } - : null; + }; + } return { configured: true, + months, overview, - readFailed, - validation, - validationReadFailed + readFailed }; }) satisfies PageServerLoad; diff --git a/apps/web/src/routes/(protected)/+page.svelte b/apps/web/src/routes/(protected)/+page.svelte index 9b44715..5368a63 100644 --- a/apps/web/src/routes/(protected)/+page.svelte +++ b/apps/web/src/routes/(protected)/+page.svelte @@ -1,27 +1,45 @@ Overview — hledger reports

Overview

+ {#if overview} +
+ {#if period?.range && overview.asOf} +

+ + – +

+ {/if} + +
+ {/if}
{#if !data.configured} @@ -33,18 +51,55 @@

{:else} - -

Settings — prepare saved reports.

{#if data.readFailed}

Saved reports couldn't be read.

{/if} - {#if overview} - - - - - {:else if !data.readFailed} + {#if !overview && !data.readFailed}

No prepared reports available.

{/if} +
+ {#if overview && period} + + {/if} +
+ {#if overview && period} + + + {/if} +
+
{/if}
+ + diff --git a/apps/web/src/routes/(protected)/page.server.test.ts b/apps/web/src/routes/(protected)/page.server.test.ts index 24fcc60..46d3eff 100644 --- a/apps/web/src/routes/(protected)/page.server.test.ts +++ b/apps/web/src/routes/(protected)/page.server.test.ts @@ -20,18 +20,21 @@ vi.mock('yaml', () => { }); const journal = '/PRIVATE/main.journal'; -const nonSixMonthRange: OverviewRange = { - transactionCount: 9, - begin: '2025-05-01', - endExclusive: '2026-04-17', - income: [{ commodity: 'PRIVATE income', quantity: '1' }], - expenses: [{ commodity: 'PRIVATE expenses', quantity: '2' }], - net: [{ commodity: 'PRIVATE net', quantity: '3' }], - expenseCategories: { - depth: 2, - rows: [{ account: 'PRIVATE non-six category', amounts: [] }] - } -}; + +function range(begin: string, quantity: string, transactionCount: number): OverviewRange { + return { + begin, + endExclusive: '2026-04-17', + transactionCount, + income: [{ commodity: 'USD', quantity }], + expenses: [{ commodity: 'USD', quantity: '2' }], + net: [{ commodity: 'USD', quantity: '-1' }], + expenseCategories: { + depth: 2, + rows: [{ account: 'expenses:food', amounts: [{ commodity: 'USD', quantity: '2' }] }] + } + }; +} const historyDates = [ ['2025-05-01', '2025-06-01', '2025-05-31'], ['2025-06-01', '2025-07-01', '2025-06-30'], @@ -54,37 +57,26 @@ const points: OverviewData['netWorth']['points'] = historyDates.map(([, , date]) amounts: [], unconverted: [] })); -const lastDroppedMonth = months[5]; -lastDroppedMonth.income = [{ commodity: 'DROPPED income', quantity: '1' }]; -const lastDroppedPoint = points[5]; -lastDroppedPoint.unconverted = ['DROPPED stock']; -const firstRetainedMonth = months[6]; -firstRetainedMonth.income = [{ commodity: 'USD', quantity: '9007199254740993.000000000000000001' }]; +months[0].income = [{ commodity: 'USD', quantity: '9007199254740993.000000000000000001' }]; +points[0].unconverted = ['EARLY STOCK']; const latestPoint = points[11]; latestPoint.amounts = [{ commodity: 'USD', quantity: '0.000000000000000001' }]; latestPoint.unconverted = ['STOCK']; const overview: OverviewData = { - hledgerVersion: '1.99.5', ranges: { - 1: nonSixMonthRange, - 3: nonSixMonthRange, - 6: { - ...nonSixMonthRange, - begin: '2025-11-01', - expenseCategories: { - depth: 2, - rows: [{ account: 'expenses:six-month', amounts: [{ commodity: 'USD', quantity: '6' }] }] - } - }, - 12: nonSixMonthRange + 1: range('2026-04-01', '1', 0), + 3: range('2026-02-01', '3', 2), + 6: range('2025-11-01', '6', 4), + 12: range('2025-05-01', '12', 9) }, balanceSheet: { - assets: { amounts: [{ commodity: 'PRIVATE assets', quantity: '7' }], unconverted: [] }, - liabilities: { amounts: [], unconverted: ['PRIVATE liabilities'] } + assets: { amounts: [{ commodity: 'USD', quantity: '7' }], unconverted: [] }, + liabilities: { amounts: [], unconverted: ['DEBT'] } }, netWorth: { currency: 'USD', points }, asOf: '2026-04-16', preparedAt: '2026-10-21T12:00:00Z', + hledgerVersion: '1.99.5', journalCoverage: { transactionCount: 9, firstDate: '2026-04-01', lastDate: '2026-04-16' }, incomeExpenses: { months } }; @@ -111,9 +103,10 @@ function event( options: { application?: ApplicationContext | null; journal?: string; + search?: string; } = {} ) { - const url = new URL('http://localhost:5173/'); + const url = new URL(`http://localhost:5173/${options.search ?? ''}`); const application: ApplicationContext | null = options.application === undefined ? { @@ -158,78 +151,86 @@ beforeEach(() => { it('returns an unconfigured shell without reads when application is unavailable', async () => { expect(await load(event({ application: null }))).toEqual({ configured: false, + months: 3, overview: null, - readFailed: false, - validation: null, - validationReadFailed: false + readFailed: false }); expect(readReportPublication).not.toHaveBeenCalled(); expect(readValidation).not.toHaveBeenCalled(); }); -it('passively returns only rendered six-month fields from the complete saved publication', async () => { - const privateSource = { - ...publication, - overview: { - ...overview, - provenance: '/PRIVATE/source', - journalCoverage: { ...overview.journalCoverage, fingerprint: 'PRIVATE identity' } - } - }; +it('passively whitelists income history and only the latest net-worth point, without Settings metadata', async () => { + const privateSource: typeof publication = JSON.parse( + JSON.stringify(publication), + (_key, value) => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? { ...value, provenance: '/PRIVATE/source' } + : value + ); vi.mocked(readReportPublication).mockResolvedValue(privateSource); - const request = event(); + const request = event({ search: '?months=12' }); const data = await load(request); + const ranges = Object.fromEntries( + Object.entries(overview.ranges!).map(([months, range]) => [ + months, + { + begin: range.begin, + endExclusive: range.endExclusive, + transactionCount: range.transactionCount, + income: range.income, + expenses: range.expenses, + expenseCategories: range.expenseCategories + } + ]) + ); expect(data).toEqual({ configured: true, + months: 12, overview: { - asOf: '2026-04-16', - preparedAt: '2026-10-21T12:00:00Z', - journalCoverage: { transactionCount: 9, firstDate: '2026-04-01', lastDate: '2026-04-16' }, - expenseCategories: { - depth: 2, - rows: [{ account: 'expenses:six-month', amounts: [{ commodity: 'USD', quantity: '6' }] }] - }, - incomeExpenses: { months: expect.any(Array) }, - netWorth: { currency: 'USD', points: expect.any(Array) } + asOf: overview.asOf, + ranges, + incomeExpenses: overview.incomeExpenses, + netWorth: { currency: overview.netWorth.currency, points: overview.netWorth.points.slice(-1) } }, - readFailed: false, - validation: null, - validationReadFailed: false - }); - const renderedMonths = data.overview!.incomeExpenses.months; - const renderedPoints = data.overview!.netWorth.points; - expect(renderedMonths).toHaveLength(6); - expect(renderedPoints).toHaveLength(6); - expect(renderedMonths[0]).toEqual({ - begin: '2025-11-01', - endExclusive: '2025-12-01', - income: [{ commodity: 'USD', quantity: '9007199254740993.000000000000000001' }], - expenses: [] - }); - expect(renderedMonths[5]).toEqual({ - begin: '2026-04-01', - endExclusive: '2026-05-01', - income: [], - expenses: [] - }); - expect(renderedPoints[0]).toEqual({ date: '2025-11-30', amounts: [], unconverted: [] }); - expect(renderedPoints[5]).toEqual({ - date: '2026-04-16', - amounts: [{ commodity: 'USD', quantity: '0.000000000000000001' }], - unconverted: ['STOCK'] + readFailed: false }); + expect(data.overview!.incomeExpenses.months).toHaveLength(12); + expect(data.overview!.netWorth.points).toHaveLength(1); + expect(readValidation).not.toHaveBeenCalled(); + expect(data.overview).not.toHaveProperty('preparedAt'); + expect(data.overview).not.toHaveProperty('journalCoverage'); + expect(data.overview).not.toHaveProperty('balanceSheet'); + expect(data.overview!.ranges![3]).not.toHaveProperty('net'); + expect(request.parent).toHaveBeenCalledOnce(); expect(readReportPublication).toHaveBeenCalledExactlyOnceWith('/PRIVATE/context/snapshots'); expect(request.setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); - expect(JSON.stringify(data)).not.toMatch( - /PRIVATE|DROPPED|directory|accounts|transactions|fingerprint/ - ); + expect(JSON.stringify(data)).not.toMatch(/PRIVATE|directory|accounts|transactions|provenance/); +}); +it('rejects invalid period input before parent or saved reads', async () => { + const request = event({ search: '?months=3&months=12' }); + await expect(load(request)).rejects.toMatchObject({ + status: 400, + body: { message: 'Invalid report period.' } + }); + expect(request.parent).not.toHaveBeenCalled(); + expect(readLocals).not.toHaveBeenCalled(); + expect(readReportPublication).not.toHaveBeenCalled(); + expect(readValidation).not.toHaveBeenCalled(); +}); +it('preserves the requested period even without application or reports', async () => { + expect(await load(event({ search: '?months=1', application: null }))).toMatchObject({ + months: 1, + overview: null + }); + vi.mocked(readReportPublication).mockResolvedValue(null); + expect(await load(event({ search: '?months=6' }))).toMatchObject({ months: 6, overview: null }); }); it('keeps an undated publication empty without inventing categories or history', async () => { vi.mocked(readReportPublication).mockResolvedValue({ ...publication, overview: { - hledgerVersion: '1.99.5', asOf: null, preparedAt: overview.preparedAt, + hledgerVersion: '1.99.5', journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, ranges: null, balanceSheet: null, @@ -239,9 +240,7 @@ it('keeps an undated publication empty without inventing categories or history', }); expect((await load(event())).overview).toEqual({ asOf: null, - preparedAt: overview.preparedAt, - journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, - expenseCategories: { depth: 2, rows: [] }, + ranges: null, incomeExpenses: { months: [] }, netWorth: { currency: null, points: [] } }); @@ -254,10 +253,9 @@ it.each(['missing-or-unsupported', 'other-journal'])( ); expect(await load(event())).toEqual({ configured: true, + months: 3, overview: null, - readFailed: false, - validation: null, - validationReadFailed: false + readFailed: false }); } ); @@ -265,33 +263,8 @@ it('sanitizes read failures without automatic repair', async () => { vi.mocked(readReportPublication).mockRejectedValue(new Error('PRIVATE 123 USD')); expect(await load(event())).toEqual({ configured: true, + months: 3, overview: null, - readFailed: true, - validation: null, - validationReadFailed: false + readFailed: true }); }); - -it('loads saved validation independently without private identity or financial preparation claims', async () => { - const validation = { outcome: 'passed' as const, completedAt: '2026-10-22T12:00:00Z' }; - vi.mocked(readValidation).mockResolvedValue({ - format: 1, - journal, - request: { id: 'PRIVATE request', createdAt: '2026-10-22T11:00:00Z' }, - result: validation - }); - const passed = await load(event()); - expect(passed).toMatchObject({ readFailed: false, validation, validationReadFailed: false }); - expect(passed.overview?.asOf).toBe('2026-04-16'); - expect(passed.validation).toEqual(validation); - expect(JSON.stringify(passed)).not.toMatch(/PRIVATE|request|format/); - expect(readValidation).toHaveBeenCalledExactlyOnceWith('/PRIVATE/context/snapshots'); - vi.mocked(readValidation).mockRejectedValue(new Error('PRIVATE corrupt validation')); - const failed = await load(event()); - expect(failed).toMatchObject({ - readFailed: false, - validation: null, - validationReadFailed: true - }); - expect(failed.overview?.asOf).toBe('2026-04-16'); -}); diff --git a/apps/web/src/routes/(protected)/reports/networth/+page.server.ts b/apps/web/src/routes/(protected)/reports/networth/+page.server.ts new file mode 100644 index 0000000..2bf9aa1 --- /dev/null +++ b/apps/web/src/routes/(protected)/reports/networth/+page.server.ts @@ -0,0 +1,32 @@ +import { error } from '@sveltejs/kit'; +import { readSavedReports } from '#lib/server/reports.js'; +import { parseReportMonths } from '#lib/report-period/period-query.js'; +import type { OverviewMonths } from '@hledger-reports/hledger'; +import type { PageServerLoad } from './$types'; + +export const load = (async (event) => { + event.setHeaders({ 'cache-control': 'no-store' }); + let months: OverviewMonths; + try { + months = parseReportMonths(event.url.searchParams); + } catch { + error(400, 'Invalid report period.'); + } + await event.parent(); + const { publication, readFailed } = await readSavedReports(event.locals); + const saved = publication?.overview; + const report = saved + ? { + asOf: saved.asOf, + netWorth: { + currency: saved.netWorth.currency, + points: saved.netWorth.points.map((point) => ({ + date: point.date, + amounts: point.amounts.map(({ commodity, quantity }) => ({ commodity, quantity })), + unconverted: [...point.unconverted] + })) + } + } + : null; + return { report, months, configured: event.locals.application !== null, readFailed }; +}) satisfies PageServerLoad; diff --git a/apps/web/src/routes/(protected)/reports/networth/+page.svelte b/apps/web/src/routes/(protected)/reports/networth/+page.svelte new file mode 100644 index 0000000..dc90f66 --- /dev/null +++ b/apps/web/src/routes/(protected)/reports/networth/+page.svelte @@ -0,0 +1,79 @@ + + + + Net worth — hledger reports + + + +
+
+

Net worth

+ {#if report} +
+ {#if report.asOf} +

As of

+ {/if} + +
+ {/if} +
+ + {#if !data.configured} +
+

No configuration selected.

+

+ Set CONFIG_PATH on the server to select a journal configuration. No personal configuration + is loaded by default. +

+
+ {:else if data.readFailed} +

Saved reports couldn't be read.

+

+ Visit Settings to prepare reports explicitly. +

+ {:else if report && netWorth} + + {:else} +

No prepared reports available.

+

+ Visit Settings to prepare reports explicitly. +

+ {/if} +
+ + diff --git a/apps/web/src/routes/(protected)/reports/networth/page.server.test.ts b/apps/web/src/routes/(protected)/reports/networth/page.server.test.ts new file mode 100644 index 0000000..c4b68a3 --- /dev/null +++ b/apps/web/src/routes/(protected)/reports/networth/page.server.test.ts @@ -0,0 +1,244 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { readReportPublication, type ReportPublication } from '@hledger-reports/snapshots'; +import { testConfig } from '#lib/server/application.fixture.js'; +import type { ApplicationContext } from '#lib/server/application.js'; +import { readSavedReports } from '#lib/server/reports.js'; +import { load } from './+page.server.js'; + +vi.mock('@hledger-reports/snapshots', () => ({ readReportPublication: vi.fn() })); +vi.mock('@hledger-reports/jobs', () => { + throw new Error('Browsing must not load jobs.'); +}); +vi.mock('@hledger-reports/hledger', () => { + throw new Error('Browsing must not load native reports.'); +}); +vi.mock('yaml', () => { + throw new Error('Browsing must not read config or inputs.'); +}); + +const calendar = [ + ['2025-01-01', '2025-02-01', '2025-01-31'], + ['2025-02-01', '2025-03-01', '2025-02-28'], + ['2025-03-01', '2025-04-01', '2025-03-31'], + ['2025-04-01', '2025-05-01', '2025-04-30'], + ['2025-05-01', '2025-06-01', '2025-05-31'], + ['2025-06-01', '2025-07-01', '2025-06-30'], + ['2025-07-01', '2025-08-01', '2025-07-31'], + ['2025-08-01', '2025-09-01', '2025-08-31'], + ['2025-09-01', '2025-10-01', '2025-09-30'], + ['2025-10-01', '2025-11-01', '2025-10-31'], + ['2025-11-01', '2025-12-01', '2025-11-30'], + ['2025-12-01', '2026-01-01', '2025-12-31'] +]; +const publication: ReportPublication = { + journal: testConfig.journal, + directory: '/PRIVATE/completed-set', + accounts: [ + { + id: 'private1', + name: 'PRIVATE account', + access: 'ready', + rowCount: 1, + summary: { + status: 'ready', + balances: [], + valuation: { amounts: [], unconverted: [] } + } + } + ], + overview: { + asOf: '2025-12-31', + preparedAt: '2026-01-01T12:00:00Z', + hledgerVersion: '1.99.5', + journalCoverage: { + transactionCount: 1, + firstDate: '2025-12-31', + lastDate: '2025-12-31' + }, + ranges: { + 1: { + begin: '2025-12-01', + endExclusive: '2026-01-01', + transactionCount: 1, + income: [], + expenses: [], + net: [], + expenseCategories: { depth: 2, rows: [] } + }, + 3: { + begin: '2025-10-01', + endExclusive: '2026-01-01', + transactionCount: 1, + income: [], + expenses: [], + net: [], + expenseCategories: { depth: 2, rows: [] } + }, + 6: { + begin: '2025-07-01', + endExclusive: '2026-01-01', + transactionCount: 1, + income: [], + expenses: [], + net: [], + expenseCategories: { depth: 2, rows: [] } + }, + 12: { + begin: '2025-01-01', + endExclusive: '2026-01-01', + transactionCount: 1, + income: [], + expenses: [], + net: [], + expenseCategories: { depth: 2, rows: [] } + } + }, + incomeExpenses: { + months: calendar.map(([begin, endExclusive]) => ({ + begin, + endExclusive, + income: [], + expenses: [] + })) + }, + balanceSheet: { + assets: { amounts: [], unconverted: [] }, + liabilities: { amounts: [], unconverted: [] } + }, + netWorth: { + currency: 'USD', + points: calendar.map(([, , date], index) => ({ + date, + amounts: [{ commodity: 'USD', quantity: '-9007199254740993.000000000000000001' }], + unconverted: index === 11 ? ['STOCK', ''] : [] + })) + } + } +}; + +const readLocals = vi.fn<(application: ApplicationContext | null) => App.Locals>(); +function event(search = '', configured = true, parent = vi.fn().mockResolvedValue({})) { + const application: ApplicationContext | null = configured + ? { + config: testConfig, + paths: { + jobsDirectory: '/PRIVATE/jobs', + snapshotDirectory: '/PRIVATE/snapshots' + }, + get auth(): never { + throw new Error('The route must not access the auth runtime.'); + }, + get jobsClient(): never { + throw new Error('Browsing must not consult the queue.'); + } + } + : null; + let locals: App.Locals | undefined; + return { + url: new URL(`http://localhost/reports/networth${search}`), + get locals() { + return (locals ??= readLocals(application)); + }, + parent, + setHeaders: vi.fn() + } as unknown as Parameters[0]; +} + +beforeEach(() => { + vi.resetAllMocks(); + readLocals.mockImplementation((application) => ({ + application, + session: null, + auth: async () => null + })); + vi.mocked(readReportPublication).mockResolvedValue(publication); +}); + +it('privately projects saved history from the publication already pinned by parent', async () => { + const privateSource: ReportPublication = JSON.parse(JSON.stringify(publication), (_key, value) => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? { ...value, provenance: '/PRIVATE/source' } + : value + ); + vi.mocked(readReportPublication).mockResolvedValueOnce(privateSource).mockResolvedValue(null); + const parent = vi.fn(); + const request = event('?months=6', true, parent); + parent.mockImplementation(async () => { + await readSavedReports(request.locals); + return {}; + }); + const data = await load(request); + expect(data).toEqual({ + report: { asOf: publication.overview.asOf, netWorth: publication.overview.netWorth }, + months: 6, + configured: true, + readFailed: false + }); + expect(data.report?.netWorth.points).toHaveLength(12); + expect(request.parent).toHaveBeenCalledOnce(); + expect(readReportPublication).toHaveBeenCalledExactlyOnceWith('/PRIVATE/snapshots'); + expect(request.setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); + expect(JSON.stringify(data)).not.toMatch( + /PRIVATE|provenance|directory|journal|accounts|private1|preparedAt|hledgerVersion|balanceSheet|incomeExpenses|ranges/ + ); +}); + +it('rejects an invalid period before parent or locals can read saved data', async () => { + const request = event('?months=3&months=12'); + await expect(load(request)).rejects.toMatchObject({ + status: 400, + body: { message: 'Invalid report period.' } + }); + expect(request.setHeaders).toHaveBeenCalledWith({ 'cache-control': 'no-store' }); + expect(request.parent).not.toHaveBeenCalled(); + expect(readLocals).not.toHaveBeenCalled(); + expect(readReportPublication).not.toHaveBeenCalled(); +}); + +it('defaults a bare report to three months without configuration or saved reads', async () => { + const request = event('', false); + expect(await load(request)).toEqual({ + report: null, + months: 3, + configured: false, + readFailed: false + }); + expect(request.parent).toHaveBeenCalledOnce(); + expect(readReportPublication).not.toHaveBeenCalled(); +}); + +it('keeps a missing publication distinct from a sanitized read failure without requesting work', async () => { + vi.mocked(readReportPublication).mockResolvedValueOnce(null); + expect(await load(event('?months=1'))).toEqual({ + report: null, + months: 1, + configured: true, + readFailed: false + }); + vi.mocked(readReportPublication).mockRejectedValueOnce(new Error('PRIVATE failure details')); + expect(await load(event('?months=12'))).toEqual({ + report: null, + months: 12, + configured: true, + readFailed: true + }); +}); + +it('preserves an undated saved report without inventing valuations', async () => { + vi.mocked(readReportPublication).mockResolvedValue({ + ...publication, + overview: { + ...publication.overview, + asOf: null, + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + ranges: null, + incomeExpenses: { months: [] }, + balanceSheet: null, + netWorth: { currency: null, points: [] } + } + }); + expect((await load(event())).report).toEqual({ + asOf: null, + netWorth: { currency: null, points: [] } + }); +}); diff --git a/apps/web/src/routes/(protected)/settings/+page.server.ts b/apps/web/src/routes/(protected)/settings/+page.server.ts index 618396a..2c83353 100644 --- a/apps/web/src/routes/(protected)/settings/+page.server.ts +++ b/apps/web/src/routes/(protected)/settings/+page.server.ts @@ -7,6 +7,7 @@ import { fail, redirect } from '@sveltejs/kit'; import { InvalidBody, readRequestText } from '#lib/server/http.js'; import { readSavedReports, readSavedValidation } from '#lib/server/reports.js'; import type { JournalValidationResult } from '@hledger-reports/snapshots'; +import type { JournalSummaryData } from '#lib/settings/data.js'; import type { Actions, PageServerLoad } from './$types'; export const load = (async (event) => { @@ -16,7 +17,7 @@ export const load = (async (event) => { const { application } = event.locals; const configured = application !== null; let preparation: ReportsPreparationState | 'unavailable' = 'idle'; - let preparedAt: string | null = null; + let journalSummary: JournalSummaryData | null = null; let validation: JournalValidationResult | null = null; let validationReadFailed = false; if (application) { @@ -30,11 +31,23 @@ export const load = (async (event) => { readSavedReports(event.locals), readSavedValidation(application.paths.snapshotDirectory, application.config.journal) ]); - preparedAt = reports.publication?.overview.preparedAt ?? null; + if (reports.publication) { + const { overview } = reports.publication; + journalSummary = { + preparedAt: overview.preparedAt, + journalCoverage: { + transactionCount: overview.journalCoverage.transactionCount, + firstDate: overview.journalCoverage.firstDate, + lastDate: overview.journalCoverage.lastDate + }, + hledgerVersion: overview.hledgerVersion, + currency: overview.netWorth.currency + }; + } validation = savedValidation.validation; validationReadFailed = savedValidation.readFailed; } - return { configured, preparation, preparedAt, validation, validationReadFailed }; + return { configured, preparation, journalSummary, validation, validationReadFailed }; }) satisfies PageServerLoad; export const actions = { diff --git a/apps/web/src/routes/(protected)/settings/+page.svelte b/apps/web/src/routes/(protected)/settings/+page.svelte index 73b9016..033982a 100644 --- a/apps/web/src/routes/(protected)/settings/+page.svelte +++ b/apps/web/src/routes/(protected)/settings/+page.svelte @@ -1,6 +1,6 @@