SPEC v0.28: web client programme (D-129 to D-134), plans W0 to W7

New SPEC §15 Web client and FR-21; §1, §2, G-5, AC-14.1 revised; §8.5 and
§10.5.2 gate lines for /app/, the invite accept and bearer requests (except
/health, /setup/, /i/, /apk/) plus the /app/ CSP via $kvitt_csp, identical in
infra/npm/kvitt.conf and infra/README.md step 4 (checked with nginx 1.31.7);
§10.3, §10.6, §11.1 to §11.3 (rows W0 to W7), §14.7 ownership; change log is
now §16. PLAN-W0..W7 and prompts/W0..W7; HANDOFF numbering D-139 / v0.30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 20:47:42 +02:00
parent 70cb25c5d0
commit 7bddc50a53
21 changed files with 892 additions and 38 deletions
+72
View File
@@ -0,0 +1,72 @@
# PLAN-W0: Web foundation
Status: running since 2026-10-21 from the orchestrator's brief (written before this plan). Where the running agent's brief and this plan differ, the brief governs this run and the W0 report records the difference; the plan is what a rerun or a W0b follow-up uses.
## 1. Goal
Create the web client's foundation in `web/` (SPEC §15, D-129 to D-134): a Vite + React 19 + TypeScript project with the design tokens, the layout shell, the router with every route, login and the token store, the API client and the household snapshot, i18n, formatting, the shared components, the money functions passing the vectors, the test setup and the web gate. After W0 lands, W1 to W5 build their pages in parallel inside their own feature folders and take everything shared from W0.
## 2. Read first
1. SPEC §15 (all of it), FR-21, §8.2, §8.3, §8.5 (the D-130 paragraph), §14.3.1, §14.3.2, §14.4.1 to §14.4.3, §14.4.6, §14.4.8, §9.8 Formatting, §9.6 Limits, §9.9, §13.1, §13.3 (rows S-1, S-18), §14.5.2 and §14.5.5.
2. `docs/ux/design-spec.md` §1, §2 (all tokens), §3.1 to §3.6 (rules, money and words, people, categories, ledger, balance), and S-1 and S-18 in §4.
3. `docs/ux/strings.csv` (keys and texts for the shared strings, S-1 and S-18).
4. `contracts/openapi.yaml`, `contracts/test-vectors/{compute_split,compute_balances,propose_allocation,validate_settlement,over_settled_accounts}.json`, `contracts/examples/` (wire shapes).
5. `docs/decisions.md` D-129 to D-134, D-110, D-117, D-126.
6. `CLAUDE.md` (Orchestration, Anti-patterns, Execution protocols).
## 3. Already established
- Stack (D-131): Vite, React 19, TypeScript `strict`, React Router 7, TanStack Query 5, `openapi-typescript` into `web/src/api/schema.d.ts` (generated, committed) with `openapi-fetch`, Recharts (a dependency for W4; W0 need not use it), Vitest with Testing Library (jsdom), ESLint (flat config) and Prettier, plain CSS modules. npm with a committed `package-lock.json`. Each library at its latest stable version, confirmed from its official documentation or the npm registry at implementation time (CLAUDE.md "Fast-moving APIs").
- Vite `base` is `/app/`; the build output is `web/dist/`, which W7's Dockerfile stage copies to `/app` in the image (§15.4). The page references only its own files (no CDN, no web font, no external script, AC-21.12); `index.html` names an icon under `/app/` (a browser's automatic `/favicon.ico` is closed by the gate from outside).
- Source layout (§15.3): `web/src/app/` (entry, providers, router, shell, shortcuts), `web/src/api/` (schema, client, token store, snapshot, writes), `web/src/i18n/`, `web/src/design/` (tokens, global CSS), `web/src/components/`, `web/src/lib/` (formatting, `lib/domain/` money functions, utilities), `web/src/features/<name>/`. W0 owns all of it except the feature folders of W1 to W5, in which it creates only a placeholder page per route (D-004's shell rule).
- Routes (§15.3): `/app/` Home (W1), `/app/login` (W0), `/app/accounts/:id` (W1), `/app/expenses/new` and `/app/expenses/:id` (W2), `/app/search` (W2), `/app/settle` (W3), `/app/settlements/:id` (W3), `/app/analytics`, `/app/analytics/:groupKind/:groupId`, `/app/insights` (W4), `/app/settings/*` (W5). Each feature folder exports its route element(s) from `web/src/features/<name>/index.ts(x)`; the router imports only those exports, so a feature plan replaces its placeholder without touching `web/src/app/`. Sub-routes below a feature's path are the feature's own (nested `<Routes>` or route objects it exports).
- Login (§15.2): code field (§8.3 normalisation, groups of 4, a pasted `/i/<CODE>` link fills it), optional name field, E-3 with device name `Web · <browser>`; token in `localStorage`; every API call sends `Authorization: Bearer`, `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version` and `X-Kvitt-App-Version-Name` (from the build: version code `git rev-list --count HEAD`, name at most 32 characters, both injected by Vite at build time, fixed values in tests); a 401 clears the token and routes to `/app/login` (with the S-18 text for `device_revoked`); after a `partner` accept the first page is `/app/settings/profile`.
- Household snapshot (§15.5): pulls `GET /sync` from 0 in pages (`limit` 1000) until `has_more` is false, keeps rows by `server_version` in memory (TanStack Query cache), pulls from its cursor on window focus and after every write, drops and re-pulls on a new epoch or 409 `cursor_ahead`; selectors for users, accounts, categories, templates, expenses, settlements and audit entries (non-deleted and deleted). Writes: a helper for one `POST /sync` mutation (UUIDv7 ids, `mutation_id`, `updated_at` from the browser clock) returning accepted or rejected with the §14.4.6 reason; a helper for REST writes that applies the returned row. No persistence beyond the token and the language; no IndexedDB.
- Money (§15.3): `web/src/lib/domain/` ports `computeSplit`, `computeBalances`, `proposeAllocation`, `validateSettlement`, `overSettledAccounts` with `number` öre (all values stay far inside 2^53, §9.6) and pass the five vector files through a Vitest test that reads `../contracts/test-vectors/*.json` (error tokens as §14.5.3). The analytics and suggestion vectors are W4's and W2's.
- Formatting (§9.8): money (`1 234,50 kr`, signs and words as the app), dates, months, ISO weeks, relative time, in sv and en, with the same outputs as `:core:design`'s formatters for the same inputs (port the tables of D-065; tests per case).
- i18n: `web/src/i18n/{sv,en}.json` for shared strings; each feature keeps `web/src/features/<name>/i18n/{sv,en}.json`, loaded by W0's loader (for example `import.meta.glob`) so a feature adds strings without editing W0's files; keys from `docs/ux/strings.csv` where the string exists there, otherwise `web_` keys; named placeholders; a test fails on a key missing from either language. Language: browser preference, overridden by a stored choice (§15.3).
- Shell (§15.6): sidebar from 720 px, bottom bar under it; destinations Home, Analytics, Insights, Search, Settings; a New expense button; keyboard shortcuts `N` → `/app/expenses/new`, `/` → `/app/search`, `?` → W-20 help dialog, `Esc` closes dialogs (none while typing in a field, except `Esc`); instant navigation.
- Shared components (design-spec §3 anatomy, web form): buttons, text and amount fields, select, date field, dialog with `Esc`, confirm dialog, segmented control, avatar (picture from E-11 as a `blob:` URL, initials otherwise, AC-4.5), category icon tile and colour, account chip, balance header, ledger row (expense and settlement, §9.2 effect line), month header, empty, loading and error states, snackbar/toast, page layout, and the expense editor fields of design-spec §3.7 (amount field, category grid and quick row, the four option rows, the split mode control with live shares from `computeSplit`), shared by W-5 (W2) and W-16 (W5) as D-019 shares them on the phone. Each with a Testing Library test.
- Design tokens (design-spec §2) as CSS custom properties for light and dark (`prefers-color-scheme`), system font stack, `font-variant-numeric: tabular-nums` for amounts, Material Symbols as bundled SVG (Apache-2.0; licence noted in the report), focus rings visible, contrast as §13.1.
- Development server: `npm run dev` serves `/app/` and forwards API paths to the server named by an environment variable (default `http://localhost:8080`), documented in `web/README.md`.
- The web gate: `npm run check` = `tsc --noEmit`, `eslint .`, `vitest run`, `vite build`, and `npm run gen:api` followed by a check that `web/src/api/schema.d.ts` is unchanged. Prettier formatting is checked inside ESLint or as its own step of `check`.
## 4. Owned paths
From SPEC §14.7: `web/**` except `web/src/features/{home,accounts,expenses,search,export,settle,analytics,settings}/**` (W1 to W5), in which W0 creates only the placeholder pages and `index.ts(x)` exports. `web/src/features/auth/**` (W-1) is W0's. Nothing outside `web/`; W7 runs in parallel in `server/**`, `contracts/**`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`.
## 5. Deliverables
- `web/package.json` (scripts `dev`, `build`, `test`, `lint`, `gen:api`, `check`), `package-lock.json`, `vite.config.ts`, `tsconfig*.json`, `eslint.config.js`, Prettier config, `index.html`, `.gitignore` (`node_modules/`, `dist/`), `web/README.md` (commands, dev proxy, layout, how a feature adds strings and routes).
- `web/src/api/schema.d.ts` generated from `contracts/openapi.yaml`; the client, token store, snapshot, writes; tests with a fetch stub over `contracts/examples/` responses.
- `web/src/lib/domain/` with the vector test; `web/src/lib/format/` with tests.
- `web/src/design/`, `web/src/components/`, `web/src/i18n/`, `web/src/app/` (router with every route, shell, shortcuts, W-20), `web/src/features/auth/` (W-1), placeholder pages in the W1 to W5 folders.
## 6. Steps
1. Worktree. From the main checkout `/Users/jhag/projects/kvitt`: `git worktree add .claude/worktrees/agent-w0 -b agent-w0 main`, then in it `git reset --hard main` and `tools/install-git-hooks.sh`. No git-ignored files are needed.
2. Read §2 in order. Confirm SPEC v0.28 is on `main` (§15 exists); if not, work from the brief and say so.
3. Scaffold with the latest stable versions; record each with its source. `npm audit` result in the report.
4. Generated types and the freshness check; the API client, token store, snapshot and writes.
5. Money functions and the vector test; formatting.
6. Tokens, shared components, i18n, shell, router, shortcuts, login.
7. Manual check (optional, no owner data): a throwaway server from the worktree (`server/README.md`; a temporary `KVITT_DATA_DIR`, `KVITT_PUBLIC_URL=http://localhost:8080`, `bootstrap` for an `admin_setup` code), `npm run dev`, log in, see the shell and the placeholders. Never pair against the owner's development stack or the Pi.
8. Gate (§7). Commit on `agent-w0`, `git rebase main`; if `contracts/openapi.yaml` changed on `main` (W7), run `npm run gen:api` and commit; rerun the gate. Never merge, push or remove the worktree.
## 7. Gate
The web gate (SPEC §15.3, D-131), from `web/`: `npm run check`. Green means every step passed in this session; the report quotes the Vitest summary (files and tests) and the `vite build` size lines. Also `python3 tools/contracts-check.py` (unchanged contracts, 134 checks).
## 8. HUMAN steps
From SPEC §11.2: none.
## 9. Out of scope
- The pages of W1 to W5 beyond placeholders; the analytics and suggestion ports (W4, W2).
- Anything in `server/**`, `contracts/**`, `infra/**`, `.gitea/**` (W7); `CLAUDE.md` (the orchestrator adds the third gate).
- A service worker, a manifest, offline storage (§15.1).
## 10. Report
- Branch `agent-w0`, head commit after rebasing on `main`.
- Gate output lines; Vitest counts; build sizes; `npm audit`.
- Libraries and versions with sources; the Material Symbols package and licence.
- The module map W1 to W5 depend on: each shared module's path and exports (snapshot selectors, write helpers, components, formatters, i18n loader, route export convention), exactly as built.
- Web-only string keys added (`web_…`) with en and sv.
- Proposed decision entries and spec text (numbered from the brief), `TODO(spec)` occurrences, open questions with SPEC section numbers.
- Proposed text for `CLAUDE.md` "Build and verify" if it differs from SPEC §15.3.
## 11. Numbering
Decision ids and the spec version are assigned in the start prompt; use `TODO(spec)` if none was given.
+54
View File
@@ -0,0 +1,54 @@
# PLAN-W1: Web home and accounts
## 1. Goal
Build the web client's Home (W-2), account detail (W-3) and account editor (W-4) in `web/src/features/home/` and `web/src/features/accounts/`, on W0's foundation, mirroring S-4, S-3, S-5 and S-6 in content, rules and states (SPEC §15.6), with tests.
## 2. Read first
1. SPEC §15 (all), FR-21, FR-6 (accounts), FR-10 (balances), §9.2 (effect line), §9.3 (balances), §9.6 (limits), §9.8, AC-8.10, AC-11.7, §13.3 rows S-3, S-4, S-5, S-6, D-052, D-068, D-080.
2. `docs/ux/design-spec.md` §3.2 to §3.6 and the S-3, S-4, S-5, S-6 sections; `docs/ux/strings.csv` keys `home_*`, `account_*` and the shared ones they use.
3. `web/README.md` and W0's report (in the start prompt's notes): the module map.
4. Files: `web/src/app/**` (router, shell; read only), `web/src/api/**` (snapshot selectors, write helpers; read only), `web/src/lib/**` (`computeBalances`, formatters; read only), `web/src/components/**` (balance header, ledger rows, month header; read only), `web/src/features/home/**`, `web/src/features/accounts/**` (W0's placeholders).
5. `CLAUDE.md` (Anti-patterns, Execution protocols).
## 3. Already established
- W0 owns everything shared; this plan edits only its two feature folders. A shared piece that is missing or wrong (a component, a selector, a formatter) is a request to the orchestrator, not an edit in W0's paths; work around it inside the feature only when the report names it.
- Balances, effect lines and month totals are computed in the browser from the snapshot with W0's `computeBalances` and formatters (§15.5), never re-implemented.
- Home (W-2): the balance header with the §9.3 wording and amount, Settle up when non-zero (to `/app/settle`), active accounts with balance wording, amount and this month's expense count, a collapsed "Archived (n)" section, the empty state "Create your first account"; while the household has no partner, the S-3 content instead (explanation; Invite partner for the admin to `/app/settings/invites`). No sync pill, no update banner, no pull to refresh (§15.6).
- Account detail (W-3): name, default split with names (`common_ratio` "60/40"), balance header, expenses and settlements interleaved by date, grouped by month with month totals (household spend), rows per AC-8.10 and AC-11.7 (opening `/app/expenses/:id` or `/app/settlements/:id`), archived badge; actions New expense (not on an archived account; to `/app/expenses/new` with the account), Settle this account (`/app/settle` with the account), Edit, Archive / Unarchive (archive shows the S-5 snackbar with undo, D-071), Delete only when allowed (AC-6.5); list order by `created_at` (AC-6.6); unique names (AC-6.2: `name_taken` shown on the name field).
- Account editor (W-4): name, the default split as two percent fields that always sum to 100 with two decimals and a slider or stepper, the "Of 100,00 kr" preview, Save and Cancel; the discard dialog has a title and no body (D-080). Create (E-18), rename and ratio (E-19), archive and unarchive (E-20, E-21), delete (E-22), each applying the returned row (§15.5); a failure shows "Kunde inte nå servern" or the error code's text and keeps the form.
- Desktop layout is this plan's within W0's shell and tokens: for example Home's accounts as a grid or list beside the balance header, account detail with a wider ledger. Under 720 px it reads as the app's screens.
- Strings from `docs/ux/strings.csv` where they exist, `web_` keys otherwise, in the feature's `i18n/{sv,en}.json`.
## 4. Owned paths
From SPEC §14.7: `web/src/features/home/**`, `web/src/features/accounts/**`. `web/package.json` only to add a dependency line (the second plan to land resolves `package-lock.json` with `npm install` on rebase). Nothing else.
## 5. Deliverables
- The three pages with every state of §15.6 (loaded, empty, loading, error, long content: long Swedish names, 7-digit amounts) and the S-3 variant of Home.
- Tests (Vitest, Testing Library) over a snapshot fixture: balance wording per direction and settled, archived section, month grouping and totals, delete allowed or not, ratio fields summing to 100, discard dialog, each write's success and failure path.
## 6. Steps
1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-w1 -b agent-w1 main`; in it `git reset --hard main`, `tools/install-git-hooks.sh`, `cd web && npm ci`.
2. Read §2. Confirm W0 has landed (`web/package.json` and the placeholders in this plan's folders exist on `main`); if not, stop and report.
3. `npm outdated` in `web/`; record it. No new library is expected.
4. Build W-2, then W-3, then W-4, with tests.
5. Manual check (optional): a throwaway server as `web/README.md` describes, `npm run dev`, create accounts and expenses through the pages that exist. Never the owner's development stack or the Pi.
6. Gate (§7). Commit on `agent-w1`, `git rebase main` (other web plans land in parallel; only `package-lock.json` may conflict), rerun the gate. Never merge, push or remove the worktree.
## 7. Gate
The web gate, from `web/`: `npm run check` (SPEC §15.3). Green: every step passed in this session; the report quotes the Vitest summary.
## 8. HUMAN steps
From SPEC §11.2: look at Home and an account in a browser against the development stack (yes/no), relayed by the orchestrator after landing and a dev-stack rebuild.
## 9. Out of scope
- Expense editor and detail, search (W2); settle up and settlement detail (W3); analytics (W4); settings (W5).
- Any edit in W0's paths (`web/src/{app,api,i18n,design,components,lib}/**`) or another feature folder.
## 10. Report
- Branch `agent-w1`, head commit after rebasing on `main`; gate lines and Vitest counts.
- Pages × states covered; screenshots are not committed (none in the web gate).
- `web_` keys added with en and sv; strings missing from `strings.csv`; design-spec mismatches.
- Requests for W0's modules, workarounds kept inside the feature, proposed decision entries (numbered from the brief), open questions with SPEC section numbers.
## 11. Numbering
Assigned in the start prompt; use `TODO(spec)` if none was given.
+57
View File
@@ -0,0 +1,57 @@
# PLAN-W2: Web expenses, search and export
## 1. Goal
Build the web client's expense editor (W-5) with the category suggestion of §9.11, expense detail with the audit timeline (W-6), and the search page (W-12) with its CSV export, in `web/src/features/expenses/`, `web/src/features/search/` and `web/src/features/export/`, on W0's foundation (SPEC §15.6, §15.7), with tests.
## 2. Read first
1. SPEC §15 (all), FR-21, FR-8 (expenses), FR-12 (audit, AC-12.4), §9.1 (splits), §9.2, §9.6 (limits), §9.8, §9.11 (CS-1 to CS-7) and §14.5.6, §14.4.3 to §14.4.6 (push, rejections, audit diff), §13.3 rows S-7 and S-8, D-049, D-055, D-080, D-102.
2. `docs/ux/design-spec.md` §3.7 (editor fields) and the S-7 and S-8 sections; `docs/ux/category-suggestion.md`; `docs/ux/strings.csv` keys `expense_*`, `editor_*` and the shared ones.
3. `contracts/test-vectors/suggest_category.json`, `contracts/category-keywords.csv`, `contracts/examples/sync/*.json`.
4. `web/README.md` and W0's report (module map).
5. Files: `web/src/{app,api,lib,components,i18n}/**` (read only), `web/src/features/{expenses,search,export}/**` (W0's placeholders).
6. `CLAUDE.md`.
## 3. Already established
- W0 owns everything shared; this plan edits only its three feature folders. Missing shared pieces are requests to the orchestrator.
- Editor (W-5, mirrors S-7), built from W0's shared editor fields (design-spec §3.7, D-019; W5's recurring editor uses the same): amount (kr, two decimals, §9.6 range), description, category grid (active categories, the S-7 quick row of `frequentCategoryIds`' rule computed in the browser from the snapshot: the five most used active categories of the account, ties by `sort_order`, D-060), account (active accounts), date (today by default), payer, split modes `default`, `ratio`, `exact`, `full` with live shares from W0's `computeSplit`, notes; account, date, payer and split as four fixed rows (D-102); validation inline; Save writes one `POST /sync` mutation (§15.5); Delete in edit mode (soft delete, with confirmation); Cancel with the discard dialog (title, no body, D-080). After saving, the page returned to shows "Utgiften sparades" (D-071) without undo.
- Category suggestion (§9.11, §15.7): a TypeScript port of `suggestCategory` and its normalisation in `web/src/features/expenses/suggest/`, passing `contracts/test-vectors/suggest_category.json` in a Vitest test; the keyword table imported at build time from `contracts/category-keywords.csv` (Vite raw import of the repository file, never a copy), parsed with the §14.8 rules. For a new expense the category follows the description until the user picks one (CS-2, CS-3, CS-7), with no marker. The history for CS-1 comes from the snapshot. Unicode normalisation with `String.prototype.normalize('NFC')` and `\p{L}`/`\p{Nd}` classes (the `UnicodeText` roles of §14.5.6).
- Detail (W-6, mirrors S-8): every field, each user's share and the split mode, "Recurring: <template>" for a generated expense, the timeline from the snapshot's audit entries (AC-12.4; AC-12.5's pending line does not apply on the web), a deleted state; Edit (`/app/expenses/:id` with an edit sub-route of this plan's choice) and Delete.
- A rejection from `POST /sync` (§14.4.6, for example `stale`) shows its reason with the copy of S-16's rejection texts and reloads the row from the server; nothing is queued (§15.5).
- Search (W-12, §15.7): in the browser over the snapshot; non-deleted expenses whose description or notes contain the query (case-insensitive); filters amount from–to, date from–to, account, category, payer, combined with AND; result count and sum; ledger rows (W0's) opening W-6; newest first. The filter state may live in the URL query so a search can be bookmarked (this plan's choice).
- CSV export (§15.7): downloads the current filtered list, one row per expense: date, description, category, account, payer, amount, each user's share, notes; a header row in the page's language. Choose separator, decimal mark, quoting, byte-order mark and file name so that the file opens correctly in Numbers and in Excel with a Swedish locale; test the output byte for byte in Vitest; propose the format as a decision in the report. Built in the browser (`Blob`, a download link); no server endpoint.
- Keyboard: W0's shell binds `N` to `/app/expenses/new` and `/` to `/app/search`; this plan focuses the amount field on W-5 and the search field on W-12 when they open, and handles `Esc` (discard rule) inside its pages.
- Receipt pictures do not exist (§2): no picture field.
## 4. Owned paths
From SPEC §14.7: `web/src/features/expenses/**`, `web/src/features/search/**`, `web/src/features/export/**`. `web/package.json` only for a dependency line. Nothing else; `contracts/**` is read only.
## 5. Deliverables
- W-5, W-6 and W-12 with every state of §15.6 (loaded, empty, loading, error, long content), the editor's validation and split states, the detail's deleted state.
- The suggestion port with the vector test (65 cases); the CSV writer with its tests; tests of every split mode's shares, the quick row, the discard dialog, the rejection path, the search filters and their combination.
## 6. Steps
1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-w2 -b agent-w2 main`; in it `git reset --hard main`, `tools/install-git-hooks.sh`, `cd web && npm ci`.
2. Read §2. Confirm W0 has landed (placeholders in this plan's folders on `main`); if not, stop and report.
3. `npm outdated`; record it. No new library is expected (a CSV library is not needed).
4. Suggestion port and vector test; editor; detail; search; export.
5. Manual check (optional): a throwaway server, `npm run dev`, add, edit and delete an expense, search, export and open the file in Numbers. Never the owner's development stack or the Pi.
6. Gate (§7). Commit on `agent-w2`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
## 7. Gate
The web gate, from `web/`: `npm run check`. Green: every step passed in this session; Vitest summary quoted, including the suggestion vector test.
## 8. HUMAN steps
From SPEC §11.2: add, edit and delete an expense in a browser; open an exported CSV in Numbers (yes/no), relayed after landing.
## 9. Out of scope
- Home and accounts (W1), settle up (W3), analytics (W4), settings (W5).
- A server search or export endpoint; receipt pictures; edits outside the three folders.
## 10. Report
- Branch `agent-w2`, head commit after rebasing; gate lines; Vitest counts (suggestion vectors: 65).
- The CSV format chosen (bytes of a two-row sample) as a proposed decision; pages × states covered.
- `web_` keys added (en, sv); strings missing from `strings.csv`; design-spec mismatches.
- Requests for W0's modules; proposed decision entries (numbered from the brief); open questions with SPEC section numbers.
## 11. Numbering
Assigned in the start prompt; use `TODO(spec)` if none was given.
+49
View File
@@ -0,0 +1,49 @@
# PLAN-W3: Web settle up
## 1. Goal
Build the web client's settle-up page (W-7) and settlement detail (W-8) in `web/src/features/settle/`, on W0's foundation, mirroring S-9 and S-10 (SPEC §15.6), with tests.
## 2. Read first
1. SPEC §15 (all), FR-21, FR-11 (AC-11.1 to AC-11.8), FR-12 (AC-12.4), §9.3, §9.4 (allocation proposal), §9.6, §9.8, §14.5.2 (`proposeAllocation`, `validateSettlement`, `overSettledAccounts`), §14.4.3 to §14.4.6, §13.3 rows S-9 and S-10, D-052, D-074, D-080, D-093.
2. `docs/ux/design-spec.md` S-9 and S-10 sections and the components they use; `docs/ux/strings.csv` keys `settle_*` and shared ones.
3. `web/README.md` and W0's report (module map).
4. Files: `web/src/{app,api,lib,components,i18n}/**` (read only), `web/src/features/settle/**` (W0's placeholder).
5. `CLAUDE.md`.
## 3. Already established
- W0 owns everything shared; this plan edits only `web/src/features/settle/`. Missing shared pieces are requests to the orchestrator.
- Settle up (W-7, mirrors S-9): global from Home (`/app/settle`) or per account (`/app/settle` with the account; one fixed row, direction swap kept, editable amount, D-052); the direction defaults to "the user who owes pays" with a swap control; the amount defaults per AC-11.3; the proposal from W0's `proposeAllocation` over all non-deleted accounts in `created_at` order with nets from `computeBalances` (excluding the settlement being edited); allocation rows with account debt, editable amount and the over-settle warning (`overSettledAccounts`, AC-11.5); the remaining-to-allocate indicator; "Add account"; date; note; when editing, the saved rows count as edited and "Föreslå igen" re-proposes; the info line on untouched accounts per `SettleState.untouched`'s rule (D-093). Confirm validates with `validateSettlement` and writes one `POST /sync` mutation (§15.5); Cancel with the discard dialog (no body, D-080). Computed in the browser as the phone does; E-33 is not needed.
- Settlement detail (W-8, mirrors S-10): payer, recipient, amount, date, note, allocations per account, the timeline (AC-12.4); Edit (W-7 with the settlement) and Delete (soft delete, confirmation).
- A rejection from `POST /sync` shows its §14.4.6 reason and reloads; nothing is queued.
## 4. Owned paths
From SPEC §14.7: `web/src/features/settle/**`. `web/package.json` only for a dependency line. Nothing else.
## 5. Deliverables
- W-7 (global, per account, edit) and W-8 (loaded, deleted, missing) with every state of §15.6.
- Tests: proposal defaults in both directions, per-account variant, over-settle warning, remaining indicator, "Föreslå igen", untouched info line, validation errors (§14.5.3 tokens mapped to text), write success and rejection, delete.
## 6. Steps
1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-w3 -b agent-w3 main`; in it `git reset --hard main`, `tools/install-git-hooks.sh`, `cd web && npm ci`.
2. Read §2. Confirm W0 has landed; if not, stop and report.
3. `npm outdated`; record it. No new library is expected.
4. W-7, then W-8, with tests.
5. Manual check (optional): a throwaway server, `npm run dev`, record and delete a settlement. Never the owner's development stack or the Pi.
6. Gate (§7). Commit on `agent-w3`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
## 7. Gate
The web gate, from `web/`: `npm run check`. Green: every step passed in this session; Vitest summary quoted.
## 8. HUMAN steps
From SPEC §11.2: record and delete a settlement in a browser (yes/no), relayed after landing.
## 9. Out of scope
- Home, accounts (W1); expenses, search (W2); analytics (W4); settings (W5); edits outside `web/src/features/settle/`.
## 10. Report
- Branch `agent-w3`, head commit after rebasing; gate lines; Vitest counts.
- Pages × states covered; `web_` keys added (en, sv); strings missing from `strings.csv`; design-spec mismatches.
- Requests for W0's modules; proposed decision entries (numbered from the brief); open questions with SPEC section numbers.
## 11. Numbering
Assigned in the start prompt; use `TODO(spec)` if none was given.
+54
View File
@@ -0,0 +1,54 @@
# PLAN-W4: Web analytics and insights
## 1. Goal
Build the web client's analytics page (W-9), drill-down with D-128's line chart (W-10) and the Insights page (W-11) on the D-133 endpoints, in `web/src/features/analytics/`, on W0's foundation (SPEC §15.6, §15.8), with a TypeScript port of `resolveRange` and `spendSeries` passing their vectors.
## 2. Read first
1. SPEC §15 (all, §15.8 especially), FR-21, FR-14 (AC-14.1 as revised, AC-14.2 to AC-14.10), §14.5.4, §9.8, §13.3 rows S-11 and S-12 and the D-128 text (SPEC v0.27: the line chart of a category or account over the range, its own period and granularity, average overlays), D-069, D-070, D-077, D-097, D-128, D-133, and the server agent's D-135 to D-138 (SPEC v0.29) once recorded.
2. `docs/ux/design-spec.md` S-11 and S-12 sections, §2.4 (chart and account colours), §2.9 (bar radii), §3.10 (charts); `docs/ux/strings.csv` keys `analytics_*`.
3. `contracts/test-vectors/resolve_range.json`, `spend_series.json`; `contracts/openapi.yaml` `/analytics/*` (after W7) and `web/src/api/schema.d.ts`.
4. `web/README.md` and W0's report (module map).
5. Files: `web/src/{app,api,lib,components,i18n}/**` (read only), `web/src/features/analytics/**` (W0's placeholders).
6. `CLAUDE.md`.
## 3. Already established
- W0 owns everything shared; this plan edits only `web/src/features/analytics/`. Missing shared pieces are requests to the orchestrator.
- Port (§15.3): `resolveRange` and `spendSeries` of §14.5.4 in `web/src/features/analytics/domain/`, passing `resolve_range.json` and `spend_series.json` in Vitest; S-11 and S-12's numbers are computed in the browser from the snapshot (§15.5), including D-128's line chart and its averages, with the same window constant as the app.
- W-9 (mirrors S-11): range presets of AC-14.2 with custom start and end, granularity month or week, measure household or my share, group by category or account (defaults as AC-14.2 to AC-14.5); the total card per D-069; stacked bars per bucket with legend in `sortOrder` / account order; category and account breakdowns with amount and percent (AC-14.10); empty state; every bucket shown (AC-14.7); deleted expenses excluded, archived included (AC-14.9). Charts with Recharts styled by the design tokens (bar corners 4 top, 2 bottom, D-097; category and account colours §2.3, §2.4); tooltips and keyboard focus on bars; a table alternative for screen readers.
- W-10 (mirrors S-12 and D-128): title naming the filter (D-070), total, the line chart of a category or account over the range with its own period and granularity and the average overlays, expense rows opening `/app/expenses/:id`. Route `/app/analytics/:groupKind/:groupId` (`groupKind` `category` or `account`), with the bucket and range as query parameters of this plan's choice.
- W-11 Insights (`/app/insights`, D-133): reads the `/analytics/*` endpoints designed by W7 (D-135 to D-138) through W0's client and the generated types: per-category averages and trends per month, overall and per category, and the 2-month forecast per account showing its two parts (recurring templates due, average of non-recurring spend over the last 3 whole months). Nothing of it is computed in the browser. Until W7 has landed on `main` (no `/analytics/*` in `web/src/api/schema.d.ts`), W-11 is a stub page saying the feature is coming, with no fake numbers; the report says which state it shipped in. Online errors as §15.5.
- Desktop layout: charts may sit side by side from 720 px; under 720 px the S-11 order.
## 4. Owned paths
From SPEC §14.7: `web/src/features/analytics/**`. `web/package.json` only for a dependency line (Recharts is already W0's). Nothing else.
## 5. Deliverables
- W-9, W-10, W-11 (full or stub) with every state of §15.6.
- The two ports with vector tests (13 and 13 cases); tests of presets, week keys, measure switch, percent rounding, drill-down title and rows, the line chart's series and averages, W-11's mapping of endpoint responses (from the contract examples once W7 provides them).
## 6. Steps
1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-w4 -b agent-w4 main`; in it `git reset --hard main`, `tools/install-git-hooks.sh`, `cd web && npm ci`.
2. Read §2. Confirm W0 has landed; if not, stop and report. Check whether W7 has landed (`/analytics/` in `contracts/openapi.yaml` on `main`) and whether SPEC v0.27 (D-128) is on `main`; say in the report what you built against.
3. `npm outdated`; record it.
4. Ports and vector tests; W-9; W-10; W-11.
5. Manual check (optional): a throwaway server with some expenses, `npm run dev`. Never the owner's development stack or the Pi.
6. Gate (§7). Commit on `agent-w4`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
## 7. Gate
The web gate, from `web/`: `npm run check`. Green: every step passed in this session; Vitest summary quoted, including both vector files.
## 8. HUMAN steps
From SPEC §11.2: look at the analytics pages in a browser (yes/no), relayed after landing.
## 9. Out of scope
- The `/analytics/*` endpoints themselves and any server or contract change (W7); the Android Insights section (W6).
- Other pages (W1, W2, W3, W5); edits outside `web/src/features/analytics/`.
## 10. Report
- Branch `agent-w4`, head commit after rebasing; gate lines; Vitest counts (vectors 13 and 13).
- W-11 full or stub, and against which contract version; D-128 as read.
- `web_` keys added (en, sv); design-spec mismatches; requests for W0's modules or W7's endpoints.
- Proposed decision entries (numbered from the brief); open questions with SPEC section numbers.
## 11. Numbering
Assigned in the start prompt; use `TODO(spec)` if none was given.
+55
View File
@@ -0,0 +1,55 @@
# PLAN-W5: Web settings
## 1. Goal
Build the web client's settings: hub with language and log out (W-13), profile (W-14), categories (W-15), recurring expenses (W-16), devices (W-17), invites (W-18) and about (W-19), in `web/src/features/settings/`, on W0's foundation, mirroring S-13, S-14, S-15, S-19, S-20, S-21, S-22 (SPEC §15.6), with tests.
## 2. Read first
1. SPEC §15 (all; §15.2 for log out and the gate's effect on `/health`), FR-21, FR-2 (AC-2.1, AC-2.2, AC-2.11), FR-3, FR-4, FR-7, FR-9, FR-20, §8.4, §9.6, §9.7 (icons, colour tokens), §13.3 rows S-2, S-13, S-14, S-15, S-19, S-20, S-21, S-22, D-072, D-080, D-090, D-091, D-099, D-107, D-110.
2. `docs/ux/design-spec.md` the S-13 to S-15 and S-19 to S-22 sections, §2.3, §2.12, §2.13; `docs/ux/strings.csv` keys `settings_*` and shared ones.
3. `contracts/openapi.yaml` E-1, E-7 to E-16, E-23 to E-30 and their examples.
4. `web/README.md` and W0's report (module map).
5. Files: `web/src/{app,api,lib,components,i18n}/**` (read only), `web/src/features/settings/**` (W0's placeholder).
6. `CLAUDE.md`.
## 3. Already established
- W0 owns everything shared, the token store and the language store included; this plan edits only `web/src/features/settings/`. Missing shared pieces are requests to the orchestrator.
- Every write here is REST (§7) and applies the returned row (§15.5); a failure shows "Kunde inte nå servern" or the code's text and keeps the form.
- W-13 (mirrors S-13): own and partner's avatar and name; rows Profile, Categories, Recurring expenses, Devices, Invites (admin; "Pair a device" for a member is not offered, AC-2.2 and §8.4), About, Language (sv, en, or the browser's; W0's store); Log out, which forgets the token through W0 and explains that this browser stays on Devices until revoked from another device (§15.2).
- W-14 (mirrors S-14; also the S-2 step once after a `partner` login, AC-2.10): avatar change (file picker, `image/jpeg`, `image/png` or `image/webp` up to the §8.6 size, E-9) and remove (E-10), display name (E-8, §9.6).
- W-15 (mirrors S-15): list with icon, colour, name, archived section; editor with name and "Reset name" for defaults, the 33 icons of §9.7 and the 14 colour tokens; delete or archive with the reason when delete is not allowed (deletability computed from the snapshot as D-059/D-060 describe; the server stays the authority); reorder by drag and by keyboard (move up and down), saved through E-24 `sort_order`.
- W-16 (mirrors S-19): list (description, amount, account, "Every month on day N", next date, paused badge); editor with S-7's fields without date, account, payer and split as fixed rows (D-102, D-107), day of month (default today's, D-072), first month, optional last month, pause; create (E-28 `NewRecurringTemplate`, D-091), edit and pause/resume (E-29), delete (E-30). The split controls reuse W0's shared editor-field components where W0 built them; otherwise a request to the orchestrator (do not import from `web/src/features/expenses/`).
- W-17 (mirrors S-20): rows per AC-3.1 (`Device.app_version_name` else the code, D-090), this browser marked "This device"; revoke with confirmation (E-16; not this browser, AC-3.2).
- W-18 (mirrors S-21, admin only): "Invite partner" before the partner joins and "Pair another device" for either user (E-12); a created invite shows the code in groups of 4, the link and the expiry, with Copy for code and link (clipboard; there is no share sheet); pending invites (E-13) with cancel (E-14).
- W-19 (mirrors S-22): the web build's version name and code; the server version and schema from E-1 `/health` when it answers, otherwise the `X-Kvitt-Server-Version` header of the last API response without the schema (D-110's fallback; `/health` is closed by the gate from outside the LAN, §15.2); the server URL (the page's own origin); no update check, no licences row.
## 4. Owned paths
From SPEC §14.7: `web/src/features/settings/**`. `web/package.json` only for a dependency line. Nothing else.
## 5. Deliverables
- W-13 to W-19 with every state of §15.6 (loaded, empty, loading, error, long content), admin and member variants.
- Tests: language switch, log out, picture upload and remove, name validation, category editor (reset name, delete vs archive reason, reorder by keyboard), recurring editor defaults and pause, device revoke (not current), invite create, copy and cancel, about with `/health` and with the header fallback.
## 6. Steps
1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-w5 -b agent-w5 main`; in it `git reset --hard main`, `tools/install-git-hooks.sh`, `cd web && npm ci`.
2. Read §2. Confirm W0 has landed; if not, stop and report.
3. `npm outdated`; record it. A drag-and-drop library is allowed only if native drag events with keyboard reordering cannot meet AC-21.11; name it with its version and licence.
4. W-13, W-14, W-17, W-18, W-19, then W-15 and W-16, with tests.
5. Manual check (optional): a throwaway server, `npm run dev`, pair the browser, create a device invite, revoke a second browser. Never the owner's development stack or the Pi.
6. Gate (§7). Commit on `agent-w5`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
## 7. Gate
The web gate, from `web/`: `npm run check`. Green: every step passed in this session; Vitest summary quoted.
## 8. HUMAN steps
From SPEC §11.2: pair a browser, see it on the phone's Devices, revoke it from the phone (yes/no), relayed after landing.
## 9. Out of scope
- Login and the token store (W0); other pages (W1 to W4); a sign-out endpoint (a contract change, §15.2); edits outside `web/src/features/settings/`.
## 10. Report
- Branch `agent-w5`, head commit after rebasing; gate lines; Vitest counts.
- Pages × states covered, admin and member; `web_` keys added (en, sv); design-spec mismatches.
- Requests for W0's modules; proposed decision entries (numbered from the brief); open questions with SPEC section numbers.
## 11. Numbering
Assigned in the start prompt; use `TODO(spec)` if none was given.
+47
View File
@@ -0,0 +1,47 @@
# PLAN-W6: Android Insights (advanced analytics from the server)
## 1. Goal
Show the server's advanced analytics (D-133: per-category averages and trends per month, the 2-month forecast per account) in the Android app as an "Insights" section on S-11, online only (§9.9), consuming W7's `/analytics/*` endpoints. This is a UI-contract change (`:core:model` is a contract file, §14.8) with the data layer, fakes, the feature and goldens.
## 2. Read first
1. SPEC §15.8 and the server agent's D-135 to D-138 (SPEC v0.29: endpoint names, shapes, rounding), AC-14.1 (as revised), FR-14, §9.9, §14.2.4 (the UI contract: `AnalyticsRepository`, `AnalyticsState`, `OnlineResult`), §14.8, §6.2 (layering), §13.3 row S-11, D-128 (v0.27), D-133.
2. `contracts/openapi.yaml` `/analytics/*` and `contracts/examples/` for them (W7).
3. `docs/ux/design-spec.md` S-11 section, §3.10 (charts), §3.6 (offline hints); `docs/ux/strings.csv` (`analytics_*`).
4. Files: `android/core/model/**` (analytics types, `AnalyticsRepository`), `android/data/**` (`KvittApi`, DTOs, the analytics repository), `android/core/fakes/**` (fake analytics repository and scenarios), `android/feature/analytics/**`, `android/core/design/**` (read only), `android/README.md`.
5. `CLAUDE.md` (Android gate, layering, anti-patterns, screenshots, the phone).
## 3. Already established
- The numbers are the server's; the app neither computes nor caches them across process restarts beyond what the brief allows (no Room table: a schema change would need its own decision). Offline, the section shows the "Needs connection" hint (`common_needs_connection`, §9.9) and S-11's own offline analytics stay as they are (AC-14.1).
- `:core:model` gains the Insights types and repository members (names proposed by this plan, recorded as a decision); `:data` calls the endpoints through `KvittApi` with the common headers (§14.3.1, D-126's app key included) and maps errors as other online reads do; `:core:fakes` gets scenarios for loaded, empty, offline, error and long content; `:feature:analytics` renders the section below S-11's charts (or as the design-spec placement the report proposes) with strings in both languages.
- New strings are proposed for `docs/ux/strings.csv` in the report (the docs agent records them; `docs/ux/**` is not this plan's).
- Goldens for every new state in the three variants (D-098); open every changed PNG.
## 4. Owned paths
From SPEC §14.7 (row W6): `android/feature/analytics/**`, and as a contract-change plan `android/core/model/**`, `android/data/**`, `android/core/fakes/**`. Catalog entries only if needed (`android/gradle/**`). Nothing in `server/**`, `contracts/**`, `web/**`, `docs/**`.
## 5. Deliverables
- The `:core:model` additions with `ContractTest` updated; the `:data` client and repository with tests over the contract examples; fakes; the S-11 Insights section; screenshot tests and goldens; unit tests of the state holder.
## 6. Steps
1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-w6 -b agent-w6 main`; in it `git reset --hard main`; copy `android/local.properties` and `android/app/google-services.json` from the main checkout (never commit); `tools/install-git-hooks.sh`.
2. Read §2. Confirm W7 has landed (`/analytics/` in `contracts/openapi.yaml` on `main`) and SPEC v0.29 records it; if not, stop and report.
3. `python3 tools/dependency-report/latest.py`; record the lines.
4. Model, data, fakes, feature, goldens.
5. Gate (§7). Commit on `agent-w6`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree. The phone only if the brief names this agent for it.
## 7. Gate
The Android gate, from `android/`: `PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ../tools/gradle-serial.sh check`. Green: `BUILD SUCCESSFUL` in this session, test counts from fresh XML, screenshot verification passing.
## 8. HUMAN steps
From SPEC §11.2: look at S-11's Insights on the phone (yes/no), after the orchestrator installs the build and the server image with W7 is deployed.
## 9. Out of scope
- The endpoints (W7); the web's W-11 (W4); a Room cache of the insights; any change to S-11's offline analytics or to D-128's line chart.
## 10. Report
- Branch `agent-w6`, head commit after rebasing; gate lines; tests; goldens added (names).
- Proposed decision entry and §14.2.4 spec text for the contract additions (numbered from the brief); proposed `strings.csv` rows (en, sv).
- Dependency-report lines; open questions with SPEC section numbers.
## 11. Numbering
Assigned in the start prompt (a decision and a spec version for the UI-contract change); use `TODO(spec)` if none was given.
+50
View File
@@ -0,0 +1,50 @@
# PLAN-W7: Server web serving and analytics endpoints
Status: running since 2026-10-21 from the orchestrator's brief (written before this plan), in parallel with W0. Where the running agent's brief and this plan differ, the brief governs this run and the W7 report records the difference.
## 1. Goal
Make `kvitt-server` serve the web client under `/app/` from the image (D-132) and add the advanced analytics read endpoints under `/analytics/*` (D-133), as a contract change (D-135 to D-138, SPEC v0.29), with the image build moved to a repository-root context that includes a Node stage for `web/`.
## 2. Read first
1. SPEC §15 (§15.2, §15.4, §15.8 especially), FR-21 (AC-21.1), §6.3, §7, §8.4, §8.6, §8.7, §9.5 (recurring dates), §10.3, §10.4, §10.5.2 (the `$kvitt_csp` policy for `/app/`), §14.3, §14.7, §14.8, D-117, D-126, D-128 (the shared 3-month window), D-129 to D-134.
2. `server/README.md`, `server/Dockerfile`, `.gitea/workflows/build-images.yaml`, `infra/build-images.sh`, `infra/dev/**`, `contracts/openapi.yaml`, `tools/contracts-check.py`.
3. `CLAUDE.md` (server gate, no `unwrap`/`expect`, contracts).
## 3. Already established
- Static serving (§15.4): when `KVITT_WEB_DIR` (default `/app`) exists, `GET /app/<file>` serves the file; every other path under `/app/` serves `index.html`; `/app` leads to `/app/`; without the directory `/app/*` is 404 `not_found`. Hashed assets are cached as immutable, `index.html` `no-store`. The `/app/` responses carry a Content-Security-Policy equal to NPM's `/app/` value in §10.5.2 (or a stricter one the report justifies, with the NPM line updated by the docs agent to match), with the other D-117 headers. Path traversal is impossible (tests). The rate limits of §8.6 apply as to any unauthenticated path. `KVITT_WEB_DIR` gets a row in §10.4 (proposed in the report).
- Analytics (§15.8): authenticated read endpoints under `/analytics/*` (any device, §8.4): per-category averages and trends per month, overall and per category, and the forecast of the next 2 calendar months per account = sum of the account's recurring templates due in that month (active, not paused, inside start and end periods, the §9.5 clamped day) + the average per month of the account's non-recurring spend over the last 3 whole months, the window constant shared with D-128 (`AnalyticsRules.MOVING_AVERAGE_WINDOW` = 3 on the phone; the server's copy carries the same value and names its source). Names, query parameters, response shapes, rounding and the edge cases (an account younger than 3 months, archived accounts, deleted rows excluded) are this plan's design, written into `contracts/openapi.yaml` with examples and proposed as D-135 to D-138 and SPEC v0.29 text (§7 rows, §14.3.4 entries). Pure computations live in `kvitt-domain` if they are rules both clients could need, else in `kvitt-server`; this plan decides and says why.
- Build (§10.3, §15.4): `server/Dockerfile` gains a Node stage that runs `npm ci` and the Vite build in `web/` and copies `dist/` to `/app` in the runtime image; the build context is the repository root (`-f server/Dockerfile .`); `.gitea/workflows/build-images.yaml` (sparse checkout adds `web`, and `contracts` if the web build reads it, path triggers add `web/**`), `infra/build-images.sh` and `infra/dev/**` follow. The Node image is pinned by version and digest like the others. While `web/` does not exist on `main`, the stage must still build (or the plan sequences its landing after W0; the report says which).
- A change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` (`npm run gen:api` in `web/`) in the same landing when `web/` exists (§15.3); it is the one file under `web/` this plan may write.
- `tools/contracts-check.py` follows the new endpoints and examples (its endpoint list is in the file).
## 4. Owned paths
From SPEC §14.7 (row W7): `server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`, and `web/src/api/schema.d.ts` for its regeneration only. Not `infra/README.md`, `infra/npm/kvitt.conf`, `infra/pi/**`, `SPEC.md`, `docs/**`.
## 5. Deliverables
- Static serving with integration tests (file, fallback, 404 without the directory, cache headers, CSP, traversal).
- The `/analytics/*` endpoints in the server and in `contracts/openapi.yaml` (the OpenAPI equality test passes), contract examples, integration tests per endpoint and per error code, the forecast rule's cases (templates due, paused, ended, clamped day, no history, mixed recurring and one-off spend).
- The Dockerfile, workflow, `infra/build-images.sh` and `infra/dev/**` changes, checked by a local image build (`linux/arm64` on the Mac) that serves `/app/` and `/health`.
## 6. Steps
1. Worktree: from `/Users/jhag/projects/kvitt`, `git worktree add .claude/worktrees/agent-w7 -b agent-w7 main`; in it `git reset --hard main`, `tools/install-git-hooks.sh`; copy `infra/secrets/` files only if a dev-stack check needs them.
2. Read §2. Design the endpoints; write the contract; implement; test.
3. Build the image locally (never while a Gradle build runs, CLAUDE.md); run it with a throwaway data directory; check `/app/` and `/health`.
4. Gates (§7). Commit on `agent-w7`, `git rebase main`; if W0 has landed meanwhile, regenerate `web/src/api/schema.d.ts` and run the web gate too. Never merge, push or remove the worktree.
## 7. Gate
The server gate, from `server/`: `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test`; `python3 tools/contracts-check.py`; the web gate (`npm run check` in `web/`) when `web/` exists on the rebased branch. Report the real result lines.
## 8. HUMAN steps
From SPEC §11.2: paste the v0.28 NPM block (`infra/README.md` step 4, with the key as in step 4a) and deploy the new image (§10.10), relayed by the orchestrator.
## 9. Out of scope
- The web client's pages (W0 to W5) and the Android Insights section (W6).
- `infra/npm/kvitt.conf`, `infra/README.md`, `infra/pi/**` (docs agent and owner); `SPEC.md`, `docs/decisions.md` (proposed in the report).
## 10. Report
- Branch `agent-w7`, head commit after rebasing; gate result lines; image build result (size, `/app/` and `/health` checks).
- The endpoint design: paths, parameters, shapes, rounding, edge cases; proposed D-135 to D-138 entries and SPEC v0.29 text (§7, §10.3, §10.4 `KVITT_WEB_DIR`, §14.3.4, §15.4, §15.8); the exact `/app/` CSP and cache headers.
- Contract files changed; `web/src/api/schema.d.ts` regenerated or not; owner steps worded for relay; open questions with SPEC section numbers.
## 11. Numbering
D-135 to D-138, SPEC v0.29; server schema stays 3 (no migration expected; a schema change needs the orchestrator first).
+190 -16
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.27** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
Version **0.28** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it.
@@ -25,6 +25,7 @@ It consists of:
* A native Android app (Kotlin, Jetpack Compose) on two Samsung phones (Galaxy S26 and Galaxy S26+, Android 16). The app keeps a full copy of the household's data in a local Room database and works fully offline for expenses and settlements.
* A small Rust server (axum, SQLite) in Docker on a Raspberry Pi 3 at the owner's home, reachable at `https://<KVITT_PUBLIC_HOST>` (the default HTTPS port 443, D-124), where `KVITT_PUBLIC_HOST` is the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), through the owner's Nginx Proxy Manager on the Home Assistant Pi (D-120). The server stores the authoritative copy, syncs the phones, creates recurring expenses, sends push notifications, keeps an audit log and hosts the APK for in-app updates.
* A web client (D-129, §15): a React single-page app built into the server image and served by the server under `/app/`, for a browser on the owner's or the partner's computer. It has the app's features except the phone-only ones (pairing flow, push, offline use, in-app update), plus search, CSV export and keyboard shortcuts. It is online only.
Goals for v1:
@@ -32,7 +33,7 @@ Goals for v1:
2. **G-2 Offline first.** Creating, editing and deleting expenses and settlements never waits for the network. Two weeks offline followed by a sync loses nothing.
3. **G-3 Flexible splitting.** Per-account default ratios (50/50 to start, configurable such as 60/40), custom ratios, exact amounts, or 100 % on one person.
4. **G-4 Clear settling.** One global balance, a per-account breakdown, and a settle-up flow that proposes how a payment is spread over accounts.
5. **G-5 Insight.** Monthly and weekly spending by category and by account, computed on the phone.
5. **G-5 Insight.** Monthly and weekly spending by category and by account, computed on the phone; averages, trends and a two-month forecast computed on the server (D-133).
6. **G-6 Trust.** Every change to an expense or settlement is visible as a timeline of who did what.
7. **G-7 Low-maintenance hosting.** One docker-compose stack on a 1 GB Raspberry Pi, nightly off-site backups, no cloud services except Firebase Cloud Messaging and Google Drive for backups.
8. **G-8 Bilingual.** Every user-visible string exists in Swedish and English. The phone's locale decides: Swedish when the device language is Swedish, English otherwise.
@@ -51,14 +52,14 @@ Not in v1. Agents must not build any of these, even partially:
* More than two users, more than one household, user deletion.
* Passwords, password reset, email, SMS, OAuth sign-in.
* Play Store distribution.
* CSV or any other export.
* A web client. The only HTML the server serves is the invite landing page (§4 FR-2).
* Server-side analytics or reporting endpoints.
* CSV or any other export, except the web client's CSV export of a filtered expense list (D-129, §15.7).
* ~~A web client. The only HTML the server serves is the invite landing page (§4 FR-2).~~ Revised by D-129: the web client of §15 is in scope; it is not a progressive web app and has no offline use. The server serves HTML for the invite landing page (§4 FR-2) and the web client under `/app/` (D-132).
* Server-side reporting beyond the advanced analytics of D-133 (§15.8): S-11 and S-12 stay computed on the phone.
* Debt simplification (meaningless with two users) and netting several accounts in opposite directions inside one settlement.
* Editing accounts, categories, recurring templates, profiles or invites while offline.
* Tablet, foldable or landscape-specific layouts. Portrait phone only; landscape must not crash.
* Tablet, foldable or landscape-specific layouts of the Android app. Portrait phone only; landscape must not crash. (The web client is designed for the desktop, §15.1.)
* Widgets, Wear OS, iOS.
* In-app language picker. The phone locale decides.
* In-app language picker in the Android app. The phone locale decides. (The web client has a language choice, §15.3.)
* Comments or chat on expenses.
---
@@ -256,7 +257,7 @@ The server starts with an empty database. The household is created from the comm
### FR-14 Analytics
* AC-14.1 Analytics are computed on the phone from Room with the pure function of §14.5.4; the server has no reporting endpoint. They work offline and include pending local changes.
* AC-14.1 Analytics (S-11, S-12) are computed on the phone from Room with the pure function of §14.5.4. They work offline and include pending local changes. Revised by D-133: the advanced analytics (per-category averages and trends per month, the two-month forecast per account) come from the server's `/analytics/*` endpoints, online only (§9.9), shown on the web (W-11) and in the app's Insights section (W6) (§15.8); the web client computes S-11 and S-12's equivalents in the browser with the same functions (§15.5).
* AC-14.2 Range presets: `this_month`, `last_3_months`, `last_6_months`, `last_12_months`, `this_year`, `all_time`, `custom` (start and end date pickers). "Last N months" means the current month and the N-1 months before it, whole months. Default `last_6_months`.
* AC-14.3 Granularity: `month` or `week` (ISO, Monday start). Default `month`.
* AC-14.4 Measure: `household` (sum of full expense amounts, whoever paid) or `my_share` (sum of the viewer's shares). Default `household`.
@@ -311,6 +312,23 @@ The server starts with an empty database. The household is created from the comm
* AC-20.1 `GET /health` returns status, server version, schema version and a database check, unauthenticated, in under 100 ms on the Pi.
* AC-20.2 The docker healthcheck of the `server` service uses it.
### FR-21 Web client
The web client of §15 (D-129 to D-134). Its acceptance criteria:
* AC-21.1 `https://<KVITT_PUBLIC_HOST>/app/` serves the web client from the server image; every path under `/app/` that is not a file serves `index.html`; without the web files (`KVITT_WEB_DIR` missing) `/app/*` is 404 `not_found` (§15.4).
* AC-21.2 A browser logs in with an invite code through E-3 like a phone pairs (any of the three kinds), as device `Web · <browser>`; the token is kept in `localStorage` and sent as a bearer token; the browser appears on Devices and can be revoked from another device (§15.2).
* AC-21.3 A 401 forgets the token and shows the login page; `device_revoked` adds the S-18 message. Log out forgets the token and says that the browser stays on Devices until revoked.
* AC-21.4 Every page of §15.6 shows the server's current data: the household snapshot is pulled from `GET /sync` on load, on window focus and after every write; nothing but the token, the language choice and per-viewer conveniences survives a reload (§15.5).
* AC-21.5 Writes go to the server at once (`POST /sync` for expenses and settlements, REST for the rest); a failed write changes nothing and keeps the form's values; nothing is queued.
* AC-21.6 The web's money functions pass the vector files of §14.5.2's functions; its analytics functions pass `resolve_range.json` and `spend_series.json`; its category suggestion passes `suggest_category.json` (§15.3).
* AC-21.7 Swedish when the browser prefers Swedish, English otherwise; a choice on W-13 overrides it in that browser; every string exists in both languages.
* AC-21.8 From 720 px wide the layout has a sidebar; under 720 px one column with a bottom bar, usable at 360 px; light and dark follow `prefers-color-scheme`.
* AC-21.9 `N` opens a new expense, `/` the search, `?` the shortcut help, `Esc` closes the open dialog or editor; `N`, `/` and `?` do nothing while typing in a field.
* AC-21.10 Search finds non-deleted expenses by text in the description or notes, filtered by amount range, date range, account, category and payer; the filtered list exports as a CSV file (§15.7).
* AC-21.11 Accessibility as §13.1, for the web: every action reachable by keyboard with a visible focus, a label for every icon-only control, text contrast at least 4.5:1, nothing conveyed by colour alone, semantic headings and landmarks.
* AC-21.12 No service worker and no web app manifest; nothing is loaded from another origin at run time (§15.2).
---
## 5. Data model
@@ -399,6 +417,8 @@ Local-only phone tables: `sync_state` and `sync_rejections` (§14.2).
Firebase Cloud Messaging ──► phones owner's M4 Mac: build linux/arm64 images ──► Gitea registry
```
A browser running the web client (§15) reaches the same server the same way: HTTPS to NPM, which lets `/app/`, the invite accept and bearer-token requests through from outside the LAN (§8.5, D-130), and plain HTTP to the server, which serves the web client's files from its own image (D-132).
### 6.2 Android app
| Concern | Choice | Why |
@@ -585,7 +605,7 @@ Two trusted users. The server is on the public internet on port 443, through NPM
### 8.5 Transport
* TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi, with NPM's Let's Encrypt certificate for the Kvitt host name (`KVITT_PUBLIC_HOST`, owner-chosen, recorded in HANDOFF), on the default HTTPS port 443 (D-124); the router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant; nothing is forwarded to the Kvitt Pi (§10.1, §10.5, D-120, D-124). TLS 1.2 minimum, NPM defaults otherwise. NPM forwards plain HTTP over the home LAN to the server's port 8080 on the Kvitt Pi (accepted by the owner, D-120).
* App gate (D-126, owner decision 2026-10-21): NPM closes the connection (`return 444`, nothing sent) of every request that neither carries the header `X-Kvitt-App: <key>` with the app key nor comes from the home LAN `192.168.1.0/24`. Every route is covered, `/health`, `/setup/status`, `/invites/accept`, `/i/{code}` and `/apk/*` included; only Let's Encrypt's `/.well-known/acme-challenge/`, which NPM serves from its own folder, passes (§10.5.2). The LAN check reads nginx's `$realip_remote_addr`, the address that really connected, because NPM's `real_ip_header X-Real-IP` for private peers lets a client set `$remote_addr`. The release app sends `X-Kvitt-App` on every request when its build carries the key: the Gradle property `kvitt.appKey` (`-Pkvitt.appKey=…`, else the key `kvitt.appKey` in `android/local.properties`, else empty), 16 to 128 printable ASCII characters without white space or the build stops with a message giving the length, never the value; exposed as `BuildConfig.APP_KEY` and to `:data` as `AppBuildInfo.appKey` (whose `toString` hides it); added in `KvittApi`'s common headers (every API call: `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` for profile pictures from the paired server; an empty key sends no header (debug builds against the development stack need none). The key is generated by the owner (`openssl rand -hex 32`) and lives only in `android/local.properties` and NPM's Custom Nginx Configuration: never in git, never in `.env`, never printed in docs or logs. The Rust server neither sees nor checks the header and `contracts/openapi.yaml` is unchanged (§14.3.1). The per-device bearer token stays the authentication (§8.2); the gate is a perimeter that keeps scanners and anyone without the app from the server. On the home LAN the "secret" is the Wi-Fi password, and the owner controls the APK's distribution (he installs it on both phones). Rotation: a second `if` line with the new key next to the old in NPM, the new APK through the update path (FR-16), then the old line removed. Accepted: the invite landing page and any browser use work only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; nothing at home protects against a bandwidth flood.
* App gate (D-126, owner decision 2026-10-21): NPM closes the connection (`return 444`, nothing sent) of every request that neither carries the header `X-Kvitt-App: <key>` with the app key nor comes from the home LAN `192.168.1.0/24`. Every route is covered, `/health`, `/setup/status`, `/invites/accept`, `/i/{code}` and `/apk/*` included; only Let's Encrypt's `/.well-known/acme-challenge/`, which NPM serves from its own folder, passes (§10.5.2). D-130 later lets the web client's requests through (below). The LAN check reads nginx's `$realip_remote_addr`, the address that really connected, because NPM's `real_ip_header X-Real-IP` for private peers lets a client set `$remote_addr`. The release app sends `X-Kvitt-App` on every request when its build carries the key: the Gradle property `kvitt.appKey` (`-Pkvitt.appKey=…`, else the key `kvitt.appKey` in `android/local.properties`, else empty), 16 to 128 printable ASCII characters without white space or the build stops with a message giving the length, never the value; exposed as `BuildConfig.APP_KEY` and to `:data` as `AppBuildInfo.appKey` (whose `toString` hides it); added in `KvittApi`'s common headers (every API call: `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` for profile pictures from the paired server; an empty key sends no header (debug builds against the development stack need none). The key is generated by the owner (`openssl rand -hex 32`) and lives only in `android/local.properties` and NPM's Custom Nginx Configuration: never in git, never in `.env`, never printed in docs or logs. The Rust server neither sees nor checks the header and `contracts/openapi.yaml` is unchanged (§14.3.1). The per-device bearer token stays the authentication (§8.2); the gate is a perimeter that keeps scanners and anyone without the app from the server. On the home LAN the "secret" is the Wi-Fi password, and the owner controls the APK's distribution (he installs it on both phones). Rotation: a second `if` line with the new key next to the old in NPM, the new APK through the update path (FR-16), then the old line removed. Accepted: the invite landing page works only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; nothing at home protects against a bandwidth flood. **Web client (D-130, extends D-126):** from outside the LAN three more kinds of request pass without the key, because a browser cannot carry it: a path that is `/app` or starts with `/app/` (the web client's files), `POST /invites/accept` (the web login; AC-2.12's lockout bounds guessing), and a request with an `Authorization: Bearer` header whose path does not start with `/health`, `/setup/`, `/i/` or `/apk/` (the server checks the token; an unknown one gets 401 and counts against its address's unauthenticated limit, §8.6, the H-1 fix, D-115). `/health`, `/setup/status`, `/i/{code}` and `/apk/*` stay LAN-or-key only. Consequence, accepted: the server's authenticated routes are reachable from the internet with a wrong token (401, at most 60 per minute per address), which the security review's threat model already assumed before D-126 (§15.2).
* Release builds' network security config (`android/app/src/main/res/xml/network_security_config.xml`): cleartext disabled, system trust anchors only, unless the pinned-root switch is on, in which case a pinned root replaces the system anchors for one domain only (available but unprepared since D-120, §10.5.3). The pinned-root config is generated at build time (task `generatePinnedRootNetworkSecurityConfig`) from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost` (D-121), into the release resources only when the Gradle property `kvitt.tls.pinnedRoot` is `true` (D-082); the build stops with a message while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (`038c603`).
* Debug builds override the config in `android/app/src/debug` and allow cleartext to every host, because a network security config cannot express IP ranges; the AC-2.8 limit (`localhost`, `10.0.2.2`, private LAN addresses) is enforced by `SessionRepositoryImpl.normaliseServerUrl`. Lint's `InsecureBaseConfiguration` warning on the debug config is intended (D-082).
* The server trusts `X-Forwarded-For` only from NPM: `KVITT_TRUSTED_PROXY` is `HA_HOST`, NPM's LAN IPv4 address (an IP literal in the Pi's `.env`, D-125), and from that peer the server takes the last `X-Forwarded-For` entry as the client IP for rate limiting (D-120; the server is unchanged). A request from any other peer counts against the peer's own address.
@@ -910,6 +930,7 @@ File `infra/pi/compose.yaml` (compose project name `kvitt`). The owner copies it
* Image names (D-104, D-120): `${KVITT_REGISTRY}/kvitt-server`, `${KVITT_REGISTRY}/kvitt-backup`, with `KVITT_REGISTRY=gitea.lan/jacobh2` (was `${KVITT_REGISTRY}/kvitt/<name>`).
* Images are built by Gitea Actions (D-104): `.gitea/workflows/build-images.yaml`, following the owner's working example workflow. Runner label `host`: act_runner 0.6.1 on a Raspberry Pi 3 with 1 GB RAM, with an Alpine job container; the builds run on that Pi itself. `sh` steps, `apk add git docker-cli docker-cli-buildx`, `docker login gitea.lan` as `${{ github.actor }}` with `kvitt_cicd`, a user-level secret of the repository owner `jacobh2` (created by the owner; a user-level secret of the repository owner reaches the repository), a plain-HTTP buildx builder `local-http-builder` (`buildkitd.toml` with `http = true` for `gitea.lan`) created once and reused so its BuildKit cache survives, and `actions/checkout@v4` (below). The token is interpolated into the login step's `run` line (`printf '%s' "${{ secrets.kvitt_cicd }}" | docker login …`), the owner's proven form, because the env-based form (`env: REGISTRY_TOKEN: ${{ secrets.kvitt_cicd }}`) came through empty on act_runner 0.6.1 ("password is empty" on the first run, 2026-10-19); act_runner masks the value in the log (`4f69ae0`, D-120 point 7; this reverses the L-8 fix of `e70fe0d`). `kvitt_cicd` needs only the `write:package` scope, because the git tag push goes through the credentials `actions/checkout` stored (the job's own token) (D-117); the tag push worked on the first successful run (its `v1.0.0` tag is why the next run made `v1.0.1`). Checkout (`3820f9c`): a cone-mode sparse checkout (`sparse-checkout` `server` and `infra/backup`, `sparse-checkout-cone-mode: true`), the two build contexts (neither Dockerfile copies anything outside its context), which makes the action fetch with `--filter=blob:none`, so only those paths' blobs are downloaded and the Android screenshot PNGs are not checked out; `fetch-depth: 0` stays, because "Determine Version" needs every `v1.0.*` tag and with a shallow fetch `fetch-tags` brings only the tags that point at the fetched tip (the full fetch carries commits and trees, no blobs). The temporary "Diagnose secrets" step (`dd07ca4`) is removed (`3820f9c`). Every `buildx` push passes `--provenance=false` and `--metadata-file`, and a step "Print image digests" prints each pushed image's digest, which the owner compares with the digest the Pi pulled (`docker image inspect`, §10.10; the Pi keeps no digest record since D-125, which revises D-117 point 5). Triggers: a push touching `server/**`, `infra/backup/**` or `.gitea/workflows/**`, and `workflow_dispatch` (`infra/cert-sync/**` dropped by D-120). Version `v<MAJOR>.<MINOR>.<PATCH>` with MAJOR 1 and MINOR 0 set in the workflow: the next patch after the newest `v1.0.*` git tag (`v1.0.0` when there is none); each image is tagged with it and `latest`, and the git tag is pushed after the build. That tag is the Pi's `KVITT_TAG` (§10.2, §10.10); it says nothing about the APK version (§10.6). The Pi runner passes `--build-arg CARGO_BUILD_JOBS=1` to the server build (Pi 3, 1 GB), and `server/Dockerfile` honours it (`ARG CARGO_BUILD_JOBS` in its build stage, passed to cargo as `--jobs` only when set; unset on the Mac means one job per core; `b4d9467`). Runs on the owner's Pi runner: the first run (2026-10-19) failed at the login with the env-based form; the next ones failed at the login with an empty secret, because the user-level secret was misnamed on the owner's side (found with the diagnostic step, fixed by the owner on 2026-10-19). Run 69 succeeded: login, version `v1.0.0`, `kvitt-backup` built and pushed in 5 min 20 s, `kvitt-server` in 2 h 42 min (the first build on the Pi 3 with `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache), the git tag pushed. The push of the sparse checkout (`3820f9c`) produced `v1.0.1`, the tag the Pi uses. Since `28fa71b` (2026-10-20) the workflow's comments and `infra/build-images.sh`'s closing hint give the compose deploy (`cd ~/kvitt`, set `KVITT_TAG`, `docker compose pull && docker compose up -d`) and `docker image inspect` instead of `/opt/kvitt/deploy.sh` and `/opt/kvitt/deployed-digests`; the workflow's header still says "NOT YET RUN" and that the first build's duration is unknown. Unverified risk: the workflow pushes a git tag, and whether that push re-triggers the path-filtered workflow is not known.
* Script `infra/build-images.sh [tag]` on the Mac is the fallback (D-104): builds the two images for `linux/arm64`, tag defaults to the short git SHA of `HEAD`, also tags `latest`; pushes when `infra/secrets/registry.env` (git-ignored: `KVITT_REGISTRY`, optional `KVITT_REGISTRY_USER` and `KVITT_REGISTRY_TOKEN`) exists, otherwise builds locally and prints the exact push commands. Refuses to run with a dirty working tree or while a Gradle build holds the `tools/gradle-serial.sh` lock (the lock check shared through `tools/gradle-lock.sh`, `2fac1ec`). Works with Docker buildx and with Podman.
* Web client (D-132): `server/Dockerfile` gains a Node stage that builds `web/` (`npm ci`, `vite build`) and the runtime image carries the result at `/app` (`KVITT_WEB_DIR`, §15.4). The build context becomes the repository root (`-f server/Dockerfile` with context `.`), so the workflow, its sparse checkout (adding `web`, and `contracts` for the files the web build reads, §15.7) and its path triggers (adding `web/**`), `infra/build-images.sh` and `infra/dev/**` change with it (W7; the exact Node image and pin are the server agent's, D-135+). The Pi runner builds the Node stage too; the web client adds no image and no service.
* `server` image: multi-stage. Stage 1 `rust:<version>-bookworm`, where `<version>` is the exact Rust release pinned in `server/rust-toolchain.toml` (the current stable when M2a pins it; the two always change together), builds `kvitt-server` and `kvitt-import` in release mode with `--locked` for `aarch64-unknown-linux-gnu` (native on the M4; no `target-cpu=native`, the Pi 3 is a Cortex-A53). Stage 2 `gcr.io/distroless/cc-debian12:nonroot` (arm64) pinned by digest, with the two binaries in `/usr/local/bin/`, user 10001, entrypoint `kvitt-server`, command `serve`. The Dockerfile selects the Rust target from `TARGETARCH`: `arm64` (`aarch64-unknown-linux-gnu`) is the shipped target; `amd64` (`x86_64-unknown-linux-gnu`) also builds, emulated on the Mac, for local use only. Since M4 the server depends on reqwest with rustls on aws-lc-rs and on jsonwebtoken (§6.3): aws-lc-rs compiles C code, so the arm64 build stage needs a C toolchain beside Rust, and the distroless runtime image must carry CA roots for the platform verifier to reach Google's endpoints. The dev image (`infra/dev`, linux/arm64 on the Mac) was rebuilt with these crates on 2026-10-18 and built cleanly. The Pi image's C stage and CA roots, and an OAuth plus FCM call from the image, were confirmed on the Mac on 2026-10-18 (`docs/spikes/pi-image-arm64.md`, landed `3e6c337`): arm64 image 63.8 MB, cold build 48 s, CA bundle from distroless, binaries run natively; running on the Pi itself is M2c-pi's.
* `backup` image: `alpine` with `sqlite`, `gzip`, `tar`, `rclone` and `tzdata`, and `infra/backup/backup.sh` run by supercronic; the healthcheck is `infra/backup/healthcheck.sh`.
* The backup sidecar schedules with supercronic instead of busybox `crond` (D-104): it runs as a non-root user, and busybox `crond` started as a non-root user runs no job (tested 2026-10-18 in alpine 3.24.2). The schedule is unchanged (`30 3 * * *`).
@@ -955,7 +976,7 @@ Run them on the Pi in `~/kvitt` with `docker compose exec server kvitt-server <c
### 10.5 TLS
TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi (D-120; Caddy and cert-sync, which held this section until v0.20, are removed). Kvitt has its own public host name, `KVITT_PUBLIC_HOST` (owner-chosen, recorded in `docs/HANDOFF.md`), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects the proxy host by name, not by port. `KVITT_PUBLIC_URL` is `https://${KVITT_PUBLIC_HOST}`, with no explicit port: Kvitt is served on the default HTTPS port 443, on which NPM listens for every proxy host and selects by name (D-124). Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant, which serves Kvitt too: no new router rule (D-124; the 8443 rule of D-120 is not used); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from `infra/README.md` "Pi: what the owner does", step 4 (step 6 before D-125); no agent and no script touches NPM. The proxy host carries the app gate of §8.5 (D-126) in its Custom Nginx Configuration (§10.5.2), set up by step 4a once both phones run an APK that carries the key; an APK without the key cannot reach the server from outside the LAN, its update download included.
TLS terminates in the owner's Nginx Proxy Manager (NPM) on the Home Assistant Pi (D-120; Caddy and cert-sync, which held this section until v0.20, are removed). Kvitt has its own public host name, `KVITT_PUBLIC_HOST` (owner-chosen, recorded in `docs/HANDOFF.md`), because `icehack.asuscomm.com` is NPM's proxy host for Home Assistant and NPM selects the proxy host by name, not by port. `KVITT_PUBLIC_URL` is `https://${KVITT_PUBLIC_HOST}`, with no explicit port: Kvitt is served on the default HTTPS port 443, on which NPM listens for every proxy host and selects by name (D-124). Serving Kvitt under a path prefix of another name (for example `icehack.asuscomm.com/kvitt/`) is not supported. The router forwards external TCP 443 to the Home Assistant Pi's 443 (NPM), already in place for Home Assistant, which serves Kvitt too: no new router rule (D-124; the 8443 rule of D-120 is not used); nothing is forwarded to the Kvitt Pi. NPM issues and renews the name's Let's Encrypt certificate through the HTTP-01 challenge on port 80, which already reaches NPM. The owner sets up the proxy host in NPM's web UI from `infra/README.md` "Pi: what the owner does", step 4 (step 6 before D-125); no agent and no script touches NPM. The proxy host carries the app gate of §8.5 (D-126) in its Custom Nginx Configuration (§10.5.2), set up by step 4a once both phones run an APK that carries the key; an APK without the key cannot reach the server from outside the LAN, its update download included. Since D-130 the gate also lets the web client through (§8.5, §15.2): its files under `/app/`, the invite accept and bearer-token requests.
#### 10.5.1 Nginx Proxy Manager
@@ -988,10 +1009,10 @@ NPM's web UI (Home Assistant, Settings, Add-ons, Nginx Proxy Manager, Open Web U
#### 10.5.2 Custom Nginx Configuration
The block pasted into the proxy host's Advanced tab (D-120; it carries over D-117's response headers and log redaction). Written against NPM v2.16.0's proxy host template (`backend/templates/proxy_host.conf`); as of v0.25 it reads (`infra/README.md`, step 4; the app gate lines added by D-126, the rest as of `62f5279`):
The block pasted into the proxy host's Advanced tab (D-120; it carries over D-117's response headers and log redaction). Written against NPM v2.16.0's proxy host template (`backend/templates/proxy_host.conf`); as of v0.28 it reads (`infra/README.md`, step 4, and the file `infra/npm/kvitt.conf`, three identical copies; the app gate lines added by D-126, the web client's gate lines and the `$kvitt_csp` variable by D-130 and D-132, the rest as of `62f5279`):
```nginx
# Kvitt (SPEC §10.5.2, D-120, D-126). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
# Kvitt (SPEC §10.5.2, D-120, D-126, D-130). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
# App gate (SPEC §8.5, D-126): outside the home LAN only a request carrying the key built into the app passes;
# everything else gets the connection closed (444: nothing sent, nothing to fingerprint). Put the key from
# android/local.properties (kvitt.appKey) in place of KVITT_APP_KEY. To rotate, add a second if-line with the new key.
@@ -1001,6 +1022,15 @@ set $kvitt_gate 0;
if ($http_x_kvitt_app = "KVITT_APP_KEY") { set $kvitt_gate 1; }
if ($realip_remote_addr ~ "^192\.168\.1\.") { set $kvitt_gate 1; }
if ($uri ~ "^/\.well-known/acme-challenge/") { set $kvitt_gate 1; }
# Web client (SPEC §15, D-130): its files under /app/, the invite accept it logs in with, and any request carrying a
# bearer token (the server checks the token; unknown tokens are rate-limited per IP), except /health, /setup/, /i/
# and /apk/, which stay LAN-or-key only.
if ($uri ~ "^/app(/|$)") { set $kvitt_gate 1; }
if ($uri = "/invites/accept") { set $kvitt_gate 1; }
set $kvitt_bearer 0;
if ($http_authorization ~ "^Bearer ") { set $kvitt_bearer 1; }
if ($uri ~ "^/(health|setup/|i/|apk/)") { set $kvitt_bearer 0; }
if ($kvitt_bearer = 1) { set $kvitt_gate 1; }
if ($kvitt_gate = 0) { return 444; }
# Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413.
client_max_body_size 6m;
@@ -1013,7 +1043,11 @@ proxy_hide_header Content-Security-Policy;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-Frame-Options;
proxy_hide_header Referrer-Policy;
add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
# The web client under /app/ (D-132) needs its own scripts, styles, images and API calls: one variable, still one
# server-level add_header.
set $kvitt_csp "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
if ($uri ~ "^/app(/|$)") { set $kvitt_csp "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; }
add_header Content-Security-Policy $kvitt_csp always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
@@ -1045,6 +1079,9 @@ location ^~ /i/ {
* `client_max_body_size 6m`: above the server's own limits (1 MiB, 5 MiB for `PUT /me/picture`, §8.6), which answer `payload_too_large` themselves; larger bodies get NPM's 413. `proxy_read_timeout 60s`: the API has no long-poll.
* Logs (§8.7): NPM writes one access log line per request to `/data/logs/proxy-host-<id>_access.log` inside NPM, in its `proxy` format: time, cache and upstream status, status, method, scheme, host, `"$request_uri"` (the path with its query string), `[Client <address>]`, length, gzip ratio, `[Sent-to <KVITT_PI_HOST>]`, user agent and referer. It logs no request headers, so the `Authorization` token is never logged. Query strings are logged for every path except `/i/`; the API's other query parameters are `since` and `limit` (`GET /sync`) and `size` and `v` (pictures), which hold no personal content. `location ^~ /i/` turns the access log off and sends its error log to `/dev/null`, so neither the invite code nor the inviter's name (`?from=`) reaches a log, a 502 included. Errors on other paths (for example a 502 while the server is down) go to `proxy-host-<id>_error.log` with the request line.
* App gate (§8.5, D-126): `set`, `if` and `return` are rewrite-module directives; at server level they run in NPM's server rewrite phase, before a location is chosen, so the gate covers every location, `/i/` included, and no `add_header` is involved. `KVITT_APP_KEY` is replaced by the key from `android/local.properties`; a rotation adds a second `if ($http_x_kvitt_app = …)` line. `$realip_remote_addr` is the TCP peer: NPM's `nginx.conf` has `set_real_ip_from` 10/8, 172.16/12 and 192.168/16 with `real_ip_header X-Real-IP`, which would let a LAN client, or every client if NPM saw a Docker bridge address (security review I-5), choose `$remote_addr`. The Let's Encrypt exemption tests the normalised `$uri`, which location matching uses, so `/.well-known/acme-challenge/../../health` is closed; that path is served by NPM's own `location ^~ /.well-known/acme-challenge/` from its webroot, never proxied. NPM's own server-level rules come first in its template: Force SSL answers port 80 with a 301 to `https://` (Let's Encrypt follows it, which is why the exemption is needed on 443), and Block Common Exploits answers its patterns with 403. If NPM saw a Docker bridge address for every client, the LAN check would match nothing; if the router rewrote internet clients to its LAN address, everything; NAT hairpin makes a LAN client appear as the router's `192.168.1.1`, still inside the /24. The owner's acceptance test (`infra/README.md` step 4a): on the home Wi-Fi `https://<KVITT_PUBLIC_HOST>/i/TEST` shows the page in a browser, with Wi-Fi off it gives a connection error, and the app's About screen still shows the server version.
* Web client gate (D-130): `/app` and `/app/…` and `POST /invites/accept` pass on the normalised `$uri` (so `/app/../health` is closed); a bearer header passes through the helper variable `$kvitt_bearer`, which the next line clears for `/health`, `/setup/`, `/i/` and `/apk/`, because nginx's `if` takes one condition. The value of the bearer header is not checked by NPM (the server does). Before the gate first goes in (step 4a), the gate lines from `set $kvitt_gate 0;` to `return 444;` are left out together.
* Content-Security-Policy (D-132): `$kvitt_csp` holds the D-117 policy, and for `/app/` the web client's: its own origin for scripts, styles (inline styles allowed, as D-117 allows them), images (plus `blob:` for profile pictures fetched with the token and `data:`), fonts and API calls; `default-src 'none'`, `base-uri 'none'`, `form-action 'none'`, `frame-ancestors 'none'` as before. One server-level `add_header` with a variable keeps the rule that no location has an `add_header`. The server's own `/app/` header (W7) is hidden like the others and should carry the same policy (it is the one the development stack shows).
* Verified on the Mac on 2026-10-21 (D-130, D-132): `nginx -t` (nginx:alpine 1.31.7) of the block above with a test key in a server block whose upstream is a stub, and 17 requests from a non-LAN address: `/health`, `/sync` without a header, `/sync` with `Authorization: Basic`, `/invites/acceptx`, `/application`, `/app/../health` and, with a bearer header, `/health`, `/i/TEST`, `/apk/latest` and `/setup/status` were closed with no reply; `/health` with the key, `/app`, `/app/`, `/app/login`, `POST /invites/accept`, `/accounts` with a bearer header and the ACME path reached the upstream; `/app/` carried the web policy and `/accounts` the D-117 policy, each once, with the other three headers. Not checked: NPM's own template and `real_ip` settings with these lines (the 2026-10-21 D-126 rehearsal covered them for the earlier lines), the real proxy host.
* Verified on the Mac on 2026-10-21 (D-126): the block above, with a test key, inside a server block with NPM v2.16.0's `letsencrypt-acme-challenge.conf` and `block-exploits.conf` includes and its `real_ip` settings, run by the openresty 1.31.1.1 of the `jc21/nginx-proxy-manager:2` image (v2.16.0): `nginx -t` successful; without the header, with a wrong key, with a forged `X-Real-IP: 192.168.1.50`, on `/i/` and `/apk/files/` without the header, and on `/.well-known/acme-challenge/../../health`, the connection was closed with no reply; with the key `/health` and `/i/` reached the upstream with the D-117 headers; an ACME token file was served; a second key line passed; a peer matching the LAN pattern passed without the header; with `$remote_addr` instead of `$realip_remote_addr` the forged `X-Real-IP` passed (why the block uses the latter). Not checked: HTTPS and HTTP/2 through NPM's real proxy host, the router and the real LAN addresses (the owner's acceptance test).
* Verified on the Mac on 2026-10-19 (D-120): `nginx -t` (nginx:alpine 1.31.7) of the block inside a minimal server block and inside a copy of NPM v2.16.0's `nginx.conf` with the proxy host rendered as NPM's template renders it for the §10.5.1 settings, both successful; a Podman rehearsal of the server image built from `server/Dockerfile` (arm64, hardened as on the Pi) behind that nginx with a self-signed certificate, 21 checks passed: the four headers and `Strict-Transport-Security` once each over HTTP/2 and no `X-Served-By`; one `Referrer-Policy` on the landing page; `http://` answered 301 to `https://`; a 7 MB body refused by nginx (413), a 2 MB body answered by the server (`payload_too_large`); the rate limit applied per forwarded client and a forged `X-Forwarded-For` did not escape it; the invite code, the name and `/i/` absent from nginx's access and error logs after `/i/`, `//i/` and `/%69/` requests and a 502 on `/i/`. Not checked (needs the real hosts): NPM's own UI and Let's Encrypt for the Kvitt host name, the router forward.
@@ -1084,6 +1121,7 @@ location ^~ /i/ {
* The server reads `latest.json` on each `GET /apk/latest` (no cache) and serves `GET /apk/files/{file_name}` only for a `file_name` matching `^kvitt-[0-9]+\.apk$` that exists, with `Content-Type: application/vnd.android.package-archive`.
* Downloads are limited to 10 per minute per client IP (§8.6, D-119); E-5 stays on the 60 per minute of the other unauthenticated endpoints. If one address still fetches `/apk/files/` over and over (NPM's access log), the owner's stop-gap is to disable Kvitt's proxy host in NPM (Hosts, Proxy Hosts, the row's menu, Disable) until it stops; the router's 443 forward stays, because it also serves Home Assistant (D-124, `infra/README.md`). Since the app gate (D-126) a request from outside without the app key never reaches the server, so this concerns the home LAN or a leaked key.
* Older APKs stay in `data/apk/` on the Pi until the owner removes them by hand (D-125; `install-apk.sh` kept the newest 5, D-103); `infra/pi/out/apk/` on the Mac keeps the newest 5.
* The web client (D-132) is part of the server image: it is published by deploying a server image (§10.10), with no step of its own here; a browser loads the new version on its next page load (`index.html` is never cached, §15.4).
### 10.7 Firebase setup (M2c-dev, with a HUMAN sign-in)
@@ -1202,7 +1240,7 @@ Restore (step by step in `infra/RESTORE.md`, run on the Pi in `~/kvitt` as the o
## 11. Milestones
Each milestone is one plan (`PLAN-<id>.md`, written by the orchestrator's plans agent from this section) run by one Opus agent in its own worktree, unless the row says otherwise. "Gate" is what must pass before landing. The Android gate is `tools/gradle-serial.sh check`; the server gate is `cargo fmt --check && cargo clippy -- -D warnings && cargo test` in `server/`; "both gates" means both. HUMAN steps are what only the owner can do.
Each milestone is one plan (`PLAN-<id>.md`, written by the orchestrator's plans agent from this section) run by one Opus agent in its own worktree, unless the row says otherwise. "Gate" is what must pass before landing. The Android gate is `tools/gradle-serial.sh check`; the server gate is `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` in `server/`; the web gate (D-131, §15.3) is `npm run check` in `web/`; "both gates" means the Android and server gates. HUMAN steps are what only the owner can do.
### 11.1 Dependency graph
@@ -1252,6 +1290,15 @@ AC-coverage audit (956908a) ─┼─► M12f Android follow-ups (landed dc7a296
After v0.18 (v0.19):
M12f ─► Android overhaul (landed e4e6e5d..c582363, D-118) ─► push to origin (c582363) ─► owner runs the M2c-pi bundle ─► M10
Web client programme (v0.28, D-129 to D-134):
SPEC v0.28 ─┬─► W0 web foundation (web/**) ──────────┬─► W1 home, accounts ─┐
│ ├─► W2 expenses, search, export
│ ├─► W3 settle up ├─► image build and deploy (§10.10)
│ ├─► W4 analytics, insights (W-11 waits for W7)
│ └─► W5 settings ───────┘
└─► W7 server: /app/ serving, /analytics/* (D-135..D-138, v0.29) ─┬─► W4's W-11
└─► W6 Android Insights
```
After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001), as the last step once the app is proven on the phone against the development server (D-100). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c (all three landed: `aa765bb`, `6eaccec`, `b09f322`). M12d (D-059) ran in parallel with M6 (landed `557af7e` and `7730a31`); M7a to M7f ran in parallel and landed on 2026-10-18 (`1ea752c`, `0e1d99d`, `0b140aa`, `7380e50`, `ea7d494`, `c4ab35f`), followed by M6b, the `:core:design` fix pass (`b24c8b5`). M8 and M7g (feature cleanups) ran in parallel (disjoint paths: `android/app/**` and `android/adapter/fcm/**` against `android/feature/**`) and landed on 2026-10-18 (`3dda414`; `cb832a7` and `3d9eea8`). The contract pass M12e (D-086 to D-095) ran after them and landed on 2026-10-18 (`8c60715` contract pass, `b7b7483` launcher icon). The M2c-pi bundle (`2476667`, D-103 to D-106), M8e (`a59abeb`, D-107) and M8f (`956908a`, D-108) landed on 2026-10-18; what remains of M2c-pi is the owner running the bundle, and M10 waits for it.
@@ -1290,6 +1337,14 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| M9 | Splitwise importer | Landed `50339ae` (D-044 to D-046). `kvitt-import` per §10.8 with tests over synthetic CSV fixtures (`crates/kvitt-import/tests/import.rs`), reconciliation against the totals rows, the committed category table | Server gate | Provide the CSV files (kept outside git) when OQ-2 is answered | M2a, M4 |
| M10 | First release | Release build signed, published, both phones paired, push working both ways, nightly backup on Drive, restore drill report, memory check | `docs/validation/first-release.md` and `docs/validation/restore-drill.md` complete; server RSS under 100 MB | Create the release keystore with the `keytool` command of §10.6 at the repository root, or allow an agent to (D-101), and keep a copy and the password in the password manager; run the Pi steps of the M2c-pi compose project (D-100, D-125); install the APK on both phones from the landing page; allow "Install unknown apps"; partner pairs with the invite; confirm notifications arrive | M2c-pi (the owner's run of the bundle), M3, M4, M8 |
| M11 | Import history | Rehearsed on 2026-10-18: the real run reconciled on both files in a throwaway database (Vardag 1746 rows, Hemma 37 rows; 21 rows unmapped before D-044). Dry run of 2026-10-18 with the M12b mapping (D-053): Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled. Real import run on the Pi per §10.8 "Production run" (D-025), `docs/validation/splitwise-import.md` with per-account totals compared to Splitwise's balances (aggregates only) | Imported balances equal Splitwise's "Total balance" rows per group | Provide the CSVs; confirm the balances on the phone match Splitwise | M9, M10 |
| W0 | Web foundation (D-131, D-134) | Running (2026-10-21). `web/`: Vite, React 19, TypeScript strict, React Router 7, TanStack Query 5, `openapi-typescript` types in `web/src/api/schema.d.ts` and `openapi-fetch`, Vitest and Testing Library, ESLint and Prettier; design tokens from `docs/ux/design-spec.md` §2 as CSS custom properties; layout shell (sidebar, bottom bar under 720 px), router with every route of §15.3, keyboard shortcuts and W-20; login (W-1), token store and 401 handling; API client and household snapshot (§15.5); i18n (sv, en); formatting (§9.8); shared components; the money functions of §14.5.2 passing their vectors; `npm run check` | Web gate | none | SPEC v0.28 |
| W1 | Web: home and accounts | Planned. W-2, W-3, W-4 in `web/src/features/{home,accounts}/` | Web gate | Look at Home and an account in a browser against the development stack (yes/no) | W0 |
| W2 | Web: expenses, search and export | Planned. W-5 with the §9.11 suggestion (`suggest_category.json`), W-6 with the timeline, W-12 with the CSV export, in `web/src/features/{expenses,search,export}/` | Web gate | Add, edit and delete an expense in a browser; open an exported CSV in Numbers (yes/no) | W0 |
| W3 | Web: settle up | Planned. W-7, W-8 in `web/src/features/settle/` | Web gate | Record and delete a settlement in a browser (yes/no) | W0 |
| W4 | Web: analytics and insights | Planned. W-9, W-10 with the D-128 line chart, W-11 on the D-133 endpoints (stubbed until W7 lands); `resolveRange` and `spendSeries` passing their vectors; in `web/src/features/analytics/` | Web gate | Look at the analytics pages in a browser (yes/no) | W0; W-11 needs W7 |
| W5 | Web: settings | Planned. W-13 to W-19 in `web/src/features/settings/` | Web gate | Pair a browser, see it on the phone's Devices, revoke it from the phone (yes/no) | W0 |
| W6 | Android: Insights | Planned. An Insights section on S-11 from the D-133 endpoints, online only (§9.9): a UI-contract change (§14.8) in `:core:model`, `:data`, `:core:fakes` and `:feature:analytics`, with goldens | Android gate | Look at S-11's Insights on the phone (yes/no) | W7 landed, its image deployed |
| W7 | Server: web serving and analytics endpoints (D-132, D-133) | Running (2026-10-21). `/app/` from `KVITT_WEB_DIR` with the SPA fallback, cache headers and the `/app/` CSP; the `/analytics/*` endpoints (D-135 to D-138, SPEC v0.29) in `contracts/openapi.yaml` and the server; the Node stage in `server/Dockerfile` with the repository root as build context (workflow, `infra/build-images.sh`, `infra/dev/**`) | Server gate; `python3 tools/contracts-check.py`; the web gate when `web/` exists and the contract changed (`web/src/api/schema.d.ts` regenerated) | Paste the v0.28 NPM block (`infra/README.md` step 4); deploy the new image (§10.10) | SPEC v0.28 |
### 11.3 Notes per milestone
@@ -1319,6 +1374,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
* **Infra overhaul** (landed `2fac1ec` to `e70fe0d`, no decision): one Gradle lock helper `tools/gradle-lock.sh` for `tools/gradle-serial.sh`, `tools/clean-builds.sh` and `infra/build-images.sh`; the pre-commit hook runs the Android tests through `tools/gradle-serial.sh` with JDK 25 on `PATH`, `JAVA_HOME` untouched; `install.sh` keeps the deployed `KVITT_TAG` (moot since D-125: no `install.sh`); `--provenance=false` on buildx pushes and the workflow token through the environment (§10.3; reversed by D-120 point 7); `RCLONE_REMOTE` dropped from the development env (§10 intro). **Infra security** (landed `7fb86f5`, D-117): Caddy log redaction and response headers (§8.7, §10.5.2), the non-root Caddy container (§8.9, §10.2), the development stack's single secret (§10 intro), the digest record (§10.3, §10.10; dropped by D-125 for `docker image inspect`), `kvitt_cicd` with `write:package` only (§10.3), the restricted cert-sync key (§10.5.1). D-120 carries the log redaction and the headers over to NPM's Custom Nginx Configuration (§10.5.2) and removes the Caddy container, the digest of its image and the cert-sync key.
* **Overhaul outcomes** (2026-10-18). Server: landed through `b985ce9` (see the server overhaul above). Infra: landed through `e70fe0d` (see the infra overhaul above). **Android overhaul** (landed `e4e6e5d` to `c582363`, D-118, no prompt file, orchestrator brief): one screenshot and test harness in `:core:design`'s test fixtures and the convention plugin `kvitt.android.screenshots` (§6.2); the shared failure texts, limits and `savedSplitLabel` in `:core:design` (§6.2); dead code removed (`KvittMotion` down to the seesaw spring, `KvittIcon.Description`, unused dimens and shapes, catalogue and fake samples); the catalog entries `roborazzi-junit-rule`, `roborazzi-compose`, `androidx-hilt-navigation-compose` and `androidx-lifecycle-viewmodel-compose` removed; one `CopyCategoryKeywords` task in `build-logic` shared by `:data` and `:core:fakes` (D-055 holds); one `configureAndroidModule` for the application and library plugins; S-7's state types in `ExpenseEditorState.kt`. Gate at landing: 941 tests, no golden changed; release APK 10.46 MB. Pushed to `origin` at `c582363`.
* **M11** runs the import per §10.8 "Production run" (D-025). The rehearsal on 2026-10-18 ran both real files against a throwaway database and reconciled on both (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044. The dry run of 2026-10-18 with M12b's mapping confirmed it: Vardag 1746 rows, 0 unmapped; Hemma 37 rows, 0 unmapped; both reconciled.
* **W0 to W7** (D-134, §15.9): the web client programme. W0 and W7 run first in parallel (disjoint paths); W1 to W5 run in parallel once W0 has landed; W4's W-11 and W6 wait for W7. W0 owns `web/**` except the feature folders, registers every route with a placeholder page per feature folder (as M2a registered every route, D-004), and lists its module paths in its report; W1 to W5 build only inside their feature folders and take everything shared from W0's modules (a missing shared piece is a request to the orchestrator, as M7x's requests went to M6b). A second agent to land that changed `web/package.json` resolves `web/package-lock.json` with `npm install` on rebase (as `Cargo.lock`, §14.7). A plan that changes `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` (§15.3). Web agents do not use the phone or `tools/gradle-serial.sh`; a manual check runs against a throwaway server, never the owner's development or Pi data (§15.4). The orchestrator adds the web gate to `CLAUDE.md` "Build and verify" (D-131).
### 11.4 Validation reports
@@ -2788,8 +2844,12 @@ android/
core/model/ core/design/ core/fakes/ domain/ data/ adapter/fcm/
feature/{onboarding, home, account, expense, settle, analytics, settings}/
app/
web/ (the web client, D-131, §15)
package.json package-lock.json vite.config.ts tsconfig.json eslint.config.js index.html
src/{app, api, i18n, design, components, lib}/ src/api/schema.d.ts (generated from contracts/openapi.yaml)
src/features/{auth, home, accounts, expenses, search, export, settle, analytics, settings}/
infra/
build-images.sh publish-apk.sh README.md RESTORE.md .shellcheckrc
build-images.sh publish-apk.sh README.md RESTORE.md .shellcheckrc npm/kvitt.conf
pi/{compose.yaml, .env.example, .gitignore} pi/.env, pi/secrets/, pi/out/ (git-ignored) (copied to /home/jacobhagstedt/kvitt on the Kvitt Pi, D-125)
dev/{compose.yaml, .env.example, lib.sh, build.sh, up.sh, logs.sh, backup.sh, copy-google-services.sh} dev/data/, dev/backups/ (git-ignored)
backup/{Dockerfile, backup.sh, healthcheck.sh} secrets/ (git-ignored)
@@ -2826,6 +2886,15 @@ Ownership (an agent edits only paths its milestone owns; anything else needs the
| M12 (D-054): `contracts/openapi.yaml` (`DefaultCategoryKey`) and the category examples M12a held back; `kvitt-server` `migrations/0002_categories_v2.sql`, `src/db/mod.rs` (`SCHEMA_VERSION`), `src/api/validate.rs` (token lists), `src/wire/category.rs` and `src/wire/mod.rs` (key enum), their tests; `kvitt-import` `data/splitwise_categories.csv`, `src/categories.rs` and its tests | M12b (contract-change plan, landed `6eaccec`) | the usual owners |
| M12 (D-054): `android/core/model/**` (enums, icons, `ExpenseRepository`), `android/data/**` (`UnicodeText` binding, `suggestCategory`, the build-time copy of the keyword table, D-055), `android/core/fakes/**` (defaults, fake `suggestCategory`) | M12c (contract-change plan, landed `b09f322`) | the usual owners |
| D-059: `android/core/model/**` (`ExpenseEditorOptions`, `CategoryRepository`), `android/data/**` (`frequentCategoryIds`, `deletable`), `android/core/fakes/**` (both, outside the `fakes/<feature>/` folders) | M12d (contract-change plan, landed `557af7e`) | the usual owners; M7x keep their `fakes/<feature>/` folders |
| `web/**` except the feature folders below and `web/src/api/schema.d.ts`'s regeneration (D-134) | W0 | later web plans; W1 to W5 may add dependency lines to `web/package.json` (second to land resolves `web/package-lock.json` with `npm install` on rebase) |
| `web/src/api/schema.d.ts` (generated) | W0 | regenerated by whichever plan changes `contracts/openapi.yaml`, in the same landing (§15.3) |
| `web/src/features/home/**`, `web/src/features/accounts/**` | W1 (W0 creates the placeholder pages) | later web plans |
| `web/src/features/expenses/**`, `web/src/features/search/**`, `web/src/features/export/**` | W2 (W0 creates the placeholder pages) | later web plans |
| `web/src/features/settle/**` | W3 (W0 creates the placeholder page) | later web plans |
| `web/src/features/analytics/**` (W-9, W-10, W-11) | W4 (W0 creates the placeholder pages) | later web plans |
| `web/src/features/settings/**` | W5 (W0 creates the placeholder page) | later web plans |
| W7 (D-132, D-133): `server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**` | W7 (contract-change plan for `contracts/**`) | the usual owners |
| W6 (D-133): `android/feature/analytics/**`, and for the Insights contract `android/core/model/**`, `android/data/**`, `android/core/fakes/**` | W6 (contract-change plan, §14.8) | the usual owners |
| `docs/validation/<file>` | the milestone that names the file | |
Module shells (D-004): a shell file or module created by M2a or M2b in a path owned by a later milestone holds only what makes the build and the 501 stubs work; the later milestone owns it from the moment the shell lands and writes the body.
@@ -2859,7 +2928,111 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
---
## 15. Change log
## 15. Web client
Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `web/` and served by `kvitt-server` under `/app/`. It is a second way into the same household: the same server, the same rules, the same data, read and written through the same API (§7, §14.3, §14.4). Nothing in the phone app changes because of it, except the Insights section of W6 (§15.8). This section is binding for the web plans W0 to W5 and for the web parts of W7; FR-21 holds the acceptance criteria.
### 15.1 Scope (D-129)
* **Parity.** Everything the Android app does, except what only makes sense on a phone: the phone pairing flow (S-1's server field, `kvitt://` links, S-3 as a separate screen, S-18 as a separate screen), push notifications (FR-13), offline use and the sync screen (FR-15, S-16), the in-app update (FR-16, S-17). In: Home balances, account detail, account editor, expense add, edit and delete, expense detail with the audit timeline, settle up and settlement detail, analytics (S-11, S-12 with the D-128 line chart), categories, recurring templates, devices, profile, invite partner and pair a device (admin), about.
* **Extras the app lacks** (web only): free-text search across descriptions and notes with amount and date filters (W-12); CSV export of the filtered expense list (W-12); keyboard shortcuts `N` new expense, `/` search, `Esc` close, `?` help (W-20); a language choice (W-13).
* **Advanced analytics** (D-133) come from the server and are shown on the web (W-11) and later in the app (W6).
* **Form.** Designed desktop first and usable on a phone: one column under 720 px wide, a sidebar from 720 px. Swedish and English, like the app (G-8). Light and dark by `prefers-color-scheme`. Not a progressive web app: no service worker, no web app manifest, no offline use, nothing cached for offline.
* **Not in scope:** a receipt picture (none exists in the app, §2); the S-1 confirm step of OQ-6; anything §2 still lists.
### 15.2 Access and login (D-130)
* **Login.** A browser logs in with an invite code exactly like a phone pairs: E-3 `POST /invites/accept` with a `device` invite (the usual case: the owner creates one on S-21 or W-18 for himself or the partner), or a `partner` or `admin_setup` invite. The device name is `Web · <browser>` (the browser's name from the user agent, `Web` alone when unknown). `display_name` is sent when the login page's name field is filled; it is needed only for `partner` and `admin_setup` codes (E-3), and an `invalid_request` for a missing name shows the field's error. A pasted invite link (`https://<host>/i/<CODE>[?from=…]`) fills the code (§8.3 normalisation, as S-1). After a `partner` accept the web opens the profile page once (the S-2 step, AC-2.10); every other accept opens Home.
* **Token.** The bearer token is kept in the browser's `localStorage` and sent as `Authorization: Bearer <token>` on every API call (§8.2). It is never put in a URL, never logged, never shown. The browser is a device like a phone: listed on Devices (AC-3.1) with its name, revocable from another device like any device (AC-3.2), and a revoked or unknown token gets 401 (AC-3.3), on which the web forgets the token and shows the login page with the S-18 message (`device_revoked`) or the plain login page (`unauthorized`).
* **Log out** (W-13) forgets the token in this browser. The device stays on the Devices list, still valid, until it is revoked from another device, because a device cannot revoke itself (`cannot_revoke_current_device`, AC-3.2); the page says so. A sign-out endpoint that revokes the current device would be a contract change (§14.8) and is not part of v0.28.
* **Headers** (§14.3.1): `X-Kvitt-Protocol: 1`; `X-Kvitt-App-Version` with the web build's version code and `X-Kvitt-App-Version-Name` with its version name (both from the build, W0; the name is at most 32 characters, so it shows on S-20 like a phone's); `Accept-Language` is the browser's own. No `X-Kvitt-App`: a browser cannot carry the app key (§8.5).
* **The NPM gate** (§8.5, §10.5.2, extends D-126). From outside the home LAN a request passes when it carries `X-Kvitt-App` with the key, comes from the LAN, is Let's Encrypt's challenge path, **or** its path is `/app` or starts with `/app/`, **or** its path is `/invites/accept`, **or** it carries an `Authorization: Bearer` header and its path does not start with `/health`, `/setup/`, `/i/` or `/apk/`. Everything else still gets 444. So the invite landing page (`/i/`), `/health`, `/setup/status` and the APK downloads stay LAN-or-key only, with or without a bearer header; the web client sends its bearer on API calls only.
* **Consequence (accepted).** The server's authenticated routes become reachable from the internet by anyone who sends some bearer token: a wrong token gets 401 `unauthorized` and counts against its address's unauthenticated limit of §8.6 (the H-1 fix, D-115, keeps junk tokens from ever counting as accepted), so the bound is 60 such requests per minute per address. `POST /invites/accept` is reachable from the internet and bounded by the 10-failures-per-hour lockout of AC-2.12 and the code's 60 bits (§8.3). The web client's static files are public (they hold no data and no secret). The security review's threat model (§8.1) assumed a reachable API before D-126; the gate stays a perimeter against scanners, and the device token stays the authentication.
* **Browser hardening.** The `/app/` pages get a Content-Security-Policy that allows only the server's own origin for scripts, styles, images (plus `blob:` and `data:`), fonts and API calls, with `frame-ancestors 'none'` (§10.5.2; the server's own `/app/` header is W7's, see the server agent's D-135+, and is hidden behind NPM like the D-117 headers). No third-party script, font or CDN at run time: everything the page loads is in the image. `localStorage` is readable by any script on the origin, so the policy's `script-src 'self'` is what protects the token.
### 15.3 Stack and layout (D-131)
* `web/` at the repository root: Vite, React 19, TypeScript in strict mode, React Router 7, TanStack Query 5, `openapi-typescript` (types generated from `contracts/openapi.yaml` into `web/src/api/schema.d.ts`, generated and committed) and `openapi-fetch` as the API client, Recharts for charts, Vitest with Testing Library for tests, ESLint and Prettier, plain CSS modules. npm with a committed `package-lock.json`; Node 25 on the Mac. Exact versions are W0's, each the latest stable at the time (recorded in its report).
* **Design.** Tokens from `docs/ux/design-spec.md` §2 as CSS custom properties: the same colour roles (light and dark), category colours (§9.7), type scale, spacing, the 4/2 corner-radius language of the charts (D-097), the same icons (Material Symbols, Apache-2.0, bundled as SVG, never from a CDN). The typeface is the platform's system font stack (D-020's "no bundled font"), amounts with tabular figures. Components follow the design spec's anatomy where one exists (ledger rows, balance header, category tile, avatar, segmented controls); desktop layouts are W0's and each plan's, within the tokens.
* **Strings.** A small JSON dictionary per language (`sv.json`, `en.json`). A string that exists in `docs/ux/strings.csv` uses its key and its text; web-only strings get keys prefixed `web_` and are listed in the plan's report (they are not added to `strings.csv`, which is the app's). Placeholders are named (`{name}`), not Android format arguments. Both languages always have every key; the gate checks it.
* **Language.** Swedish when the browser's preferred language is Swedish, English otherwise (G-8); W-13 offers a choice that overrides it in this browser (`localStorage`).
* **Money and dates.** Integer öre everywhere, formatting per §9.8 (the web formats with the same rules and the same outputs as the app for the same input). The money functions of §14.5.2 (`computeSplit`, `computeBalances`, `proposeAllocation`, `validateSettlement`, `overSettledAccounts`) exist in TypeScript and pass `contracts/test-vectors/*.json` for those functions in a Vitest test; `resolveRange` and `spendSeries` (§14.5.4) pass their vectors (W4), the category suggestion of §9.11 passes `suggest_category.json` (W2). The rules live in `kvitt-domain`, `:domain` and the web's ports of them; the vectors keep the three equal (G-1).
* **Source layout** (agreed for W0 to W5): `web/src/app/` (entry, providers, router with every route, layout shell, keyboard shortcuts), `web/src/api/` (generated schema, client, token store, household snapshot and writes, §15.5), `web/src/i18n/`, `web/src/design/` (tokens, global styles), `web/src/components/` (shared components), `web/src/lib/` (formatting, the money functions under `lib/domain/`, utilities), `web/src/features/<name>/` (one folder per feature plan, each with its own pages, components, tests and strings). Routes: `/app/` Home, `/app/login`, `/app/accounts/:id`, `/app/expenses/new`, `/app/expenses/:id`, `/app/settle`, `/app/settlements/:id`, `/app/analytics`, `/app/analytics/:groupKind/:groupId`, `/app/insights`, `/app/search`, `/app/settings/*`; sub-routes and query parameters are the owning plan's.
* **The web gate** (the third gate, beside the Android and server gates): `npm run check` in `web/`, which runs `tsc --noEmit`, `eslint .`, `vitest run`, `vite build` and the generated-types freshness check (it fails when `web/src/api/schema.d.ts` differs from what `openapi-typescript` generates from `contracts/openapi.yaml`). A change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` in the same landing, by the agent that changes the contract; it is the one file under `web/` that a contract-change plan may write.
### 15.4 Build and serving (D-132)
* `server/Dockerfile` gets a Node stage that builds `web/` (`npm ci`, `vite build`); the runtime image carries `dist/` at `/app`. The build context becomes the repository root (`docker build -f server/Dockerfile .`), so the Gitea workflow, `infra/build-images.sh` and `infra/dev/**` change with it and the workflow's sparse checkout adds `web` (and `contracts` if the web build reads from it; W2's keyword table does, §15.7) (W7). The web client is part of the server image and its version: there is no separate publish, no separate container and no change to `infra/pi/compose.yaml`; a deploy (§10.10) ships both.
* `kvitt-server` serves the files when the directory `KVITT_WEB_DIR` (default `/app`) exists: `GET /app/<file>` for files in it, `GET /app/` and every other path under `/app/` that is not a file get `index.html` (the client-side router handles the path), `/app` without the slash leads to `/app/`. Without the directory, `/app/*` is 404 `not_found`. Hashed asset files are cached as immutable, `index.html` is sent with `Cache-Control: no-store`. The exact handling and header values, whether these routes appear in `contracts/openapi.yaml` (E-4's HTML page does) and the `KVITT_WEB_DIR` row of §10.4 are W7's (see the server agent's D-135+).
* The development stack (`infra/dev`) serves the web client the same way, from its image. For work on the web client, `npm run dev` in `web/` runs Vite's dev server with `/app/` as its base and forwards API paths to a Kvitt server (W0 documents the setting); agents use a throwaway server (`cargo run -p kvitt-server -- serve` with a temporary `KVITT_DATA_DIR`), never the owner's development or Pi data.
### 15.5 Data: reads, writes, freshness
* **Reads.** Accounts, categories, templates, expenses, settlements, users and audit entries are readable only through `GET /sync` (§7). The web keeps an in-memory household snapshot built from it: after login and on every page load it pulls from `since=0` in pages until `has_more` is false and applies rows as §14.4.2 step 3 does (by `server_version`; the web has no pending rows); afterwards it pulls from its cursor on window focus and after every write. A different `epoch` or a 409 `cursor_ahead` drops the snapshot and pulls from 0. Nothing is persisted across a reload except the token, the language choice and per-viewer conveniences (a remembered filter); there is no IndexedDB copy. `GET /me` (E-7), `GET /devices`, `GET /invites` and the `/analytics/*` endpoints (D-133) are read directly.
* **Computed in the browser**, as the phone does: balances (§9.3, `computeBalances`), effect lines (§9.2), the settle-up proposal (§9.4, `proposeAllocation`), the S-11 and S-12 analytics (§14.5.4) and the D-128 line chart. The advanced analytics of D-133 are not computed in the browser.
* **Writes.** Expenses and settlements are written with `POST /sync`, one mutation per user action (a new UUIDv7 id for a new row, a new `mutation_id` per attempt, the browser's clock for `updated_at`), and the result is applied to the snapshot at once: `accepted` updates the row; a rejection shows its §14.4.6 reason on the page and changes nothing. Everything else uses its REST endpoint (§7) and applies the returned row. The web never queues a write: a network error shows "Kunde inte nå servern" (`common_could_not_reach_server`) and the form keeps its values (§9.9's rule, for every write). Pictures (E-11) are fetched with the bearer header and shown from a `blob:` URL.
### 15.6 Screens
The web's pages. "Mirrors" names the app screen whose content, rules and states the page follows (§13.3 and `docs/ux/design-spec.md`); the web adapts layout to the desktop, not content. States per page: loaded, empty, loading (the first snapshot pull), error (a failed request, with retry), long content; there is no offline state.
| Id | Page | Route | Mirrors | Must show (beyond the mirrored screen) | Actions | Plan |
|---|---|---|---|---|---|---|
| W-1 | Login | `/app/login` | S-1 (code and name only), S-18's message | App name and one-line purpose; invite code field (12 characters in groups of 4, §8.3); name field for a first login; where the code comes from (S-21 on the owner's phone); errors `invite_invalid`, `rate_limited`, network | Log in | W0 |
| W-2 | Home | `/app/` | S-4; S-3 while there is no partner | Balance header, Settle up, active accounts with balance and this month's count, collapsed archived section, empty state; no sync pill, no update banner, no pull to refresh | New expense (`N`), Settle up, open account, New account | W1 |
| W-3 | Account detail | `/app/accounts/:id` | S-5 | Month groups with month totals, expense and settlement rows, archived badge | New expense, Settle this account, Edit, Archive / Unarchive, Delete | W1 |
| W-4 | Account editor | the owning plan's (a dialog or `/app/accounts/…`) | S-6 | Name, default split with the 100,00 kr preview | Save, Cancel | W1 |
| W-5 | Expense editor | `/app/expenses/new` (optional account), the owning plan's edit route | S-7 | Amount, description, category grid with the §9.11 suggestion for a new expense, account, date, payer, split modes `default`, `ratio`, `exact`, `full` with live shares, notes; validation per §9.6 inline | Save, Delete (edit), Cancel | W2 |
| W-6 | Expense detail | `/app/expenses/:id` | S-8 | Every field, shares and mode, "Recurring: <template>", the audit timeline (AC-12.4, AC-12.5), deleted state | Edit, Delete | W2 |
| W-7 | Settle up | `/app/settle` (optional account; editing a settlement) | S-9 | Direction with swap, amount, allocation rows with debts and over-settle warnings, remaining indicator, add account, date, note, "Föreslå igen" | Confirm, Cancel | W3 |
| W-8 | Settlement detail | `/app/settlements/:id` | S-10 | Payer, recipient, amount, date, note, allocations, timeline | Edit, Delete | W3 |
| W-9 | Analytics | `/app/analytics` | S-11 | Range, granularity, measure, group-by; total; stacked bars with legend; category and account breakdowns | Open a drill-down | W4 |
| W-10 | Drill-down | `/app/analytics/:groupKind/:groupId` | S-12 with D-128's line chart and average overlays | Title naming the filter, total, line chart for a category or account over the range, expense rows | Open expense | W4 |
| W-11 | Insights | `/app/insights` | none (D-133) | Per-category averages and trends per month (overall and per category), the 2-month forecast per account with its parts (recurring, average) | Choose category or account | W4 |
| W-12 | Search | `/app/search` | none (D-129) | Free-text search in descriptions and notes (case-insensitive), filters for amount range, date range, account, category, payer; result count and sum; expense rows | Open expense; Export CSV of the filtered list | W2 |
| W-13 | Settings | `/app/settings` | S-13 | Own and partner's avatar and name; rows Profile, Categories, Recurring expenses, Devices, Invites (admin), About, Language; Log out | Navigate; Log out | W5 |
| W-14 | Profile | `/app/settings/profile` | S-14 (and S-2 once after a `partner` login) | Avatar with change and remove (file picker), display name | Save | W5 |
| W-15 | Categories | `/app/settings/categories…` | S-15 | List with archived section; editor with name, reset name, 33 icons, 14 colours, delete or archive with the reason | Add, Edit, Reorder (drag, and by keyboard), Archive, Delete | W5 |
| W-16 | Recurring expenses | `/app/settings/recurring…` | S-19 | List and editor as S-19 | Add, Edit, Pause / Resume, Delete | W5 |
| W-17 | Devices | `/app/settings/devices` | S-20 | Rows per AC-3.1; this browser marked as "This device" | Revoke (with confirmation; not this browser) | W5 |
| W-18 | Invites | `/app/settings/invites` | S-21 | Invite partner (before the partner joins) and pair a device; a created invite's code in groups of 4, link and expiry; pending invites | Create, Copy (code and link, to the clipboard), Cancel | W5 |
| W-19 | About | `/app/settings/about` | S-22 | The web build's version name and code; the server's version and schema from E-1 `/health` when it answers (on the LAN), otherwise the `X-Kvitt-Server-Version` of the last API response without the schema (the S-22 fallback, D-110; `/health` is closed from outside, §15.2) | none | W5 |
| W-20 | Keyboard help | dialog on `?` | none (D-129) | The shortcuts and what they do | Close (`Esc`) | W0 |
Navigation (W0): a sidebar from 720 px wide and a bottom bar under it, with Home, Analytics, Insights, Search and Settings; New expense is a button in the sidebar and on Home and account detail (the app's FAB). Dialogs close with `Esc`. Destination changes are instant (D-108).
### 15.7 Search, export and shortcuts (D-129)
* **Search** (W-12) runs in the browser over the snapshot (§15.5): non-deleted expenses whose description or notes contain the query, ignoring case; filters combine with AND. Settlements are not searched. Results sort by date, newest first.
* **CSV export** (W-12) downloads the filtered list as a file, one row per expense: date, description, category, account, payer, amount, each user's share, notes, with a header row in the page's language. The file format (separator, decimal mark, byte-order mark, file name) is chosen by W2 so that the file opens correctly in Numbers and Excel with a Swedish locale, and proposed as a decision in its report. The export is built in the browser; the server has no export endpoint.
* **Keyboard shortcuts** (W0 binds them in the layout shell; the targets are the owning plans'): `N` opens W-5, `/` opens W-12 with the search field focused, `Esc` closes the open dialog or editor (with the discard rule of the mirrored screen), `?` opens W-20. `N`, `/` and `?` do nothing while focus is in a text field.
* **Category suggestion** (W-5, §9.11): the web ports the rule and passes `suggest_category.json`; the keyword table is read at build time from `contracts/category-keywords.csv` (the single source, never copied by hand), as the phone does (D-055).
### 15.8 Advanced analytics on the server (D-133)
* Owner, 2026-10-21: "These calculations should be put in the backend because I will want to see this in the app as well." New read endpoints under `/analytics/*` (authenticated, any device), designed by W7: per-category averages and trends per month, overall and per category; and a forecast of the next 2 calendar months per account. Names, shapes, the exact averaging and rounding are the server agent's (D-135 to D-138, SPEC v0.29), recorded with its report.
* **Forecast rule** (settled): for each account and each of the next two calendar months, forecast = the sum of the account's recurring templates due in that month (the known future expenses: active, not paused, inside their start and end periods, §9.5) + the average per month of the account's non-recurring spend (expenses not generated from a template) over the last 3 whole months. The 3-month window is the constant of D-128's trailing moving average (`AnalyticsRules.MOVING_AVERAGE_WINDOW`, 3, counted in buckets there and in whole months here), shared, not a second number; the server holds its own copy of the value and a change to it changes both.
* AC-14.1 is revised: the phone keeps computing S-11 and S-12 offline from Room; the advanced analytics come from the server, online only (§9.9), on the web (W-11) and in the app (W6, an "Insights" section on S-11).
### 15.9 Plans and parallel work (D-134)
Plans with disjoint paths (§14.7), one Opus agent each in its own worktree. W0 and W7 run first, in parallel; W1 to W5 run in parallel after W0 lands; W-11 (in W4) and W6 consume W7's endpoints once W7 has landed (W4 stubs W-11 until then). Web agents build with npm in their own worktree (`npm ci` in `web/`); Gradle and the phone are not involved, so W1 to W5 do not queue on `tools/gradle-serial.sh`; at most one `cargo` build per worktree (a throwaway server for a manual check).
| Plan | Paths | Content |
|---|---|---|
| W0 Web foundation | `web/**` except the feature folders of W1 to W5 | Scaffold, tokens, layout shell, router, login and token store, API client and household snapshot, i18n, formatting, shared components, money functions, test setup, the web gate; W-1, W-20 |
| W1 Home and accounts | `web/src/features/home/**`, `web/src/features/accounts/**` | W-2, W-3, W-4 |
| W2 Expenses and search | `web/src/features/expenses/**`, `web/src/features/search/**`, `web/src/features/export/**` | W-5, W-6, W-12, CSV export, the §9.11 port |
| W3 Settle up | `web/src/features/settle/**` | W-7, W-8 |
| W4 Analytics | `web/src/features/analytics/**` | W-9, W-10, W-11, the §14.5.4 port |
| W5 Settings | `web/src/features/settings/**` | W-13 to W-19 |
| W6 Android Insights | `android/feature/analytics/**`, and as a UI-contract change (§14.8) `android/core/model/**`, `android/data/**`, `android/core/fakes/**` | The D-133 endpoints on S-11 |
| W7 Server web serving and analytics | `server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`, and `web/src/api/schema.d.ts` when it changes `contracts/openapi.yaml` | §15.4, §15.8 |
---
## 16. Change log
| Version | Date | Change |
|---|---|---|
@@ -2890,3 +3063,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 0.25 | 2026-10-21 | The app gate (D-126, owner decision 2026-10-21; Android `34e5cad`): outside the home LAN `192.168.1.0/24` NPM closes the connection (`return 444`) of every request without `X-Kvitt-App: <key>`, every route included (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*`); only `/.well-known/acme-challenge/` passes (Let's Encrypt renewal), tested on `$uri`; the LAN check reads `$realip_remote_addr`, because NPM's `real_ip_header X-Real-IP` for private peers makes `$remote_addr` forgeable; the server and `contracts/openapi.yaml` are unchanged, the bearer token stays the authentication. The app sends the header in `KvittApi`'s common headers and `PictureAuth` when built with `kvitt.appKey` (Gradle property, else `android/local.properties`, else empty = no header; 16 to 128 printable ASCII characters without white space; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey`; 952 Android tests); the key is the owner's `openssl rand -hex 32`, only in `local.properties` and NPM; rotation by a second `if` line, a new APK through FR-16, then the old line removed. Accepted: landing page and browser use only on the LAN, outside health check through the app's About screen or `curl -H`, a leaked APK leaks the key until rotation, no flood protection. The gate rehearsed in NPM v2.16.0's openresty with its includes (AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1). Without a decision id: `infra/README.md` step 4a (key, APK, gate, acceptance test), its `CARGO_BUILD_JOBS` text (in effect since `b4d9467`; run 69: `kvitt-server` 2 h 42 min) and its owner decisions marked answered. |
| 0.26 | 2026-10-21 | Pull-to-refresh on Home and account detail (D-127, owner request 2026-10-21; Android `3778f5f`; revises design-spec S-4 "Pull-to-refresh is not offered"): pulling S-4 or S-5 past the threshold runs `SyncRepository.syncNow()`, the call behind S-16 "Synka nu"; the indicator stays while the sync runs and goes when it returns, success or failure; a failure shows only in the sync pill (S-5 has none, so a failed pull there shows nothing until Home's pill); offline the pull ends at once without a sync; a pull while a sync runs starts no second one. Shared `KvittPullToRefresh` in `:core:design` (`component/Refresh.kt`): Material 3 `PullToRefreshBox` with a Kvitt indicator (48 dp `primary-container` disc, the `sync` icon 18 dp turning 270° with the pull and popping from 80 % at the threshold on the new `KvittMotion.spatialFast()` with a `GestureThresholdActivate` haptic, a 32 dp `primary` arc that fills to the threshold and turns indeterminate while refreshing); `ContainedLoadingIndicator` replaces the arc once the pinned Material 3 (1.4.0) ships it. TalkBack reads "Synkar…" (`sync_syncing`, reused) as a polite live region; S-16 stays the TalkBack path. New goldens `S-4-home-refreshing` and `S-5-account-refreshing`; 959 Android tests. No change to `:core:model`, Room, the sync protocol, the server or `docs/ux/strings.csv` (AC-15.3, §9.9, §13.3; design-spec §2.11, §3.1, §3.6, S-4, S-5). |
| 0.27 | 2026-10-21 | S-12 line chart for a category or account over the range (D-128, owner request 2026-10-21; Android `12e2508`). S-12 opened from a breakdown row shows its own period chip (AC-14.2 presets plus custom) and Month/Week toggle, both starting from S-11's values and changing chart, total and list on S-12 only (S-11 keeps its state; measure and group-by come from S-11 and are fixed on S-12), the header total, the "Utgifter över tid" card and the expense rows; a drill-down from a bar is unchanged. One point per bucket, empty buckets as zero (AC-14.7), from the group's bucket totals of `spendSeries` (§14.5.4); an empty range shows only the 0 baseline (D-068) and "Inga utgifter i den här perioden". Overlays: the range average (total ÷ buckets, half-up to öre) as a dashed line in the group colour with its amount at the right end; a trailing moving average over 3 buckets (`AnalyticsRules.MOVING_AVERAGE_WINDOW`; the first buckets use the 1 or 2 that exist) as a thinner neutral line; legend "Snitt", "Glidande snitt (3)"; caption "<Bucket>: N % över snittet" / "under snittet" / "på snittet" for the latest bucket the range does not cut short, N a whole percent half-up. Overlays and caption omitted when the average is 0 or the range has one bucket; caption omitted when no bucket is whole. Not interactive; one TalkBack summary. `:core:design` gains `SpendLineChart` and `LineLegend` (`chart/LineChart.kt`), axis code shared with `StackedBarChart` in `Charts.kt`; `AnalyticsFilterBar` measure and group-by toggles optional. Seven `strings.csv` keys (569 rows). New goldens `s12_category_groceries`, `s12_category_week`, `s12_category_empty_range`, `line_chart`, `line_chart_variants`; `s12_category`, `s12_account`, `s12_long` re-recorded; 977 Android tests. No change to `:core:model`, `:domain`, `:core:fakes`, Room, the server or the contracts (new AC-14.11; §13.3 S-12, §13.4; design-spec §3.10, S-12). |
| 0.28 | 2026-10-21 | The web client programme (owner interview 2026-10-21; no implementation yet; v0.27 is D-128's). D-129: a web client with the Android app's features except the phone-only ones (pairing flow, push, offline, in-app update), plus free-text search, CSV export of the filtered list, keyboard shortcuts N, /, Esc, ? and a language choice; desktop first, one column under 720 px, sv and en, not a PWA (§1, §2 revised, new §15, FR-21). D-130: the browser logs in with an invite code through E-3 as device `Web · <browser>`, token in `localStorage`, revocable on Devices; the NPM gate also passes `/app` and `/app/…`, `POST /invites/accept` and bearer-token requests except `/health`, `/setup/`, `/i/` and `/apk/` (§8.5, §10.5, §10.5.2, `infra/README.md` steps 4 and 4a, `infra/npm/kvitt.conf`; checked with nginx 1.31.7, 17 requests). D-131: `web/` stack (Vite, React 19, TypeScript strict, React Router 7, TanStack Query 5, `openapi-typescript` and `openapi-fetch`, Recharts, Vitest, ESLint, Prettier, CSS modules with design-spec tokens, JSON dictionaries), the money functions passing the vectors, the web gate `npm run check` (§11, §15.3). D-132: the web client is built by a Node stage of `server/Dockerfile` (context the repository root) and served by `kvitt-server` under `/app/` from `KVITT_WEB_DIR` with an SPA fallback; immutable hashed assets, `no-store` `index.html`; the `/app/` CSP through `$kvitt_csp` in NPM (§10.3, §10.5.2, §10.6, §15.4). D-133: advanced analytics on the server under `/analytics/*` (per-category averages and trends, a two-month forecast per account = recurring templates due + the 3-month average of non-recurring spend), for the web and the app; AC-14.1 and G-5 revised (§15.8; endpoints D-135 to D-138, v0.29). D-134: plans W0 to W7 with disjoint paths (§11.1, §11.2, §11.3, §14.7, §15.9; `PLAN-W0.md` to `PLAN-W7.md`, `prompts/W0.md` to `prompts/W7.md`). The change log becomes §16. |
+11 -8
View File
@@ -3,12 +3,13 @@
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
## 1. Where things stand
- State as of 2026-10-21. `main` at `12e2508` (S-12 line chart, D-128), after `b0a90da` (SPEC v0.26, D-127), `05ec3f3` (editor keyboard fix) and `3778f5f` (pull-to-refresh, D-127); `origin/main` (Gitea) at `12e2508`; plus the SPEC v0.27 docs commit from `agent-catline-docs` once it lands. The web-client programme started 2026-10-21: its architect agent holds D-129 onward and SPEC v0.28 onward. Landed since SPEC v0.26: `05ec3f3`, `12e2508` (below).
- State as of 2026-10-21. `main` at `44c8cbb` (SPEC v0.27, D-128), after `12e2508` (S-12 line chart, D-128), `b0a90da` (SPEC v0.26, D-127), `05ec3f3` (editor keyboard fix) and `3778f5f` (pull-to-refresh, D-127); `origin/main` (Gitea) at `12e2508`. The web-client programme started 2026-10-21: SPEC v0.28 (D-129 to D-134, this docs pass `agent-web-arch`) and the W0 and W7 implementation agents are in flight. Landed since SPEC v0.26: `05ec3f3`, `12e2508` (below).
- **Kvitt host name** (owner decision 2026-10-19): `kvitt.suorra.duckdns.org`, a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`; NPM issues its Let's Encrypt certificate; `KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org`, so `KVITT_PUBLIC_URL=https://kvitt.suorra.duckdns.org` (port 443, D-124). It is in `infra/pi/.env.example` and `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`, git-ignored). SPEC §10.1 and §10.5.1 point here.
- **NPM proxy host** for `kvitt.suorra.duckdns.org` is Online with a Let's Encrypt certificate (owner, 2026-10-19). The router needs no new rule: external TCP 443 already forwards to the Home Assistant Pi (owner confirmed, D-124).
- **Images**: `gitea.lan/jacobh2/kvitt-server` and `gitea.lan/jacobh2/kvitt-backup`, tags `v1.0.0` (run 69) and `v1.0.1` (the sparse-checkout push; the tag the Pi uses), plus `latest`; the packages are public, so the Pi pulls without `docker login`. Gitea Actions on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (the user-level secret was misnamed on the owner's side, fixed 2026-10-19; a user-level secret of the repository owner reaches the repository); run 69 succeeded: login ok, `v1.0.0`, `kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min on the Pi 3 (first build, `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache); the tag push works with the `write:package` token. SPEC §10.3.
- **Kvitt Pi** (D-125): Kvitt runs from `/home/jacobhagstedt/kvitt` as the owner's user `jacobhagstedt`, uid and gid 1000:1000 (`KVITT_UID`/`KVITT_GID`); Docker and the `gitea.lan` registry trust were already on the Pi; nothing outside that folder is installed or changed. **The stack is running** (owner, 2026-10-21): image `v1.0.1`, `server` and `backup` both healthy after a `chown` of `data/` (it had been created as root); compose's `mem_limit` warning (the Pi kernel has no memory cgroup, so the 160 MiB and 96 MiB limits are not enforced) is accepted. `/health` through NPM from outside answered `"status":"ok"` (public health OK). **Live** (2026-10-21): both phones are paired against it and the Splitwise import (M11) is done.
- **App gate** (D-126, owner decision 2026-10-21): outside the home LAN `192.168.1.0/24` NPM closes (`return 444`) every request without `X-Kvitt-App: <key>`, every route included; the key is the build property `kvitt.appKey` (`android/local.properties`, git-ignored), generated by the owner with `openssl rand -hex 32` and pasted into NPM's block (`infra/npm/kvitt.conf`, `7521c01`); the server and `contracts/openapi.yaml` are unchanged. Android side `34e5cad`, docs `2acfc34` (SPEC v0.25, `infra/README.md` step 4a). `android/local.properties` has a `kvitt.appKey` and `infra/pi/out/apk/` holds `kvitt-125.apk` (checked 2026-10-21); whether both phones run a keyed APK and the gate is in NPM is not recorded here (orchestrator confirms with the owner, §3).
- **Web client programme** (owner interview 2026-10-21, SPEC v0.28, D-129 to D-134, SPEC §15, FR-21): a React web client in `web/`, served by `kvitt-server` under `/app/` from the server image, with the app's features except the phone-only ones, plus search, CSV export, keyboard shortcuts and a language choice; login with an invite code (device `Web · <browser>`); the NPM gate also passes `/app/`, `POST /invites/accept` and bearer-token requests except `/health`, `/setup/`, `/i/`, `/apk/` (new block in `infra/npm/kvitt.conf`, README step 4, SPEC §10.5.2; checked with nginx 1.31.7, 17 requests); advanced analytics on the server (`/analytics/*`, D-133, forecast = recurring templates due + 3-month average of non-recurring spend). Plans `PLAN-W0.md` to `PLAN-W7.md`, prompts `prompts/W0.md` to `prompts/W7.md`. W0 (`web/**`) and W7 (`server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`; D-135 to D-138, SPEC v0.29) are running; W1 to W5 start in parallel when W0 has landed (each in its own `web/src/features/<name>/`); W6 (Android Insights) after W7 lands and is deployed. The web gate is `npm run check` in `web/` (third gate; `CLAUDE.md` "Build and verify" still to be updated by the orchestrator).
- **First release APK**: `apk/kvitt-123.apk` (10.0 MB, git-ignored by `*.apk`) handed to the owner from the main checkout for sideloading on both phones (both paired, 2026-10-21); it was built before any `kvitt.appKey` existed, so it sends no `X-Kvitt-App`: it works only until the gate is pasted (on the home Wi-Fi it keeps working afterwards). The phones need a keyed APK before the gate goes in (§3).
- History rewrite and first push, 2026-10-18 (owner authorised): superseded PNG versions were dropped from the whole history (no LFS), the current goldens and UX mock-ups restored in `18c527f`; pack 45 MiB; `origin/main` pushed at `18c527f`. A bundle of the pre-rewrite history (`kvitt-pre-rewrite.bundle`) is kept outside the repository, in the orchestrator's scratchpad. Every commit from M2b on got a new hash; the oxipng pass (`7c453d8` before the rewrite) no longer exists as a commit (it only touched PNGs). SPEC, `docs/decisions.md`, this file and `prompts/README.md` cite the rewritten hashes since v0.18 (`7c453d8` stays as the oxipng pass's name; `docs/validation/ac-coverage.md` keeps its pre-rewrite hashes verbatim); since v0.19 the plans cite rewritten hashes too (eight pre-rewrite hashes in `PLAN-*.md` replaced by the commit with the same subject); `docs/spikes/pi-image-arm64.md` and the READMEs had none stale. For any other old hash, find the commit by its subject with `git log --oneline --grep`.
- History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f, `8103dae` to `b985ce9` server overhaul, `48fe119` SPEC v0.18, `e4e6e5d` to `c582363` Android overhaul, `3ef2c15` SPEC v0.19, `96e4cbd` APK download rate limit (D-119), `481889a` SPEC v0.20, `f1abb0d` `cargo audit` in the server gate, `4f69ae0` Gitea login in the run line, `d05f13a` Pi stack behind NPM, `62f5279` and `9231a1d` infra README for NPM (D-120), `d090c6c` Android host property (D-121), `4bcff2c` SPEC v0.21, `dd07ca4` temporary workflow diagnostic step, `4a4e45f` S-22's release server and `4b1ae0c` `publish-apk.sh` host (D-122), `c9abd24` and `e381e37` server host defaults and OpenAPI `{host}` (D-123), `ac17b41` `install.sh` step number, `cce3e4e` SPEC v0.22, `a86ad24` server and contracts on port 443, `4852803` bundle on port 443 (D-124), `038c603` Android on port 443 and the pinned-root template, `ad35231` portless Android fixtures, `b1f4c10` SPEC v0.23, `3820f9c` sparse-checkout workflow, `200c99f` `.env` without informational keys, `6e5981d` self-contained Pi compose project (D-125), `28fa71b` compose deploy hint in the workflow and `infra/build-images.sh`.
@@ -49,19 +50,19 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4). Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 is fixed (`725901c`).
- Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18.
- In flight:
- This docs pass (`agent-catline-docs`: SPEC v0.27, D-128, AC-14.11, §13.3 S-12, §13.4, design-spec §3.10 and S-12, `docs/ux/strings.csv`, this file).
- The web-client programme (started 2026-10-21; architect agent, holds D-129 onward and SPEC v0.28 onward).
- This docs pass (`agent-web-arch`: SPEC v0.28, D-129 to D-134, §15 Web client, FR-21, `infra/npm/kvitt.conf`, `infra/README.md` steps 4 and 4a, `PLAN-W*.md`, `prompts/W*.md`, this file).
- W0 web foundation (`web/**`) and W7 server web serving and analytics endpoints (D-135 to D-138, SPEC v0.29), in parallel.
- Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2).
- Compatibility: an APK from M12e on cannot pair or list devices against a pre-M12e server, so the server is rebuilt and redeployed before a new APK is installed (SPEC §14.8). The dev server already runs schema 3.
- Remote: `origin` on Gitea, SSH key works; pushed at `7521c01`. The Gitea secret `kvitt_cicd` (a user-level secret of `jacobh2`, renamed by the owner 2026-10-19) works; the Drive token is `infra/secrets/rclone.conf` (`[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted).
- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 977 tests at `12e2508`; server 276 tests at `c9abd24`).
- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 977 tests at `12e2508`; server 276 tests at `c9abd24`). A third gate comes with W0 (D-131): `npm run check` in `web/` (`tsc --noEmit`, `eslint .`, `vitest run`, `vite build`, generated-types freshness); a change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` in the same landing.
- Importer: the dry run of 2026-10-18 reconciled on both real files (Vardag 1746 rows, Hemma 37 rows, 0 unmapped). The production import on the Kvitt Pi is done (owner, 2026-10-21). The CSV files stay in `export/` (git-ignored).
- Development stack (M2c-dev): running on the Mac at `http://192.168.1.210:8080`, database at schema 3, data kept; backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` and `kvitt-20261018T130443Z.sqlite.gz` (git-ignored). On this Podman setup run `infra/dev/up.sh --down`, then `infra/dev/up.sh`, to swap in a rebuilt image. Since `7fb86f5` `up.sh` copies only `fcm-service-account.json` into `infra/dev/secrets/` (git-ignored). The owner's phone is paired with it; a scripted partner exists (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`.
- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the release app `se.hagstedt.kvitt`, rebuilt with the host `kvitt.suorra.duckdns.org` and no port (D-124) and installed 2026-10-19; paired against the Kvitt Pi 2026-10-21. The partner's phone is paired too.
- Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB.
## 2. Open work (one line each, with who owns it: agent or owner)
- Land `agent-catline-docs` (orchestrator): SPEC v0.27, D-128, design-spec, `strings.csv`, HANDOFF; run the Android gate on landing (`strings.csv` changed). Push it to `origin` (it touches no `server/**`, `infra/backup/**` or `.gitea/**`, so no workflow run).
- `agent-catline-docs` landed as `44c8cbb` (SPEC v0.27, D-128); push it to `origin` if not done (it touches no `server/**`, `infra/backup/**` or `.gitea/**`, so no workflow run). Land `agent-web-arch` (orchestrator): SPEC v0.28, D-129 to D-134, `infra/npm/kvitt.conf`, `infra/README.md`, `PLAN-W*.md`, `prompts/W*.md`, HANDOFF; docs only, no gate but `python3 tools/contracts-check.py`; pushing it triggers no workflow run.
- Closed by `28fa71b`: the `/opt/kvitt` leftovers in `.gitea/workflows/build-images.yaml` and `infra/build-images.sh` (compose deploy hint, `docker image inspect`). Still stale in the workflow header (agent, next infra pass): "NOT YET RUN" and "First-build duration on the Pi: unknown" (now 2 h 42 min).
- Pushed: through `12e2508` (S-12 line chart, D-128).
- Closed by `2acfc34` (`agent-gate-docs`): `infra/README.md`'s stale `CARGO_BUILD_JOBS` text and its "Owner decisions (open)" (now answered).
@@ -78,12 +79,14 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- `PLAN-M2c-pi.md` is superseded where it names Caddy, cert-sync, scripts or `/opt/kvitt` (note at its top, D-120 and D-125); SPEC §10 and `infra/README.md` are current. `PLAN-M11.md` has a note that `/opt/kvitt/import` is now `~/kvitt/import`.
- Plans (SPEC §11.2 v0.24), in dependency order; prompt `prompts/<id>.md`:
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12f, U1, U2, R8 release build, M2c-pi bundle with its NPM rework and the self-contained compose project (D-125), server gap pass, infra overhaul, infra security, the server overhaul, the Android overhaul, the APK download rate limit, `cargo audit`, the Android host property and its follow-ups (D-122), the server host defaults (D-123), port 443 (D-124), pull-to-refresh (D-127), the editor keyboard fix, the S-12 line chart (D-128): landed.
- Web client programme (D-134, SPEC §11.2 rows W0 to W7): W0 and W7 running; then W1 to W5 in parallel after W0 (briefs from `prompts/W1.md` to `prompts/W5.md` with W0's module map in the notes; if W0 lacks a shared piece the plans name, a W0b follow-up first); W4's W-11 and W6 after W7. Docs agent afterwards: record W0's and W7's reports (D-135 to D-138, SPEC v0.29, `KVITT_WEB_DIR` in §10.4, §7 rows for `/analytics/*`, the README's workflow and build-images sections for the repository-root context).
- Next: the app key and gate if not yet done (D-126, §3); M10 First release (keystore per §10.6, D-101; the reports `first-release.md` and `restore-drill.md`). Done 2026-10-21: release pairing of both phones against the Kvitt Pi and M11 Import history (SPEC §10.8 "Production run").
## 3. HUMAN steps pending (relay in chat)
Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers healthy), the public health check through NPM, bootstrap, release pairing of both phones, the Splitwise import.
1. App key (D-126; `infra/README.md` step 4a items 1 to 3; the key and `kvitt-125.apk` exist on the Mac, the rest is unconfirmed): generate `kvitt.appKey` in `android/local.properties` on the Mac (an agent may run the one-line `openssl` command; the value is never printed); build and publish a keyed APK (`infra/publish-apk.sh`, copy to `data/apk/`); both phones on it (update banner, or sideload).
2. Gate (step 4a items 4 and 5), only after both phones run the keyed APK: NPM, Hosts, Proxy Hosts, Kvitt row, Edit, Advanced: paste the step 4 block with the key in place of `KVITT_APP_KEY`, Save (row Online, yes/no). Acceptance test: on home Wi-Fi Chrome opens `https://kvitt.suorra.duckdns.org/i/TEST` and the invite page shows (yes/no); with Wi-Fi off the same URL gives a connection error (yes/no); with Wi-Fi off Kvitt's About screen shows the server version (yes/no).
3. Web client gate lines (D-130, SPEC v0.28): when the NPM block is pasted (item 2, or again if the D-126 block is already in NPM), paste the current `infra/npm/kvitt.conf` content (step 4), which also lets the web client through and sets its Content-Security-Policy; Save (row Online, yes/no). After the first image with the web client is deployed (W7, §10.10), acceptance test with Wi-Fi off on the phone: `https://kvitt.suorra.duckdns.org/app/` shows the Kvitt login page (yes/no) and `https://kvitt.suorra.duckdns.org/health` still gives a connection error (yes/no) (step 4a item 5 d and e).
## 4. Owner questions (asked, unanswered)
- OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1".
@@ -95,9 +98,9 @@ Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers health
- Nothing.
## 6. Numbering
- Decisions: D-001 to D-128 used, except D-008 and D-009 (never used). Next free: **D-129**, taken by the web-client architect agent (it holds D-129 onward).
- Spec version: v0.27 written. Next **v0.28**, taken by the web-client architect agent (it holds v0.28 onward).
- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.27.
- Decisions: D-001 to D-134 used, except D-008 and D-009 (never used); D-135 to D-138 reserved for W7 (the server agent); D-139 taken by `infra/publish-apk.sh --deploy` (`70cb25c`, cited in `infra/README.md`, not yet in `docs/decisions.md` or SPEC: a docs agent records it). Next free: **D-140**.
- Spec version: v0.28 written (web client programme, after v0.27 for D-128); v0.29 reserved for W7's contract change. Next **v0.30** (D-139's recording may take it).
- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.27 and v0.28 (W7 expects no migration).
- Sync protocol **1**, push payload **1**.
## 7. How the work is run
+6
View File
@@ -133,3 +133,9 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| D-126 | 2026-10-21 | App gate in NPM. Outside the home LAN, Nginx Proxy Manager closes the connection (`return 444`, nothing sent) of every request that does not carry the header `X-Kvitt-App: <key>` with the app key; requests from the home LAN `192.168.1.0/24` pass without it. Every route is covered (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*` included); only `/.well-known/acme-challenge/` passes, served by NPM from its own webroot, so Let's Encrypt can renew (Force SSL redirects the challenge to 443). The gate is five server-level rewrite lines at the top of the Custom Nginx Configuration (`set $kvitt_gate 0`; an `if` per accepted key; `if ($realip_remote_addr ~ "^192\.168\.1\.")`; `if ($uri ~ "^/\.well-known/acme-challenge/")`; `if ($kvitt_gate = 0) { return 444; }`), which run before location selection. The LAN check reads `$realip_remote_addr` (the TCP peer), not `$remote_addr`, because NPM's `nginx.conf` applies `real_ip_header X-Real-IP` to 10/8, 172.16/12 and 192.168/16 peers, so `$remote_addr` can be set by the client (by every client if NPM saw a Docker bridge address, security review I-5); the ACME exemption tests the normalised `$uri`, so `../` cannot reach Kvitt through it. The Rust server neither sees nor checks the header; `contracts/openapi.yaml` is unchanged (a proxy-level header, §14.3.1); the per-device bearer token stays the authentication (§8.2), and the gate is a perimeter. The app (`34e5cad`) sends `X-Kvitt-App` in `KvittApi`'s common headers (every API call, `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` when built with the property `kvitt.appKey` (`-Pkvitt.appKey`, else `kvitt.appKey` in `android/local.properties`, else empty = no header; set, it must be 16 to 128 printable ASCII characters without white space, or the build stops with a message giving the length, never the value), modelled on D-121; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey` in `:data` (`toString` hides it); `infra/publish-apk.sh` unchanged (it builds on the Mac). The key: the owner's `openssl rand -hex 32`, only in `android/local.properties` and NPM's block; never in git, `.env`, docs or logs. Rotation: a second `if` line with the new key, the new APK through the update path (FR-16), then the old line removed. Both phones must run a keyed APK before the gate goes into NPM (`infra/README.md` step 4a). Accepted consequences: the invite landing page and any browser use work only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; port 80 still answers NPM's 301, Block Common Exploits' patterns NPM's 403, and the TLS handshake shows the certificate; nothing protects against a bandwidth flood (not winnable at home). Caveat: the LAN check needs NPM to see real client addresses (a bridge address matches nothing, a router-rewritten address everything; NAT hairpin's `192.168.1.1` is inside the /24); the owner's acceptance test is the landing page on home Wi-Fi (shows) and with Wi-Fi off (connection error). Rehearsed on the Mac in NPM v2.16.0's openresty 1.31.1.1 with its includes. | Owner decision 2026-10-21: "have auth on everything", the key "is the app itself", the owner controls the APK's distribution (first install on both phones by hand), and "the landing page should work if you are on the same LAN, that is OK. In this case, the 'secret' is that you have my wifi password." An NPM access list cannot check a header, and its basic auth would take the `Authorization` header the bearer token needs. | SPEC AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1; `infra/README.md` steps 4, 4a, 5, 6, Operations; `android/app/build.gradle.kts`, `KvittApi.kt`, `PictureAuth.kt`, `DataModule.kt`, `android/README.md` |
| D-127 | 2026-10-21 | Pull-to-refresh on Home and account detail (revises design-spec S-4 "Pull-to-refresh is not offered (sync runs on its own triggers, AC-15.3)"). S-4 (Home) and S-5 (account detail) offer pull-to-refresh; pulling past the threshold runs `SyncRepository.syncNow()`, the call behind S-16 "Synka nu". The indicator stays while the sync runs and goes when it returns, success or failure. A failure is reported only by the sync pill (design-spec §3.6); there is no new error UI. S-5 has no pill, so a failed pull there shows nothing until Home's pill. Offline (§9.9) the pull animates and ends at once without a sync. A pull while a sync runs starts no second sync. Shared `KvittPullToRefresh(refreshing, onRefresh, content)` in `:core:design` (`component/Refresh.kt`): Material 3 `PullToRefreshBox` with a Kvitt indicator: a 48 dp `primary-container` disc (Cookie9 shape, circle fallback); the `sync` icon at 18 dp in `on-primary-container`, turning 270° with the pull and popping from 80 % to full size at the threshold on a new `KvittMotion.spatialFast()` spring (0.6 / 800), with a `GestureThresholdActivate` haptic; a 32 dp `primary` arc (stroke 3, track `primary` at 24 %) that fills to the threshold and turns indeterminate while refreshing. The pinned Material 3 1.4.0 has no `LoadingIndicator`, `ContainedLoadingIndicator` or `LinearWavyProgressIndicator`; `ContainedLoadingIndicator` replaces the arc when the pinned Material 3 ships it, in `Refresh.kt` only. TalkBack reads "Synkar…" (`sync_syncing`, reused; `docs/ux/strings.csv` unchanged) as a polite live region while refreshing; S-16 "Synka nu" remains the TalkBack path. Consequences: `HomeUi.Ready.refreshing`, `AccountDetailUi.Ready.refreshing`, `HomeActions.onRefresh`, `AccountDetailActions.onRefresh`; `HomeViewModel` and `AccountDetailViewModel` take `SyncRepository`; test fixture `GatedSyncRepository` in `:core:design` testFixtures; no change to `:core:model`, Room, the sync protocol, the server or `strings.csv`; new goldens `S-4-home-refreshing` and `S-5-account-refreshing` (three variants each: sv light, sv dark, en light); 959 Android tests. Landed `3778f5f`. | Owner request 2026-10-21: pull-to-refresh on Home and the account screen "with some nice fitting design when you pull and then some nice loading that fits the UX theme of the app". The indicator reuses the hero's first-sync tile look (design-spec §3.6 `Loading`) and the `sync` icon of the pill and "Synka nu", so it reads as Kvitt's own; reporting failure only in the pill keeps sync never blocking (§3.6). | SPEC AC-15.3, §9.9, §13.3 (S-4, S-5); design-spec §2.11, §3.1, §3.6, S-4, S-5; `android/core/design/.../component/Refresh.kt`, `KvittMotion.kt`, `KvittDimens.kt`, `:feature:home`, `:feature:account` |
| D-128 | 2026-10-21 | S-12 line chart for a category or account over the range (new AC-14.11). S-12 opened from a category or account breakdown row shows, in order: its own period chip (AC-14.2 presets plus custom) and Month/Week toggle, both starting from S-11's values; the header total; the "Utgifter över tid" card; then the expense rows. Changing the period or granularity recomputes chart, total and list on S-12 only; S-11 keeps its state. Measure and group-by come from S-11 and cannot be changed on S-12. A drill-down opened from a bar keeps today's header and list. The chart has one point per bucket, empty buckets as zero (AC-14.7), computed on the phone from the per-group bucket totals of `spendSeries` (§14.5.4); an empty range shows only the 0 baseline (D-068) and "Inga utgifter i den här perioden". Overlays: the range average (total ÷ buckets, rounded half-up to öre) as a dashed line in the group colour at reduced opacity, its amount at the right end in axis typography; a trailing moving average over 3 buckets (the bucket and the two before it; the first buckets use the 1 or 2 that exist; constant `AnalyticsRules.MOVING_AVERAGE_WINDOW`) as a thinner neutral line; the legend names the series, "Snitt", "Glidande snitt (3)"; the caption compares the latest bucket not cut short by the range (a whole month or ISO week) with the average: "<Bucket>: N % över snittet" / "under snittet" / "på snittet" when N rounds to 0, N a whole percent rounded half-up. Overlays and caption are omitted when the average is 0 or the range has one bucket; the caption is omitted when no bucket is whole. The chart is not interactive; its TalkBack summary gives the first and last bucket, the range total and the average. Consequences: no change to `:core:model`, `:domain`, `:core:fakes`, Room, the server or the contracts; `AnalyticsFilterBar`'s measure and group-by toggles are optional; `:core:design` gains `SpendLineChart` and `LineLegend` (`chart/LineChart.kt`), with the axis code shared with `StackedBarChart` in `Charts.kt` (bar chart pixels unchanged); seven `strings.csv` keys (`analytics_average`, `analytics_moving_average`, `analytics_vs_average_above`, `analytics_vs_average_below`, `analytics_vs_average_on`, `analytics_summary_line`, `analytics_summary_line_single`; 569 rows); new goldens `s12_category_groceries`, `s12_category_week`, `s12_category_empty_range`, `line_chart`, `line_chart_variants`; re-recorded `s12_category`, `s12_account`, `s12_long`; 977 Android tests. Landed `12e2508`. | Owner request 2026-10-21: "When clicking on a category in the analytics tab, the top should show total expense over time as a line graph. This time period should have similar config as the main analytics page. Underneath there can be all expenses listed as it is today." Follow-up: "I also want to see, per category what my average is, so I can see if we are above or below … some moving average to see if we are spending more or less." The series is the group's column of the bucket totals S-11 already computes, so no contract changes. | SPEC AC-14.11, §13.3 (S-12), §13.4; design-spec §3.10, S-12; `docs/ux/strings.csv`; `android/core/design/.../chart/LineChart.kt`, `Charts.kt`, `:feature:analytics` (`AnalyticsModel.kt`, `DrillDownScreen.kt`, `DrillDownViewModel.kt`) |
| D-129 | 2026-10-21 | Web client in scope (revises §2's "A web client", "CSV or any other export" and, for the web only, the language-picker and layout non-goals). Kvitt gets a web client with the Android app's features except the phone-only ones: the phone pairing flow (S-1's server field, `kvitt://` links), push notifications, offline use and the sync screen (S-16), the in-app update (S-17). In: Home balances, account detail and editor, expense add, edit, delete and detail with the audit timeline, settle up and settlement detail, analytics (S-11, S-12 with D-128's line chart), categories, recurring templates, devices, profile, invite partner and pair a device (admin), about. Extras the app lacks: free-text search across descriptions and notes with amount, date, account, category and payer filters; CSV export of the filtered expense list (format chosen by W2 for Numbers and Excel in a Swedish locale, proposed in its report); keyboard shortcuts `N` new expense, `/` search, `Esc` close, `?` help; a language choice overriding the browser's language. Desktop first, one column with a bottom bar under 720 px; sv and en; light and dark by `prefers-color-scheme`; not a PWA (no service worker, no manifest, no offline). Pages W-1 to W-20 mirror the S-n screens (§15.6). No receipt picture (none in the app). | Owner interview 2026-10-21: the owner and the partner want the household on a computer too, with the bigger screen used for search, export and analytics; the phone-only parts have no meaning in a browser. | SPEC §1, §2, §15.1, §15.6, §15.7, FR-21 |
| D-130 | 2026-10-21 | Web access and login (extends D-126). The web client is served by `kvitt-server` under `/app/`. A browser logs in with an invite code exactly like a phone pairs: E-3 `POST /invites/accept` with a `device` invite (or `partner`/`admin_setup`), device name `Web · <browser>`; the token kept in `localStorage`, sent as `Authorization: Bearer` on API calls only; the browser is listed and revocable on Devices like any device; a 401 forgets the token (with S-18's message for `device_revoked`); Log out forgets the token in the browser, and the device stays valid until revoked from another device (a device cannot revoke itself, AC-3.2; a sign-out endpoint would be a contract change, not taken). The NPM gate also passes, from outside the LAN: a path `/app` or under `/app/`; `/invites/accept`; a request with an `Authorization: Bearer` header whose path does not start with `/health`, `/setup/`, `/i/` or `/apk/` (a helper variable `$kvitt_bearer`, because nginx's `if` takes one condition). Everything else still gets 444; `/i/`, `/health`, `/setup/status` and `/apk/*` stay LAN-or-key only, with or without a bearer header. Lines in `infra/npm/kvitt.conf`, `infra/README.md` step 4 and SPEC §10.5.2 (three identical copies, D-126's rule); step 4a's acceptance test gains: with Wi-Fi off `/app/` shows the login page, `/health` is still a connection error. Checked with nginx 1.31.7 (nginx:alpine) on the Mac: `nginx -t` and 17 requests as intended. Consequence, accepted: the server's authenticated routes are reachable from the internet with a wrong token (401, at most 60 per minute per address by §8.6 and the H-1 fix), and the invite accept by anyone (AC-2.12's lockout, 60-bit codes), which the security review's model already assumed before D-126. | Owner interview 2026-10-21: the web client must work from outside the home; a browser cannot carry the app key, so the gate admits what the web needs and nothing more. The bearer exemption excludes the four public paths so that sending any bearer header does not reopen what D-126 closed. | SPEC §8.5, §10.5, §10.5.2, §15.2, FR-21; `infra/npm/kvitt.conf`; `infra/README.md` steps 4 and 4a |
| D-131 | 2026-10-21 | Web stack. `web/` at the repository root: Vite, React 19, TypeScript strict, React Router 7, TanStack Query 5, `openapi-typescript` generating `web/src/api/schema.d.ts` from `contracts/openapi.yaml` (generated, committed, checked for freshness) with `openapi-fetch` as the client, Recharts, Vitest with Testing Library, ESLint and Prettier, plain CSS modules with tokens from `docs/ux/design-spec.md` §2 (same colour roles, category colours, type scale, the 4/2 radii; light and dark by `prefers-color-scheme`; system font stack, tabular figures; Material Symbols bundled as SVG), JSON dictionaries `sv.json` and `en.json` with `docs/ux/strings.csv`'s keys where a string exists there and `web_` keys otherwise. Money in integer öre with the same rules as `kvitt-domain` and `:domain`: the web's ports of §14.5.2's functions pass their `contracts/test-vectors/*.json` (Vitest), `resolveRange`/`spendSeries` and the §9.11 suggestion pass theirs. Node 25 and npm on the Mac. The web gate, the third gate: `npm run check` in `web/` = `tsc --noEmit`, `eslint .`, `vitest run`, `vite build` and the generated-types freshness check. A change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` in the same landing. Source layout `web/src/{app,api,i18n,design,components,lib,features/<name>}`. | Owner interview 2026-10-21. One typed contract for three clients; the vectors keep the money rules equal on phone, server and web (G-1). | SPEC §11 intro, §14.7, §15.3, FR-21; `CLAUDE.md` "Build and verify" (orchestrator) |
| D-132 | 2026-10-21 | Web build and serving. A Node stage in `server/Dockerfile` builds `web/` and the runtime image carries `dist/` at `/app`; `kvitt-server` serves it when `KVITT_WEB_DIR` (default `/app`) exists, with every non-file path under `/app/` answered by `index.html` (SPA fallback), and `/app/*` is 404 otherwise. The build context becomes the repository root (`-f server/Dockerfile`); the workflow (sparse checkout adds `web`, and `contracts` for files the web build reads), its path triggers, `infra/build-images.sh` and `infra/dev/**` follow (W7). The development stack serves it the same way. Hashed assets immutable, `index.html` `no-store`. CSP: NPM sets `Content-Security-Policy` from a variable `$kvitt_csp`, the D-117 policy everywhere and for `/app/` `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`, still one server-level `add_header`; the server's own `/app/` header and the exact cache values are the server agent's (see D-135+). No separate publish: a server deploy ships the web client. | Owner interview 2026-10-21: one image, one deploy, nothing new on the Pi; a variable keeps D-117's no-`add_header`-in-locations rule. | SPEC §10.3, §10.5.2, §10.6, §15.4; `infra/npm/kvitt.conf`; `server/Dockerfile`, `.gitea/workflows/build-images.yaml`, `infra/build-images.sh` (W7) |
| D-133 | 2026-10-21 | Advanced analytics in the backend (revises AC-14.1's "the server has no reporting endpoint" and §2's "Server-side analytics or reporting endpoints"). New authenticated read endpoints under `/analytics/*`, designed by the server agent (D-135 to D-138, SPEC v0.29): per-category averages and trends per month, overall and per category, and a forecast of the next 2 calendar months per account. Forecast rule: for each account and each of the next two calendar months, forecast = the sum of the account's recurring templates due in that month (known future expenses) + the average per month of the account's non-recurring spend over the last 3 whole months; the 3-month window is the constant of D-128's trailing moving average (`AnalyticsRules.MOVING_AVERAGE_WINDOW`, 3), shared. The phone keeps computing S-11 and S-12 offline; the advanced analytics come from the server, online only (§9.9), on the web (W-11) and in the app (W6, an Insights section on S-11). | Owner, 2026-10-21: "These calculations should be put in the backend because I will want to see this in the app as well." | SPEC §1 G-5, §2, AC-14.1, §15.8 |
| D-134 | 2026-10-21 | Plans for the web client programme, disjoint paths so Opus agents run in parallel after the foundation: W0 web foundation (`web/**` except feature folders; scaffold, tokens, shell, router with every route and a placeholder page per feature folder, login, token store, API client and household snapshot, i18n, formatting, shared components, money functions, the gate; running); W1 home and accounts (`features/home`, `accounts`); W2 expenses, search and export (`features/expenses`, `search`, `export`); W3 settle up (`features/settle`); W4 analytics and insights (`features/analytics`; W-11 stubbed until W7); W5 settings (`features/settings`); W6 Android Insights (`android/**`, a UI-contract change, after W7); W7 server web serving and analytics endpoints (`server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`; running). Routes `/app/`, `/app/login`, `/app/accounts/:id`, `/app/expenses/new`, `/app/expenses/:id`, `/app/settle`, `/app/settlements/:id`, `/app/analytics`, `/app/analytics/:groupKind/:groupId`, `/app/insights`, `/app/search`, `/app/settings/*`. One `PLAN-W<n>.md` and `prompts/W<n>.md` each. | Owner interview 2026-10-21: run every independent task in parallel. | SPEC §11.1, §11.2, §11.3, §14.7, §15.9; `PLAN-W0.md` to `PLAN-W7.md`; `prompts/W0.md` to `prompts/W7.md` |
+40 -12
View File
@@ -452,12 +452,12 @@ SSL tab:
Advanced tab, Custom Nginx Configuration: paste this block exactly (all of it, comments included), with the app key in
place of `KVITT_APP_KEY`. Before the app gate first goes in, do step 4a items 1 to 3 (both phones on an APK that
carries the key); until then paste the block without the five lines from `set $kvitt_gate 0;` to `return 444;`.
carries the key); until then paste the block without the gate lines, from `set $kvitt_gate 0;` to `return 444;`.
The same block is the file `infra/npm/kvitt.conf`: open it, replace `KVITT_APP_KEY` with the key from `android/local.properties`, and paste the whole content. The copy here and the copy in SPEC §10.5.2 stay the reference and must match the file.
```nginx
# Kvitt (SPEC §10.5.2, D-120, D-126). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
# Kvitt (SPEC §10.5.2, D-120, D-126, D-130). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
# App gate (SPEC §8.5, D-126): outside the home LAN only a request carrying the key built into the app passes;
# everything else gets the connection closed (444: nothing sent, nothing to fingerprint). Put the key from
# android/local.properties (kvitt.appKey) in place of KVITT_APP_KEY. To rotate, add a second if-line with the new key.
@@ -467,6 +467,15 @@ set $kvitt_gate 0;
if ($http_x_kvitt_app = "KVITT_APP_KEY") { set $kvitt_gate 1; }
if ($realip_remote_addr ~ "^192\.168\.1\.") { set $kvitt_gate 1; }
if ($uri ~ "^/\.well-known/acme-challenge/") { set $kvitt_gate 1; }
# Web client (SPEC §15, D-130): its files under /app/, the invite accept it logs in with, and any request carrying a
# bearer token (the server checks the token; unknown tokens are rate-limited per IP), except /health, /setup/, /i/
# and /apk/, which stay LAN-or-key only.
if ($uri ~ "^/app(/|$)") { set $kvitt_gate 1; }
if ($uri = "/invites/accept") { set $kvitt_gate 1; }
set $kvitt_bearer 0;
if ($http_authorization ~ "^Bearer ") { set $kvitt_bearer 1; }
if ($uri ~ "^/(health|setup/|i/|apk/)") { set $kvitt_bearer 0; }
if ($kvitt_bearer = 1) { set $kvitt_gate 1; }
if ($kvitt_gate = 0) { return 444; }
# Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413.
client_max_body_size 6m;
@@ -479,7 +488,11 @@ proxy_hide_header Content-Security-Policy;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-Frame-Options;
proxy_hide_header Referrer-Policy;
add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
# The web client under /app/ (D-132) needs its own scripts, styles, images and API calls: one variable, still one
# server-level add_header.
set $kvitt_csp "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
if ($uri ~ "^/app(/|$)") { set $kvitt_csp "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; }
add_header Content-Security-Policy $kvitt_csp always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
@@ -550,9 +563,13 @@ router's forward keeps the source address), so their `X-Real-IP` is ignored. The
### 4a. The app key and the app gate (D-126)
Outside the home LAN, NPM lets a request through only when it carries the header `X-Kvitt-App: <key>`, and closes
the connection of every other request without answering (nginx's 444). Every path is covered: `/health`,
`/setup/status`, `/invites/accept`, `/i/<code>`, `/apk/*`. The release app sends the header on every request once
its build carries the key (`kvitt.appKey`). On the home Wi-Fi (`192.168.1.0/24`) everything passes without it: the
the connection of every other request without answering (nginx's 444). The release app sends the header on every
request once its build carries the key (`kvitt.appKey`). Since the web client (D-130, SPEC §15) three kinds of
request pass without the key, because a browser cannot carry it: the web client's own files (`/app/` and `/app`),
the login call `POST /invites/accept` (10 failed codes per hour per address lock it, SPEC AC-2.12), and any request
with an `Authorization: Bearer` header (the server checks the token; a wrong one gets 401 and counts against the
address's limit, SPEC §8.6). `/health`, `/setup/status`, `/i/<code>` and `/apk/*` stay closed from outside without
the key, with or without a bearer header. On the home Wi-Fi (`192.168.1.0/24`) everything passes without it: the
"secret" there is the Wi-Fi password. Each phone's own token stays the authentication (SPEC §8.2); the gate keeps
scanners and anyone without the app away from the server altogether. The Kvitt server neither sees nor checks the
header.
@@ -579,9 +596,13 @@ header.
5. Acceptance test, on the phone: (a) on the home Wi-Fi, open `https://kvitt.suorra.duckdns.org/i/TEST` in Chrome:
the Kvitt invite page shows (yes/no); (b) the same with Wi-Fi off: Chrome shows a connection error, for example
"This site can't be reached" (yes/no); (c) still with Wi-Fi off, open Kvitt's About screen (S-22): it shows the
server version (yes/no). If (a) is no, NPM does not see LAN clients' addresses (below): take the five gate
lines out again and tell the orchestrator. If (b) is no, NPM sees internet clients as LAN addresses and the gate
is open to everyone: tell the orchestrator.
server version (yes/no); (d) still with Wi-Fi off, open `https://kvitt.suorra.duckdns.org/app/` in Chrome: the
Kvitt web login page shows (yes/no; once the web client is in the deployed image, D-132); (e) still with Wi-Fi
off, open `https://kvitt.suorra.duckdns.org/health` in Chrome: a connection error, as in (b) (yes/no). If (a) is
no, NPM does not see LAN clients' addresses (below): take the gate lines (from `set $kvitt_gate 0;` to
`return 444;`) out again and tell the orchestrator. If (b) or (e) is no, NPM sees internet clients as LAN
addresses and the gate is open to everyone: tell the orchestrator. If (d) is no while the image carries the web
client, the block in NPM is older than this one: paste it again.
**If NPM does not see the real client address.** The LAN check relies on NPM seeing the address that really
connected, the same question as security review I-5 (Operations, "Client IPs are real"). If NPM saw a Docker bridge
@@ -602,9 +623,16 @@ the old one (item 1's `openssl rand -hex 32`); in NPM add a second line
`if ($http_x_kvitt_app = "<new key>") { set $kvitt_gate 1; }` next to the old one and Save; publish an APK built with
the new key and update both phones; then remove the old key's line in NPM and Save.
Accepted (D-126): the invite landing page and any browser use work only on the home Wi-Fi; `/health` from outside
without the header gets a closed connection, so the outside check is the app's About screen or
`curl -H "X-Kvitt-App: $KEY"`; nothing at home stops a flood of traffic.
Accepted (D-126, D-130): the invite landing page works only on the home Wi-Fi; `/health` from outside without the
header gets a closed connection, so the outside check is the app's About screen or `curl -H "X-Kvitt-App: $KEY"`;
nothing at home stops a flood of traffic. Since the web client (D-130) anyone on the internet can load the web
client's files and reach the server's authenticated routes with a wrong token (401, rate-limited per address) and
the invite accept (locked after 10 failures per hour per address); the device token stays the authentication.
**The web client's Content-Security-Policy (D-132).** The block sets the policy through the variable `$kvitt_csp`:
the strict D-117 value everywhere, and for `/app/` one that allows the web client's own scripts, styles, images
(`blob:` for profile pictures) and API calls (`'self'` only). It stays one server-level `add_header`, so no location
needs its own.
### 5. Router: no new rule
+15 -2
View File
@@ -1,4 +1,4 @@
# Kvitt (SPEC §10.5.2, D-120, D-126). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
# Kvitt (SPEC §10.5.2, D-120, D-126, D-130). NPM, Proxy Host <KVITT_PUBLIC_HOST>, Advanced tab, Custom Nginx Configuration.
# App gate (SPEC §8.5, D-126): outside the home LAN only a request carrying the key built into the app passes;
# everything else gets the connection closed (444: nothing sent, nothing to fingerprint). Put the key from
# android/local.properties (kvitt.appKey) in place of KVITT_APP_KEY. To rotate, add a second if-line with the new key.
@@ -8,6 +8,15 @@ set $kvitt_gate 0;
if ($http_x_kvitt_app = "KVITT_APP_KEY") { set $kvitt_gate 1; }
if ($realip_remote_addr ~ "^192\.168\.1\.") { set $kvitt_gate 1; }
if ($uri ~ "^/\.well-known/acme-challenge/") { set $kvitt_gate 1; }
# Web client (SPEC §15, D-130): its files under /app/, the invite accept it logs in with, and any request carrying a
# bearer token (the server checks the token; unknown tokens are rate-limited per IP), except /health, /setup/, /i/
# and /apk/, which stay LAN-or-key only.
if ($uri ~ "^/app(/|$)") { set $kvitt_gate 1; }
if ($uri = "/invites/accept") { set $kvitt_gate 1; }
set $kvitt_bearer 0;
if ($http_authorization ~ "^Bearer ") { set $kvitt_bearer 1; }
if ($uri ~ "^/(health|setup/|i/|apk/)") { set $kvitt_bearer 0; }
if ($kvitt_bearer = 1) { set $kvitt_gate 1; }
if ($kvitt_gate = 0) { return 444; }
# Server body: 1 MiB, PUT /me/picture 5 MiB (SPEC §8.6); the server answers anything larger with its own 413.
client_max_body_size 6m;
@@ -20,7 +29,11 @@ proxy_hide_header Content-Security-Policy;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header X-Frame-Options;
proxy_hide_header Referrer-Policy;
add_header Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
# The web client under /app/ (D-132) needs its own scripts, styles, images and API calls: one variable, still one
# server-level add_header.
set $kvitt_csp "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
if ($uri ~ "^/app(/|$)") { set $kvitt_csp "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; }
add_header Content-Security-Policy $kvitt_csp always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer always;
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: W0 Web foundation
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), plan W0: Web foundation. You have no context other than this prompt and the files it names.
Read PLAN-W0.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both (§15 is the web client). This plan records a run that started from an earlier brief; follow the brief where they differ and say so.
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-w0 -b agent-w0 main`; work only there, on branch `agent-w0`; `git reset --hard main`. Nothing to copy (no git-ignored files needed); run `npm ci` in `web/` once the scaffold exists.
- Edit only `web/**` except the W1 to W5 feature folders (placeholders only there); anything else is a request to the orchestrator.
- Gate: `npm run check` in `web/`, and `python3 tools/contracts-check.py`. Report only results you ran in this session.
- Never pair a browser or a phone against the owner's development stack or the Pi; manual checks use a throwaway server (PLAN-W0.md).
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-W0.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), requests to the orchestrator, open questions and spec gaps with section numbers.
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: W1 Web home and accounts
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), plan W1: Web home and accounts. You have no context other than this prompt and the files it names.
Read PLAN-W1.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both (§15 is the web client).
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-w1 -b agent-w1 main`; work only there, on branch `agent-w1`; `git reset --hard main`. Then `npm ci` in `web/`. Stop and report if W0 has not landed on `main`.
- Edit only `web/src/features/home/**`, `web/src/features/accounts/**` (and a dependency line in `web/package.json` if needed); anything else is a request to the orchestrator.
- Gate: `npm run check` in `web/`. Report only results you ran in this session.
- Never pair a browser or a phone against the owner's development stack or the Pi; manual checks use a throwaway server (PLAN-W1.md).
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-W1.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), requests to the orchestrator, open questions and spec gaps with section numbers.
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: W2 Web expenses, search and export
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), plan W2: Web expenses, search and export. You have no context other than this prompt and the files it names.
Read PLAN-W2.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both (§15 is the web client).
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-w2 -b agent-w2 main`; work only there, on branch `agent-w2`; `git reset --hard main`. Then `npm ci` in `web/`. Stop and report if W0 has not landed on `main`.
- Edit only `web/src/features/expenses/**`, `web/src/features/search/**`, `web/src/features/export/**` (and a dependency line in `web/package.json` if needed); anything else is a request to the orchestrator.
- Gate: `npm run check` in `web/`. Report only results you ran in this session.
- Never pair a browser or a phone against the owner's development stack or the Pi; manual checks use a throwaway server (PLAN-W2.md).
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-W2.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), requests to the orchestrator, open questions and spec gaps with section numbers.
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: W3 Web settle up
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), plan W3: Web settle up. You have no context other than this prompt and the files it names.
Read PLAN-W3.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both (§15 is the web client).
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-w3 -b agent-w3 main`; work only there, on branch `agent-w3`; `git reset --hard main`. Then `npm ci` in `web/`. Stop and report if W0 has not landed on `main`.
- Edit only `web/src/features/settle/**` (and a dependency line in `web/package.json` if needed); anything else is a request to the orchestrator.
- Gate: `npm run check` in `web/`. Report only results you ran in this session.
- Never pair a browser or a phone against the owner's development stack or the Pi; manual checks use a throwaway server (PLAN-W3.md).
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-W3.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), requests to the orchestrator, open questions and spec gaps with section numbers.
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: W4 Web analytics and insights
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), plan W4: Web analytics and insights. You have no context other than this prompt and the files it names.
Read PLAN-W4.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both (§15 is the web client).
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-w4 -b agent-w4 main`; work only there, on branch `agent-w4`; `git reset --hard main`. Then `npm ci` in `web/`. Stop and report if W0 has not landed on `main`; build W-11 as a stub until W7 has landed.
- Edit only `web/src/features/analytics/**` (and a dependency line in `web/package.json` if needed); anything else is a request to the orchestrator.
- Gate: `npm run check` in `web/`. Report only results you ran in this session.
- Never pair a browser or a phone against the owner's development stack or the Pi; manual checks use a throwaway server (PLAN-W4.md).
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-W4.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), requests to the orchestrator, open questions and spec gaps with section numbers.
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: W5 Web settings
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), plan W5: Web settings. You have no context other than this prompt and the files it names.
Read PLAN-W5.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both (§15 is the web client).
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-w5 -b agent-w5 main`; work only there, on branch `agent-w5`; `git reset --hard main`. Then `npm ci` in `web/`. Stop and report if W0 has not landed on `main`.
- Edit only `web/src/features/settings/**` (and a dependency line in `web/package.json` if needed); anything else is a request to the orchestrator.
- Gate: `npm run check` in `web/`. Report only results you ran in this session.
- Never pair a browser or a phone against the owner's development stack or the Pi; manual checks use a throwaway server (PLAN-W5.md).
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-W5.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), requests to the orchestrator, open questions and spec gaps with section numbers.
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: W6 Android Insights
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), plan W6: Android Insights. You have no context other than this prompt and the files it names.
Read PLAN-W6.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both (§15 is the web client).
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-w6 -b agent-w6 main`; work only there, on branch `agent-w6`; `git reset --hard main`. Then copy `android/local.properties` and `android/app/google-services.json` from the main checkout (never commit them). Stop and report if W7 has not landed on `main`.
- Edit only `android/feature/analytics/**`, and as a UI-contract change `android/core/model/**`, `android/data/**`, `android/core/fakes/**`; anything else is a request to the orchestrator.
- Gate: `PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ../tools/gradle-serial.sh check` in `android/`. Report only results you ran in this session.
- Use the phone only if the orchestrator notes name you for it (one agent on the phone at a time); never clear or uninstall the real app's data.
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-W6.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), requests to the orchestrator, open questions and spec gaps with section numbers.
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: W7 Server web serving and analytics endpoints
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), plan W7: Server web serving and analytics endpoints. You have no context other than this prompt and the files it names.
Read PLAN-W7.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both (§15 is the web client). This plan records a run that started from an earlier brief; follow the brief where they differ and say so.
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-w7 -b agent-w7 main`; work only there, on branch `agent-w7`; `git reset --hard main`. Copy files from `infra/secrets/` only if a dev-stack check needs them.
- Edit only `server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`, and `web/src/api/schema.d.ts` only to regenerate it; anything else is a request to the orchestrator.
- Gate: `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` in `server/`, `python3 tools/contracts-check.py`, and `npm run check` in `web/` when `web/` exists. Report only results you ran in this session.
- Never pair a browser or a phone against the owner's development stack or the Pi; manual checks use a throwaway server (PLAN-W7.md).
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-W7.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), requests to the orchestrator, open questions and spec gaps with section numbers.