From fd384b022d72a52d7829294468fecb879bdd2efe Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 4 Nov 2026 15:50:54 -0800 Subject: [PATCH] feat: move report tools to settings and record validation --- .changeset/record-validation-and-settings.md | 6 + README.md | 13 +- apps/web/README.md | 66 +++--- apps/web/e2e/manual-preparation.e2e.ts | 2 +- apps/web/e2e/overview-fixture.ts | 16 +- .../src/lib/overview/IncomeExpenses.svelte | 4 +- .../lib/preparation/ValidationStatus.svelte | 26 +++ .../lib/preparation/ValidationStatus.test.ts | 38 +++ apps/web/src/lib/server/auth/gate.test.ts | 2 +- apps/web/src/lib/server/reports.test.ts | 113 +++++++++ apps/web/src/lib/server/reports.ts | 25 +- .../web/src/routes/(protected)/+layout.svelte | 6 +- .../src/routes/(protected)/+page.server.ts | 27 ++- apps/web/src/routes/(protected)/+page.svelte | 4 + .../routes/(protected)/holdings/+page.svelte | 12 +- .../routes/(protected)/page.server.test.ts | 63 ++++- .../(protected)/registers/cash/+page.svelte | 12 +- .../registers/cash/[accountId]/+page.svelte | 13 +- .../registers/investments/+page.svelte | 12 +- .../investments/[accountId]/+page.svelte | 12 +- .../{prepare => settings}/+page.server.ts | 21 +- .../{prepare => settings}/+page.svelte | 24 +- .../{prepare => settings}/page.server.test.ts | 55 ++++- .../routes/(protected)/settings/page.test.ts | 35 +++ apps/worker/README.md | 34 ++- .../prepare-reports/run.integration.test.ts | 28 ++- .../src/workflows/prepare-reports/run.test.ts | 80 ++++++- .../src/workflows/prepare-reports/run.ts | 23 +- docs/architecture.md | 101 +++++--- docs/development.md | 37 ++- docs/philosophy.md | 28 ++- packages/hledger/README.md | 53 +++-- packages/hledger/src/checks.test.ts | 68 +++++- packages/hledger/src/checks.ts | 20 +- packages/hledger/src/index.test.ts | 13 -- packages/hledger/src/index.ts | 7 +- packages/hledger/src/process.test.ts | 71 +++++- packages/hledger/src/process.ts | 28 ++- packages/snapshots/README.md | 69 +++++- packages/snapshots/src/files.ts | 63 +++++ packages/snapshots/src/index.ts | 2 + packages/snapshots/src/reports.test.ts | 9 - packages/snapshots/src/reports.ts | 63 +---- packages/snapshots/src/validation.test.ts | 220 ++++++++++++++++++ packages/snapshots/src/validation.ts | 104 +++++++++ 45 files changed, 1426 insertions(+), 302 deletions(-) create mode 100644 .changeset/record-validation-and-settings.md create mode 100644 apps/web/src/lib/preparation/ValidationStatus.svelte create mode 100644 apps/web/src/lib/preparation/ValidationStatus.test.ts create mode 100644 apps/web/src/lib/server/reports.test.ts rename apps/web/src/routes/(protected)/{prepare => settings}/+page.server.ts (73%) rename apps/web/src/routes/(protected)/{prepare => settings}/+page.svelte (61%) rename apps/web/src/routes/(protected)/{prepare => settings}/page.server.test.ts (76%) create mode 100644 apps/web/src/routes/(protected)/settings/page.test.ts delete mode 100644 packages/hledger/src/index.test.ts create mode 100644 packages/snapshots/src/files.ts create mode 100644 packages/snapshots/src/validation.test.ts create mode 100644 packages/snapshots/src/validation.ts diff --git a/.changeset/record-validation-and-settings.md b/.changeset/record-validation-and-settings.md new file mode 100644 index 0000000..1bf7068 --- /dev/null +++ b/.changeset/record-validation-and-settings.md @@ -0,0 +1,6 @@ +--- +'@hledger-reports/web': patch +'@hledger-reports/worker': patch +--- + +Move report preparation and the Live queries entry into Settings. Record each completed strict journal check before preparation and show its last saved outcome and time independently of financial snapshots. Incomplete checks retain earlier evidence, and browsing never checks journals or queues work. Existing prepared-report format and financial views are unchanged. diff --git a/README.md b/README.md index f83095a..58dbf0b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A local, read-only hledger reporter. hledger owns accounting; OpenWorkflow + SQL ## Overview, transactions and live queries -**Overview (`/`) is the prepared financial summary:** monthly income-vs-expenses bars, depth-2 expense-category bars and a native market-valued net-worth trend, with exact supporting tables, for the latest six calendar months of the journal: the month of its latest recorded transaction and the previous five. Each page reads the latest complete current-format saved report. Without one, it says “No prepared reports available.” and links to manual preparation. **Reload overview** only reads saved data; it never starts calculations or checks journal changes. Pending or failed preparation leaves previous data available. +**Overview (`/`) is the prepared financial summary:** monthly income-vs-expenses bars, depth-2 expense-category bars and a native market-valued net-worth trend, with exact supporting tables, for the latest six calendar months of the journal: the month of its latest recorded transaction and the previous five. Each page reads the latest complete current-format saved report. Without one, it says “No prepared reports available.” and links to manual preparation. **Reload overview** only reads saved data; it never starts calculations or checks journal changes. Pending preparation keeps saved data available; a failed job does not necessarily mean no new set was committed. 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. @@ -14,9 +14,11 @@ The hledger library reads the journal and organizes neutral, table/chart-ready ` **Cash and Investment registers list their explicitly configured accounts.** Select one for prepared activity with ordinary pagination: 50 rows for Cash, or 50 whole transactions for Investments. Dates, exact changes and running balances come from hledger and do not reset on page boundaries. The web reads the native account directory in `overview.json`, opens the selected account's complete JSON file, and projects only the requested page. Friendly labels do not change native identity or report scope. Browsing never executes native work; Overview and all native account activity still publish together as one complete set. -**Prepare reports (`/prepare`) explicitly requests work.** Submit the ordinary form to prepare Overview and all account registers together. Reload that page to check job status, then navigate to the saved reports. 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. +**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. -**`/queries` remains a 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. +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. @@ -34,7 +36,7 @@ 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** 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. +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. 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. @@ -76,10 +78,11 @@ Account identities and labels are literal strings, with exact matching and no en 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 a stable eight-character lowercase-letter/digit ID, full name, access state and row count. - `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. -Files are prepared together in a hidden staging set and published as a complete directory; `current` is the inspectable entry point. Readers accept only complete format-4 publications for the selected journal. Previous table-only snapshots require explicit preparation again. Unsupported older files are ignored and left untouched; prepare reports manually to create a current set. Readers do not migrate, delete or automatically regenerate files. Former source-observation files are also left untouched and are not read or written. +Financial files are prepared together in a hidden staging set and published as a complete directory; `current` is the inspectable entry point. Readers accept only complete format-5 publications for the selected journal; this change does not bump the financial format or require re-preparing supported sets. Unsupported older files are ignored and left untouched; explicitly prepare reports in Settings to create a current set. Readers do not migrate, delete or automatically regenerate files. Former source-observation files are also left untouched and are not read or written. A failed preparation can still leave a new complete report available; reload saved reports to observe the result. 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#prepared-reports) covers growth, last-good recovery and untouched unsupported files. diff --git a/apps/web/README.md b/apps/web/README.md index 65e088f..8a546ba 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -7,24 +7,37 @@ See the [architecture](../../docs/architecture.md) and ## Pages -| Page | Behavior | -| -------------------------------------------------- | ---------------------------------------------------------------------------------- | -| `/` | Read saved Overview: income/expenses, categories, net worth and journal coverage | -| `/transactions` | Read the saved directory of native declared/used accounts | -| `/transactions/account?account=&page=` | Read one complete account file and return a 50-row slice | -| `/prepare` | Show preparation status at page load; ordinary POST form explicitly requests a job | -| `/queries` | Explicit native recorded-cost balance Run; reload clears the displayed result | +| 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 | -Overview and Transactions do not query job status, hash inputs, infer freshness or -submit work. Without saved data, they say it has not been prepared and link to -Prepare reports. Existing reports remain visible if the worker is stopped or a new -preparation fails. After editing the journal, explicitly prepare again. +Overview and registers do not query job status, hash inputs, infer freshness or +submit work. Without saved financial data, they say it has not been prepared and +link to Settings. Saved files remain readable if the worker is stopped. After +editing the journal, explicitly prepare again. The six-month Overview and register +presentation are unchanged; there are no new period controls or account sidebar. -The 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 form works without JavaScript. There is no polling, -fetch controller, automatic invalidation or Overview/Transactions API. Reload the -preparation page to check status, then navigate to saved reports. +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, automatic invalidation or Overview/Transactions API. Settings links +to the existing `/queries` screen instead of a standalone Live queries utility-nav +entry; this adds no query history or new navigation feature. + +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. ## Account labels and register membership @@ -96,18 +109,18 @@ data/snapshots/current/ Overview contains the selected journal identity and account directory. One whole account file contains all native rows. The worker publishes a complete immutable set; a request pins that set before reading. Private journal identities and set -paths never enter page data. Format 4 also retains full exported transaction/posting -metadata and private source/assertion positions. The existing Transactions table -still receives only its displayed fields: date, description, related accounts, -split flag and exact quantity/commodity pairs. Projection happens after the -50-row slice, mapping stored `accountDate` to public `date`; stored records are -never passed directly into PageData. +paths never enter page data. Financial format **5** retains 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. -Only format 4 is read. Previous table-only snapshots require explicit preparation -again. Unsupported saved files are left untouched; use Prepare reports to create +Financial format remains **5**, with no re-preparation requirement for supported sets. +Unsupported saved files are left untouched; use Prepare reports in Settings to create a current complete set. There is no older-format fallback, partial-card compatibility or automatic migration. -Reports for a different configured journal are withheld. +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#prepared-reports) and the [old-volume copy guide](../../docs/development.md#inspecting-data-and-preserving-the-previous-volume). @@ -168,7 +181,7 @@ 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. From the repository root: +Open **http://127.0.0.1:5173/** and use Prepare reports in Settings. From the repository root: ```sh pnpm build:packages @@ -184,6 +197,7 @@ src/lib/ charts/ Chart canvas and configurations named by chart overview/ Financial summary panels registers/ Cash and investment presentation + preparation/ Shared last recorded validation presentation queries/ Live-query result presentation server/ Server-only request and saved-report helpers styles/ Document reset/theme, layout mechanics and native controls diff --git a/apps/web/e2e/manual-preparation.e2e.ts b/apps/web/e2e/manual-preparation.e2e.ts index 984d72a..91fbf66 100644 --- a/apps/web/e2e/manual-preparation.e2e.ts +++ b/apps/web/e2e/manual-preparation.e2e.ts @@ -19,5 +19,5 @@ test('explicitly prepares reports and browses saved Overview without JavaScript' await page.reload(); await expect(income.getByRole('table')).toContainText('1000.00'); expect(await readPreparationTimestamp(page)).toBe(preparedAt); - expect(posts).toEqual(['/prepare']); + expect(posts).toEqual(['/settings']); }); diff --git a/apps/web/e2e/overview-fixture.ts b/apps/web/e2e/overview-fixture.ts index 64079f2..2fc0724 100644 --- a/apps/web/e2e/overview-fixture.ts +++ b/apps/web/e2e/overview-fixture.ts @@ -3,21 +3,19 @@ import { expect, type Page } from '@playwright/test'; const preparationStamp = (page: Page) => page .getByRole('region', { name: 'Income vs expenses', exact: true }) - .locator('p') - .filter({ hasText: /^Prepared at / }) - .locator('time'); + .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('/prepare'); - await expect(page.getByRole('heading', { name: 'Prepare reports', exact: true })).toBeVisible(); + await page.goto('/settings'); + await expect(page.getByRole('heading', { name: 'Settings', exact: true })).toBeVisible(); await Promise.all([ page.waitForResponse( (response) => - new URL(response.url()).pathname === '/prepare' && + new URL(response.url()).pathname === '/settings' && response.request().method() === 'POST' && response.request().isNavigationRequest() && response.request().resourceType() === 'document' @@ -30,7 +28,7 @@ export async function prepareReports(page: Page): Promise { .poll( async () => { await page.reload(); - const time = status.locator('time'); + const time = status.getByLabel('Financial reports prepared at', { exact: true }); const current = (await time.count()) ? await time.getAttribute('datetime') : null; return ( current !== null && @@ -41,7 +39,9 @@ export async function prepareReports(page: Page): Promise { { timeout: 25_000, intervals: [100, 250, 500] } ) .toBe(true); - const preparedAt = (await status.locator('time').getAttribute('datetime'))!; + const preparedAt = (await status + .getByLabel('Financial reports prepared at', { exact: true }) + .getAttribute('datetime'))!; await page.goto('/'); return preparedAt; } diff --git a/apps/web/src/lib/overview/IncomeExpenses.svelte b/apps/web/src/lib/overview/IncomeExpenses.svelte index 8ff6cc5..eba38c0 100644 --- a/apps/web/src/lib/overview/IncomeExpenses.svelte +++ b/apps/web/src/lib/overview/IncomeExpenses.svelte @@ -21,7 +21,9 @@

Latest recorded transaction: .

{/if}

- Prepared at . + Prepared at .

{#if overview.asOf === null}

No dated transactions.

diff --git a/apps/web/src/lib/preparation/ValidationStatus.svelte b/apps/web/src/lib/preparation/ValidationStatus.svelte new file mode 100644 index 0000000..cb39dd0 --- /dev/null +++ b/apps/web/src/lib/preparation/ValidationStatus.svelte @@ -0,0 +1,26 @@ + + +
+ {#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 new file mode 100644 index 0000000..42a1a23 --- /dev/null +++ b/apps/web/src/lib/preparation/ValidationStatus.test.ts @@ -0,0 +1,38 @@ +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/server/auth/gate.test.ts b/apps/web/src/lib/server/auth/gate.test.ts index 57d2d79..ff80c2e 100644 --- a/apps/web/src/lib/server/auth/gate.test.ts +++ b/apps/web/src/lib/server/auth/gate.test.ts @@ -65,7 +65,7 @@ it('requires a session for protected and unclassified application routes, includ it('rejects anonymous endpoints and actions instead of resolving them', async () => { for (const input of [ setup('/(protected)/(endpoints)/api/queries'), - setup('/(protected)/prepare', 'POST') + setup('/(protected)/settings', 'POST') ]) { const response = await requireSignIn(input); expect(response.status).toBe(401); diff --git a/apps/web/src/lib/server/reports.test.ts b/apps/web/src/lib/server/reports.test.ts new file mode 100644 index 0000000..4b7fef5 --- /dev/null +++ b/apps/web/src/lib/server/reports.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +import { + readReportPublication, + readValidation, + type ReportPublication, + type ValidationRecord +} from '@hledger-reports/snapshots'; +import { readSavedReports, readSavedValidation } from './reports.js'; + +vi.mock('@hledger-reports/snapshots', () => ({ + readReportPublication: vi.fn(), + readValidation: vi.fn() +})); +vi.mock('@hledger-reports/jobs', () => { + throw new Error('Saved reads must not load jobs.'); +}); +vi.mock('@hledger-reports/hledger', () => { + throw new Error('Saved reads must not execute native reports.'); +}); + +const directory = '/PRIVATE/snapshots'; +const journal = '/PRIVATE/journal'; +const record: ValidationRecord = { + format: 1, + journal, + request: { id: 'PRIVATE-request', createdAt: '2026-05-01T12:00:00Z' }, + result: { outcome: 'passed', completedAt: '2026-05-01T12:01:00Z' } +}; +const publication: ReportPublication = { + journal, + directory: '/PRIVATE/set', + accounts: [], + overview: { + asOf: null, + preparedAt: '2026-04-01T12:00:00Z', + journalCoverage: { transactionCount: 0, firstDate: null, lastDate: null }, + incomeExpenses: { months: [] }, + expenseCategories: { depth: 2, rows: [] }, + netWorth: { currency: null, points: [] } + } +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(readValidation).mockResolvedValue(record); + vi.mocked(readReportPublication).mockResolvedValue(publication); +}); + +it('projects only the saved outcome and completion time for the configured journal', async () => { + // Even extra private fields must not cross the server projection boundary. + vi.mocked(readValidation).mockResolvedValue({ + ...record, + result: { ...record.result, privateDiagnostic: 'PRIVATE error' } + } as ValidationRecord); + const saved = await readSavedValidation(directory, journal); + expect(saved).toEqual({ validation: record.result, readFailed: false }); + expect(saved.validation).not.toBe(record.result); + expect(readValidation).toHaveBeenCalledExactlyOnceWith(directory); + expect(JSON.stringify(saved)).not.toMatch(/PRIVATE|request|format|journal|privateDiagnostic/); + expect(readReportPublication).not.toHaveBeenCalled(); +}); + +it('preserves a recorded could-not-validate outcome rather than inventing a pass', async () => { + const result = { ...record.result, outcome: 'could-not-validate' as const }; + vi.mocked(readValidation).mockResolvedValue({ ...record, result }); + expect(await readSavedValidation(directory, journal)).toEqual({ + validation: result, + readFailed: false + }); +}); + +it('withholds missing or unrelated evidence, comparing private journal identity exactly', async () => { + vi.mocked(readValidation).mockResolvedValueOnce(null); + expect(await readSavedValidation(directory, journal)).toEqual({ + validation: null, + readFailed: false + }); + expect(await readSavedValidation(directory, journal.toUpperCase())).toEqual({ + validation: null, + readFailed: false + }); +}); + +it('keeps financial reports available when the independent validation read fails', async () => { + vi.mocked(readValidation).mockRejectedValue(new Error('PRIVATE corrupt evidence')); + const [reports, validation] = await Promise.all([ + readSavedReports(directory, journal), + readSavedValidation(directory, journal) + ]); + expect(reports).toEqual({ publication, readFailed: false }); + expect(validation).toEqual({ validation: null, readFailed: true }); +}); + +it('keeps validation available when saved financial reports fail, without leaking errors', async () => { + vi.mocked(readReportPublication).mockRejectedValue(new Error('PRIVATE corrupt reports')); + expect(await readSavedReports(directory, journal)).toEqual({ + publication: null, + readFailed: true + }); + expect(await readSavedValidation(directory, journal)).toEqual({ + validation: record.result, + readFailed: false + }); +}); + +it('withholds a financial publication from another journal without changing its private read API', async () => { + expect(await readSavedReports(directory, '/PRIVATE/other')).toEqual({ + publication: null, + readFailed: false + }); + expect(readReportPublication).toHaveBeenCalledExactlyOnceWith(directory); + expect(readValidation).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/lib/server/reports.ts b/apps/web/src/lib/server/reports.ts index 7624b47..ae2fa2b 100644 --- a/apps/web/src/lib/server/reports.ts +++ b/apps/web/src/lib/server/reports.ts @@ -1,4 +1,27 @@ -import { readReportPublication, type ReportPublication } from '@hledger-reports/snapshots'; +import { + readReportPublication, + readValidation, + type JournalValidationResult, + type ReportPublication +} from '@hledger-reports/snapshots'; + +// Validation is independent of financial publication and queue status. +export async function readSavedValidation( + snapshotDirectory: string, + journal: string +): Promise<{ validation: JournalValidationResult | null; readFailed: boolean }> { + try { + const record = await readValidation(snapshotDirectory); + if (!record || record.journal !== journal) return { validation: null, readFailed: false }; + const validation = { + outcome: record.result.outcome, + completedAt: record.result.completedAt + }; + return { validation, readFailed: false }; + } catch { + return { validation: null, readFailed: true }; + } +} // Private publication context must never be included in page data. export async function readSavedReports( diff --git a/apps/web/src/routes/(protected)/+layout.svelte b/apps/web/src/routes/(protected)/+layout.svelte index f74957e..fed9c6e 100644 --- a/apps/web/src/routes/(protected)/+layout.svelte +++ b/apps/web/src/routes/(protected)/+layout.svelte @@ -37,11 +37,9 @@
{data.session?.user.name} diff --git a/apps/web/src/routes/(protected)/+page.server.ts b/apps/web/src/routes/(protected)/+page.server.ts index 6e83796..057347a 100644 --- a/apps/web/src/routes/(protected)/+page.server.ts +++ b/apps/web/src/routes/(protected)/+page.server.ts @@ -1,15 +1,28 @@ -import { readSavedReports } from '#lib/server/reports.js'; +import { readSavedReports, readSavedValidation } from '#lib/server/reports.js'; import type { PageServerLoad } from './$types'; export const load = (async (event) => { event.setHeaders({ 'cache-control': 'no-store' }); const { application } = event.locals; if (!application) { - return { configured: false, overview: null, readFailed: false }; + return { + configured: false, + overview: null, + readFailed: false, + validation: null, + validationReadFailed: false + }; } - const { publication, readFailed } = await readSavedReports( - application.paths.snapshotDirectory, - application.config.journal - ); - return { configured: true, overview: publication?.overview ?? null, readFailed }; + const [{ publication, readFailed }, { validation, readFailed: validationReadFailed }] = + await Promise.all([ + readSavedReports(application.paths.snapshotDirectory, application.config.journal), + readSavedValidation(application.paths.snapshotDirectory, application.config.journal) + ]); + return { + configured: true, + overview: publication?.overview ?? null, + readFailed, + validation, + validationReadFailed + }; }) satisfies PageServerLoad; diff --git a/apps/web/src/routes/(protected)/+page.svelte b/apps/web/src/routes/(protected)/+page.svelte index 111687b..9b44715 100644 --- a/apps/web/src/routes/(protected)/+page.svelte +++ b/apps/web/src/routes/(protected)/+page.svelte @@ -1,4 +1,6 @@ - Prepare reports — hledger reports + Settings — hledger reports
-

Prepare reports

+

Settings

{#if !data.configured}

No configuration selected.

@@ -20,7 +21,8 @@ is loaded by default.

{:else} -
+

Report preparation

+

Status when this page loaded.

{#if data.preparation === 'pending'}

Report preparation was queued. Waiting for the worker.

@@ -28,8 +30,8 @@

Worker was preparing reports.

{:else if data.preparation === 'failed'}

- Report preparation failed. Any previously saved reports are unchanged. Check the journal - and worker, then prepare reports again to retry. + Report preparation failed. Reload saved reports to see the available result. Check the + journal and worker, then prepare reports again to retry.

{:else if data.preparation === 'unavailable'}

Report preparation status is unavailable.

@@ -38,17 +40,23 @@ {/if} {#if data.preparedAt}

- Last saved reports prepared at . + Last saved reports prepared at .

{/if} -
-
+ + +

Reload status

+ {/if} {#if form?.message}

{form.message}

{/if} +

Live queries runs reports on demand.