diff --git a/SPEC.md b/SPEC.md index d8a0a4a..c15c105 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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//i18n/{sv,en}.json` (keys `web__…`), 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//` (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: