SPEC v0.32: web plans W1 to W5 (D-144 to D-154), APK install failures (D-155)
Build Kvitt images / build (push) Failing after 14m44s

D-144 to D-154 record the web feature plans W1 to W5 (934eee7, a65ab71,
f9a9bfb, 1aecf51, 3ebe57d): preset account and payment in the query
string, Home empty state, the CSV format, AC-8.6 in the browser and
search in the URL, the expense editor's banner and controls, live
settle-up errors, analytics drill-down routes and chart accessibility,
W-18's route, the partner intro and About's fallback; D-150 unused.
D-155 records d686ee0: new AC-16.7, UpdateFailure in §14.2.4,
design-spec §3.6, S-17 and S-22. SPEC §11.2, §11.3, §15.3, §15.6 and
§15.7 follow the code; HANDOFF gets the state, the owner's browser
checks and the web follow-ups for a W0b plan.

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 21:52:13 +02:00
parent 83f52937cf
commit c13be3bbfd
4 changed files with 94 additions and 49 deletions
+39 -32
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.31** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
Version **0.32** (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.
@@ -198,7 +198,7 @@ The server starts with an empty database. The household is created from the comm
* AC-8.3 The payer is either user. The editor defaults the payer to the current user.
* AC-8.4 The date is any date within §9.6 limits, past or future. It defaults to today in the phone's time zone. This date is the only date used in analytics and lists.
* AC-8.5 Split modes (enum §9.1): `default` (the account's default ratio, copied into the expense when saved), `ratio` (a custom ratio), `exact` (exact öre per user), `full` (the whole amount borne by one chosen user, who may be the payer or not). Share computation per §9.1.
* AC-8.6 The editor defaults: account = the account last used on this phone if still active, else the oldest active account; category = the category suggestion of §9.11 for a new expense, whose last step is the category last used in that account on this phone if still active, else `other` (D-049); when `other` is archived too and no active last-used category exists, the editor shows no category until the user picks one (v0.9); editing an existing expense keeps its category; split mode `default`.
* AC-8.6 The editor defaults: account = the account last used on this phone if still active, else the oldest active account; category = the category suggestion of §9.11 for a new expense, whose last step is the category last used in that account on this phone if still active, else `other` (D-049); when `other` is archived too and no active last-used category exists, the editor shows no category until the user picks one (v0.9); editing an existing expense keeps its category; split mode `default`. On the web "this phone" means this browser (`localStorage`, D-147).
* AC-8.7 Exact mode: the user types one user's share; the other user's share is the amount minus it and is shown read-only. A typed share larger than the amount blocks saving with an inline error.
* AC-8.8 Changing the account of an expense in mode `default` re-copies the new account's ratio. In other modes the split is kept.
* AC-8.9 Deleting asks for confirmation and is a soft delete. Deleted expenses disappear from lists, balances and analytics on both phones after sync.
@@ -286,6 +286,7 @@ The server starts with an empty database. The household is created from the comm
* AC-16.4 A verification failure deletes the file and shows "Download failed, try again".
* AC-16.5 Debug builds never check for updates.
* AC-16.6 "Check for update" on S-22 calls `UpdateRepository.checkNow()` (D-140; `check()` before v0.30), which returns an `UpdateCheck` (D-092, revising D-073): `UpToDate` (nothing newer published; a 404 from E-5 counts as up to date) reports "You have the latest version" (`settings_about_up_to_date`, "Du har den senaste versionen"); `Failed` (no answer or another error) reports "Could not reach the server" (`common_could_not_reach_server`); `Available` shows the update banner. Debug builds report `UpToDate` without a request (AC-16.5). S-22 "Check for update" always calls `GET /apk/latest`, whatever the 6-hour throttle of AC-16.2 says, and restarts that window; the automatic checks on cold start and resume keep the throttle (D-140).
* AC-16.7 If the system installer refuses or fails the install, Home and S-22 show the Error banner "Kunde inte installera uppdateringen" (`update_install_failed`) with the reason (blocked by the phone's security settings, `update_install_blocked`, or the installer's own message) and "Försök igen", which downloads again; declining the system dialog shows "Uppdatera" again without an error (D-155). The cached APK is deleted once the installer answers.
### FR-17 Splitwise import
@@ -1342,11 +1343,11 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| 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) | Landed `2ae65a5` (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 |
| W1 | Web: home and accounts | Landed `934eee7` (2026-10-21, D-144, D-145, SPEC v0.32). 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 | Landed `a65ab71` (2026-10-21, D-146 to D-148, SPEC v0.32; the shared editor fields in `web/src/components/editor/`). W-5 with the §9.11 suggestion (`suggest_category.json`, in the vector test since `83f5293`), 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 | Landed `f9a9bfb` (2026-10-21, D-144, D-149, SPEC v0.32). 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 | Landed `1aecf51` (2026-10-21, D-151, D-152, SPEC v0.32). W-9, W-10 with the D-128 line chart, W-11 on the D-133 endpoints (E-34, E-35); `resolveRange` and `spendSeries` passing their vectors (`features/analytics/domain/analytics.vectors.test.ts`); 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 | Landed `3ebe57d` (2026-10-21, D-153, D-154, SPEC v0.32). 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 | Landed `1c9856b` (2026-10-21, D-141 to D-143, SPEC v0.31; Android gate green, 40 `s11_insights_*` goldens). 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) | Landed `50a0d67` (2026-10-21, D-135 to D-138, SPEC v0.29; 299 server tests, contracts-check 136). `/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 |
@@ -1378,7 +1379,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).
* **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). W1 to W5 landed 2026-10-21 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; D-144 to D-154, D-150 unused) with the web gate green on `main` (59 test files, 532 tests at `1aecf51`); a web fix (`83f5293`) followed (598 tests). Pieces built twice or three times in feature folders and other loose ends are listed for a W0b plan in `docs/HANDOFF.md` "Web follow-ups".
### 11.4 Validation reports
@@ -1453,7 +1454,7 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
| S-14 | Profile | Avatar with change/remove; display name; "Needs connection" state | Save |
| S-15 | Categories | List: icon, colour, name, archived section; editor: name (with "Reset name" for defaults), icon grid (33 icons of §9.7), colour swatches (14 tokens); delete or archive with the reason when delete is not allowed | Add; Edit; Reorder (drag); Archive; Delete |
| S-16 | Sync status | Last successful sync time; pending changes count with a list (the shared ledger rows of `:core:design`, D-019, not a separate 36 dp row); "Changes not synced" list from `sync_rejections` with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync failed" when online and `SyncSummary.lastAttemptFailed` (D-088); the stale-change copy names `Rejection.actor`, the partner when it is null (D-089); "Sync now", online only (§9.9, D-080); "Öppna" on a rejection shows the phone's lost version read-only (D-109): a dialog "Din version" (`home_sync_lost_title`, body `home_sync_lost_body`) with the ledger row it would have been (read-only: the row's `onClick` is null), the account, payer, split and notes (`Rejection.lost`, §14.2.4), the badge "Du tog bort den här" (`home_sync_lost_deleted`) when the refused change was a deletion, and "Visa nuvarande version" (`home_sync_lost_show_current`), which opens S-8 or S-10; when `lost` is null (unreadable payload, or its category or a user is not on the phone) "Öppna" opens the current version directly | Sync now; open a rejected change (read-only); dismiss it |
| S-17 | Update available | Banner on Home with version name; dialog explaining "Install unknown apps" the first time; download progress; failure | Update; Later |
| S-17 | Update available | Banner on Home with version name; dialog explaining "Install unknown apps" the first time; download progress; failure (download, or the install refused or failed with its reason, AC-16.7, D-155) | Update; Later |
| S-18 | Device removed | Explanation that this phone's access was removed and local data deleted | Pair again (goes to S-1) |
| S-19 | Recurring expenses | List: description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 (no date); account, payer and split as three fixed full-width rows (Konto, Betalt av, Delning; D-102, D-107) plus day of month (a new template defaults to today's day, AC-9.8, D-072), first month, optional last month, pause switch; a new template is sent as `NewRecurringTemplate` (`RecurringForm.newTemplate()`), an edit as `RecurringTemplate` (`RecurringForm.template(original, …)`), D-091; the discard dialog has no body text (D-080) | Add; Edit; Pause/Resume; Delete |
| S-20 | Devices | Rows per AC-3.1; the app version shows `Device.appVersionName` ("Kvitt 1.1"), else the version code (D-090) | Revoke (with confirmation) |
@@ -2111,9 +2112,14 @@ sealed interface UpdateState {
data object None : UpdateState
data class Available(val info: UpdateInfo) : UpdateState
data class Downloading(val info: UpdateInfo, val progress: Float) : UpdateState
data class Failed(val info: UpdateInfo) : UpdateState
data class Failed(val info: UpdateInfo, val reason: UpdateFailure = UpdateFailure.Download) : UpdateState // reason: v0.32, D-155
data class NeedsInstallPermission(val info: UpdateInfo) : UpdateState
}
sealed interface UpdateFailure { // v0.32, D-155: why Failed; the downloaded file is deleted in every case
data object Download : UpdateFailure // no answer, an error, or size or SHA-256 mismatch (AC-16.4)
data object Blocked : UpdateFailure // STATUS_FAILURE_BLOCKED: the phone's security settings
data class Install(val detail: String?) : UpdateFailure // any other installer failure; detail = the installer's status message
}
sealed interface UpdateCheck { // v0.13, D-092
data object UpToDate : UpdateCheck
data class Available(val info: UpdateInfo) : UpdateCheck
@@ -3008,7 +3014,7 @@ Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `w
* `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.
* **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. As built (W0, `web/README.md` "Strings"): `web/src/i18n/{sv,en}.json` are generated from `docs/ux/strings.csv` plus `web/src/i18n/web-strings.csv` (the shared web-only strings, W0's), with numbered placeholders `{1}`, `{2}` in place of `%1$s`; each feature keeps its own strings in `web/src/features/<name>/i18n/{sv,en}.json` (keys `web_<name>_…`), merged at build time; a string two features render moves to `web-strings.csv` (`83f5293`). 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.
@@ -3033,34 +3039,34 @@ The web's pages. "Mirrors" names the app screen whose content, rules and states
| 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-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 (its open state kept for the page session, D-145), empty state with the "Du och <partner> är med" card in place of the balance header (D-145); no sync pill, no update banner, no pull to refresh | New expense (`N`), Settle up, open account, New account | W1 (`934eee7`) |
| W-3 | Account detail | `/app/accounts/:id` | S-5 | Month groups with month totals, expense and settlement rows, archived badge | New expense (`/app/expenses/new?account=<id>`), Settle this account (`/app/settle?account=<id>`, D-144), Edit, Archive / Unarchive, Delete | W1 (`934eee7`) |
| W-4 | Account editor | `/app/accounts/new`, `/app/accounts/:id/edit` | S-6 | Name, default split with the 100,00 kr preview | Save, Cancel | W1 (`934eee7`) |
| W-5 | Expense editor | `/app/expenses/new` (`?account=<id>` presets the account, D-144), `/app/expenses/:id/edit` | 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; a rejected or failed save in a banner with "Visa nuvarande version", the typed values kept; browser controls in place of S-7's keypad, slider and summary row (D-148) | Save, Delete (edit), Cancel (`Esc`, with the discard dialog) | W2 (`a65ab71`) |
| 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 (`a65ab71`) |
| W-7 | Settle up | `/app/settle` (`?account=<id>` one account, `?settlement=<id>` editing a payment, D-144) | S-9 | Direction with swap, amount, allocation rows with debts and over-settle warnings, allocation errors as you type (D-149), remaining indicator, add account, date, note, "Föreslå igen" | Confirm, Cancel | W3 (`f9a9bfb`) |
| W-8 | Settlement detail | `/app/settlements/:id` | S-10 | Payer, recipient, amount, date, note, allocations, timeline | Edit (`/app/settle?settlement=<id>`), Delete | W3 (`f9a9bfb`) |
| W-9 | Analytics | `/app/analytics` | S-11 | Range, granularity, measure, group-by; total; stacked bars with legend; category and account breakdowns; "Visa som tabell" under each chart, bar labels as keyboard stops (D-152) | Open a drill-down: a bar opens `/app/analytics/bucket/<key>`, a breakdown row `/app/analytics/:groupKind/:groupId`, the filter in the query string (D-151) | W4 (`1aecf51`) |
| W-10 | Drill-down | `/app/analytics/:groupKind/:groupId` (`groupKind` `category`, `account` or `bucket`; query `range`, `from`, `to`, `interval`, `measure`, `group`, `bucket`, D-151) | 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; its own period changes replace the query string (D-151) | Open expense | W4 (`1aecf51`) |
| W-11 | Insights | `/app/insights` | none (D-133) | Per-category averages and trends per month (overall and per category) over 3, 6 or 12 whole months (default 6, D-152), the 2-month forecast per account with its parts (recurring, average) | Choose category or account; choose months | W4 (`1aecf51`) |
| 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, kept in the URL query string (D-147); result count and sum; expense rows | Open expense; Export CSV of the filtered list (format D-146) | W2 (`a65ab71`) |
| 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 (`3ebe57d`) |
| W-14 | Profile | `/app/settings/profile` | S-14 (and S-2 once after a `partner` login, as an intro remembered per browser device in `kvitt.profileStepDone`, D-154) | Avatar with change and remove (file picker), display name | Save | W5 (`3ebe57d`) |
| W-15 | Categories | `/app/settings/categories`, editor `/app/settings/categories/new` and `/app/settings/categories/:id` (D-153) | 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 (`3ebe57d`) |
| W-16 | Recurring expenses | `/app/settings/recurring`, editor `/app/settings/recurring/new` and `/app/settings/recurring/:id` (D-153) | S-19 | List and editor as S-19 | Add, Edit, Pause / Resume, Delete | W5 (`3ebe57d`) |
| 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 (`3ebe57d`) |
| W-18 | Invites | `/app/settings/invite` (D-153; `/app/settings/invites` opens it too) | 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 (`3ebe57d`) |
| 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` header of a fresh `GET /me` without the schema (the S-22 fallback, D-110, D-154; `/health` is closed from outside, §15.2) | none | W5 (`3ebe57d`) |
| 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.
* **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. The query and filters live in the URL query string (`q`, `min` and `max` in öre, `from`, `to`, `account`, `category`, `payer`), so a search can be bookmarked (D-147).
* **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 (D-146, W2 `a65ab71`): UTF-8 with a byte-order mark, `;` separator, CRLF, RFC 4180 quoting, amounts in kronor with a decimal comma and no grouping ("1234,50") in every page language, ISO dates, one share column per household user ("Andel <namn>"); a text cell starting with `=`, `+`, `-`, `@`, TAB or CR gets a leading `'`; file name `kvitt-utgifter-<YYYY-MM-DD>.csv` (Swedish) or `kvitt-expenses-<YYYY-MM-DD>.csv` (English). It opens correctly in Numbers and Excel with a Swedish locale. 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).
* **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); as built it is a raw import, allowed by `server.fs.allow` in `web/vite.config.ts` (D-148).
### 15.8 Advanced analytics on the server (D-133)
@@ -3121,3 +3127,4 @@ Plans with disjoint paths (§14.7), one Opus agent each in its own worktree. W0
| 0.29 | 2026-10-21 | The server side of the web client programme (W7, `50a0d67`) and the unattended APK deploy (`70cb25c`). D-135 web serving: `KVITT_WEB_DIR` (default `/app`); `GET /app` → 308 `/app/`; files from the directory, every other path `index.html`, a missing `assets/*` 404; `assets/*` `public, max-age=31536000, immutable`, `index.html` `no-store`, others `no-cache`; the `$kvitt_csp` web policy plus `nosniff`, `DENY`, `no-referrer`; safe paths at most 512 bytes and 8 segments of `[A-Za-z0-9._-]` not starting with `.`; no directory → `/app` 404 (§10.4, §15.4, AC-21.1). D-136: `/app`, `/app/`, `/app/*` count against the unauthenticated 60/min per IP, bearer token or not (§8.6). D-137 image build: repository-root context with an allowlist `.dockerignore` (`server`, `web`, `contracts`), `server/.dockerignore` removed; a Node `node:24.22.1-bookworm-slim` stage pinned by digest on the build platform, its `dist` passed through the Rust stage so npm and rustc never run together on the Pi 3; workflow context `.`, sparse checkout and path filter add `web` and `contracts`; `infra/build-images.sh` and `infra/dev/build.sh` follow; `Cargo.lock` bumps `rustls-webpki` 0.103.16 and `webpki-root-certs` 1.0.10 (RUSTSEC-2026-0382, -0383) (§10.3). D-138 analytics: E-34 `GET /analytics/trends` and E-35 `GET /analytics/forecast`, authenticated, read-only, with examples; rules in new §14.5.7 (`kvitt-domain::analytics`) (§7, §14.3.4, §14.3.5, AC-14.1, §15.8). D-139 (`70cb25c`): `infra/publish-apk.sh --deploy` copies the APK and `latest.json` with a forced-command key and checks `/apk/latest` (§10.6). Server tests 299; contracts-check 136. |
| 0.30 | 2026-10-21 | The manual update check (`c0f4345`). D-140 (owner, 2026-10-21): S-22 "Check for update" shared the throttled automatic check, so a tap within 6 hours of the last automatic check reported the stored answer (the owner deployed a new APK and was told "You have the latest version"); `UpdateRepository.checkNow()` (default implementation calls `check()`, so fakes are unchanged) always asks the server and restarts the 6-hour window, debug builds still return `UpToDate` without a request; `AboutViewModel.check()` calls `checkNow()`; the automatic checks on cold start and resume keep the throttle (AC-16.2, AC-16.6, §13.3 S-22, §14.2.4). |
| 0.31 | 2026-10-21 | Android Insights on S-11 (W6, `1c9856b`; Android gate green, which also fixes the E-34/E-35 example tests red since `50a0d67`). D-141 UI contract: `:core:model` `InsightsRepository` with `trends(months: 1..24, measure)` and `forecast(measure)`, both `OnlineResult` (`Offline` without a request, `Failed("network")` when unreachable); types `SpendTrends`, `SpendTrend`, `TrendPoint`, `CategoryTrend` (`category: Category?`), `SpendForecast`, `AccountForecast` (`account: Account?`), `ForecastMonth`, named after the E-34/E-35 fields; names resolved from Room, archived and deleted included, unknown id → null; nothing stored; `:data` `AnalyticsDtos.kt`, `InsightsRepositoryImpl`, two `KvittApi` calls; fakes `FakeInsightsRepository`, `InsightsScenarios`, `insightsFailure` (§14.2.4). D-142 placement and loading: an "Insights" ("Insikter") section below S-11's local analytics, following S-11's measure toggle, always 6 whole months (E-34's default); loads when S-11 opens, when the measure changes to one not yet loaded, when the phone comes online and on "Försök igen"; data kept in memory per measure while S-11 is open; the last data stays visible under the offline or error banner; S-11's offline analytics unchanged (AC-14.1) (§13.3 S-11, design-spec S-11). D-143 display rules: the household chart and a per-category chart on D-128's `SpendLineChart` with the server's average and moving average; categories in the server's order, the open one defaults to the largest total; unknown category ids skipped; one forecast card per account (archived badge) with the current month so far and two months as "Recurring + average = forecast"; an all-zero card shows one line "No recurring expenses and no recent spending" (design-spec S-11, §3.10). `strings.csv` 588 rows (16 `analytics_insights_*` keys, 19 rows) (§13.4). Goldens `s11_insights_<state>` for 13 states in three variants plus `s11_insights_long_sv_light_200`. |
| 0.32 | 2026-10-21 | The web feature plans W1 to W5 (`934eee7`, `a65ab71`, `f9a9bfb`, `1aecf51`, `3ebe57d`; web gate on `main` 59 files, 532 tests at `1aecf51`, 598 at `83f5293` with the `suggest_category` vectors) and the update install-failure fix (`d686ee0`). D-144: the preset account travels as `?account=<id>` for `/app/expenses/new` and `/app/settle`, a payment being edited as `/app/settle?settlement=<id>`; builders in `features/accounts/links.ts` and `features/settle/links.ts` until W0 adds them to `routes.ts`. D-145: web Home's empty state shows the "Du och <partner> är med" card in place of the balance header; the archived section's open state lasts for the page session in memory. D-146: CSV export format (UTF-8 with BOM, `;`, CRLF, RFC 4180 quoting, "1234,50" in every language, ISO dates, header in the page language, one share column per user, formula cells prefixed `'`, `kvitt-utgifter-<date>.csv` / `kvitt-expenses-<date>.csv`). D-147: AC-8.6's "on this phone" is this browser (`localStorage`); search filters in the URL (amounts in öre). D-148: web expense editor: rejections and failures in a banner with S-16's text and "Visa nuvarande version", the server's row into the snapshot, typed values kept; "Utgiften sparades" after edits too; the discard dialog on `Esc` and in-app navigation; browser controls in place of S-7's keypad, ratio slider and summary row; shared fields in `web/src/components/editor/`; the keyword table imported raw (`server.fs.allow`). D-149: W-7 shows the §14.5.3 allocation errors as you type. D-150 unused. D-151: a bar opens `/app/analytics/bucket/<key>`; the filter in the query string (`range`, `from`, `to`, `interval`, `measure`, `group`, `bucket`), replaced by W-10's own period changes. D-152: chart SVGs hidden from screen readers, bar labels as keyboard stops, "Visa som tabell"; W-11 offers 3, 6 or 12 months. D-153: W-18's route is `/app/settings/invite` (`/invites` still opens it); W-15 and W-16 editor routes. D-154: the partner's first-login step is an intro on W-14 remembered in `kvitt.profileStepDone` (the device id); W-19's fallback reads the server-version header of a fresh `GET /me` (§15.3, §15.6, §15.7, AC-8.6). D-155 (`d686ee0`): a refused or failed install shows "Kunde inte installera uppdateringen" with the reason (blocked by the security settings, or the installer's message) and "Försök igen", which downloads again; a declined dialog returns to `Available`; `UpdateState.Failed` gains `reason: UpdateFailure` (`Download`, `Blocked`, `Install(detail)`) (new AC-16.7, §13.3 S-17, §14.2.4; design-spec §3.6, S-17, S-22). `strings.csv` 590 rows (`update_install_failed`, `update_install_blocked`). Goldens `S-17-install-blocked`, `S-17-install-failed`, `s22_about_install_blocked` new, `update_banner` re-recorded. contracts-check 136. |