diff --git a/.changeset/present-responsive-homepage.md b/.changeset/present-responsive-homepage.md new file mode 100644 index 0000000..1efc753 --- /dev/null +++ b/.changeset/present-responsive-homepage.md @@ -0,0 +1,32 @@ +--- +'@hledger-reports/web': patch +'@hledger-reports/worker': patch +--- + +Make Overview a responsive saved financial summary with four headlines: +selected-period Income, Spending and Transactions, plus latest whole-journal +Net worth. Two equal cards show income/spending and categories with visible exact +tables. Choose 1, 3, 6 or 12 months; bare links default to 3. With JavaScript, +selection and browser history reuse loaded data without requests. Direct links, +reload and the native GET form work without JavaScript. Latest net worth and +Accounts balances stay at the saved cutoff, independent of period. + +Spending by category uses a horizontal Top 5 + Other chart. Its chart-only currency +selector appears after hydration when needed, with automatic fallback when a period +removes a currency. Exact grouping stays within one commodity, without conversion +or changes to native totals. The complete raw category table remains unfiltered. +A Chart values disclosure after that table provides exact values in chart order, +including grouped Other, as the canvas's text alternative. + +Open the saved Net worth trend at `/reports/networth` through the entire Net worth +row in Accounts, even without configured accounts or readable balances. Settings +shows the six-fact Journal summary below preparation controls: date span, transaction +count, recorded strict check, preparation time, saved hledger version and currency. +Exact precision and incomplete valuations remain explicit throughout. + +This release requires financial format 8. Follow the +[manual paired activation steps](../docs/development.md#financial-format-8-activation) +for compatible web, worker and rebuilt shared packages, then explicitly prepare +reports again. Unsupported formats 7, 6 and older remain untouched; account IDs may +reset. There is no automatic preparation, migration or deletion. Authentication and +explicit-work behavior are unchanged. diff --git a/.dockerignore b/.dockerignore index 94ef7ab..df320a9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index e26449c..7a3bc66 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -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: diff --git a/.gitignore b/.gitignore index b45c29b..8782cd7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.woodpecker/web.yml b/.woodpecker/web.yml index e2f2402..ed65513 100644 --- a/.woodpecker/web.yml +++ b/.woodpecker/web.yml @@ -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: diff --git a/README.md b/README.md index ba4d2ee..c4a921a 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,75 @@ # hledger web reports -A local, read-only hledger reporter. hledger owns accounting; OpenWorkflow + SQLite coordinate work, not a second accounting engine. The web app never opens journals or executes hledger. +A local, read-only financial app for an hledger journal. Hledger owns accounting; +the web reads saved reports and never opens journals or executes hledger. -## Overview, transactions and live queries +## Product -**Overview (`/`) is the prepared financial summary:** monthly income-vs-expenses bars, depth-2 expense-category bars and a native market-valued net-worth trend, with exact supporting tables, for the latest six calendar months of the journal: the month of its latest recorded transaction and the previous five. The protected shell and saved page share one complete current-format report set per request. Without one, it says “No prepared reports available.” and links to manual preparation. **Reload overview** only reads saved data; it never starts calculations or checks journal changes. Pending preparation keeps saved data available; a failed job does not necessarily mean no new set was committed. +- **Overview** shows selected-period Income, Spending and Transactions, plus latest + whole-journal Net worth, with income/spending and category charts and exact tables. +- **Accounts** links to configured Cash/Investment registers and the saved + **Net worth** trend at `/reports/networth`. +- **Settings** owns explicit report preparation, status and a six-fact Journal + summary. Browsing, startup and journal edits never prepare reports automatically. +- **Live queries**, linked from Settings, run a separate recorded-cost report on + explicit request. -The window is journal-anchored, not clock-anchored or a freshness guarantee. Exact native quantities and commodities are preserved. **Income/expense amounts are not available cash, cash flow or net worth**; the separate net-worth card uses native dated asset and liability values. See the [library contract](packages/hledger/README.md#overview-data) for report semantics and empty-journal behavior. +Exact quantities, separate commodities and incomplete valuations stay visible. +Saved timestamps describe preparation, not freshness. Saved reports remain readable +when the worker is unavailable. See [web usage and routes](apps/web/README.md). -A compact journal-coverage panel shows the recorded transaction count and inclusive native date range for the whole journal. Secondary and posting dates can extend that range; the financial window remains anchored to the latest primary transaction date. This is context, not proof of completeness or freshness. +## Start with Docker Compose -The hledger library reads the journal and organizes neutral, table/chart-ready `OverviewData`. The worker publishes native 1/3/6/12-month income, expenses, NET and category totals, shared twelve-month income/expense and net-worth histories, latest assets/liabilities and raw/valued account summaries. Web uses type-only hledger imports and an explicit current-page projection: range-6 categories and the last six history points, with only rendered context. The homepage omits other ranges, latest sides and the account catalog; the protected shell separately projects only configured account links and snapshot values. Neither exposes private provenance or full account histories. The current six-month homepage is deliberate; new figures, period controls and a three-month default remain future work. Chart.js renders separate income/category commodity scales and one net-worth line in hledger's inferred base currency; the tables remain exact and server-rendered. Income/category totals and dated historical balance sheets share one native loaded journal and complete prepared-report set. Only complete current-format reports are supported; every overview includes all four panels. Unconverted holdings mark net-worth points incomplete rather than silently becoming zero or a partial consolidated total. +Create private `apps/web/config/hledger-reports.yaml` from the +[safe example](apps/web/config/hledger-reports.example.yaml), without overwriting +an existing configuration. Select your read-only journal tree under +`apps/web/journals/` (`main.journal`), or set `JOURNAL_DIR` to another directory. +Configure exactly one sign-in strategy and supply its secrets and `AUTH_SECRET` +through your secret tooling; see [authentication setup](docs/authentication.md). +No host Node, pnpm or hledger installation is needed for Compose. -**Cash and Investment registers list their explicitly configured accounts.** Select one for prepared activity with ordinary pagination: 50 rows for Cash, or 50 whole transactions for Investments. Dates, exact changes and running balances come from hledger and do not reset on page boundaries. The web reads the native account directory in `overview.json`, opens the selected account's complete JSON file, and projects only the requested page. Friendly labels do not change native identity or report scope. Browsing never executes native work; Overview and all native account activity still publish together as one complete set. - -**Accounts navigation is available throughout signed-in pages**, in a responsive drawer or desktop right rail. It shows configured Cash/Investment links and saved balances, plus native whole-journal net worth—not a sum of configured accounts. Browsing remains manual-only 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/.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. diff --git a/apps/web/README.md b/apps/web/README.md index e08be5c..4db11f3 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,134 +1,177 @@ # Web app -Svelte 5/SvelteKit with adapter-node. The web app displays saved reports and accepts -explicit requests for background work. It never opens journals or executes hledger. -See the [architecture](../../docs/architecture.md) and -[philosophy](../../docs/philosophy.md) for the ownership rules. +Svelte 5/SvelteKit with adapter-node. The web displays saved reports and accepts +explicit background-work requests. It never opens journals or executes hledger. +See [architecture](../../docs/architecture.md) for request and public-data boundaries. ## Pages -| Page | Behavior | -| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `/` | Read saved six-month Overview and independent last recorded validation outcome/time | -| `/registers/cash`, `/registers/investments` | Read configured members from the saved account directory | -| `/registers/cash/`, `/registers/investments/` | Read saved account activity with ordinary pagination | -| `/settings` | Inline preparation/status and saved validation; ordinary POST requests work; links to Live queries | -| `/queries` | Existing explicit recorded-cost balance tool; reload clears the displayed result | +| Page | Behavior | +| ----------------------------------------------------------- | ----------------------------------------------------------------------- | +| `/`, `/?months=1\|3\|6\|12` | Saved Overview; default 3 months | +| `/reports/networth`, `/reports/networth?months=1\|3\|6\|12` | Saved native net-worth history; default 3 months | +| `/registers/cash`, `/registers/investments` | Configured members from the saved account directory | +| `/registers/cash/`, `/registers/investments/` | Saved account activity with ordinary pagination | +| `/holdings` | Explicit investment holdings report for configured members | +| `/settings` | Preparation/status, Journal summary and link to Live queries | +| `/queries` | Explicit recorded-cost balance tool; reload clears its displayed result | -Overview and registers do not query job status, hash inputs, infer freshness or -submit work. Without saved financial data, they say it has not been prepared and -link to Settings. Saved files remain readable if the worker is stopped. After -editing the journal, explicitly prepare again. The homepage still presents six -months; new figures, period controls and a three-month default remain future work. -Registers retain their existing presentation. +`/reports` is a namespace only, with no index or redirect; there is no +`/reports/net-worth` alias. `/prepare` has no redirect or POST alias. -Settings is the canonical preparation/status page; `/prepare` has been removed with -no redirect or POST alias. Its inline Prepare reports form requests Overview and -all account registers together. Concurrent requests join active preparation; a later -request recalculates even if inputs are unchanged. The ordinary empty form works -without JavaScript and redirects with 303 to `/settings`. Reload Settings to check -this deployment's queue status, then navigate to saved reports. There is no polling, -fetch controller, background refresh or Overview/Transactions API. Settings links -to the existing `/queries` screen instead of a standalone Live queries utility-nav -entry; this adds no query history. +Browsing does not check inputs, infer freshness, query job status or submit work. +Without a complete supported financial set, saved views say reports are unavailable; +use Settings to prepare them. Saved reports remain readable while preparing or +with the worker stopped. Open pages do not refresh automatically. -Overview and Settings show the last recorded strict validation outcome/time separately -from the saved financial preparation time. Unavailable validation never hides readable -finances; only outcome/time is public, not journal/request identity or diagnostics. -A failed job does not guarantee reports are unchanged: reload saved reports to see -the available result. See [validation ownership](../../docs/architecture.md#last-recorded-validation) -and [publication/recovery](../../packages/snapshots/README.md#publication-and-reads) -for the lifecycle details. +## Overview periods + +Bare `/` and the header's Overview link select **3 months**. Choose **1/3/6/12 +months**, or use a URL such as `/?months=12`; there is no remembered preference. +The route accepts exactly one literal `months` value or no query. Repeated, empty, +unsupported values and unknown keys return 400. + +With JavaScript, selection and Back/Forward use already-loaded data without data, +API, job or native requests, preserving the snapshot, focus and scroll. Before or +without JavaScript, the native select and Apply button submit the same GET route. +Direct links and reloads can observe a newer saved set. + +Four headlines show selected-period **Income**, **Spending** and **Transactions**, +plus **latest whole-journal Net worth**. Transactions counts actual primary-date +transactions, including transfers and opening balances, not postings or chart rows. +The window includes the latest recorded primary transaction's month and preceding +calendar months, not the computer's month. Latest net worth and Accounts remain at +the saved cutoff regardless of selection. There is no income-less-expenses headline. + +Two equal **40rem** cards show monthly income/spending and depth-2 expense categories, +with exact tables visible by default. On wide screens each spans two headline tiles +plus their gap. Pinned headings and keyboard-accessible scrolling keep all table rows +available. The category plot stays in a bounded **19rem** slot without a chart scroll +wrapper; full accessible captions remain available when visual captions are clamped. + +### Spending by category chart + +One horizontal **Top 5 + Other** chart ranks signed net spending within a single +commodity, with native-order ties. Other appears last only when more than five +eligible categories exist and sums the remainder exactly; a real category named +Other remains distinct. Native zeros and refunds retain their signs. Missing +commodities are omitted, not turned into zero bars. Ambiguous duplicate amounts +for the same commodity in any row make that commodity's chart unavailable rather +than silently combining them. + +After hydration, a native **Spending chart currency** selector appears when several +commodities are available. If a period removes the chosen commodity, it falls back +to an available choice. Period/currency changes reuse saved data without requests. +The chart selector is absent without JavaScript; the complete raw category table +still shows every commodity and category in native order, never filtered or truncated. + +After that complete table, a native **Chart values** disclosure provides an ordered +text alternative to the chart, including exact grouped Other values and commodity. +The canvas is described by that list. The disclosure is available when the client +can produce the chart; it does not replace or modify the raw table. + +This chart-only exact grouping does not convert currencies, change native totals +or authorize browser accounting elsewhere. See the narrow +[arithmetic principle](../../docs/philosophy.md#ownership-before-abstraction). + +## Net worth report + +The entire Net worth footer row in Accounts links to **`/reports/networth`** and +marks the active page. It stays available without configured groups or saved +balances, with honest unavailable values. + +The report shows the saved twelve-point native trend and a visible exact table. +Its own 1/3/6/12-month selector defaults to 3 and has the same URL, history and GET +behavior as Overview. It selects loaded points without recalculation. The chosen +native currency and incomplete valuations remain explicit; selection does not +change latest NET or Accounts balances. + +## Settings journal summary + +Settings' ordinary **Prepare reports** form requests Overview and all account +registers together. Concurrent requests join active work; a later request prepares +again even with unchanged inputs. The empty form works without JavaScript and +redirects with 303 to `/settings`. Reload for this deployment's queue status, then +navigate to saved reports. There is no polling or automatic follow-up after edits. +A failed job does not guarantee unchanged reports; reload to inspect the available +result. See [publication/recovery](../../packages/snapshots/README.md#publication-and-reads). + +Below execution status and controls, **Journal summary** shows six facts: + +- **Journal**: full recorded date span (including posting/secondary dates), total + whole-journal transactions and last recorded strict-check outcome/time. +- **Saved report**: preparation time, saved hledger version and report currency. + +The summary owns the single preparation/check timestamp display; status above is +execution-only. Validation is independent of saved finances: unavailable validation +does not hide readable reports, and a recorded pass is not a freshness guarantee. +No journal/request identity or native diagnostic reaches the browser. + +Follow **Live queries** to `/queries` for a separate current-input calculation. +Each Run returns exact recorded-cost results; reload clears the display. Closing +HTTP does not cancel durable work, and there is no user-facing run history. ## Account labels and register membership -`accounts` in the shared YAML selects exact Cash/Investment register members and -optional app-wide labels. A register without members is hidden and returns 404, -including direct account URLs. Label-only entries do not enable registers. A member -missing from saved data is shown as unavailable, without a fabricated link or count. +Configure exact Cash/Investment members and optional app-wide labels in shared YAML. +A register without members is hidden and returns 404, including direct account URLs. +Label-only entries do not enable registers. Missing saved members appear unavailable +without fabricated links or counts. Holdings uses investment membership; without +investment members it is unavailable too. -Labels also appear in Holdings, category charts/tables, live query results and -transaction details. Canonical identities remain available; labels never enter -native job inputs or replace saved account IDs. Holdings uses exact investment -membership for its selector, direct submissions and displayed native account groups. -No investment members means no Holdings view. Preparation still covers all native -accounts. Names/labels are literal, with no environment or prefix expansion. -See [account configuration](../../packages/config/README.md#account-presentation). +Labels appear throughout presentation while canonical identities remain available. +They do not alter native job inputs, report scope or saved IDs. Preparation still +covers all native accounts. See [account configuration](../../packages/config/README.md#account-presentation). ## Accounts navigation -On signed-in pages, open Accounts on narrow screens or use the permanent desktop -rail. Cash/Investments headings open configured register directories; select an -account to read its saved activity. Holdings is reached from an investment account -register, not Accounts or the header. Close, Escape or the backdrop dismisses the -drawer; navigation also closes it. Without JavaScript, Accounts is a native -disclosure with real links. Public/sign-in pages have no financial navigation. +Signed-in pages have a permanent right rail on desktop, a right overlay drawer on +medium screens and a full-screen drawer on narrow screens. The compact branded +header keeps Overview and Settings left, with accessible Accounts/sign-out icon +actions right. Sign-out is a native POST, including without JavaScript. -Accounts shows saved Cash raw balances, Investment valuations including cash and -native whole-journal NET, not configured-row or group sums. The balance cutoff is -visible; no preparation timestamp appears here. Unavailable and incomplete values -are explicit. Navigation/reload reads saved data for shell and page together; -open pages do not refresh automatically or request work. +Cash/Investments headings open configured register directories; accounts link to +saved activity. Holdings is reached from an investment account register, not the +header or Accounts. Close, Escape, backdrop and navigation dismiss the drawer. +Without JavaScript, Accounts is a native disclosure with real links. Public/sign-in +pages have no financial navigation. -See [architecture](../../docs/architecture.md#shared-saved-reads-and-account-navigation) -for responsive widths, private projection, component and lifecycle ownership, and -[the file map](#run-and-check) for implementation locations. +Accounts shows Cash raw balances, Investment valuations including cash, and native +whole-journal net worth—not sums of configured accounts. The balance cutoff is +visible, without a preparation timestamp. Unavailable and incomplete values stay +explicit. Shell and page observe the same saved set on navigation/reload. ## Financial presentation -The [native library](../../packages/hledger/README.md#overview-data) owns financial -meaning, periods and exact quantities. Web renders its report data, with type-only -hledger imports; it does not interpret native tuples or reconstruct balances. -Preparation now saves native 1/3/6/12-month range totals/categories, shared twelve-month -income/expense and net-worth histories, latest assets/liabilities and account summaries. -The homepage server explicitly projects only rendered context, range-6 categories -and the last six monthly/net-worth points into `OverviewPageData`. That homepage -projection omits range headline totals, other ranges, latest sides, account -values/catalog and private provenance; the shell has its separate compact account -projection described above. This is the current page model, not a legacy producer -adapter. New homepage figures, period controls and a three-month default remain -future work. Settings, live queries and themes are unchanged. +Exact quantity strings and separate commodities remain visible. Display formatting +never rounds meaningful precision or converts quantities to floating point. Charts +use approximate geometry; exact tables and individual native tooltips retain the +producer values. Category Other is the sole chart-derived tooltip amount. Values +outside numeric range or ambiguous duplicate-commodity bags retain the exact table +instead of a misleading chart. Mixed/unconverted valuations are visibly incomplete, +not consolidated scalars or invented zeros. Empty and undated results are explicit. +See the [native contract](../../packages/hledger/README.md#overview-data) for financial +meaning, dates and valuation evidence. -Overview uses the latest primary transaction's month and preceding five months, -not the computer's month. Income/expenses are not available cash, cash flow or net -worth. Expense categories use native absolute depth2 totals. Net worth uses native -historical assets minus liabilities at dated market values in the inferred base -currency; unconverted units mark incomplete points, never invented zeros. Coverage -uses the whole native journal span, including secondary/posting dates, not just the -Overview window. A prepared Overview always contains all four sections; a genuinely -empty journal still has explicit empty results, not fabricated financial values. +Cash pages show 50 rows; Investment pages show 50 whole transactions. Full recorded +account activity, including declared-parent subtree history, is not filtered by +Overview's period. Native running balances do not reset at page boundaries. +First/Previous/Next/Last are ordinary links. Account names remain exact data, never +paths or slugs; IDs may reset across an unsupported-format upgrade. -Chart.js4.5.1 draws approximate geometry. Exact tables/tooltips preserve producer -strings, separate commodities and every reported amount. Duplicate-commodity bags -that cannot fit a single bar/point and values outside the numeric range retain the -exact table instead. There is no web financial summation or currency conversion. +### Transaction dialogs -Transactions show full recorded account activity, including subaccounts for real -parents. Implied ancestors are not extra reports. Each native account has a stable -eight-character lowercase-letter/digit ID within supported same-journal publications; -IDs may reset across the format transition. Names remain exact data, never paths or -slugs. Multiple securities can belong to one account. Native dates, related-account -labels, split flags, changes and attached running balances survive pagination. -Empty accounts have no invented transaction; ambiguous native selectors show no -account amounts. First/Previous/Next/Last are ordinary links, including without JS. - -## Transaction dialogs - -Cash breakdowns and Investment details share a small Bits UI shell. Their financial -content and selection stay in the owning feature. Escape and the close button dismiss; -backdrop clicks do not. Closing restores focus to the opener when available, otherwise -to main content. Navigation or replacement page data invalidates the selected record. - -The portal inherits document theme/typography and keeps the desktop dialog and narrow -bottom-sheet layout. Bits UI supplies focus trapping and body scroll lock. Detail -buttons remain disabled before hydration; the underlying financial tables work -without JavaScript. There are no forced SSR dialog contents or unrelated Bits controls. +Cash breakdowns and Investment details use a shared Bits UI shell. Escape/Close +dismiss; backdrop clicks do not. Focus returns to the opener or main content. +Navigation/replacement data clears the selection. The portal inherits theme and +supports desktop dialogs/narrow bottom sheets; Bits UI owns focus trapping and +scroll lock. Detail buttons are disabled before hydration; financial tables work +without JavaScript. ## Files and state -Compose shares ignored **`apps/web/data/`** at `/app/data` in both processes; no -placeholder is tracked. Vite denies raw journal/data/SQLite serving. Files are -private financial information, not public static assets. +Compose shares private ignored **`apps/web/data/`** with the worker. Vite denies +raw journal/data/SQLite serving. Nothing here is a public static asset. ```text data/snapshots/current/ @@ -136,140 +179,51 @@ data/snapshots/current/ transactions/.json ``` -The saved `overview.json` envelope contains the selected journal identity, financial -overview and account directory, including required raw/valued summaries. Register -directories explicitly project only identity/access/count fields, not summaries. One whole -account file contains all native rows. The worker publishes a complete immutable -set; the shell and saved-data leaf share one pinned selection per request. Private -journal identities and set paths never enter page data. Financial format **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. diff --git a/apps/web/docker-compose.e2e.yml b/apps/web/docker-compose.e2e.yml index 6ec7d27..8e5b844 100644 --- a/apps/web/docker-compose.e2e.yml +++ b/apps/web/docker-compose.e2e.yml @@ -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 diff --git a/apps/web/e2e/account-navigation.e2e.ts b/apps/web/e2e/account-navigation.e2e.ts index 7a6e308..48b2226 100644 --- a/apps/web/e2e/account-navigation.e2e.ts +++ b/apps/web/e2e/account-navigation.e2e.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, test, type Request } from '@playwright/test'; import { prepared, prepareReports, readPreparationTimestamp } from './overview-fixture'; test('desktop account navigation preserves client routing and observes later saved registers', async ({ @@ -8,7 +8,9 @@ test('desktop account navigation preserves client routing and observes later sav await page.setViewportSize({ width: 1440, height: 900 }); await page.emulateMedia({ colorScheme: 'light' }); await prepared(page); + await page.goto('/settings'); const savedAt = await readPreparationTimestamp(page); + await page.goto('/'); const accounts = page.getByRole('complementary', { name: 'Accounts', exact: true }); const balanceDate = accounts.getByText(/^Balances as of/); await expect(balanceDate).toBeVisible(); @@ -31,6 +33,76 @@ test('desktop account navigation preserves client routing and observes later sav expect(railBox).not.toBeNull(); expect(mainBox!.x).toBeCloseTo(0, 0); expect(mainBox!.x + mainBox!.width).toBeCloseTo(railBox!.x, 0); + const header = page.getByRole('banner'); + const headerBox = await header.boundingBox(); + const accountsHeaderBox = await accounts.locator('header').boundingBox(); + expect(headerBox!.height).toBeCloseTo(accountsHeaderBox!.height, 0); + expect(headerBox!.y + headerBox!.height).toBeCloseTo( + accountsHeaderBox!.y + accountsHeaderBox!.height, + 0 + ); + await expect( + header.getByRole('link', { name: 'Overview home', exact: true }).locator('img') + ).toBeVisible(); + + // The Accounts total opens the saved trend rather than another homepage card. + const netWorthLink = accounts.getByRole('link', { name: /^Net worth/ }); + await expect(netWorthLink).toHaveAttribute('href', '/reports/networth'); + await netWorthLink.click(); + await expect(page).toHaveURL(/\/reports\/networth$/); + await expect(main.getByRole('heading', { name: 'Net worth', exact: true })).toBeVisible(); + await expect(netWorthLink).toHaveAttribute('aria-current', 'page'); + const period = page.getByRole('button', { name: 'Net worth period', exact: true }); + const rows = page + .getByRole('table', { name: 'Net worth by valuation date', exact: true }) + .locator('tbody tr'); + await expect(period).toHaveText('3 months'); + await expect(rows).toHaveCount(3); + await expect(rows.getByRole('rowheader')).toHaveText(['2026-02-28', '2026-03-31', '2026-04-16']); + for (const index of [0, 1]) { + await expect(rows.nth(index).getByRole('cell')).toHaveText([ + 'No reported amounts', + 'No reported valuation' + ]); + } + await expect(rows.last().getByRole('cell')).toHaveText([ + '11825.00', + 'USD', + 'No unconverted units' + ]); + const accountsText = await accounts.innerText(); + const requests: string[] = []; + const observe = (request: Request) => { + if ( + ['fetch', 'xhr', 'document'].includes(request.resourceType()) || + request.method() === 'POST' + ) { + requests.push(`${request.method()} ${new URL(request.url()).pathname}`); + } + }; + page.on('request', observe); + await page.evaluate(() => Object.assign(window, { netWorthNavigationToken: 'retained' })); + await period.click(); + await page.getByRole('option', { name: '1 month', exact: true }).click(); + await expect(page).toHaveURL(/\/reports\/networth\?months=1$/); + await expect(rows).toHaveCount(1); + await expect(netWorthLink).toHaveAttribute('aria-current', 'page'); + await page.goBack(); + await expect(period).toHaveText('3 months'); + await expect(rows).toHaveCount(3); + expect(await accounts.innerText()).toBe(accountsText); + expect(await page.evaluate(() => Reflect.get(window, 'netWorthNavigationToken'))).toBe( + 'retained' + ); + expect(requests).toEqual([]); + page.off('request', observe); + + await page.goto('/reports/networth?months=12'); + await expect(period).toHaveText('12 months'); + await expect(rows).toHaveCount(12); + await expect(rows.first().getByRole('rowheader')).toHaveText('2025-05-31'); + await expect(rows.last().getByRole('rowheader')).toHaveText('2026-04-16'); + await expect(netWorthLink).toHaveAttribute('aria-current', 'page'); await accounts.getByRole('link', { name: /^Everyday checking\b/ }).click(); await expect(main.getByRole('heading', { name: 'Everyday checking', exact: true })).toBeVisible(); @@ -98,6 +170,40 @@ test('responsive Accounts supports phone, overlay drawer and desktop transitions await expect(drawer).not.toBeVisible(); await expect(trigger).toBeFocused(); + await trigger.click(); + await drawer.getByRole('link', { name: /^Net worth/ }).click(); + await expect(page).toHaveURL(/\/reports\/networth$/); + await expect( + page.locator('main').getByRole('heading', { name: 'Net worth', exact: true }) + ).toBeVisible(); + await expect(drawer).not.toBeVisible(); + await trigger.click(); + await expect(drawer.getByRole('link', { name: /^Net worth/ })).toHaveAttribute( + 'aria-current', + 'page' + ); + // The current destination still refreshes saved reports; closing must not cancel it. + const refreshed = page.waitForResponse( + (response) => + response.request().method() === 'GET' && + new URL(response.url()).pathname === '/reports/networth/__data.json' + ); + await drawer.getByRole('link', { name: /^Net worth/ }).focus(); + await page.keyboard.press('Enter'); + const refresh = await refreshed; + expect(refresh.status()).toBe(200); + expect(await refresh.finished()).toBeNull(); + await expect(page).toHaveURL(/\/reports\/networth$/); + await expect(drawer).not.toBeVisible(); + await expect(page.locator('main')).toBeFocused(); + await page + .getByRole('navigation', { name: 'Primary', exact: true }) + .getByRole('link', { name: 'Overview', exact: true }) + .click(); + await expect( + page.locator('main').getByRole('heading', { name: 'Overview', exact: true }) + ).toBeVisible(); + await trigger.click(); await drawer.getByRole('link', { name: 'Investments', exact: true }).click(); await expect(page).toHaveURL(/\/registers\/investments$/); diff --git a/apps/web/e2e/auth-fixture.mjs b/apps/web/e2e/auth-fixture.js similarity index 100% rename from apps/web/e2e/auth-fixture.mjs rename to apps/web/e2e/auth-fixture.js diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 7f3897a..aa0568b 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -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: [] } }); diff --git a/apps/web/e2e/auth.setup.ts b/apps/web/e2e/auth.setup.ts index 426ef91..f3ba804 100644 --- a/apps/web/e2e/auth.setup.ts +++ b/apps/web/e2e/auth.setup.ts @@ -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; diff --git a/apps/web/e2e/fixture.js b/apps/web/e2e/fixture.js new file mode 100644 index 0000000..ca514dd --- /dev/null +++ b/apps/web/e2e/fixture.js @@ -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; + } +} diff --git a/apps/web/e2e/fixture.test.ts b/apps/web/e2e/fixture.test.ts new file mode 100644 index 0000000..13db190 --- /dev/null +++ b/apps/web/e2e/fixture.test.ts @@ -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']); +}); diff --git a/apps/web/e2e/fixtures/category-allocation.js b/apps/web/e2e/fixtures/category-allocation.js new file mode 100644 index 0000000..29754d6 --- /dev/null +++ b/apps/web/e2e/fixtures/category-allocation.js @@ -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 +`; diff --git a/apps/web/e2e/holdings.e2e.ts b/apps/web/e2e/holdings.e2e.ts index c1c9061..947382f 100644 --- a/apps/web/e2e/holdings.e2e.ts +++ b/apps/web/e2e/holdings.e2e.ts @@ -1,11 +1,11 @@ import { expect, test } from '@playwright/test'; import { prepared, readPreparationTimestamp } from './overview-fixture'; -test('Holdings runs fresh investment reports without replacing saved Overview', async ({ - page -}) => { +test('Holdings runs fresh investment reports without replacing saved reports', async ({ page }) => { await prepared(page); + await page.goto('/settings'); const savedAt = await readPreparationTimestamp(page); + await page.goto('/'); const requests: string[] = []; page.on('request', (request) => { if (request.method() === 'POST' && new URL(request.url()).pathname === '/holdings') { @@ -68,6 +68,6 @@ test('Holdings runs fresh investment reports without replacing saved Overview', await expect(page.getByRole('region', { name: 'Holdings result', exact: true })).toHaveCount(0); expect(requests).toHaveLength(2); - await page.goto('/'); + await page.goto('/settings'); expect(await readPreparationTimestamp(page)).toBe(savedAt); }); diff --git a/apps/web/e2e/homepage.e2e.ts b/apps/web/e2e/homepage.e2e.ts new file mode 100644 index 0000000..d188469 --- /dev/null +++ b/apps/web/e2e/homepage.e2e.ts @@ -0,0 +1,284 @@ +import { expect, test, type Request } from '@playwright/test'; +import { prepared } from './overview-fixture'; + +test('homepage periods select one loaded snapshot and history without data requests', async ({ + page +}) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await prepared(page); + const controls = page.getByRole('button', { name: 'Overview period', exact: true }); + const transactions = page + .getByText('Transactions', { exact: true }) + .locator('..') + .getByRole('definition'); + await expect(transactions).toHaveText('10'); + const incomeRows = page + .getByRole('table', { name: 'Monthly income and spending', exact: true }) + .locator('tbody tr'); + await expect(controls).toHaveText('3 months'); + await expect(incomeRows).toHaveCount(6); + await expect(incomeRows.nth(4).getByRole('rowheader')).toHaveText('2026-04'); + await expect(incomeRows.nth(4).getByRole('cell')).toHaveText(['Income', '1000.00', 'USD']); + await expect(incomeRows.nth(5).getByRole('rowheader')).toHaveText('2026-04'); + await expect(incomeRows.nth(5).getByRole('cell')).toHaveText(['Spending', '100.00', 'USD']); + await expect( + page.getByRole('img', { name: 'Income and spending by month in USD', exact: true }) + ).toBeVisible(); + const latest = page + .getByRole('region', { name: 'Key figures', exact: true }) + .getByText('Latest net worth', { exact: true }) + .locator('..') + .getByRole('definition'); + await expect(latest).toHaveText('$11,825.00'); + const latestText = await latest.innerText(); + await expect(page.getByRole('region', { name: 'Journal summary', exact: true })).toHaveCount(0); + await expect(page.getByRole('region', { name: 'Net worth trend', exact: true })).toHaveCount(0); + const mainCards = [ + page.getByRole('region', { name: 'Income and spending', exact: true }), + page.getByRole('region', { name: 'Spending by category', exact: true }) + ]; + async function expectCardSizes() { + const boxes = await Promise.all(mainCards.map((card) => card.boundingBox())); + const first = boxes[0]!; + const figures = page.getByRole('region', { name: 'Key figures', exact: true }); + const income = (await figures + .getByText('Income', { exact: true }) + .locator('..') + .boundingBox())!; + const spending = (await figures + .getByText('Spending', { exact: true }) + .locator('..') + .boundingBox())!; + const titleSpan = + Math.abs(income.y - spending.y) < 1 ? spending.x + spending.width - income.x : income.width; + expect(first.width).toBeCloseTo(titleSpan, 0); + for (const box of boxes) expect(box!.width).toBeCloseTo(first.width, 0); + for (const box of boxes) expect(box!.height).toBeCloseTo(first.height, 0); + } + const categoryCard = mainCards[1]; + const categoryCharts = categoryCard.getByRole('region', { + name: 'Spending by category charts', + exact: true + }); + const categoryRows = categoryCard + .getByRole('table', { name: 'Spending by account', exact: true }) + .locator('tbody tr'); + const clothing = categoryRows.filter({ + has: page.getByRole('rowheader', { name: 'expenses:clothing', exact: true }) + }); + const foreign = categoryRows.filter({ + has: page.getByRole('rowheader', { name: 'expenses:foreign', exact: true }) + }); + const refund = categoryRows.filter({ + has: page.getByRole('rowheader', { name: 'expenses:foreign-refund', exact: true }) + }); + const currency = categoryCard.getByRole('combobox', { + name: 'Spending chart currency', + exact: true + }); + const chartValues = categoryCard.locator('#expense-categories-chart-values'); + const usdChartValues = [ + 'expenses:travel: 50.00 USD', + 'expenses:food: 20.00 USD', + 'expenses:utilities: 10.00 USD', + 'expenses:books: 8.00 USD', + 'expenses:health: 6.00 USD', + 'Other (grouped): 6.00 USD' + ]; + async function expectContainedCategoryChart(commodity = 'USD') { + await expect( + categoryCharts.getByRole('img', { + name: `Spending by category in ${commodity}`, + exact: true + }) + ).toBeVisible(); + await expect(categoryCharts.getByRole('img')).toHaveCount(1); + await expect + .poll(() => + categoryCharts.evaluate( + (node) => node.scrollHeight <= node.clientHeight && node.scrollWidth <= node.clientWidth + ) + ) + .toBe(true); + } + await expectCardSizes(); + await expect(categoryRows).toHaveCount(7); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(foreign).toHaveCount(0); + await expect(currency).toHaveCount(0); + await expectContainedCategoryChart(); + const usdCanvas = categoryCharts.getByRole('img', { + name: 'Spending by category in USD', + exact: true + }); + await expect(usdCanvas).toHaveAttribute('aria-describedby', 'expense-categories-chart-values'); + await expect(chartValues).not.toBeVisible(); + // The closed native disclosure still describes the plotted result for assistive technology. + await expect(usdCanvas).toHaveAccessibleDescription(usdChartValues.join(' ')); + const summary = categoryCard.locator('summary', { hasText: /^Chart values$/ }); + await summary.focus(); + await summary.press('Enter'); + await expect(chartValues).toBeVisible(); + await expect(chartValues.getByRole('listitem')).toHaveText(usdChartValues); + const spendingAmounts = page + .getByRole('region', { name: 'Key figures', exact: true }) + .getByText('Spending', { exact: true }) + .locator('..') + .getByRole('listitem'); + await expect(spendingAmounts).toHaveText(['$100.00']); + const accounts = page.getByRole('complementary', { name: 'Accounts', exact: true }); + const accountsText = await accounts.innerText(); + const requests: string[] = []; + const observe = (request: Request) => { + if ( + ['fetch', 'xhr', 'document'].includes(request.resourceType()) || + request.method() === 'POST' + ) { + requests.push(`${request.method()} ${new URL(request.url()).pathname}`); + } + }; + page.on('request', observe); + await page.evaluate(() => Object.assign(window, { periodNavigationToken: 'retained' })); + + await controls.focus(); + await controls.press('Enter'); + await expect(page.getByRole('listbox')).toBeVisible(); + await page.keyboard.press('End'); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\?months=12$/); + await expect(controls).toHaveText('12 months'); + await expect(page.getByRole('listbox')).not.toBeVisible(); + await expect(incomeRows).toHaveCount(24); + await expect(transactions).toHaveText('11'); + await expect(controls).toBeFocused(); + await expectCardSizes(); + await expectContainedCategoryChart(); + await expect(spendingAmounts).toHaveText(['€0.00', '$100.00']); + const januarySpending = incomeRows.filter({ hasText: '2026-01' }).filter({ hasText: 'Spending' }); + await expect(januarySpending.getByRole('cell')).toHaveText(['Spending', '0.00', 'EUR']); + // One full native inventory: neither Top 5/Other nor chart currency filters this table. + await expect(categoryRows.getByRole('rowheader')).toHaveText([ + 'expenses:living', + 'expenses:travel', + 'expenses:food', + 'expenses:utilities', + 'expenses:books', + 'expenses:health', + 'expenses:clothing', + 'expenses:foreign', + 'expenses:foreign-refund' + ]); + await expect(categoryRows.getByRole('cell')).toHaveText([ + '2.00', + 'USD', + '50.00', + 'USD', + '20.00', + 'USD', + '10.00', + 'USD', + '8.00', + 'USD', + '6.00', + 'USD', + '4.00', + 'USD', + '5.00', + 'EUR', + '-5.00', + 'EUR' + ]); + await expect(currency).toHaveValue('USD'); + await expect(currency.locator('option')).toHaveText(['USD', 'EUR']); + await currency.selectOption('EUR'); + await expectContainedCategoryChart('EUR'); + await expect(usdCanvas).toHaveCount(0); + await expect(chartValues.getByRole('listitem')).toHaveText([ + 'expenses:foreign: 5.00 EUR', + 'expenses:foreign-refund: -5.00 EUR' + ]); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(refund.getByRole('cell')).toHaveText(['-5.00', 'EUR']); + const details = page.getByRole('region', { name: 'Income and spending details', exact: true }); + await page.mouse.move(0, 0); + await expect( + details.locator('..').locator('[data-scroll-area-scrollbar][data-orientation="vertical"]') + ).toBeVisible(); + const tableHeading = details.locator('thead'); + await details.focus(); + const headingBefore = await tableHeading.boundingBox(); + await page.keyboard.press('PageDown'); + await expect.poll(() => details.evaluate((node) => node.scrollTop)).toBeGreaterThan(0); + const headingAfter = await tableHeading.boundingBox(); + expect(headingAfter!.y).toBeCloseTo(headingBefore!.y, 0); + + // Leaving January removes EUR amounts altogether, not just their geometry. + await page.goBack(); + await expect(controls).toHaveText('3 months'); + await expect(incomeRows).toHaveCount(6); + await expect(transactions).toHaveText('10'); + await expect(currency).toHaveCount(0); + await expectContainedCategoryChart(); + await expect(chartValues.getByRole('listitem')).toHaveText(usdChartValues); + await expect(categoryRows).toHaveCount(7); + await expect(foreign).toHaveCount(0); + await expect(refund).toHaveCount(0); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(spendingAmounts).toHaveText(['$100.00']); + await controls.press('Enter'); + await expect(page.getByRole('option', { name: '3 months', exact: true })).toHaveAttribute( + 'aria-selected', + 'true' + ); + await page.keyboard.press('Escape'); + await expect(page.getByRole('listbox')).not.toBeVisible(); + await expect(controls).toBeFocused(); + await page.goForward(); + await expect(controls).toHaveText('12 months'); + await expect(currency.locator('option')).toHaveText(['USD', 'EUR']); + await expect(foreign.getByRole('cell')).toHaveText(['5.00', 'EUR']); + await currency.selectOption('USD'); + await expectContainedCategoryChart(); + await expect(chartValues.getByRole('listitem')).toHaveText(usdChartValues); + await expect(refund.getByRole('cell')).toHaveText(['-5.00', 'EUR']); + expect(await latest.innerText()).toBe(latestText); + expect(await accounts.innerText()).toBe(accountsText); + expect(await page.evaluate(() => Reflect.get(window, 'periodNavigationToken'))).toBe('retained'); + expect(requests).toEqual([]); + page.off('request', observe); + + await page + .getByRole('navigation', { name: 'Primary', exact: true }) + .getByRole('link', { name: 'Overview', exact: true }) + .click(); + await expect(page).toHaveURL(/\/$/); + await expect(controls).toHaveText('3 months'); + await expect(incomeRows).toHaveCount(6); + + await page.goto('/?months=6'); + await expect(controls).toHaveText('6 months'); + await expect(incomeRows).toHaveCount(12); + await expect(transactions).toHaveText('11'); + await expectContainedCategoryChart(); + await expect(spendingAmounts).toHaveText(['€0.00', '$100.00']); + await expect(januarySpending.getByRole('cell')).toHaveText(['Spending', '0.00', 'EUR']); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(refund.getByRole('cell')).toHaveText(['-5.00', 'EUR']); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1440); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.emulateMedia({ colorScheme: 'dark' }); + await expectCardSizes(); + await expectContainedCategoryChart(); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(foreign.getByRole('cell')).toHaveText(['5.00', 'EUR']); + await controls.click(); + await expect(page.getByRole('option', { name: '12 months', exact: true })).toBeInViewport(); + await page.keyboard.press('Escape'); + await expect(controls).toBeFocused(); + await expect(page.getByRole('region', { name: 'Key figures', exact: true })).toBeVisible(); + await expect( + page.getByRole('table', { name: 'Monthly income and spending', exact: true }) + ).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); +}); diff --git a/apps/web/e2e/manual-preparation.e2e.ts b/apps/web/e2e/manual-preparation.e2e.ts index cc01b90..1996cbc 100644 --- a/apps/web/e2e/manual-preparation.e2e.ts +++ b/apps/web/e2e/manual-preparation.e2e.ts @@ -3,7 +3,7 @@ import { prepareReports, readPreparationTimestamp } from './overview-fixture'; test.use({ javaScriptEnabled: false }); -test('explicitly prepares reports, reads Overview and follows Accounts without JavaScript', async ({ +test('explicitly prepares reports and reads Overview, Net worth and Settings without JavaScript', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); @@ -13,36 +13,141 @@ test('explicitly prepares reports, reads Overview and follows Accounts without J }); const preparedAt = await prepareReports(page); - const income = page.getByRole('region', { name: 'Income vs expenses', exact: true }); - await expect(income.getByRole('table')).toContainText('1000.00'); - await expect(income.locator('tbody tr')).toHaveCount(12); // Two series for six months. - const netWorth = page.getByRole('table', { name: 'Net worth by valuation date', exact: true }); - await expect(netWorth.locator('tbody tr')).toHaveCount(6); - await expect(netWorth.locator('tbody tr').last()).toContainText('2026-04-16'); - expect(await readPreparationTimestamp(page)).toBe(preparedAt); + const income = page.getByRole('region', { name: 'Income and spending', exact: true }); + await expect(income.locator('tbody tr')).toHaveCount(6); + await expect(income.locator('tbody tr').nth(4).getByRole('cell')).toHaveText([ + 'Income', + '1000.00', + 'USD' + ]); + await expect(income.locator('tbody tr').nth(5).getByRole('cell')).toHaveText([ + 'Spending', + '100.00', + 'USD' + ]); + const categories = page.getByRole('region', { name: 'Spending by category', exact: true }); + const categoryRows = categories + .getByRole('table', { name: 'Spending by account', exact: true }) + .locator('tbody tr'); + const clothing = categoryRows.filter({ + has: page.getByRole('rowheader', { name: 'expenses:clothing', exact: true }) + }); + const transactions = page + .getByText('Transactions', { exact: true }) + .locator('..') + .getByRole('definition'); + const primary = page.getByRole('navigation', { name: 'Primary', exact: true }); + await expect(primary.getByRole('link', { name: 'Overview', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); + await expect(transactions).toHaveText('10'); + await expect( + categories.getByText('Chart loads with JavaScript; exact amounts below.', { exact: true }) + ).toBeVisible(); + await expect(categories.getByRole('img')).toHaveCount(0); + // The complete native table remains usable without a client-only chart selector. + await expect(categoryRows).toHaveCount(7); + await expect(categoryRows.first().getByRole('cell')).toHaveText(['2.00', 'USD']); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(page.getByRole('region', { name: 'Net worth trend', exact: true })).toHaveCount(0); + await expect(page.getByRole('region', { name: 'Journal summary', exact: true })).toHaveCount(0); + + await page.getByRole('combobox', { name: 'Overview period', exact: true }).selectOption('1'); + await page.getByRole('button', { name: 'Apply period', exact: true }).click(); + await expect(page).toHaveURL(/\/\?months=1$/); + await expect(income.locator('tbody tr')).toHaveCount(2); + await expect(income.locator('tbody tr').getByRole('rowheader')).toHaveText([ + '2026-04', + '2026-04' + ]); + await expect(income.locator('tbody tr').getByRole('cell')).toHaveText([ + 'Income', + '1000.00', + 'USD', + 'Spending', + '100.00', + 'USD' + ]); + await expect(categoryRows).toHaveCount(7); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(transactions).toHaveText('10'); await page.reload(); + await expect(page.getByRole('combobox', { name: 'Overview period', exact: true })).toHaveValue( + '1' + ); await expect(income.getByRole('table')).toContainText('1000.00'); - expect(await readPreparationTimestamp(page)).toBe(preparedAt); + await expect(income.locator('tbody tr')).toHaveCount(2); + await expect(categoryRows).toHaveCount(7); + await expect(clothing.getByRole('cell')).toHaveText(['4.00', 'USD']); + await expect(transactions).toHaveText('10'); - // The server-rendered disclosure remains real navigation without hydration. + // The server-rendered Accounts disclosure remains real navigation without hydration. const disclosure = page .locator('details') .filter({ has: page.locator('summary', { hasText: /^Accounts$/ }) }); - const accountsToggle = disclosure.locator('summary').filter({ hasText: /^Accounts$/ }); - await accountsToggle.click(); + await disclosure + .locator('summary') + .filter({ hasText: /^Accounts$/ }) + .click(); const balanceDate = disclosure.getByText(/^Balances as of/); await expect(balanceDate).toBeVisible(); await expect(balanceDate.locator('time')).toHaveAttribute('datetime', '2026-04-16'); - await disclosure.getByRole('link', { name: /^Everyday checking\b/ }).click(); + await disclosure.getByRole('link', { name: /^Net worth/ }).click(); + await expect(page).toHaveURL(/\/reports\/networth$/); + const period = page.getByRole('combobox', { name: 'Net worth period', exact: true }); + const netWorth = page.getByRole('table', { name: 'Net worth by valuation date', exact: true }); + await expect(period).toHaveValue('3'); + await expect(netWorth.locator('tbody tr')).toHaveCount(3); + await period.selectOption('1'); + await page.getByRole('button', { name: 'Apply period', exact: true }).click(); + await expect(page).toHaveURL(/\/reports\/networth\?months=1$/); + await expect(period).toHaveValue('1'); + await expect(netWorth.locator('tbody tr')).toHaveCount(1); + await expect(netWorth.locator('tbody tr').getByRole('rowheader')).toHaveText('2026-04-16'); + await expect(netWorth.locator('tbody tr').getByRole('cell')).toHaveText([ + '11825.00', + 'USD', + 'No unconverted units' + ]); + // Real SSR query navigation must retain the footer's current-page semantics. + await disclosure + .locator('summary') + .filter({ hasText: /^Accounts$/ }) + .click(); + await expect(disclosure.getByRole('link', { name: /^Net worth/ })).toHaveAttribute( + 'aria-current', + 'page' + ); + + await page + .getByRole('navigation', { name: 'Primary', exact: true }) + .getByRole('link', { name: 'Settings', exact: true }) + .click(); + await expect(primary.getByRole('link', { name: 'Settings', exact: true })).toHaveAttribute( + 'aria-current', + 'page' + ); + const journal = page.getByRole('region', { name: 'Journal summary', exact: true }); + const span = journal.getByText('Full date span', { exact: true }).locator('..'); + await expect(span.locator('time').first()).toHaveAttribute('datetime', '2026-01-15'); + await expect(span.locator('time').last()).toHaveAttribute('datetime', '2026-04-16'); await expect( - page.locator('main').getByRole('heading', { name: 'Everyday checking', exact: true }) - ).toBeVisible(); - await expect(page.locator('main time')).toHaveAttribute('datetime', preparedAt); - await accountsToggle.click(); - await disclosure.getByRole('link', { name: /^Brokerage\b/ }).click(); + journal.getByText('Total transactions', { exact: true }).locator('..').getByRole('definition') + ).toHaveText('11'); await expect( - page.locator('main').getByRole('heading', { name: 'Brokerage', exact: true }) - ).toBeVisible(); + journal.getByText('hledger version', { exact: true }).locator('..').getByRole('definition') + ).toHaveText('1.99.5'); + await expect( + journal.getByText('Report currency', { exact: true }).locator('..').getByRole('definition') + ).toHaveText('USD'); + await expect(journal.getByLabel('Saved journal validation', { exact: true })).toContainText( + 'Passed' + ); + await expect( + journal.getByLabel('Journal validation completed at', { exact: true }) + ).toHaveAttribute('datetime', /.+/); + expect(await readPreparationTimestamp(page)).toBe(preparedAt); expect(posts).toEqual(['/settings']); }); diff --git a/apps/web/e2e/overview-fixture.ts b/apps/web/e2e/overview-fixture.ts index 2fc0724..81e8bb5 100644 --- a/apps/web/e2e/overview-fixture.ts +++ b/apps/web/e2e/overview-fixture.ts @@ -1,17 +1,14 @@ import { expect, type Page } from '@playwright/test'; const preparationStamp = (page: Page) => - page - .getByRole('region', { name: 'Income vs expenses', exact: true }) - .getByLabel('Financial reports prepared at', { exact: true }); + page.getByLabel('Financial reports prepared at', { exact: true }); /** Explicit user action, including for external fictional-fixture smoke tests. */ export async function prepareReports(page: Page): Promise { - await page.goto('/'); - const stamp = preparationStamp(page); - const previous = (await stamp.count()) ? await stamp.getAttribute('datetime') : null; await page.goto('/settings'); await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); + const stamp = preparationStamp(page); + const previous = (await stamp.count()) ? await stamp.getAttribute('datetime') : null; await Promise.all([ page.waitForResponse( (response) => @@ -28,8 +25,7 @@ export async function prepareReports(page: Page): Promise { .poll( async () => { await page.reload(); - const time = status.getByLabel('Financial reports prepared at', { exact: true }); - const current = (await time.count()) ? await time.getAttribute('datetime') : null; + const current = (await stamp.count()) ? await stamp.getAttribute('datetime') : null; return ( current !== null && current !== previous && @@ -39,9 +35,7 @@ export async function prepareReports(page: Page): Promise { { timeout: 25_000, intervals: [100, 250, 500] } ) .toBe(true); - const preparedAt = (await status - .getByLabel('Financial reports prepared at', { exact: true }) - .getAttribute('datetime'))!; + const preparedAt = (await stamp.getAttribute('datetime'))!; await page.goto('/'); return preparedAt; } @@ -49,10 +43,11 @@ export async function prepareReports(page: Page): Promise { // Financial cases explicitly prepare only when there is no saved report. export async function prepared(page: Page) { await page.goto('/'); - if (!(await preparationStamp(page).count())) await prepareReports(page); - await expect( - page.getByRole('region', { name: 'Income vs expenses', exact: true }).getByRole('table') - ).toBeVisible(); + const table = page + .getByRole('region', { name: 'Income and spending', exact: true }) + .getByRole('table'); + if (!(await table.count())) await prepareReports(page); + await expect(table).toBeVisible(); } export async function readPreparationTimestamp(page: Page): Promise { diff --git a/apps/web/e2e/permissions.js b/apps/web/e2e/permissions.js new file mode 100644 index 0000000..fc11799 --- /dev/null +++ b/apps/web/e2e/permissions.js @@ -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; +} diff --git a/apps/web/e2e/prepare.js b/apps/web/e2e/prepare.js new file mode 100644 index 0000000..4964954 --- /dev/null +++ b/apps/web/e2e/prepare.js @@ -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; + } +} diff --git a/apps/web/e2e/server.mjs b/apps/web/e2e/server.js similarity index 60% rename from apps/web/e2e/server.mjs rename to apps/web/e2e/server.js index dff5457..d4678cf 100644 --- a/apps/web/e2e/server.mjs +++ b/apps/web/e2e/server.js @@ -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', diff --git a/apps/web/e2e/web.mjs b/apps/web/e2e/web.js similarity index 100% rename from apps/web/e2e/web.mjs rename to apps/web/e2e/web.js diff --git a/apps/web/package.json b/apps/web/package.json index d5965e5..0b6afe8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@eslint/compat": "2.1.0", "@eslint/js": "catalog:", + "@iconify-json/ph": "1.2.2", "@playwright/test": "1.61.1", "@sveltejs/adapter-node": "6.0.1", "@sveltejs/kit": "3.1.1", @@ -50,6 +51,7 @@ "svelte-check": "4.7.7", "typescript": "catalog:", "typescript-eslint": "catalog:", + "unplugin-icons": "23.0.1", "vite": "catalog:", "vitest": "catalog:" }, diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 4caf4f4..25a3c07 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -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 diff --git a/apps/web/src/app.html b/apps/web/src/app.html index b8ba469..3a6eff9 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -3,6 +3,7 @@ + %sveltekit.head% diff --git a/apps/web/src/lib/charts/ChartCanvas.svelte b/apps/web/src/lib/charts/ChartCanvas.svelte index df71fc7..91cee89 100644 --- a/apps/web/src/lib/charts/ChartCanvas.svelte +++ b/apps/web/src/lib/charts/ChartCanvas.svelte @@ -36,12 +36,14 @@ let { label, + describedBy, makeConfig, height = 280 }: { label: string; + describedBy?: string; makeConfig: (theme: ChartTheme) => CanvasConfiguration | null; - height?: number; + height?: number | 'fill'; } = $props(); let theme = $state(); const config = $derived(theme ? makeConfig(theme) : undefined); @@ -65,17 +67,17 @@ }); -
-
{label}
+
+
{label}
{#if config === null}

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

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

Net worth

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

Expenses by category

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

No dated transactions.

- {:else} -

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

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

No reported expenses in this window.

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

No dated value available.

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

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

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

No reported amounts.

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

Income vs expenses

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

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

-

Latest recorded transaction: .

- {/if} -

- Prepared at . -

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

No dated transactions.

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

No reported income or expenses to chart.

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

Journal coverage

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

Net worth

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

No dated transactions.

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

No reported net-worth valuations in this window.

- {:else} -

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

- {#if incomplete} -

Incomplete valuations: unconverted commodities.

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

No dated transactions.

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

No reported income or expenses to chart.

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

No dated transactions.

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

No reported expenses in this window.

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

Top five; any remainder grouped as Other.

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

Spending by category, in chart order:

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

Saved journal validation couldn't be read.

- {:else if validation} -

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

-

Recorded separately from saved reports.

- {:else} -

No recorded journal check available.

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

{title}

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

No dated transactions.

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

No reported net-worth valuations in this window.

+ {:else} +

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

+ {#if incomplete} +

Incomplete valuations: unconverted commodities.

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

Journal

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

Includes posting and secondary dates.

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

Saved report

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

No saved report available.

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

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

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

Overview

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

+ + – +

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

{:else} - -

Settings — prepare saved reports.

{#if data.readFailed}

Saved reports couldn't be read.

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

No prepared reports available.

{/if} +
+ {#if overview && period} + + {/if} +
+ {#if overview && period} + + + {/if} +
+
{/if}
+ + diff --git a/apps/web/src/routes/(protected)/holdings/page.server.test.ts b/apps/web/src/routes/(protected)/holdings/page.server.test.ts index c3cb0ab..464e4ba 100644 --- a/apps/web/src/routes/(protected)/holdings/page.server.test.ts +++ b/apps/web/src/routes/(protected)/holdings/page.server.test.ts @@ -49,6 +49,7 @@ const nonInvestmentSettings: ApplicationContext['config']['accounts'] = [ const anchorRange: OverviewRange = { begin: '2026-04-01', endExclusive: '2026-04-17', + transactionCount: 1, income: [], expenses: [], net: [], @@ -85,6 +86,7 @@ const publication: ReportPublication = { asOf: '2026-04-16', preparedAt: '2026-05-01T12:34:56Z', journalCoverage: { transactionCount: 3, firstDate: '2026-04-01', lastDate: '2026-04-16' }, + hledgerVersion: '1.99.5', ranges: { 1: anchorRange, 3: { ...anchorRange, begin: '2026-02-01' }, diff --git a/apps/web/src/routes/(protected)/layout.test.ts b/apps/web/src/routes/(protected)/layout.test.ts index 51a82c0..3c49650 100644 --- a/apps/web/src/routes/(protected)/layout.test.ts +++ b/apps/web/src/routes/(protected)/layout.test.ts @@ -22,6 +22,7 @@ it('projects configured sidebar data and labels without queue access, session da asOf: null, preparedAt, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, incomeExpenses: { months: [] }, diff --git a/apps/web/src/routes/(protected)/page.server.test.ts b/apps/web/src/routes/(protected)/page.server.test.ts index 0ec2f2a..46d3eff 100644 --- a/apps/web/src/routes/(protected)/page.server.test.ts +++ b/apps/web/src/routes/(protected)/page.server.test.ts @@ -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'); -}); diff --git a/apps/web/src/routes/(protected)/queries/+page.svelte b/apps/web/src/routes/(protected)/queries/+page.svelte index 3c4d57d..0722e7a 100644 --- a/apps/web/src/routes/(protected)/queries/+page.svelte +++ b/apps/web/src/routes/(protected)/queries/+page.svelte @@ -66,9 +66,8 @@

Journal configuration is unavailable

- Set the server's CONFIG_PATH explicitly to - apps/worker/fixtures/config/hledger-reports.yaml - (absolute path) and start the fixture worker. No personal configuration is discovered. + Set the server's CONFIG_PATH explicitly to your deployment configuration's absolute + path and start its worker. No personal configuration is discovered.

{:else} diff --git a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts index e5fb755..989da39 100644 --- a/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/[accountId]/page.server.test.ts @@ -33,6 +33,7 @@ const publication: ReportPublication = { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, netWorth: { currency: null, points: [] }, diff --git a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts index 769ea88..487a08d 100644 --- a/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/cash/page.server.test.ts @@ -34,6 +34,7 @@ const publication: ReportPublication = { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, netWorth: { currency: null, points: [] }, diff --git a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts index 090e4e0..e6312c3 100644 --- a/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/[accountId]/page.server.test.ts @@ -33,6 +33,7 @@ const publication: ReportPublication = { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, netWorth: { currency: null, points: [] }, diff --git a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts index 5652952..ff50027 100644 --- a/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts +++ b/apps/web/src/routes/(protected)/registers/investments/page.server.test.ts @@ -34,6 +34,7 @@ const publication: ReportPublication = { preparedAt: '2026-05-01T12:34:56Z', asOf: null, journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + hledgerVersion: '1.99.5', ranges: null, balanceSheet: null, netWorth: { currency: null, points: [] }, diff --git a/apps/web/src/routes/(protected)/reports/networth/+page.server.ts b/apps/web/src/routes/(protected)/reports/networth/+page.server.ts new file mode 100644 index 0000000..2bf9aa1 --- /dev/null +++ b/apps/web/src/routes/(protected)/reports/networth/+page.server.ts @@ -0,0 +1,32 @@ +import { error } from '@sveltejs/kit'; +import { readSavedReports } from '#lib/server/reports.js'; +import { parseReportMonths } from '#lib/report-period/period-query.js'; +import type { OverviewMonths } from '@hledger-reports/hledger'; +import type { PageServerLoad } from './$types'; + +export const load = (async (event) => { + event.setHeaders({ 'cache-control': 'no-store' }); + let months: OverviewMonths; + try { + months = parseReportMonths(event.url.searchParams); + } catch { + error(400, 'Invalid report period.'); + } + await event.parent(); + const { publication, readFailed } = await readSavedReports(event.locals); + const saved = publication?.overview; + const report = saved + ? { + asOf: saved.asOf, + netWorth: { + currency: saved.netWorth.currency, + points: saved.netWorth.points.map((point) => ({ + date: point.date, + amounts: point.amounts.map(({ commodity, quantity }) => ({ commodity, quantity })), + unconverted: [...point.unconverted] + })) + } + } + : null; + return { report, months, configured: event.locals.application !== null, readFailed }; +}) satisfies PageServerLoad; diff --git a/apps/web/src/routes/(protected)/reports/networth/+page.svelte b/apps/web/src/routes/(protected)/reports/networth/+page.svelte new file mode 100644 index 0000000..dc90f66 --- /dev/null +++ b/apps/web/src/routes/(protected)/reports/networth/+page.svelte @@ -0,0 +1,79 @@ + + + + Net worth — hledger reports + + + +
+
+

Net worth

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

As of

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

No configuration selected.

+

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

+
+ {:else if data.readFailed} +

Saved reports couldn't be read.

+

+ Visit Settings to prepare reports explicitly. +

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

No prepared reports available.

+

+ Visit Settings to prepare reports explicitly. +

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