Present period-aware Overview and dedicated financial pages #42
No files matched your search
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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,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,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
@@ -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:"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+19
-22
@@ -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);
|
||||
});
|
||||
+4
-4
@@ -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>
|
||||
+6
-23
@@ -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;
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface JournalSummaryData {
|
||||
preparedAt: string;
|
||||
journalCoverage: {
|
||||
transactionCount: number;
|
||||
firstDate: string | null;
|
||||
lastDate: string | null;
|
||||
};
|
||||
hledgerVersion: string;
|
||||
currency: string | null;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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 |
@@ -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"]
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
@@ -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}
|
||||
@@ -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
Reference in new issue
Block a user