Present period-aware Overview and dedicated financial pages #42

Merged
eleith merged 3 commits from feat/period-aware-overview into main 2026-11-07 05:01:21 +00:00
139 changed files with 5275 additions and 2349 deletions

No files matched your search

+32
View File
@@ -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.
+2 -3
View File
@@ -50,12 +50,11 @@ apps/*/config/**
**/results
**/*.sqlite*
**/*.journal
**/*.journals
**/*.ledger
**/*.csv
**/*.tsv
**/config/*.yaml
**/config/*.yml
# Keep the existing correctness fixture and safe configuration example.
!apps/worker/fixtures/lots.journal
!apps/worker/fixtures/config/hledger-reports.yaml
# Test journals are generated in temporary directories, never part of the image context.
!apps/web/config/hledger-reports.example.yaml
+11 -23
View File
@@ -64,7 +64,7 @@ jobs:
run: pnpm --filter @hledger-reports/web lint
- name: Lint workspace tooling
run: |
pnpm exec prettier --check package.json pnpm-workspace.yaml tsconfig.base.json .prettierrc tools/hooks.mjs tools/register.mjs README.md docs/development.md docs/authentication.md .github .woodpecker .changeset
pnpm exec prettier --check package.json pnpm-workspace.yaml tsconfig.base.json .prettierrc tools/hooks.js tools/register.js README.md docs/development.md docs/authentication.md .github .woodpecker .changeset
sh -n tools/install-hledger.sh
- name: Test
run: pnpm --filter @hledger-reports/web test
@@ -81,12 +81,11 @@ jobs:
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- name: Stage owned configuration and journal for container tests
run: |
umask 077
mkdir -p apps/web/journals
cp apps/worker/fixtures/lots.journal apps/web/journals/main.journal
cp apps/web/config/hledger-reports.example.yaml apps/web/config/hledger-reports.yaml
- uses: actions/setup-node@v6
with:
node-version: 26.5.0
- name: Prepare isolated fictional browser fixture
run: node apps/web/e2e/prepare.js
- name: Validate Compose
run: docker compose config --quiet
- name: Build compiled fixture images
@@ -95,19 +94,9 @@ jobs:
id: data-directory
run: |
docker compose run --rm --no-deps --user 0:0 --entrypoint node \
-v "$PWD/apps/web/config/hledger-reports.yaml:/ci/config.yaml" \
-v "$PWD/apps/web/journals:/ci/journals" worker -e '
const fs = require("node:fs");
if (fs.readdirSync("/app/data").length) throw Error("Expected fresh CI fixture data");
for (const path of ["/app/data", "/ci/journals"]) {
fs.chownSync(path, 1000, 1000);
fs.chmodSync(path, 0o700);
}
for (const path of ["/ci/config.yaml", "/ci/journals/main.journal"]) {
fs.chownSync(path, 1000, 1000);
fs.chmodSync(path, 0o600);
}
'
-v "$PWD/apps/web/.cache/browser-fixture:/fixture" \
-v "$PWD/apps/web/e2e/permissions.js:/app/e2e/permissions.js:ro" \
worker /app/e2e/permissions.js
- name: Start compiled web and fixture worker
run: docker compose up --wait --wait-timeout 120 web worker
- name: Browser smoke test
@@ -121,9 +110,8 @@ jobs:
docker compose down --volumes --remove-orphans
if [ '${{ steps.data-directory.outcome }}' = success ]; then
docker compose run --rm --no-deps --user 0:0 --entrypoint chown \
-v "$PWD/apps/web/config/hledger-reports.yaml:/ci/config.yaml" \
-v "$PWD/apps/web/journals:/ci/journals" \
worker -R "$(id -u):$(id -g)" /app/data /ci/config.yaml /ci/journals
-v "$PWD/apps/web/.cache/browser-fixture:/fixture" \
worker -R "$(id -u):$(id -g)" /fixture
fi
build-push:
+3
View File
@@ -20,6 +20,9 @@ test-results/
/apps/web/data/
/apps/web/config/*
!/apps/web/config/hledger-reports.example.yaml
*.journal
*.journals
*.ledger
.env
.env.*
!.env.example
+2 -1
View File
@@ -63,7 +63,7 @@ steps:
- pnpm --filter @hledger-reports/config check:schema
- pnpm --filter @hledger-reports/web check
- pnpm --filter @hledger-reports/web lint
- pnpm exec prettier --check package.json pnpm-workspace.yaml tsconfig.base.json .prettierrc tools/hooks.mjs tools/register.mjs README.md docs/development.md docs/authentication.md .github .woodpecker .changeset
- pnpm exec prettier --check package.json pnpm-workspace.yaml tsconfig.base.json .prettierrc tools/hooks.js tools/register.js README.md docs/development.md docs/authentication.md .github .woodpecker .changeset
- sh -n tools/install-hledger.sh
test:
@@ -86,6 +86,7 @@ steps:
commands:
- pnpm config set store-dir .pnpm-store
- export PATH="$PWD/.cache/ci-bin:$PATH"
# Playwright owns server.js startup/teardown and the shared fictional fixture.
- pnpm --filter @hledger-reports/web test:e2e
check-version:
+53 -122
View File
@@ -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 and existing format-6 sets need no additional preparation. See [Accounts usage](apps/web/README.md#accounts-navigation) and [ownership and lifecycle](docs/architecture.md#shared-saved-reads-and-account-navigation).
**Settings (`/settings`) owns inline report preparation and status.** Submit its ordinary **Prepare reports** form to prepare Overview and all account registers together. Reload Settings to check this deployment's queue status, then navigate to saved reports. `/prepare` has been removed, with no redirect or POST alias. Worker startup and journal edits do not prepare anything automatically. After editing your journal, prepare again when you want new results. There is no browser polling or Overview/Transactions API.
Overview and Settings show the **last recorded strict validation outcome and time**, separately from when financial reports were prepared. Reading either never checks the journal; unavailable validation does not hide saved finances. See [validation and preparation](docs/architecture.md#last-recorded-validation) for their independent lifecycles.
**`/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/<account-id>.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.
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.
+187 -233
View File
@@ -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/<id>`, `/registers/investments/<id>` | 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/<id>`, `/registers/investments/<id>` | 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,140 +179,51 @@ data/snapshots/current/
transactions/<account-id>.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 **6** retains unchanged full exported
transaction/posting metadata and private source/assertion positions. Register loaders
project only the selected page's public table/detail fields; stored records are never
passed directly into PageData.
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).
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.
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.
+34 -3
View File
@@ -1,12 +1,43 @@
# Test-only HTTP boundary for the compiled image; never use for production traffic.
# Base config/journal/data mounts are unchanged: use an isolated fictional fixture.
# prepare.js exclusively creates this fictional layout; never mount operator paths.
services:
web:
command: ['node', 'e2e/web.mjs']
command: ['node', 'e2e/web.js']
environment:
PROTOCOL_HEADER: x-forwarded-proto
ADDRESS_HEADER: x-real-ip
HOST_HEADER: ''
PORT_HEADER: ''
volumes:
- ./e2e/web.mjs:/app/e2e/web.mjs:ro
- type: bind
source: ./.cache/browser-fixture/config/hledger-reports.yaml
target: /app/config/hledger-reports.yaml
read_only: true
bind:
create_host_path: false
- type: bind
source: ./.cache/browser-fixture/data
target: /app/data
bind:
create_host_path: false
- ./e2e/web.js:/app/e2e/web.js:ro
worker:
volumes:
- type: bind
source: ./.cache/browser-fixture/config/hledger-reports.yaml
target: /app/config/hledger-reports.yaml
read_only: true
bind:
create_host_path: false
- type: bind
source: ./.cache/browser-fixture/journal
target: /journal
read_only: true
bind:
create_host_path: false
- type: bind
source: ./.cache/browser-fixture/data
target: /app/data
bind:
create_host_path: false
+107 -1
View File
@@ -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$/);
File renamed without changes.
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import { browserCredentials } from './auth-fixture.mjs';
import { browserCredentials } from './auth-fixture.js';
test.use({ storageState: { cookies: [], origins: [] } });
+1 -1
View File
@@ -1,7 +1,7 @@
import { request, type FullConfig } from '@playwright/test';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { browserCredentials } from './auth-fixture.mjs';
import { browserCredentials } from './auth-fixture.js';
export default async function setup(config: FullConfig) {
const { baseURL, storageState } = config.projects[0].use;
+61
View File
@@ -0,0 +1,61 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { lotsJournal } from '../../worker/fixtures/journal.ts';
import { ownedAuthSecret, ownedCredentials } from './auth-fixture.js';
import { categoryAllocationJournal } from './fixtures/category-allocation.js';
/**
* Materialize one fictional deployment, never an operator's config or data.
* The caller must supply an absent directory whose parent already exists.
* @param {string} directory
* @param {{ journal?: string, auth?: { secret: string, credentials: { username: string, password: string } } }} [options]
*/
export async function prepareBrowserFixture(
directory,
{ journal, auth = { secret: ownedAuthSecret, credentials: ownedCredentials } } = {}
) {
directory = resolve(directory);
// This must stay outside the cleanup block: an existing directory is not ours.
await mkdir(directory, { mode: 0o700 });
try {
for (const part of ['config', 'journal', 'journal/parts', 'data']) {
await mkdir(join(directory, part), { mode: 0o700 });
}
const sourceJournal = join(directory, 'journal', 'main.journal');
const configPath = join(directory, 'config', 'hledger-reports.yaml');
const files = [
[join(directory, 'journal', 'parts', 'base.journal'), lotsJournal],
[
join(directory, 'journal', 'parts', 'category-allocation.journal'),
categoryAllocationJournal
],
[sourceJournal, 'include parts/*.journal\n'],
[
configPath,
JSON.stringify({
version: 1,
journal: journal ?? sourceJournal,
accounts: [
{ account: 'assets:bank:checking', label: 'Everyday checking', register: 'cash' },
{ account: 'assets:investments:brokerage', label: 'Brokerage', register: 'investment' },
{ account: 'assets:investments:retirement', register: 'investment' }
],
url: { app: 'http://127.0.0.1:3000' },
auth
}) + '\n'
]
];
for (const [path, content] of files) {
await writeFile(path, content, { flag: 'wx', mode: 0o600 });
}
// Written last: permissions tooling accepts only a completed owned deployment.
await writeFile(join(directory, '.fixture'), 'hledger-browser-fixture\n', {
flag: 'wx',
mode: 0o600
});
return { directory, configPath };
} catch (error) {
await rm(directory, { recursive: true, force: true });
throw error;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, expect, it } from 'vitest';
import { prepareBrowserFixture } from './fixture.js';
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))
);
});
async function destination() {
const parent = await mkdtemp(join(tmpdir(), 'browser-fixture-test-'));
directories.push(parent);
return join(parent, 'deployment');
}
it('creates a private fictional layout while preserving container paths and secret references', async () => {
const directory = await destination();
const auth = {
secret: '${AUTH_SECRET}',
credentials: { username: 'fictional-reader', password: '${AUTH_PASSWORD}' }
};
const { configPath } = await prepareBrowserFixture(directory, {
journal: '/journal/main.journal',
auth
});
expect(JSON.parse(await readFile(configPath, 'utf8'))).toMatchObject({
journal: '/journal/main.journal',
auth
});
expect(await readFile(join(directory, '.fixture'), 'utf8')).toBe('hledger-browser-fixture\n');
expect(await readFile(join(directory, 'journal', 'main.journal'), 'utf8')).toBe(
'include parts/*.journal\n'
);
expect(await readdir(join(directory, 'journal', 'parts'))).toEqual([
'base.journal',
'category-allocation.journal'
]);
expect(await readdir(join(directory, 'data'))).toEqual([]);
expect((await stat(directory)).mode & 0o777).toBe(0o700);
expect((await stat(configPath)).mode & 0o777).toBe(0o600);
});
it('refuses an existing deployment without modifying or deleting its contents', async () => {
const directory = await destination();
await mkdir(directory);
const sentinel = join(directory, 'existing');
await writeFile(sentinel, 'leave untouched');
await expect(prepareBrowserFixture(directory)).rejects.toMatchObject({ code: 'EEXIST' });
expect(await readFile(sentinel, 'utf8')).toBe('leave untouched');
expect(await readdir(directory)).toEqual(['existing']);
});
@@ -0,0 +1,24 @@
export const categoryAllocationJournal = `; 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
`;
+4 -4
View File
@@ -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);
});
+284
View File
@@ -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);
});
+125 -20
View File
@@ -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']);
});
+10 -15
View File
@@ -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<string> {
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<string> {
.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<string> {
{ 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<string> {
// 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<string> {
+56
View File
@@ -0,0 +1,56 @@
import { chown, chmod, lstat, readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
// Standalone root-container entry point, inside /app's explicit ES module package.
// No configurable targets: this can touch only the marked owned /fixture mount.
async function main() {
if (process.argv.length !== 2 || process.getuid?.() !== 0) {
throw new Error('Fixture permissions require a root container without arguments.');
}
const root = '/fixture';
const marker = 'hledger-browser-fixture\n';
if (!(await lstat(root)).isDirectory()) throw new Error('Fixture mount must be a directory.');
const markerPath = join(root, '.fixture');
const markerStat = await lstat(markerPath);
if (
!markerStat.isFile() ||
markerStat.size !== Buffer.byteLength(marker) ||
(await readFile(markerPath, 'utf8')) !== marker
) {
throw new Error('Fixture ownership marker is missing or invalid.');
}
/** @type {{ path: string, directory: boolean }[]} */
const entries = [];
/** @param {string} path */
async function walk(path) {
const stat = await lstat(path);
if (!stat.isDirectory() && !stat.isFile()) {
throw new Error('Fixture contains a symlink or nonregular entry.');
}
entries.push({ path, directory: stat.isDirectory() });
if (stat.isDirectory()) {
for (const name of await readdir(path)) await walk(join(path, name));
}
}
// Validate the entire tree before changing any ownership or permissions.
await walk(root);
const data = join(root, 'data');
if (!(await lstat(data)).isDirectory() || (await readdir(data)).length !== 0) {
throw new Error('Fixture data must be an empty directory.');
}
for (const { path, directory } of entries) {
// Keep host-owned parents traversable by Compose when it resolves file mounts.
if (path !== root && path !== join(root, 'config')) await chown(path, 1000, 1000);
await chmod(path, directory ? 0o700 : 0o600);
}
}
try {
await main();
} catch {
console.error(
'Browser fixture permissions failed. Require a completed marked /fixture mount with empty data and no symlinks or nonregular entries, in a root container without arguments.'
);
process.exitCode = 1;
}
+45
View File
@@ -0,0 +1,45 @@
import { lstat, mkdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { prepareBrowserFixture } from './fixture.js';
async function main() {
const root = fileURLToPath(new URL('../../../', import.meta.url));
if (process.cwd() !== resolve(root) || process.argv.length !== 2) {
throw new Error('Run node apps/web/e2e/prepare.js from the repository root without arguments.');
}
const username = process.env.PLAYWRIGHT_USERNAME;
if (!username?.trim() || username.includes('${')) {
throw new Error('Set an explicit fictional PLAYWRIGHT_USERNAME before preparing the fixture.');
}
const cache = join(root, 'apps', 'web', '.cache');
try {
await mkdir(cache, { mode: 0o700 });
} catch (error) {
if (!(error instanceof Error) || !('code' in error) || error.code !== 'EEXIST') throw error;
// Never follow an operator-controlled cache symlink to another deployment.
if (!(await lstat(cache)).isDirectory())
throw new Error('Browser cache must be a directory.', { cause: error });
}
await prepareBrowserFixture(join(cache, 'browser-fixture'), {
journal: '/journal/main.journal',
auth: {
secret: '${AUTH_SECRET}',
credentials: { username, password: '${AUTH_PASSWORD}' }
}
});
console.log('Prepared apps/web/.cache/browser-fixture for the fictional browser deployment.');
}
// Importing the helper or CLI never materializes a deployment.
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await main();
} catch {
// Do not print environment values, config contents, or unsanitized errors.
console.error(
'Browser fixture preparation failed. Run from the repository root without arguments, set an explicit fictional PLAYWRIGHT_USERNAME, and use an absent apps/web/.cache/browser-fixture directory. Existing files were not overwritten.'
);
process.exitCode = 1;
}
}
@@ -1,40 +1,17 @@
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { copyFile, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { ownedAuthSecret, ownedCredentials } from './auth-fixture.mjs';
import { prepareBrowserFixture } from './fixture.js';
// App-owned test deployment: no personal config and no reused SQLite/data files.
const deployment =
process.env.HLEDGER_TEST_DEPLOYMENT ?? (await mkdtemp(join(tmpdir(), 'hledger-browser-')));
// Exclusive creation: never reuse or later remove a caller's existing directory.
if (process.env.HLEDGER_TEST_DEPLOYMENT) await mkdir(deployment, { mode: 0o700 });
const configPath = join(deployment, 'config', 'hledger-reports.yaml');
const journal = join(deployment, 'journal', 'main.journal');
await mkdir(join(deployment, 'config'));
await mkdir(join(deployment, 'journal', 'parts'), { recursive: true });
await copyFile(
fileURLToPath(new URL('../../worker/fixtures/lots.journal', import.meta.url)),
join(deployment, 'journal', 'parts', 'base.journal')
);
await writeFile(journal, 'include parts/*.journal\n');
await writeFile(
configPath,
JSON.stringify({
version: 1,
journal,
accounts: [
{ account: 'assets:bank:checking', label: 'Everyday checking', register: 'cash' },
{ account: 'assets:investments:brokerage', label: 'Brokerage', register: 'investment' },
{ account: 'assets:investments:retirement', register: 'investment' }
],
url: { app: 'http://127.0.0.1:3000' },
auth: { secret: ownedAuthSecret, credentials: ownedCredentials }
}) + '\n'
);
process.env.HLEDGER_TEST_DEPLOYMENT ?? join(tmpdir(), `hledger-browser-${randomUUID()}`);
const { configPath } = await prepareBrowserFixture(deployment);
const children = [];
let stopping = false;
@@ -83,7 +60,7 @@ try {
if (!ready) await delay(100);
}
if (!ready) throw new Error('Fixture worker did not become ready.');
start('./web.mjs', {
start('./web.js', {
HOST: '127.0.0.1',
PORT: '3000',
ADDRESS_HEADER: 'x-real-ip',
File renamed without changes.
+2
View File
@@ -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:"
},
+3 -3
View File
@@ -2,10 +2,10 @@ import { defineConfig, devices } from '@playwright/test';
import { randomUUID } from 'node:crypto';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { browserCredentials } from './e2e/auth-fixture.mjs';
import { browserCredentials } from './e2e/auth-fixture.js';
// Compose tests its already-running services; direct/CI runs own an isolated
// temporary deployment via e2e/server.mjs. Neither uses personal configuration.
// temporary deployment via e2e/server.js. Neither uses personal configuration.
const externalServer = process.env.PLAYWRIGHT_BASE_URL;
// External deployments must explicitly provide their own fictional-test login.
browserCredentials();
@@ -36,7 +36,7 @@ export default defineConfig({
webServer: externalServer
? undefined
: {
command: 'node e2e/server.mjs',
command: 'node e2e/server.js',
url: 'http://127.0.0.1:3000/healthz',
reuseExistingServer: false,
timeout: 60_000
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="16x16 32x32 48x48" />
%sveltekit.head%
</head>
<body>
+16 -5
View File
@@ -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<ChartTheme>();
const config = $derived(theme ? makeConfig(theme) : undefined);
@@ -65,17 +67,17 @@
});
</script>
<figure bind:this={figure}>
<figcaption>{label}</figcaption>
<figure bind:this={figure} class:fill={height === 'fill'}>
<figcaption title={label}>{label}</figcaption>
{#if config === null}
<p>
This chart cannot represent all reported amounts for this commodity; see the exact table
below. Amounts have not been combined or replaced.
</p>
{:else if config}
<div class="chart" style:height={`${height}px`}>
<div class="chart" style:height={height === 'fill' ? undefined : `${height}px`}>
<!-- svelte-ignore a11y_no_interactive_element_to_noninteractive_role (This canvas is a static chart image; exact data is in the table.) -->
<canvas bind:this={canvas} role="img" aria-label={label}>
<canvas bind:this={canvas} role="img" aria-label={label} aria-describedby={describedBy}>
Exact amounts are in the table below.
</canvas>
</div>
@@ -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;
}
@@ -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: '生活 <Daily>' },
{ account: 'Living:Food', label: '生活 <Daily>' },
{ account: 'Outgoings', label: 'Not a prefix' }
])!;
expect(config.data.labels).toEqual([
'生活 <Daily>',
'Expenses',
'生活 <Daily>',
'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);
});
@@ -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;
}
}
}
}
}
};
}
@@ -9,29 +9,32 @@ it('renders extreme bars and lines with gaps and constant values without hanging
process.execPath,
[
'--import',
fileURLToPath(new URL('../../../../../tools/register.mjs', import.meta.url)),
fileURLToPath(new URL('../../../../../tools/register.js', import.meta.url)),
'--input-type=module',
'-e',
`
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);
+6 -1
View File
@@ -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');
});
+16
View File
@@ -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);
@@ -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);
}
</script>
@@ -115,7 +115,9 @@
<a
class="group-link"
href={directoryHref}
aria-current={pathname === directoryHref ? 'page' : undefined}
aria-current={url.pathname === new URL(directoryHref, url).pathname
? 'page'
: undefined}
onclick={handleNavigate}>{group.kind === 'cash' ? 'Cash' : 'Investments'}</a
>
</h3>
@@ -134,7 +136,7 @@
<a
class="account-row"
{href}
aria-current={pathname === href ? 'page' : undefined}
aria-current={url.pathname === new URL(href, url).pathname ? 'page' : undefined}
onclick={handleNavigate}
>
{@render accountContents(account)}
@@ -151,8 +153,15 @@
{:else}
<p class="empty-accounts">No Cash or Investment accounts configured.</p>
{/if}
{#if sidebar.groups.length > 0 || sidebar.netWorth !== null}
<section class="net-worth" aria-label="Net worth">
<section class="net-worth" aria-label="Net worth">
<a
class="net-worth-link"
href={resolve('/(protected)/reports/networth')}
aria-current={url.pathname === new URL(resolve('/(protected)/reports/networth'), url).pathname
? 'page'
: undefined}
onclick={handleNavigate}
>
<div class="total-row">
<div class="total-label">
<h3>Net worth</h3>
@@ -185,8 +194,8 @@
{/if}
</div>
</div>
</section>
{/if}
</a>
</section>
</div>
<style>
@@ -206,7 +215,9 @@
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-small);
padding-block-end: var(--space-medium);
min-block-size: calc(var(--app-header-height) + env(safe-area-inset-top));
padding-block: var(--space-tight);
padding-block-start: calc(var(--space-tight) + env(safe-area-inset-top));
}
.accounts-heading {
flex: 1;
@@ -216,9 +227,7 @@
color: var(--color-muted);
font-size: 0.75rem;
line-height: var(--line-section);
}
.balance-cutoff {
margin-block-start: var(--space-tight);
margin-block-start: var(--space-tiny);
}
.account-group {
border-block-start: var(--border-width) solid var(--color-border);
@@ -238,10 +247,13 @@
text-decoration: none;
}
.group-link:hover,
.group-link[aria-current='page'] {
.group-link[aria-current='page'],
.net-worth-link:hover,
.net-worth-link[aria-current='page'] {
background: var(--color-background);
}
.group-link[aria-current='page'] {
.group-link[aria-current='page'],
.net-worth-link[aria-current='page'] {
box-shadow: inset var(--focus-width) 0 var(--color-accent);
}
ul {
@@ -278,7 +290,8 @@
box-shadow: inset var(--focus-width) 0 var(--color-accent);
}
a.account-row:focus-visible,
.group-link:focus-visible {
.group-link:focus-visible,
.net-worth-link:focus-visible {
outline: var(--focus-width) solid var(--color-accent);
outline-offset: calc(0px - var(--focus-width));
}
@@ -318,7 +331,10 @@
p {
margin: 0;
}
.net-worth {
.net-worth-link {
display: block;
color: inherit;
text-decoration: none;
padding-block: var(--space-small);
padding-inline: var(--accounts-inset-start, var(--space-medium))
var(--accounts-inset-end, var(--space-medium));
@@ -1,7 +1,41 @@
import { render } from 'svelte/server';
import { expect, it } from 'vitest';
import { afterEach, expect, it, vi } from 'vitest';
import type { AccountSidebarData } from './data.js';
import AccountSidebar from './AccountSidebar.svelte';
import AppHeader from './AppHeader.svelte';
const paths = vi.hoisted(() => ({
base: '',
url: new URL('https://reports.example.test/')
}));
vi.mock('$app/paths', () => ({
asset: (file: string) => `./${file}`,
// Model Kit 3 SSR relative hrefs using the current request depth after its configured base.
resolve: (route: string, params: Record<string, string> = {}) => {
let path = route.replace(/\/\([^)]+\)/g, '');
for (const [key, value] of Object.entries(params)) {
path = path.replace(`[${key}]`, encodeURIComponent(value));
}
const afterBase = paths.url.pathname.slice(paths.base.length);
const prefix =
afterBase
.split('/')
.slice(2)
.map(() => '..')
.join('/') || '.';
return `${prefix}${path || '/'}`;
}
}));
afterEach(() => {
paths.base = '';
paths.url = new URL('https://reports.example.test/');
});
function requestUrl(pathname: string) {
paths.url = new URL(pathname, 'https://reports.example.test');
return paths.url;
}
function text(body: string) {
return body
@@ -12,6 +46,13 @@ function text(body: string) {
.replace(/\s+([.,])/g, '$1');
}
function currentHrefs(body: string) {
return Array.from(
body.matchAll(/<a\b[^>]*href="([^"]+)"[^>]*aria-current="page"/g),
([, href]) => href
);
}
function links(body: string) {
return Array.from(
body.matchAll(/<a\b[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g),
@@ -72,28 +113,31 @@ it('formats exact native bags and incomplete whole-journal net worth without res
]
};
const { body } = render(AccountSidebar, {
props: { sidebar, pathname: '/registers/cash/bank0001' }
props: { sidebar, url: requestUrl('/registers/cash/bank0001') }
});
const content = text(body);
expect(links(body)).toEqual([
{ href: '/registers/cash', content: 'Cash' },
{ href: '/registers/cash/bank0001', content: 'Checking assets:bank $8,520.00' },
{ href: '../../registers/cash', content: 'Cash' },
{ href: '../../registers/cash/bank0001', content: 'Checking assets:bank $8,520.00' },
{
href: '/registers/cash/ambig001',
href: '../../registers/cash/ambig001',
content: 'Ambiguous ASSETS Ambiguous account; register access is limited.'
},
{ href: '/registers/investments', content: 'Investments' },
{ href: '../../registers/investments', content: 'Investments' },
{
href: '/registers/investments/broker01',
href: '../../registers/investments/broker01',
content:
'Brokerage assets:broker Incomplete valuation; unconverted: XYZ, no commodity $42.000000000000000001 -3.25 XYZ'
},
{
href: '../../reports/networth',
content:
'Net worth Incomplete valuation; unconverted: XYZ, no commodity $9,007,199,254,740,993.000000000000000001 -3.25 XYZ'
}
]);
expect(content).toContain('Missing missing Account unavailable in saved reports.');
expect(content).toContain(
'Net worth Incomplete valuation; unconverted: XYZ, no commodity $9,007,199,254,740,993.000000000000000001 -3.25 XYZ'
);
expect(body).toContain('aria-label="Net worth"');
expect(body).not.toMatch(/<a\b[^>]*aria-label="Net worth"/);
expect(currentHrefs(body)).toEqual(['../../registers/cash/bank0001']);
expect(content).toContain('Accounts Balances as of Apr 30, 2026');
expect(body).toContain('datetime="2026-04-30"');
});
@@ -126,14 +170,14 @@ it('distinguishes known empty, undated and unavailable values without inventing
}
]
};
const saved = render(AccountSidebar, { props: { sidebar, pathname: '/' } });
const saved = render(AccountSidebar, { props: { sidebar, url: requestUrl('/') } });
expect(links(saved.body)).toEqual([
{ href: '/registers/cash', content: 'Cash' },
{ href: '/registers/cash/empty001', content: 'assets:retirement No balance' },
{ href: '/registers/cash/undated1', content: 'Undated No dated balance.' }
{ href: './registers/cash', content: 'Cash' },
{ href: './registers/cash/empty001', content: 'assets:retirement No balance' },
{ href: './registers/cash/undated1', content: 'Undated No dated balance.' },
{ href: './reports/networth', content: 'Net worth No dated balance.' }
]);
expect(text(saved.body)).toContain('Accounts No dated balances');
expect(text(saved.body)).toContain('Net worth No dated balance.');
expect(text(saved.body)).not.toMatch(/\b0\b/);
const failed = render(AccountSidebar, {
@@ -151,13 +195,16 @@ it('distinguishes known empty, undated and unavailable values without inventing
}
]
},
pathname: '/'
url: requestUrl('/')
}
});
expect(text(failed.body)).toContain("Saved account values couldn't be read.");
expect(text(failed.body)).toContain('Net worth Balance unavailable.');
expect(text(failed.body)).not.toMatch(/No balance|\b0\b/);
expect(links(failed.body)).toEqual([{ href: '/registers/cash', content: 'Cash' }]);
expect(links(failed.body)).toEqual([
{ href: './registers/cash', content: 'Cash' },
{ href: './reports/networth', content: 'Net worth Balance unavailable.' }
]);
});
it('shows prepared whole-journal net worth independently of configured rows, preserving native zero and empty meanings', () => {
@@ -168,16 +215,16 @@ it('shows prepared whole-journal net worth independently of configured rows, pre
netWorth: { amounts: [{ commodity: 'USD', quantity: '0' }], unconverted: [] },
groups: []
};
const zero = render(AccountSidebar, { props: { sidebar, pathname: '/' } });
const zero = render(AccountSidebar, { props: { sidebar, url: requestUrl('/') } });
expect(text(zero.body)).toContain('No Cash or Investment accounts configured.');
expect(text(zero.body)).toContain('Balances as of Apr 30, 2026');
expect(text(zero.body)).toContain('Net worth $0.00');
expect(zero.body).not.toContain('aria-label="Account registers"');
expect(links(zero.body)).toEqual([{ href: './reports/networth', content: 'Net worth $0.00' }]);
const empty = render(AccountSidebar, {
props: {
sidebar: { ...sidebar, netWorth: { amounts: [], unconverted: [] } },
pathname: '/'
url: requestUrl('/')
}
});
expect(text(empty.body)).toContain('Net worth No balance');
@@ -186,9 +233,51 @@ it('shows prepared whole-journal net worth independently of configured rows, pre
const unconfigured = render(AccountSidebar, {
props: {
sidebar: { ...sidebar, asOf: null, hasSavedReports: false, netWorth: null },
pathname: '/'
url: requestUrl('/')
}
});
expect(text(unconfigured.body)).toContain('Accounts No prepared account values available.');
expect(text(unconfigured.body)).toContain('No Cash or Investment accounts configured.');
expect(links(unconfigured.body)).toEqual([
{ href: './reports/networth', content: 'Net worth Balance unavailable.' }
]);
});
it('keeps header current-page state independent of relative hrefs, including a configured base', () => {
const sidebar: AccountSidebarData = {
asOf: null,
hasSavedReports: false,
readFailed: false,
netWorth: null,
groups: []
};
const overview = render(AppHeader, { props: { sidebar, url: requestUrl('/') } });
expect(currentHrefs(overview.body)).toEqual(['./']);
paths.base = '/finance';
const settings = render(AppHeader, { props: { sidebar, url: requestUrl('/finance/settings') } });
expect(currentHrefs(settings.body)).toEqual(['./settings']);
});
it('uses base-prefixed pathnames for register directories and the always-available Net worth destination', () => {
paths.base = '/finance';
const sidebar: AccountSidebarData = {
asOf: null,
hasSavedReports: false,
readFailed: false,
netWorth: null,
groups: [{ kind: 'investment', accounts: [] }]
};
const directory = render(AccountSidebar, {
props: { sidebar, url: requestUrl('/finance/registers/investments') }
});
expect(currentHrefs(directory.body)).toEqual(['../registers/investments']);
const netWorth = render(AccountSidebar, {
props: { sidebar, url: requestUrl('/finance/reports/networth') }
});
expect(currentHrefs(netWorth.body)).toEqual(['../reports/networth']);
expect(links(netWorth.body)).toContainEqual({
href: '../reports/networth',
content: 'Net worth Balance unavailable.'
});
});
@@ -1,22 +1,40 @@
<script lang="ts">
import { flushSync, onMount } from 'svelte';
import { Dialog } from 'bits-ui';
import { beforeNavigate } from '$app/navigation';
import IconSidebar from 'virtual:icons/ph/sidebar-simple';
import IconX from 'virtual:icons/ph/x';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import type { AccountSidebarData } from './data.js';
import AccountSidebar from './AccountSidebar.svelte';
let { sidebar, pathname }: { sidebar: AccountSidebarData; pathname: string } = $props();
let { sidebar, url }: { sidebar: AccountSidebarData; url: URL } = $props();
let ready = $state(false);
let desktop = $state(false);
let open = $state(false);
let trigger = $state<HTMLElement | null>(null);
let closeReason: 'dismiss' | 'navigate' | 'resize' = 'dismiss';
let drawerLinkEvent: MouseEvent | null = null;
beforeNavigate(() => {
if (open) closeForNavigation();
});
afterNavigate((navigation) => {
const fromDrawer = navigation.type === 'link' && navigation.event === drawerLinkEvent;
drawerLinkEvent = null;
if (
fromDrawer &&
!open &&
navigation.from &&
navigation.to &&
navigation.from.url.href === navigation.to.url.href
) {
// Kit refreshes same-URL links and resets focus before this callback.
document.querySelector<HTMLElement>('#main')?.focus({ preventScroll: true });
}
});
onMount(() => {
// Keep this in step with the shell's content-width breakpoint.
const wide = window.matchMedia('(min-width: 80rem)');
@@ -36,7 +54,8 @@
return () => wide.removeEventListener('change', updateLayout);
});
function closeForNavigation() {
function closeForNavigation(event?: MouseEvent) {
if (event) drawerLinkEvent = event;
closeReason = 'navigate';
// Finish focus/lock cleanup before the ordinary link reaches the router.
flushSync(() => (open = false));
@@ -46,9 +65,7 @@
event.preventDefault();
if (closeReason === 'dismiss') {
trigger?.focus({ preventScroll: true });
} else {
// Same-URL links may not move focus themselves. Focus main now, never
// after navigation, so a changed destination keeps Kit's normal focus.
} else if (closeReason === 'resize') {
document.querySelector<HTMLElement>('#main')?.focus({ preventScroll: true });
}
}
@@ -59,20 +76,31 @@
<details class="accounts-fallback">
<summary>Accounts</summary>
<div class="fallback-links">
<AccountSidebar {sidebar} {pathname} />
<AccountSidebar {sidebar} {url} />
</div>
</details>
{:else}
<Dialog.Root
bind:open
onOpenChange={(next) => {
if (next) closeReason = 'dismiss';
if (next) {
closeReason = 'dismiss';
drawerLinkEvent = null;
}
}}
>
{#if !desktop}
<Dialog.Trigger bind:ref={trigger}>
{#snippet child({ props })}
<button {...props} type="button" class="ui-button">Accounts</button>
<button
{...props}
type="button"
class="ui-icon-button"
aria-label="Accounts"
title="Accounts"
>
<IconSidebar aria-hidden="true" />
</button>
{/snippet}
</Dialog.Trigger>
{/if}
@@ -85,7 +113,7 @@
<Dialog.Content onCloseAutoFocus={restoreFocus} restoreScrollDelay={0}>
{#snippet child({ props })}
<div {...props} class="accounts-drawer">
<AccountSidebar {sidebar} {pathname} onNavigate={closeForNavigation}>
<AccountSidebar {sidebar} {url} onNavigate={closeForNavigation}>
{#snippet heading()}
<Dialog.Title level={2}>
{#snippet child({ props: titleProps })}
@@ -96,8 +124,15 @@
{#snippet actions()}
<Dialog.Close>
{#snippet child({ props: closeProps })}
<button {...closeProps} type="button" class="ui-button">Close accounts</button
<button
{...closeProps}
type="button"
class="ui-icon-button"
aria-label="Close accounts"
title="Close accounts"
>
<IconX aria-hidden="true" />
</button>
{/snippet}
</Dialog.Close>
{/snippet}
@@ -120,7 +155,7 @@
}
summary {
min-height: var(--control-height);
padding: var(--space-small);
padding: var(--space-tight);
color: var(--color-accent);
cursor: pointer;
}
@@ -143,7 +178,6 @@
min-width: 0;
height: 100dvh;
padding-inline: 0;
padding-block-start: max(var(--space-medium), env(safe-area-inset-top));
padding-block-end: max(var(--space-medium), env(safe-area-inset-bottom));
background: var(--color-surface);
color: var(--color-text);
@@ -0,0 +1,101 @@
<script lang="ts">
import { asset, resolve } from '$app/paths';
import IconSignOut from 'virtual:icons/ph/sign-out';
import AccountsDrawer from './AccountsDrawer.svelte';
import type { AccountSidebarData } from './data.js';
let { sidebar, url }: { sidebar: AccountSidebarData; url: URL } = $props();
</script>
<header class="app-header">
<nav aria-label="Primary">
<a class="brand" href={resolve('/(protected)')} aria-label="Overview home">
<img src={asset('branding/app-icon.png')} alt="" width="32" height="32" />
</a>
<a
class="nav-link"
href={resolve('/(protected)')}
aria-current={url.pathname === new URL(resolve('/(protected)'), url).pathname
? 'page'
: undefined}>Overview</a
>
<a
class="nav-link"
href={resolve('/(protected)/settings')}
aria-current={url.pathname === new URL(resolve('/(protected)/settings'), url).pathname
? 'page'
: undefined}>Settings</a
>
</nav>
<div class="actions">
<AccountsDrawer {sidebar} {url} />
<form method="POST" action={resolve('/(public)/(auth)/signout')}>
<button type="submit" class="ui-icon-button" aria-label="Sign out" title="Sign out">
<IconSignOut aria-hidden="true" />
</button>
</form>
</div>
</header>
<style>
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-tight);
min-width: 0;
min-block-size: calc(var(--app-header-height) + env(safe-area-inset-top));
padding-block: var(--space-tight);
padding-block-start: calc(var(--space-tight) + env(safe-area-inset-top));
padding-inline: max(var(--space-small), env(safe-area-inset-left))
max(var(--space-small), env(safe-area-inset-right));
border-bottom: var(--border-width) solid var(--color-border);
}
nav,
.actions {
display: flex;
align-items: center;
min-width: 0;
}
nav {
flex-wrap: wrap;
}
.actions {
gap: var(--space-tiny);
margin-inline-start: auto;
}
.brand,
.nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--control-height);
border-radius: var(--radius-control);
text-decoration: none;
}
.brand {
width: var(--control-height);
flex-shrink: 0;
}
img {
display: block;
}
.nav-link {
padding-inline: var(--space-tight);
color: var(--color-muted);
font-size: var(--font-small);
}
.nav-link[aria-current='page'] {
color: var(--color-text);
font-weight: var(--font-weight-strong);
}
.nav-link:hover,
.brand:hover {
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
}
form {
display: flex;
margin: 0;
}
</style>
@@ -1,87 +0,0 @@
<script lang="ts">
import type { OverviewPageData } from '#lib/overview/data.js';
import { accountLabel, type AccountLabel } from '#lib/accounts.js';
import ChartCanvas from '#lib/charts/ChartCanvas.svelte';
import { expenseCategoriesChart } from '#lib/charts/expense-categories.js';
let { overview, labels = [] }: { overview: OverviewPageData; labels?: readonly AccountLabel[] } =
$props();
const categories = $derived(overview.expenseCategories);
const commodities = $derived([
...new Set(categories.rows.flatMap((row) => row.amounts.map((amount) => amount.commodity)))
]);
const chartHeight = $derived(Math.min(800, Math.max(160, categories.rows.length * 32 + 80)));
</script>
<section class="panel" aria-labelledby="expense-categories-heading">
<h2 class="section-title" id="expense-categories-heading">Expenses by category</h2>
{#if overview.asOf === null}
<p>No dated transactions.</p>
{:else}
<p>Six months ending in {overview.asOf.slice(0, 7)} · Account depth {categories.depth}</p>
{#if categories.rows.length === 0}
<p>No reported expenses in this window.</p>
{:else}
{#if commodities.length > 0}
{#each commodities as commodity (commodity)}
<ChartCanvas
label={`Expenses by category in ${commodity === '' ? 'no commodity' : commodity}`}
height={chartHeight}
makeConfig={(theme) =>
expenseCategoriesChart(categories.rows, commodity, theme, labels)}
/>
{/each}
{/if}
<div class="table-scroll">
<table class="report-table" id="expense-categories-table" aria-label="Expenses by account">
<thead>
<tr
><th scope="col">Account</th><th scope="col">Amount</th><th scope="col">Commodity</th
></tr
>
</thead>
<tbody>
{#each categories.rows as row, rowIndex (rowIndex)}
{@const label = accountLabel(row.account, labels)}
{#each row.amounts as amount, amountIndex (amountIndex)}
<tr>
<th scope="row" class="account">
{label}{#if label !== row.account}<small>{` (${row.account})`}</small>{/if}
</th>
<td class="amount">{amount.quantity}</td><td class="commodity"
>{amount.commodity}</td
>
</tr>
{:else}
<tr>
<th scope="row" class="account">
{label}{#if label !== row.account}<small>{` (${row.account})`}</small>{/if}
</th><td colspan="2">No reported amounts</td>
</tr>
{/each}
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</section>
<style>
th,
td {
padding: var(--space-small);
border-bottom: var(--border-width) solid var(--color-border);
text-align: left;
overflow-wrap: anywhere;
}
.account,
.commodity {
white-space: pre-wrap;
}
.amount {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: pre;
}
</style>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { NativeValue } from '@hledger-reports/hledger';
import { formatAmount } from '#lib/amounts.js';
let { value, align = 'end' }: { value: NativeValue | null; align?: 'start' | 'end' } = $props();
</script>
{#if value === null}
<p class="status">No dated value available.</p>
{:else}
{#if value.unconverted.length > 0}
<p class="status">
Incomplete valuation; unconverted:
{#each value.unconverted as commodity, index (index)}
{index > 0 ? ', ' : ''}<span class="commodity"
>{commodity === '' ? 'no commodity' : commodity}</span
>
{/each}
</p>
{/if}
{#if value.amounts.length > 0}
<ul aria-label="Reported amounts" style:text-align={align}>
{#each value.amounts as amount, index (index)}
<li>{formatAmount(amount)}</li>
{/each}
</ul>
{:else}
<p class="status">No reported amounts.</p>
{/if}
{/if}
<style>
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
font-size: clamp(1.25rem, 2vw, 1.75rem);
font-weight: var(--font-weight-strong);
font-variant-numeric: tabular-nums;
text-align: inherit;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.status {
margin: 0;
font-size: var(--font-small);
overflow-wrap: anywhere;
}
.commodity {
white-space: pre-wrap;
}
</style>
@@ -1,108 +0,0 @@
<script lang="ts">
import type { OverviewPageData } from '#lib/overview/data.js';
import ChartCanvas from '#lib/charts/ChartCanvas.svelte';
import { incomeExpensesChart } from '#lib/charts/income-expenses.js';
let { overview }: { overview: OverviewPageData } = $props();
const series = ['income', 'expenses'] as const;
const labels = { income: 'Income', expenses: 'Expenses' };
const commodities = $derived([
...new Set(
overview.incomeExpenses.months.flatMap((month) =>
series.flatMap((kind) => month[kind].map((amount) => amount.commodity))
)
)
]);
</script>
<section class="panel" aria-labelledby="income-heading">
<h2 class="section-title" id="income-heading">Income vs expenses</h2>
{#if overview.asOf !== null}
<p>Six months ending in {overview.asOf.slice(0, 7)}.</p>
<p>Latest recorded transaction: <time datetime={overview.asOf}>{overview.asOf}</time>.</p>
{/if}
<p class="muted">
Prepared at <time aria-label="Financial reports prepared at" datetime={overview.preparedAt}
>{overview.preparedAt}</time
>.
</p>
{#if overview.asOf === null}
<p>No dated transactions.</p>
{:else}
{#if commodities.length > 0}
{#each commodities as commodity (commodity)}
<ChartCanvas
label={`Income and expenses by month in ${commodity === '' ? 'no commodity' : commodity}`}
makeConfig={(theme) =>
incomeExpensesChart(overview.incomeExpenses.months, commodity, theme)}
/>
{/each}
{:else}
<p>No reported income or expenses to chart.</p>
{/if}
<div class="table-scroll">
<table
class="report-table"
id="income-expenses-table"
aria-label="Monthly income and expenses"
>
<thead
><tr
><th scope="col">Month</th><th scope="col">Series</th><th scope="col">Amount</th><th
scope="col">Commodity</th
></tr
></thead
>
<tbody>
{#each overview.incomeExpenses.months as month, monthIndex (monthIndex)}
{#each series as kind (kind)}
{#each month[kind] as amount, index (index)}
<tr>
<th scope="row"
>{month.begin.slice(0, 7)}{monthIndex ===
overview.incomeExpenses.months.length - 1
? ` (through ${overview.asOf})`
: ''}</th
>
<td>{labels[kind]}</td><td class="amount">{amount.quantity}</td><td
class="commodity">{amount.commodity}</td
>
</tr>
{:else}
<tr>
<th scope="row"
>{month.begin.slice(0, 7)}{monthIndex ===
overview.incomeExpenses.months.length - 1
? ` (through ${overview.asOf})`
: ''}</th
>
<td>{labels[kind]}</td><td colspan="2">No reported amounts</td>
</tr>
{/each}
{/each}
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<style>
.muted {
color: var(--color-muted);
}
th,
td {
padding: var(--space-small);
border-bottom: var(--border-width) solid var(--color-border);
text-align: left;
overflow-wrap: anywhere;
}
.commodity {
white-space: pre-wrap;
}
.amount {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: pre;
}
</style>
@@ -1,52 +0,0 @@
<script lang="ts">
import type { OverviewPageData } from '#lib/overview/data.js';
let { overview }: { overview: OverviewPageData } = $props();
const coverage = $derived(overview.journalCoverage);
</script>
<section class="panel" aria-labelledby="journal-coverage-heading">
<h2 class="section-title" id="journal-coverage-heading">Journal coverage</h2>
<dl>
<div>
<dt>Recorded transactions</dt>
<dd>{coverage.transactionCount}</dd>
</div>
<div>
<dt>Journal date range (inclusive)</dt>
<dd>
{#if coverage.firstDate !== null && coverage.lastDate !== null}
<time datetime={coverage.firstDate}>{coverage.firstDate}</time>–<time
datetime={coverage.lastDate}>{coverage.lastDate}</time
>
{:else}
No dated transactions
{/if}
</dd>
</div>
</dl>
</section>
<style>
section {
overflow-wrap: anywhere;
}
dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-small);
}
dt {
color: var(--color-muted);
font-size: var(--font-small);
}
dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
@media (max-width: 40rem) {
dl {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,84 @@
<script lang="ts">
import type { OverviewPageData, OverviewPeriodData } from './data.js';
import FinancialValue from './FinancialValue.svelte';
let { overview, period }: { overview: OverviewPageData; period: OverviewPeriodData } = $props();
const latest = $derived(overview.netWorth.points.at(-1));
const counts = new Intl.NumberFormat('en-US');
const flows = [
{ key: 'income', label: 'Income' },
{ key: 'expenses', label: 'Spending' }
] as const;
</script>
<section aria-label="Key figures">
<dl>
{#each flows as flow (flow.key)}
<div class="panel">
<dt>{flow.label}</dt>
<dd>
<FinancialValue
value={period.range ? { amounts: period.range[flow.key], unconverted: [] } : null}
align="start"
/>
</dd>
</div>
{/each}
<div class="panel">
<dt>Transactions</dt>
<dd>
{#if period.range}
<span class="count">{counts.format(period.range.transactionCount)}</span>
{:else}
<span class="status">No dated period available.</span>
{/if}
</dd>
</div>
<div class="panel">
<dt>Latest net worth</dt>
<dd><FinancialValue value={latest ?? null} align="start" /></dd>
</div>
</dl>
</section>
<style>
section {
container-type: inline-size;
}
dl {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-small);
margin: 0;
}
.panel {
padding: var(--space-small);
gap: var(--space-tight);
}
dt {
font-size: var(--font-small);
color: var(--color-muted);
}
dd {
margin: 0;
}
.count {
font-size: clamp(1.25rem, 2vw, 1.75rem);
font-weight: var(--font-weight-strong);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.status {
font-size: var(--font-small);
}
@container (min-width: 18rem) {
dl {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container (min-width: 48rem) {
dl {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
</style>
@@ -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(/<!--[\s\S]*?-->/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(/<dt\b[^>]*>([\s\S]*?)<\/dt>\s*<dd\b[^>]*>([\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');
});
-102
View File
@@ -1,102 +0,0 @@
<script lang="ts">
import type { OverviewPageData } from '#lib/overview/data.js';
import ChartCanvas from '#lib/charts/ChartCanvas.svelte';
import { netWorthChart } from '#lib/charts/net-worth.js';
let { overview }: { overview: OverviewPageData } = $props();
const netWorth = $derived(overview.netWorth);
const incomplete = $derived(netWorth.points.some((point) => point.unconverted.length > 0));
</script>
<section class="panel" aria-labelledby="net-worth-heading">
<h2 class="section-title" id="net-worth-heading">Net worth</h2>
{#if overview.asOf === null}
<p>No dated transactions.</p>
{:else if netWorth.points.length === 0}
<p>No reported net-worth valuations in this window.</p>
{:else}
<p>
Market value
{#if netWorth.currency !== null}
· <span class="commodity"
>{netWorth.currency === '' ? 'no commodity' : netWorth.currency}</span
>
{/if}
· As of <time datetime={overview.asOf}>{overview.asOf}</time>
</p>
{#if incomplete}
<p>Incomplete valuations: unconverted commodities.</p>
{/if}
{#if netWorth.currency !== null}
<ChartCanvas
label={`Net worth in inferred base currency ${netWorth.currency === '' ? 'no commodity' : netWorth.currency}`}
makeConfig={(theme) => netWorthChart(netWorth, theme)}
/>
{/if}
<div class="table-scroll">
<table class="report-table" id="net-worth-table" aria-label="Net worth by valuation date">
<thead>
<tr>
<th scope="col">Valuation date</th><th scope="col">Amount</th>
<th scope="col">Commodity</th><th scope="col">Status</th>
</tr>
</thead>
<tbody>
{#each netWorth.points as point, pointIndex (pointIndex)}
{#each point.amounts.length > 0 ? point.amounts : [null] as amount, amountIndex (amountIndex)}
<tr>
<th scope="row"><time datetime={point.date}>{point.date}</time></th>
{#if amount !== null}
<td class="amount">{amount.quantity}</td>
<td class="commodity"
>{amount.commodity === '' ? 'no commodity' : amount.commodity}</td
>
{:else}
<td colspan="2">No reported amounts</td>
{/if}
<td>
{#if point.unconverted.length > 0}
Incomplete valuation; unconverted:
{#each point.unconverted as commodity, index (index)}
{index > 0 ? ', ' : ''}<span class="commodity"
>{commodity === '' ? 'no commodity' : commodity}</span
>
{/each}
{:else if point.amounts.length === 0}
No reported valuation
{:else}
No unconverted units
{/if}
</td>
</tr>
{/each}
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<style>
table {
min-width: 36rem;
}
th,
td {
padding: var(--space-small);
border-bottom: var(--border-width) solid var(--color-border);
text-align: left;
overflow-wrap: anywhere;
}
th[scope='row'] {
white-space: nowrap;
}
.commodity {
white-space: pre-wrap;
}
.amount {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: pre;
}
</style>
+17 -6
View File
@@ -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<OverviewData, 'asOf' | 'preparedAt' | 'journalCoverage'> & {
expenseCategories: OverviewRange['expenseCategories'];
incomeExpenses: OverviewData['incomeExpenses'];
netWorth: OverviewData['netWorth'];
export type { OverviewMonths } from '@hledger-reports/hledger';
export type OverviewRange = Omit<PreparedOverviewRange, 'net'>;
export type OverviewPageData = Pick<OverviewData, 'asOf' | 'incomeExpenses' | 'netWorth'> & {
ranges: Record<OverviewMonths, OverviewRange> | null;
};
export interface OverviewPeriodData {
months: OverviewMonths;
asOf: string | null;
range: OverviewRange | null;
incomeExpenses: OverviewData['incomeExpenses'];
}
@@ -0,0 +1,84 @@
<script lang="ts">
import type { OverviewPeriodData } from '#lib/overview/data.js';
import ChartCanvas from '#lib/charts/ChartCanvas.svelte';
import ReportCard from '#lib/report-card/ReportCard.svelte';
import { incomeSpendingChart } from './chart.js';
let { period }: { period: OverviewPeriodData } = $props();
const series = ['income', 'expenses'] as const;
const labels = { income: 'Income', expenses: 'Spending' };
const commodities = $derived([
...new Set(
period.incomeExpenses.months.flatMap((month) =>
series.flatMap((kind) => month[kind].map((amount) => amount.commodity))
)
)
]);
</script>
<ReportCard title="Income and spending" headingId="income-heading">
{#snippet chart()}
{#if period.asOf === null}
<p>No dated transactions.</p>
{:else if commodities.length > 0}
{#each commodities as commodity (commodity)}
<ChartCanvas
label={`Income and spending by month in ${commodity === '' ? 'no commodity' : commodity}`}
height={220}
makeConfig={(theme) =>
incomeSpendingChart(period.incomeExpenses.months, commodity, theme)}
/>
{/each}
{:else}
<p>No reported income or expenses to chart.</p>
{/if}
{/snippet}
{#if period.asOf !== null}
<table class="report-table" id="income-expenses-table" aria-label="Monthly income and spending">
<thead
><tr
><th scope="col">Month</th><th scope="col">Series</th><th scope="col">Amount</th><th
scope="col">Commodity</th
></tr
></thead
>
<tbody>
{#each period.incomeExpenses.months as month, monthIndex (monthIndex)}
{#each series as kind (kind)}
{#each month[kind] as amount, index (index)}
<tr>
<th scope="row">{month.begin.slice(0, 7)}</th>
<td>{labels[kind]}</td><td class="amount">{amount.quantity}</td><td
class="commodity"
>{amount.commodity === '' ? 'no commodity' : amount.commodity}</td
>
</tr>
{:else}
<tr>
<th scope="row">{month.begin.slice(0, 7)}</th>
<td>{labels[kind]}</td><td colspan="2">No reported amounts</td>
</tr>
{/each}
{/each}
{/each}
</tbody>
</table>
{/if}
</ReportCard>
<style>
th,
td {
padding: var(--space-small);
border-bottom: var(--border-width) solid var(--color-border);
text-align: left;
overflow-wrap: anywhere;
}
.commodity {
white-space: pre-wrap;
}
.amount {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: pre;
}
</style>
@@ -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);
});
@@ -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 {
@@ -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: [] }
});
});
@@ -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) }
};
}
@@ -0,0 +1,190 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { OverviewPeriodData } from '#lib/overview/data.js';
import { accountLabel, type AccountLabel } from '#lib/accounts.js';
import ChartCanvas from '#lib/charts/ChartCanvas.svelte';
import ReportCard from '#lib/report-card/ReportCard.svelte';
import { spendingCategoriesChart } from './chart.js';
import { spendingCategoriesPresentation } from './presentation.js';
let { period, labels = [] }: { period: OverviewPeriodData; labels?: readonly AccountLabel[] } =
$props();
let ready = $state(false);
onMount(() => {
ready = true;
});
const categories = $derived(period.range?.expenseCategories);
const commodities = $derived([
...new Set(
(categories?.rows ?? []).flatMap((row) => row.amounts.map((amount) => amount.commodity))
)
]);
let chosenCommodity = $state<string | null>(null);
const selectedCommodity = $derived(
chosenCommodity !== null && commodities.includes(chosenCommodity)
? chosenCommodity
: commodities[0]
);
const presentation = $derived(
ready && categories && selectedCommodity !== undefined
? spendingCategoriesPresentation(categories.rows, selectedCommodity, labels)
: null
);
</script>
<ReportCard title="Spending by category" headingId="expense-categories-heading" containedChart>
{#snippet chart()}
<div class="category-chart">
{#if categories === undefined}
<p>No dated transactions.</p>
{:else if categories.rows.length === 0}
<p>No reported expenses in this window.</p>
{:else if selectedCommodity !== undefined}
<p class="chart-note">Top five; any remainder grouped as Other.</p>
{#if ready && commodities.length > 1}
<label class="commodity-choice">
Currency
<select
aria-label="Spending chart currency"
value={selectedCommodity}
onchange={(event) => (chosenCommodity = event.currentTarget.value)}
>
{#each commodities as commodity (commodity)}
<option value={commodity}>{commodity === '' ? 'no commodity' : commodity}</option>
{/each}
</select>
</label>
{/if}
<ChartCanvas
label={`Spending by category in ${selectedCommodity === '' ? 'no commodity' : selectedCommodity}`}
height="fill"
describedBy={presentation ? 'expense-categories-chart-values' : undefined}
makeConfig={(theme) =>
presentation ? spendingCategoriesChart(presentation, theme) : null}
/>
{/if}
</div>
{/snippet}
{#if categories !== undefined && categories.rows.length > 0}
<table class="report-table" id="expense-categories-table" aria-label="Spending by account">
<thead>
<tr
><th scope="col">Account</th><th scope="col">Amount</th><th scope="col">Commodity</th></tr
>
</thead>
<tbody>
{#each categories.rows as row, rowIndex (rowIndex)}
{@const label = accountLabel(row.account, labels)}
{#each row.amounts as amount, amountIndex (amountIndex)}
<tr>
<th scope="row" class="account">
{label}{#if label !== row.account}<small>{` (${row.account})`}</small>{/if}
</th>
<td class="amount">{amount.quantity}</td><td class="commodity"
>{amount.commodity === '' ? 'no commodity' : amount.commodity}</td
>
</tr>
{:else}
<tr>
<th scope="row" class="account">
{label}{#if label !== row.account}<small>{` (${row.account})`}</small>{/if}
</th><td colspan="2">No reported amounts</td>
</tr>
{/each}
{/each}
</tbody>
</table>
{#if presentation}
<details class="chart-values">
<summary>Chart values</summary>
<div>
<p>Spending by category, in chart order:</p>
<ol id="expense-categories-chart-values">
{#each presentation.bars as bar, index (index)}
<li>
<span>{bar.grouped ? 'Other (grouped)' : bar.label}</span>:
<span>{bar.quantity}</span>
<span
>{presentation.commodity === '' ? 'no commodity' : presentation.commodity}</span
>
</li>
{/each}
</ol>
</div>
</details>
{/if}
{/if}
</ReportCard>
<style>
.category-chart {
display: flex;
flex-direction: column;
gap: var(--space-tight);
block-size: 100%;
min-height: 0;
min-width: 0;
}
.chart-note {
flex-shrink: 0;
color: var(--color-muted);
font-size: 0.75rem;
line-height: 1.2;
}
.commodity-choice {
display: flex;
align-items: center;
gap: var(--space-tight);
flex-shrink: 0;
font-size: var(--font-small);
}
select {
flex: 1;
min-width: 0;
min-block-size: 2rem;
padding: var(--space-compact) var(--space-tight);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
}
.category-chart :global(figcaption) {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.chart-values {
padding: var(--space-small);
font-size: var(--font-small);
}
.chart-values summary {
cursor: pointer;
}
.chart-values ol {
padding-inline-start: 2rem;
list-style: decimal;
}
.chart-values li {
white-space: pre-wrap;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
}
th,
td {
padding: var(--space-small);
border-bottom: var(--border-width) solid var(--color-border);
text-align: left;
overflow-wrap: anywhere;
}
.account,
.commodity {
white-space: pre-wrap;
}
.amount {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: pre;
}
</style>
@@ -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<typeof spendingCategoriesChart>;
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: '生活 <Daily>', quantity: '0.00', grouped: false, coordinate: 0 },
{ label: '生活 <Daily>', 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'
);
});
@@ -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;
}
}
}
}
}
};
}
@@ -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: '生活 <Daily>' },
{ account: rows[2].account, label: '生活 <Daily>' },
{ account: 'Custom root', label: 'Not a prefix' },
{ account: 'Other', label: 'Not the group' }
])!;
expect(aliased.bars.map((bar) => bar.label)).toEqual([
'Other',
'生活 <Daily>',
'生活 <Daily>',
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);
});
@@ -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 };
}
@@ -1,26 +0,0 @@
<script lang="ts">
import type { JournalValidationResult } from '@hledger-reports/snapshots';
let {
validation,
readFailed
}: { validation: JournalValidationResult | null; readFailed: boolean } = $props();
</script>
<section aria-label="Saved journal validation">
{#if readFailed}
<p>Saved journal validation couldn't be read.</p>
{:else if validation}
<p>
Last recorded strict validation: {validation.outcome === 'passed'
? 'Passed'
: 'Could not validate'}. Completed at
<time aria-label="Journal validation completed at" datetime={validation.completedAt}
>{validation.completedAt}</time
>.
</p>
<p>Recorded separately from saved reports.</p>
{:else}
<p>No recorded journal check available.</p>
{/if}
</section>
@@ -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/);
});
@@ -0,0 +1,68 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import ScrollArea from './ScrollArea.svelte';
let {
title,
headingId,
chart,
containedChart = false,
children
}: {
title: string;
headingId: string;
chart?: Snippet;
containedChart?: boolean;
children: Snippet;
} = $props();
</script>
<section class="panel report-card" aria-labelledby={headingId}>
<h2 class="section-title" id={headingId}>{title}</h2>
{#if chart}
<div
class="charts"
role={containedChart ? 'region' : undefined}
aria-label={containedChart ? `${title} charts` : undefined}
>
{#if containedChart}
{@render chart()}
{:else}
<ScrollArea label={`${title} charts`}>{@render chart()}</ScrollArea>
{/if}
</div>
{/if}
<div class="details">
<ScrollArea label={`${title} details`}>{@render children()}</ScrollArea>
</div>
</section>
<style>
.report-card {
block-size: var(--report-card-height, 40rem);
width: 100%;
min-height: 0;
}
h2 {
flex-shrink: 0;
}
.charts,
.details {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.charts {
flex: 0 0 19rem;
block-size: 19rem;
}
.details {
flex: 1;
}
.details :global(thead) {
position: sticky;
inset-block-start: 0;
z-index: 2;
background: var(--color-surface);
}
</style>
@@ -0,0 +1,99 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { ScrollArea } from 'bits-ui';
let { label, children }: { label: string; children: Snippet } = $props();
let ready = $state(false);
const axes = ['vertical', 'horizontal'] as const;
onMount(() => {
ready = true;
});
</script>
{#if ready}
<ScrollArea.Root type="auto">
{#snippet child({ props })}
<div {...props} class="scroll-root">
<ScrollArea.Viewport role="region" aria-label={label} tabindex={0}>
{@render children()}
</ScrollArea.Viewport>
{#each axes as orientation (orientation)}
<ScrollArea.Scrollbar {orientation}>
{#snippet child({ props: barProps })}
<div {...barProps} class="scrollbar">
<ScrollArea.Thumb>
{#snippet child({ props: thumbProps })}
<div {...thumbProps} class="thumb"></div>
{/snippet}
</ScrollArea.Thumb>
</div>
{/snippet}
</ScrollArea.Scrollbar>
{/each}
<ScrollArea.Corner />
</div>
{/snippet}
</ScrollArea.Root>
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Native overflow regions need keyboard focus for scrolling.) -->
<div class="native-region" role="region" aria-label={label} tabindex="0">
{@render children()}
</div>
{/if}
<style>
.scroll-root,
.native-region {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.scroll-root {
padding-inline-end: var(--space-small);
padding-block-end: var(--space-small);
}
.scroll-root :global([data-scroll-area-viewport]) {
width: 100%;
height: 100%;
overscroll-behavior: contain;
}
.scrollbar {
display: flex;
padding: var(--space-tiny);
background: var(--color-border);
user-select: none;
touch-action: none;
z-index: 3;
}
.scrollbar[data-orientation='vertical'] {
width: var(--space-small);
}
.scrollbar[data-orientation='horizontal'] {
flex-direction: column;
height: var(--space-small);
}
.thumb {
flex: 1;
border-radius: var(--radius-control);
background: var(--color-muted);
}
.native-region {
overflow: auto;
overscroll-behavior: contain;
scrollbar-color: var(--color-muted) var(--color-border);
}
.native-region::-webkit-scrollbar {
width: var(--space-small);
height: var(--space-small);
}
.native-region::-webkit-scrollbar-track {
background: var(--color-border);
}
.native-region::-webkit-scrollbar-thumb {
border: var(--space-tiny) solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-muted);
}
</style>
@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Select } from 'bits-ui';
import IconCaretDown from 'virtual:icons/ph/caret-down';
import IconCheck from 'virtual:icons/ph/check';
import { reportMonths } from './period-query.js';
import type { OverviewMonths } from '@hledger-reports/hledger';
let { months, action, label }: { months: OverviewMonths; action: string; label: string } =
$props();
let ready = $state(false);
const options = reportMonths.map((value) => ({
value: String(value),
label: `${value} ${value === 1 ? 'month' : 'months'}`
}));
onMount(() => {
ready = true;
});
function selectPeriod(value: string) {
if (!value || value === String(months)) return;
const target = new URL(action, page.url.href);
target.searchParams.set('months', value);
void goto(target, { shallow: true, reset: false });
}
</script>
{#if ready}
<Select.Root type="single" value={String(months)} items={options} onValueChange={selectPeriod}>
<Select.Trigger aria-label={label}>
{#snippet child({ props })}
<button {...props} class="period-trigger">
{months}
{months === 1 ? 'month' : 'months'}
<IconCaretDown aria-hidden="true" />
</button>
{/snippet}
</Select.Trigger>
<Select.Portal>
<Select.Content align="start" sideOffset={4}>
{#snippet child({ props, wrapperProps })}
<div {...wrapperProps}>
<div {...props} class="period-menu">
<Select.Viewport>
{#each options as option (option.value)}
<Select.Item value={option.value} label={option.label}>
{#snippet child({ props: itemProps, selected })}
<div {...itemProps} class="period-option">
{option.label}
{#if selected}<IconCheck aria-hidden="true" />{/if}
</div>
{/snippet}
</Select.Item>
{/each}
</Select.Viewport>
</div>
</div>
{/snippet}
</Select.Content>
</Select.Portal>
</Select.Root>
{:else}
<form method="GET" {action}>
<select name="months" aria-label={label} class="native-select">
{#each options as option (option.value)}
<option value={option.value} selected={option.value === String(months)}
>{option.label}</option
>
{/each}
</select>
<button type="submit" class="apply" aria-label="Apply period">Apply</button>
</form>
{/if}
<style>
.period-trigger,
.native-select,
.apply {
min-block-size: 2rem;
padding: var(--space-compact) var(--space-tight);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-muted);
font-size: var(--font-small);
cursor: pointer;
}
.period-trigger,
form {
display: flex;
align-items: center;
gap: var(--space-tight);
}
.period-trigger:hover,
.apply:hover {
color: var(--color-text);
border-color: var(--color-border-strong);
}
.period-menu {
min-width: 9rem;
max-height: calc(100dvh - var(--space-medium));
overflow: auto;
padding: var(--space-tight);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-control);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-small);
z-index: 7;
}
.period-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-small);
min-height: var(--control-height);
padding-inline: var(--space-tight);
border-radius: var(--radius-control);
cursor: pointer;
}
.period-option[data-highlighted] {
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
}
</style>
@@ -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);
}
);
@@ -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<URLSearchParams, 'size' | 'has' | 'get'>
): 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;
}
@@ -0,0 +1,103 @@
<script lang="ts">
import type { OverviewData } from '@hledger-reports/hledger';
import ChartCanvas from '#lib/charts/ChartCanvas.svelte';
import ReportCard from '#lib/report-card/ReportCard.svelte';
import { netWorthChart } from './chart.js';
let { asOf, netWorth }: { asOf: string | null; netWorth: OverviewData['netWorth'] } = $props();
const incomplete = $derived(netWorth.points.some((point) => point.unconverted.length > 0));
</script>
<ReportCard title="Net worth trend" headingId="net-worth-heading">
{#snippet chart()}
{#if asOf === null}
<p>No dated transactions.</p>
{:else if netWorth.points.length === 0}
<p>No reported net-worth valuations in this window.</p>
{:else}
<p>
Market value
{#if netWorth.currency !== null}
· <span class="commodity"
>{netWorth.currency === '' ? 'no commodity' : netWorth.currency}</span
>
{/if}
</p>
{#if incomplete}
<p>Incomplete valuations: unconverted commodities.</p>
{/if}
{#if netWorth.currency !== null}
<ChartCanvas
label={`Net worth in inferred base currency ${netWorth.currency === '' ? 'no commodity' : netWorth.currency}`}
height={220}
makeConfig={(theme) => netWorthChart(netWorth, theme)}
/>
{/if}
{/if}
{/snippet}
{#if asOf !== null && netWorth.points.length > 0}
<table class="report-table" id="net-worth-table" aria-label="Net worth by valuation date">
<thead>
<tr>
<th scope="col">Valuation date</th><th scope="col">Amount</th>
<th scope="col">Commodity</th><th scope="col">Status</th>
</tr>
</thead>
<tbody>
{#each netWorth.points as point, pointIndex (pointIndex)}
{#each point.amounts.length > 0 ? point.amounts : [null] as amount, amountIndex (amountIndex)}
<tr>
<th scope="row"><time datetime={point.date}>{point.date}</time></th>
{#if amount !== null}
<td class="amount">{amount.quantity}</td>
<td class="commodity"
>{amount.commodity === '' ? 'no commodity' : amount.commodity}</td
>
{:else}
<td colspan="2">No reported amounts</td>
{/if}
<td>
{#if point.unconverted.length > 0}
Incomplete valuation; unconverted:
{#each point.unconverted as commodity, index (index)}
{index > 0 ? ', ' : ''}<span class="commodity"
>{commodity === '' ? 'no commodity' : commodity}</span
>
{/each}
{:else if point.amounts.length === 0}
No reported valuation
{:else}
No unconverted units
{/if}
</td>
</tr>
{/each}
{/each}
</tbody>
</table>
{/if}
</ReportCard>
<style>
table {
min-width: 36rem;
}
th,
td {
padding: var(--space-small);
border-bottom: var(--border-width) solid var(--color-border);
text-align: left;
overflow-wrap: anywhere;
}
th[scope='row'] {
white-space: nowrap;
}
.commodity {
white-space: pre-wrap;
}
.amount {
font-variant-numeric: tabular-nums;
text-align: right;
white-space: pre;
}
</style>
@@ -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<OverviewData, 'netWorth'> {
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', () => {
@@ -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;
+1
View File
@@ -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: [] },
@@ -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: [] },
@@ -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: [] },
+1
View File
@@ -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,
@@ -0,0 +1,155 @@
<script lang="ts">
import type { JournalValidationResult } from '@hledger-reports/snapshots';
import type { JournalSummaryData } from './data.js';
import { formatDate, formatDateTime } from '#lib/dates.js';
import ValidationStatus from '#lib/settings/ValidationStatus.svelte';
import ReportCard from '#lib/report-card/ReportCard.svelte';
let {
journalSummary,
validation,
validationReadFailed
}: {
journalSummary: JournalSummaryData | null;
validation: JournalValidationResult | null;
validationReadFailed: boolean;
} = $props();
</script>
<ReportCard title="Journal summary" headingId="journal-summary-heading">
<div class="journal-summary">
<section aria-labelledby="journal-coverage-heading">
<h3 id="journal-coverage-heading">Journal</h3>
<dl>
{#if journalSummary}
<div>
<dt>Full date span</dt>
<dd>
<div class="date-span">
{#if journalSummary.journalCoverage.firstDate !== null && journalSummary.journalCoverage.lastDate !== null}
<time datetime={journalSummary.journalCoverage.firstDate}
>{formatDate(journalSummary.journalCoverage.firstDate)}</time
>
<span class="separator">–</span>
<time datetime={journalSummary.journalCoverage.lastDate}
>{formatDate(journalSummary.journalCoverage.lastDate)}</time
>
{:else}
No recorded dates
{/if}
</div>
<p class="note">Includes posting and secondary dates.</p>
</dd>
</div>
<div>
<dt>Total transactions</dt>
<dd class="count">
{journalSummary.journalCoverage.transactionCount.toLocaleString('en-US')}
</dd>
</div>
{:else}
<div>
<dt>Coverage</dt>
<dd>No saved journal coverage available.</dd>
</div>
{/if}
<div>
<dt>Last recorded strict check</dt>
<dd><ValidationStatus {validation} readFailed={validationReadFailed} /></dd>
</div>
</dl>
</section>
<section class="report" aria-labelledby="journal-reports-heading">
<h3 id="journal-reports-heading">Saved report</h3>
{#if journalSummary}
<dl class="report-facts">
<div class="wide">
<dt>Generated</dt>
<dd>
<time aria-label="Financial reports prepared at" datetime={journalSummary.preparedAt}
>{formatDateTime(journalSummary.preparedAt)}</time
>
</dd>
</div>
<div>
<dt>hledger version</dt>
<dd>{journalSummary.hledgerVersion}</dd>
</div>
<div>
<dt>Report currency</dt>
<dd class="currency">
{#if journalSummary.currency === null}
No dated valuation
{:else if journalSummary.currency === ''}
Unnamed commodity
{:else}{journalSummary.currency}{/if}
</dd>
</div>
</dl>
{:else}
<p>No saved report available.</p>
{/if}
</section>
</div>
</ReportCard>
<style>
.journal-summary {
container-type: inline-size;
display: grid;
gap: var(--space-medium);
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
}
h3 {
margin: 0 0 var(--space-small);
font-size: var(--font-small);
font-weight: var(--font-weight-strong);
}
dl {
display: grid;
gap: var(--space-medium);
margin: 0;
}
dt,
.note {
color: var(--color-muted);
font-size: var(--font-small);
}
dd {
margin: var(--space-tight) 0 0;
}
.date-span {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-tight);
font-size: clamp(1rem, 2vw, 1.25rem);
}
.separator {
color: var(--color-muted);
}
.count {
font-size: 1.75rem;
font-weight: var(--font-weight-strong);
}
.note {
margin: var(--space-tight) 0 0;
}
.report {
border-block-start: var(--border-width) solid var(--color-border);
padding-block-start: var(--space-medium);
}
.wide {
grid-column: 1 / -1;
}
.currency {
white-space: pre-wrap;
}
@container (min-width: 20rem) {
.report-facts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { JournalValidationResult } from '@hledger-reports/snapshots';
import { formatDateTime } from '#lib/dates.js';
let {
validation,
readFailed
}: {
validation: JournalValidationResult | null;
readFailed: boolean;
} = $props();
</script>
<section aria-label="Saved journal validation">
<p>
{#if readFailed}
Unavailable
{:else if validation}
{#if validation.outcome === 'passed'}
Passed
{:else}
Could not validate
{/if}
·
<time aria-label="Journal validation completed at" datetime={validation.completedAt}
>{formatDateTime(validation.completedAt)}</time
>
{:else}
Not recorded
{/if}
</p>
</section>
@@ -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('<time');
});
+10
View File
@@ -0,0 +1,10 @@
export interface JournalSummaryData {
preparedAt: string;
journalCoverage: {
transactionCount: number;
firstDate: string | null;
lastDate: string | null;
};
hledgerVersion: string;
currency: string | null;
}
+21
View File
@@ -74,6 +74,27 @@
opacity: 1;
}
.ui-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: var(--control-height);
height: var(--control-height);
padding: var(--space-tight);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--color-muted);
font-size: var(--font-section);
cursor: pointer;
}
.ui-icon-button:hover {
color: var(--color-text);
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-surface));
}
.ui-label {
display: block;
font-weight: var(--font-weight-strong);
+1
View File
@@ -24,6 +24,7 @@
--content-width: 52rem;
--register-width: 88rem;
--accounts-width: 20rem;
--app-header-height: 4rem;
--dialog-width: 48rem;
--color-backdrop: #00000080;
--font-body: system-ui, sans-serif;
+4 -46
View File
@@ -1,37 +1,20 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import AccountSidebar from '#lib/navigation/AccountSidebar.svelte';
import AccountsDrawer from '#lib/navigation/AccountsDrawer.svelte';
import AppHeader from '#lib/navigation/AppHeader.svelte';
import type { LayoutProps } from './$types';
let { children, data }: Pick<LayoutProps, 'children' | 'data'> = $props();
const url = $derived(new URL(page.url.href));
</script>
<div class="app-shell">
<div class="app-body">
<header class="app-header">
<nav aria-label="Primary">
<a
href={resolve('/(protected)')}
aria-current={page.url.pathname === '/' ? 'page' : undefined}>Overview</a
>
<AccountsDrawer sidebar={data.accountSidebar} pathname={data.pathname} />
</nav>
<nav class="utility" aria-label="Utilities">
<a
href={resolve('/(protected)/settings')}
aria-current={page.url.pathname === '/settings' ? 'page' : undefined}>Settings</a
>
</nav>
<form method="POST" action={resolve('/(public)/(auth)/signout')}>
<button class="ui-button" type="submit">Sign out</button>
</form>
</header>
<AppHeader sidebar={data.accountSidebar} {url} />
{@render children()}
</div>
<aside class="accounts-rail" aria-label="Accounts">
<AccountSidebar sidebar={data.accountSidebar} pathname={data.pathname} />
<AccountSidebar sidebar={data.accountSidebar} {url} />
</aside>
</div>
@@ -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);
+67 -48
View File
@@ -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;
+69 -14
View File
@@ -1,27 +1,45 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import ValidationStatus from '#lib/preparation/ValidationStatus.svelte';
import IncomeExpenses from '#lib/overview/IncomeExpenses.svelte';
import ExpenseCategories from '#lib/overview/ExpenseCategories.svelte';
import JournalCoverage from '#lib/overview/JournalCoverage.svelte';
import NetWorth from '#lib/overview/NetWorth.svelte';
import { formatDate } from '#lib/dates.js';
import PeriodControls from '#lib/report-period/PeriodControls.svelte';
import KeyFigures from '#lib/overview/KeyFigures.svelte';
import IncomeSpending from '#lib/overview/income-spending/IncomeSpending.svelte';
import SpendingCategories from '#lib/overview/spending-categories/SpendingCategories.svelte';
import { selectOverviewPeriod } from '#lib/overview/select-period.js';
import { parseReportMonths } from '#lib/report-period/period-query.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const overview = $derived(data.overview);
const months = $derived(
page.shallow ? parseReportMonths(page.shallow.url.searchParams) : data.months
);
const period = $derived(overview ? selectOverviewPeriod(overview, months) : null);
</script>
<svelte:head>
<title>Overview — hledger reports</title>
<meta
name="description"
content="Income, expenses and category spending, in original commodities, from a prepared journal report."
content="Prepared income, spending and net worth with exact supporting data."
/>
</svelte:head>
<main class="page" id="main" tabindex="-1">
<header>
<h1 class="page-title">Overview</h1>
{#if overview}
<div class="overview-options">
{#if period?.range && overview.asOf}
<p class="period-dates">
<time datetime={period.range.begin}>{formatDate(period.range.begin)}</time>
– <time datetime={overview.asOf}>{formatDate(overview.asOf)}</time>
</p>
{/if}
<PeriodControls {months} action={resolve('/(protected)')} label="Overview period" />
</div>
{/if}
</header>
{#if !data.configured}
@@ -33,18 +51,55 @@
</p>
</section>
{:else}
<ValidationStatus validation={data.validation} readFailed={data.validationReadFailed} />
<p><a href={resolve('/(protected)/settings')}>Settings</a> — prepare saved reports.</p>
{#if data.readFailed}
<p role="alert">Saved reports couldn't be read.</p>
{/if}
{#if overview}
<JournalCoverage {overview} />
<IncomeExpenses {overview} />
<ExpenseCategories {overview} labels={data.accountLabels} />
<NetWorth {overview} />
{:else if !data.readFailed}
{#if !overview && !data.readFailed}
<p>No prepared reports available.</p>
{/if}
<div class="dashboard-content">
{#if overview && period}
<KeyFigures {overview} {period} />
{/if}
<div class="dashboard-grid">
{#if overview && period}
<IncomeSpending {period} />
<SpendingCategories {period} labels={data.accountLabels} />
{/if}
</div>
</div>
{/if}
</main>
<style>
.overview-options {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-small);
margin-block-start: var(--space-tight);
}
.period-dates {
color: var(--color-muted);
font-size: var(--font-small);
}
.dashboard-content {
container-type: inline-size;
display: flex;
flex-direction: column;
gap: var(--space-medium);
min-width: 0;
}
.dashboard-grid {
--report-card-height: 40rem;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-small);
align-items: start;
}
@container (min-width: 48rem) {
.dashboard-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -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' },
@@ -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: [] },
@@ -20,17 +20,21 @@ vi.mock('yaml', () => {
});
const journal = '/PRIVATE/main.journal';
const nonSixMonthRange: OverviewRange = {
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'],
@@ -53,36 +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 = {
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 }
};
@@ -109,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
? {
@@ -156,70 +151,78 @@ 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({
@@ -227,6 +230,7 @@ it('keeps an undated publication empty without inventing categories or history',
overview: {
asOf: null,
preparedAt: overview.preparedAt,
hledgerVersion: '1.99.5',
journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null },
ranges: null,
balanceSheet: null,
@@ -236,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: [] }
});
@@ -251,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
});
}
);
@@ -262,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');
});
@@ -66,9 +66,8 @@
<section class="panel" aria-labelledby="disabled-heading">
<h2 class="section-title" id="disabled-heading">Journal configuration is unavailable</h2>
<p>
Set the server's <code>CONFIG_PATH</code> explicitly to
<code>apps/worker/fixtures/config/hledger-reports.yaml</code>
(absolute path) and start the fixture worker. No personal configuration is discovered.
Set the server's <code>CONFIG_PATH</code> explicitly to your deployment configuration's absolute
path and start its worker. No personal configuration is discovered.
</p>
</section>
{:else}
@@ -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: [] },
@@ -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: [] },
@@ -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: [] },
@@ -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: [] },
@@ -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;
@@ -0,0 +1,79 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { formatDate } from '#lib/dates.js';
import PeriodControls from '#lib/report-period/PeriodControls.svelte';
import NetWorth from '#lib/reports/networth/NetWorth.svelte';
import { parseReportMonths } from '#lib/report-period/period-query.js';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const report = $derived(data.report);
const months = $derived(
page.shallow ? parseReportMonths(page.shallow.url.searchParams) : data.months
);
const netWorth = $derived(
report
? { currency: report.netWorth.currency, points: report.netWorth.points.slice(-months) }
: null
);
</script>
<svelte:head>
<title>Net worth — hledger reports</title>
<meta name="description" content="Saved net worth valuations with exact supporting data." />
</svelte:head>
<main class="page" id="main" tabindex="-1">
<header>
<h1 class="page-title">Net worth</h1>
{#if report}
<div class="report-options">
{#if report.asOf}
<p class="cutoff">As of <time datetime={report.asOf}>{formatDate(report.asOf)}</time></p>
{/if}
<PeriodControls
{months}
action={resolve('/(protected)/reports/networth')}
label="Net worth period"
/>
</div>
{/if}
</header>
{#if !data.configured}
<section class="panel" aria-labelledby="configuration-heading">
<h2 class="section-title" id="configuration-heading">No configuration selected.</h2>
<p>
Set <code>CONFIG_PATH</code> on the server to select a journal configuration. No personal configuration
is loaded by default.
</p>
</section>
{:else if data.readFailed}
<p role="alert">Saved reports couldn't be read.</p>
<p>
Visit <a href={resolve('/(protected)/settings')}>Settings</a> to prepare reports explicitly.
</p>
{:else if report && netWorth}
<NetWorth asOf={report.asOf} {netWorth} />
{:else}
<p>No prepared reports available.</p>
<p>
Visit <a href={resolve('/(protected)/settings')}>Settings</a> to prepare reports explicitly.
</p>
{/if}
</main>
<style>
.report-options {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-small);
margin-block-start: var(--space-tight);
}
.cutoff {
color: var(--color-muted);
font-size: var(--font-small);
}
</style>
@@ -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<typeof load>[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: [] }
});
});
@@ -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 = {
@@ -1,6 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ValidationStatus from '#lib/preparation/ValidationStatus.svelte';
import JournalSummary from '#lib/settings/JournalSummary.svelte';
import type { PageProps } from './$types';
let { data, form }: Pick<PageProps, 'data' | 'form'> = $props();
@@ -38,15 +38,6 @@
{:else}
<p>No preparation is currently running.</p>
{/if}
{#if data.preparedAt}
<p>
Last saved reports prepared at <time
aria-label="Financial reports prepared at"
datetime={data.preparedAt}>{data.preparedAt}</time
>.
</p>
{/if}
<ValidationStatus validation={data.validation} readFailed={data.validationReadFailed} />
</section>
<p><a href={resolve('/(protected)/settings')} data-sveltekit-reload>Reload status</a></p>
<form method="POST" action={resolve('/(protected)/settings')}>
@@ -56,6 +47,11 @@
</form>
{/if}
{#if form?.message}<p role="alert">{form.message}</p>{/if}
<JournalSummary
journalSummary={data.journalSummary}
validation={data.validation}
validationReadFailed={data.validationReadFailed}
/>
<p><a href={resolve('/(protected)/queries')}>Live queries</a> runs reports on demand.</p>
</main>
@@ -77,7 +77,7 @@ it('passively observes preparation status without requesting work', async () =>
expect(await load(request)).toEqual({
configured: true,
preparation: 'running',
preparedAt: null,
journalSummary: null,
validation: null,
validationReadFailed: false
});
@@ -85,19 +85,84 @@ it('passively observes preparation status without requesting work', async () =>
expect(readReportsPreparation).toHaveBeenCalledExactlyOnceWith(client);
expect(requestReportPreparation).not.toHaveBeenCalled();
});
it('serializes only saved time, never financial data, journal or publication directories', async () => {
const calendar = [
['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'],
['2026-01-01', '2026-02-01', '2026-01-31'],
['2026-02-01', '2026-03-01', '2026-02-28'],
['2026-03-01', '2026-04-01', '2026-03-31'],
['2026-04-01', '2026-05-01', '2026-04-16']
];
it('whitelists journal summary metadata without private publication or financial details', async () => {
vi.mocked(readReportPublication).mockResolvedValue({
journal: '/PRIVATE/journal',
directory: '/PRIVATE/set',
accounts: [],
overview: {
journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null },
ranges: null,
balanceSheet: null,
netWorth: { currency: null, points: [] },
journalCoverage: { transactionCount: 42, firstDate: '2024-01-01', lastDate: '2026-05-01' },
hledgerVersion: '1.99.5',
preparedAt,
asOf: null,
incomeExpenses: { months: [] }
asOf: '2026-04-16',
ranges: {
1: {
begin: '2026-04-01',
endExclusive: '2026-04-17',
transactionCount: 42,
income: [],
expenses: [],
net: [],
expenseCategories: { depth: 2, rows: [] }
},
3: {
begin: '2026-02-01',
endExclusive: '2026-04-17',
transactionCount: 42,
income: [],
expenses: [],
net: [],
expenseCategories: { depth: 2, rows: [] }
},
6: {
begin: '2025-11-01',
endExclusive: '2026-04-17',
transactionCount: 42,
income: [],
expenses: [],
net: [],
expenseCategories: { depth: 2, rows: [] }
},
12: {
begin: '2025-05-01',
endExclusive: '2026-04-17',
transactionCount: 42,
income: [],
expenses: [],
net: [],
expenseCategories: { depth: 2, rows: [] }
}
},
balanceSheet: {
assets: { amounts: [], unconverted: [] },
liabilities: { amounts: [], unconverted: [] }
},
netWorth: {
currency: ' EUR ',
points: calendar.map(([, , date]) => ({ date, amounts: [], unconverted: [] }))
},
incomeExpenses: {
months: calendar.map(([begin, endExclusive]) => ({
begin,
endExclusive,
income: [],
expenses: []
}))
}
}
});
vi.mocked(readReportsPreparation).mockRejectedValue(new Error('PRIVATE queue'));
@@ -105,18 +170,25 @@ it('serializes only saved time, never financial data, journal or publication dir
expect(result).toEqual({
configured: true,
preparation: 'unavailable',
preparedAt,
journalSummary: {
preparedAt,
journalCoverage: { transactionCount: 42, firstDate: '2024-01-01', lastDate: '2026-05-01' },
hledgerVersion: '1.99.5',
currency: ' EUR '
},
validation: null,
validationReadFailed: false
});
expect(JSON.stringify(result)).not.toMatch(/PRIVATE|overview|accounts|directory|journal/);
expect(JSON.stringify(result)).not.toMatch(
/PRIVATE|overview|accounts|directory|ranges|netWorth|journalStatistics/
);
});
it('unavailable application makes no reads or writes', async () => {
readLocals.mockReturnValue({ application: null, session: null, auth: async () => null });
expect(await load(event())).toEqual({
configured: false,
preparation: 'idle',
preparedAt: null,
journalSummary: null,
validation: null,
validationReadFailed: false
});
@@ -173,7 +245,7 @@ it('shows saved validation even when financial reading fails and does not derive
expect(await load(event())).toEqual({
configured: true,
preparation: 'failed',
preparedAt: null,
journalSummary: null,
validation,
validationReadFailed: false
});
@@ -6,26 +6,39 @@ vi.mock('$app/paths', () => ({
resolve: (route: string) => route.replace('/(protected)', '')
}));
it('keeps failed execution, saved finances and recorded validation distinct', () => {
const layoutData = {
pathname: '/settings',
accountSidebar: {
asOf: null,
hasSavedReports: true,
netWorth: null,
readFailed: false,
groups: []
},
accountLabels: []
};
it('keeps execution controls ahead of saved metadata and recorded validation', () => {
const preparedAt = '2026-05-01T12:01:00Z';
const completedAt = '2026-05-01T12:00:00Z';
const { body } = render(Settings, {
props: {
data: {
...layoutData,
configured: true,
preparation: 'failed',
preparedAt,
validation: { outcome: 'passed', completedAt },
validationReadFailed: false,
pathname: '/settings',
accountSidebar: {
asOf: null,
hasSavedReports: true,
netWorth: null,
readFailed: false,
groups: []
journalSummary: {
preparedAt,
journalCoverage: {
transactionCount: 1234,
firstDate: '2024-01-01',
lastDate: '2026-05-01'
},
hledgerVersion: '1.99.5',
currency: ' EUR '
},
accountLabels: []
validation: { outcome: 'passed', completedAt },
validationReadFailed: false
},
form: null
}
@@ -33,9 +46,36 @@ it('keeps failed execution, saved finances and recorded validation distinct', ()
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
expect(text).toContain('Report preparation failed.');
expect(text).toContain('Reload saved reports');
expect(text).toContain(`Last saved reports prepared at ${preparedAt}`);
expect(text).toContain('Last recorded strict validation: Passed.');
expect(text).toContain(`Completed at ${completedAt}`);
expect(text.indexOf('Prepare reports')).toBeLessThan(text.indexOf('Journal summary'));
expect(text).toContain('Total transactions 1,234');
expect(text).toContain('Includes posting and secondary dates.');
expect(body.replace(/<!--[\s\S]*?-->/g, '')).toContain('> EUR </dd>');
expect(body.match(/aria-label="Financial reports prepared at"/g)).toHaveLength(1);
expect(body).toContain(`datetime="${preparedAt}"`);
expect(text).toContain('Last recorded strict check Passed');
expect(body.match(/aria-label="Journal validation completed at"/g)).toHaveLength(1);
expect(body).toContain(`datetime="${completedAt}"`);
// An inspection-alias failure can follow a successful financial commit.
expect(text).not.toMatch(/reports[^.]*unchanged/i);
});
it('shows recorded checks independently of unavailable finances for a configured journal', () => {
const { body } = render(Settings, {
props: {
data: {
...layoutData,
configured: true,
preparation: 'idle',
journalSummary: null,
validation: { outcome: 'could-not-validate', completedAt: '2026-05-01T12:00:00Z' },
validationReadFailed: false
},
form: null
}
});
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
expect(text).toContain('Prepare reports');
expect(text).not.toContain('No configuration selected.');
expect(text).toContain('No saved report available.');
expect(text).toContain('Last recorded strict check Could not validate');
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": ["$app/types", "node"]
"types": ["$app/types", "node", "unplugin-icons/types/svelte"]
},
"include": ["src", "vite.config.ts", "playwright.config.ts", "e2e/**/*.ts"]
}
+5 -2
View File
@@ -2,6 +2,7 @@ import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vitest/config';
import Icons from 'unplugin-icons/vite';
export default defineConfig(({ command }) => {
const useWorkspaceSource = command === 'serve' && !process.env.VITEST;
@@ -16,7 +17,8 @@ export default defineConfig(({ command }) => {
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
version: { pollInterval: 0 }
})
}),
Icons({ compiler: 'svelte' })
],
server: {
fs: {
@@ -39,6 +41,7 @@ export default defineConfig(({ command }) => {
'**/*compose*.local.y*ml',
'**/*compose*.override.y*ml',
'**/*.journal',
'**/*.journals',
'**/*.ledger',
'**/*.csv',
'**/*.tsv',
@@ -51,7 +54,7 @@ export default defineConfig(({ command }) => {
},
ssr: useWorkspaceSource ? { noExternal: [/^@hledger-reports\//] } : {},
test: {
include: ['src/**/*.test.ts'],
include: ['src/**/*.test.ts', 'e2e/*.test.ts'],
environment: 'node'
}
};
+52 -34
View File
@@ -9,10 +9,10 @@ The service entry is `src/index.ts` (`dist/src/index.js` in production), exporti
From the repository root:
```bash
pnpm worker:dev
pnpm worker:dev --config /absolute/deployment/config/hledger-reports.yaml
```
This selects the fictional fixture configuration explicitly. For another configuration, from this package directory:
Development requires an explicit deployment configuration; there is no fixture default or configuration discovery. Alternatively set `CONFIG_PATH`. From this package directory:
```bash
CONFIG_PATH=/absolute/deployment/config/hledger-reports.yaml pnpm dev
@@ -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#settings-journal-summary).
`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
@@ -121,16 +137,18 @@ Existing input generations, pointers, and result files are left untouched; sched
## Retained preflight command
```bash
# Repository root
# Repository root: inspect the small code fixture in an owned temporary deployment
pnpm worker:inspect
# This package directory
pnpm inspect --config fixtures/config/hledger-reports.yaml
# This package directory: inspect your own explicit configuration
pnpm inspect --config /absolute/deployment/config/hledger-reports.yaml
# Configuration only: no native checks, journal reads or storage creation
pnpm config:validate --config fixtures/config/hledger-reports.yaml
pnpm config:validate --config /absolute/deployment/config/hledger-reports.yaml
```
Root `worker:inspect` runs the single-purpose `fixtures/inspect.js` launcher with Node 26. It materializes the `lotsJournal` code literal and a minimal JSON config under an owned OS temporary directory, waits for `src/inspect.ts`, and cleans up in `finally`. No journal files or fixture YAML are tracked. The launcher does not start a worker or web service or forward inspection options; use the package commands above for your deployment.
`--config-only` validates the full resolved web configuration, including authentication secrets, using the current environment and prints only success metadata or a safe failure code. Explicit `--config` takes precedence over `CONFIG_PATH`; neither mode discovers personal configuration.
Default preflight uses worker-only configuration resolution (no web secret requirement), verifies hledger **1.99.5** and runs read-only `check --strict` on the configured journal. It does not create query jobs, prepare snapshots or write validation evidence. It remains read-only, separate from worker startup, preparation preflight recording and query execution.
Tests exercise real hledger/SQLite workflows with fictional fixtures: whole-set preparation and recovery, live edits, current includes, read-only source handling, safe failures and correction, durable query output without query capture/result files, legacy rejection without file removal, and startup/shutdown ordering. They require the pinned native engine on PATH. Fixture data is under this app's [fixtures](fixtures/README.md).
Tests exercise real hledger/SQLite workflows with temporary files materialized from small fictional code fixtures: whole-set preparation and recovery, live edits, current includes, read-only source handling, safe failures and correction, durable query output without query capture/result files, legacy rejection without file removal, and startup/shutdown ordering. They require the pinned native engine on PATH. The shared code literal and inspection launcher are under this app's [fixtures](fixtures/README.md).
+11 -24
View File
@@ -1,38 +1,25 @@
# Active fixtures
# Code fixtures
These are fictional, hand-checkable inputs owned by the worker application. They are correctness fixtures, not personal journals or capacity benchmarks.
These small, fictional, hand-checkable inputs are owned by the worker application. No journal files are tracked, private or fictional. Tests and the inspection command materialize the code literal only in their own temporary directories and remove those directories afterward.
## `lots.journal`
## `journal.ts`
A hand-checkable **nine-transaction native FIFO lot fixture** for the pinned hledger **1.99.5**. It covers two investment accounts, VTI/BND securities, brokerage cash, purchases at different prices, a partial sale, a lot-preserving transfer, salary, expenses, and dated prices. hledger infers the realized-gain posting without writing to the source.
From the repository root, with hledger on `PATH`:
```bash
hledger --no-conf -f apps/worker/fixtures/lots.journal check --strict
hledger --no-conf -f apps/worker/fixtures/lots.journal holdings -e 2026-05-01 -X USD -O json
hledger --no-conf -f apps/worker/fixtures/lots.journal holdings --lots -e 2026-05-01 -X USD -O json
```
The named `lotsJournal` export is the exact **61-line nine-transaction native FIFO lot fixture** for pinned hledger **1.99.5**. Native and worker integration tests share it. It covers two investment accounts, VTI/BND securities, brokerage cash, purchases at different prices, a partial sale, a lot-preserving transfer, salary, expenses, and dated prices. hledger infers the realized-gain posting without writing to the source.
At April 30, 2026, expected securities value is **3,290 USD**, basis **2,430 USD**, unrealized gain **860 USD**, and realized gain **250 USD**. Checking holds **8,520 USD**, brokerage cash **200 USD**, and net worth is **12,010 USD**. Native holdings returns three account/security summaries or five lot rows. This is correctness coverage, not a capacity benchmark.
## `config/hledger-reports.yaml`
## `inspect.js`
The YAML selects `journal: ../lots.journal`, resolved to `apps/worker/fixtures/lots.journal` relative to the file, plus version, app URL and a fictional credentials strategy. Worker inspection does not resolve its web-only secret references. Web execution requires those environment values; there are no built-in authentication defaults. Storage paths, reporting currency, and feature flags are not configuration settings.
The fixed deployment layout keeps the private queue, captured inputs, and query results in `apps/worker/fixtures/data/jobs/`, alongside the fixture's `config/` directory. `data/snapshots/` is reserved for future prepared data. Fixture data remains isolated from other deployments.
The editor schema reference is `../../../../packages/config/schema.json`,
also relative to the YAML file.
With the workspace already installed, Node 26, pnpm 11, and hledger 1.99.5 available, run from the repository root:
With the workspace already installed, Node **26** and hledger **1.99.5** on `PATH`, run from the repository root:
```bash
pnpm worker:inspect
# Equivalent direct invocation
node apps/worker/fixtures/inspect.js
```
This command remains a read-only configuration/version/strict-journal preflight. It does not create jobs or write journals. To run the managed query demo, use `pnpm worker:dev` and start web with this configuration as described in the [worker documentation](../README.md) and [repository README](../../../README.md). Query `type:X` as of **2026-04-30** reports **100.00 USD**; checking reports **8,520.00 USD**. The report is a native balance at cost, not the market-valued holdings totals above.
This single-operation launcher imports `lotsJournal` using native TypeScript support. It creates an owned deployment directory under `os.tmpdir()`, writes the journal and a minimal JSON configuration in `config/`, and runs the existing `src/inspect.ts` command in a child Node process with the development import hook. It waits for that command, preserves its exit code, and removes its temporary deployment in `finally`.
The query worker copies the journal into private fingerprinted input storage; it never modifies this fixture. It captures on startup, not on every edit. Full prepared snapshots are still future work.
The configuration supplies version 1, a relative journal path, an app URL and a fictional credentials strategy with a password environment reference. Account settings use their empty-list default. Worker inspection validates the shared raw configuration but does not resolve web-only secrets, so `AUTH_PASSWORD` and `AUTH_SECRET` are not required. This is not a web configuration launcher or a persistent worker deployment.
Root `pnpm test` covers workspace tests.
Inspection verifies the native version and performs a read-only strict journal check. It does not create jobs, prepare snapshots, or write validation evidence. There is no static fixture YAML or repository-local fixture data directory. For normal worker development, select your own explicit deployment configuration as described in the [worker documentation](../README.md).
@@ -1,18 +0,0 @@
# yaml-language-server: $schema=../../../../packages/config/schema.json
version: 1
journal: ../lots.journal
accounts:
- account: assets:bank:checking
label: Everyday checking
register: cash
- account: assets:investments:brokerage
label: Brokerage
register: investment
- account: assets:investments:retirement
register: investment
url:
app: http://127.0.0.1:3000
auth:
credentials:
username: fixture
password: ${AUTH_PASSWORD}
+41
View File
@@ -0,0 +1,41 @@
import { spawn } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { lotsJournal } from './journal.ts';
// One fixture inspection, not a persistent deployment or a worker/web launcher.
const directory = await mkdtemp(join(tmpdir(), 'hledger-worker-inspect-'));
try {
const config = join(directory, 'config', 'hledger-reports.json');
await mkdir(join(directory, 'config'));
await writeFile(join(directory, 'source.journal'), lotsJournal);
await writeFile(
config,
JSON.stringify({
version: 1,
journal: '../source.journal',
url: { app: 'http://127.0.0.1:3000' },
auth: { credentials: { username: 'fixture', password: '${AUTH_PASSWORD}' } }
})
);
const child = spawn(
process.execPath,
[
'--conditions=development',
'--import',
new URL('../../../tools/register.js', import.meta.url).href,
fileURLToPath(new URL('../src/inspect.ts', import.meta.url)),
'--config',
config
],
{ stdio: 'inherit' }
);
process.exitCode = await new Promise((resolve, reject) => {
child.once('error', reject);
child.once('close', (code) => resolve(code ?? 1));
});
} finally {
await rm(directory, { recursive: true, force: true });
}
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff. Show more