diff --git a/SPEC.md b/SPEC.md index c2f0605..535b272 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.39** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**. +Version **0.40** (2026-10-24). Schema version **4**. 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. @@ -1425,7 +1425,7 @@ SPEC v0.28 ─┬─► W0 web foundation (web/**) ───────── └─► W7 server: /app/ serving, /analytics/* (D-135..D-138, v0.29) ─┬─► W4's W-11 └─► W6 Android Insights -Card purchases (v0.35, D-158 to D-176; K0 done, v0.36; K1 landed `671c00d`, its report recorded in v0.38, D-181 to D-188; K2's report goes into v0.39, K3's into v0.40): +Card purchases (v0.35, D-158 to D-176; K0 done, v0.36; K1 landed `671c00d`, its report recorded in v0.38, D-181 to D-188; K2 landed `a8f91ee`, its report recorded in v0.39, D-191 to D-200; K3 landed `faeb752`, its report recorded in v0.40, D-201 to D-207): SPEC v0.35 ─► owner steps (Enable Banking account, ToS, application, key, linked cards) ─► K0 local test app on the Mac ─► K0 report ─► SPEC v0.36 (K0's answers, D-177 to D-180) ────────┬─► K1 server (schema 4, adapter, E-36..E-44, poller, NPM text) ──┐ lands first ├─► K2 Android (:feature:cards, Room 2, Kortköp tab) ──────────┼─► lands after K1 @@ -1478,7 +1478,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's | K0 | Card purchases spike: local test app (D-176) | The test app landed `2ce317f`: `docs/spikes/k0/app.py` and its `README.md`, one command (`python3 docs/spikes/k0/app.py`, the standard library only, the RS256 JWT signed with `openssl`), a page on `http://localhost:8765/` that runs the real Enable Banking flow (bank list, BankID, paste-back of the redirect address, accounts, "Uppdatera" with PSU headers, "Bakgrundshämtning" without, balances, the purchases, an aggregate "Analys", "Koppla från"), key and state in the git-ignored `docs/spikes/k0/.local/`. Done 2026-10-24: run with the owner on his card; report `docs/spikes/K0-enable-banking.md` (aggregates only), its answers recorded in SPEC v0.36 (D-177 to D-180); the partner's card and the next-day checks were not run (owner's decision, D-177) and move to K1's owner steps | The flow worked end to end with the owner's card (owner's yes); the report answers every K0 item; `git status` shows nothing from `.local/`; no gate (not part of any gate) | Done 2026-10-24: Enable Banking account and Terms of Service, the restricted production application, the key in `.local/`, the owner's card linked with BankID, the test app run (PLAN-K0 §8) | SPEC v0.35 | | K1 | Card purchases: server (D-158 to D-175) | Landed `671c00d` (2026-10-24; D-181 to D-188, SPEC v0.38). Migration 0004 with its contract text and migration test, server schema 4; `src/cards/` (registry, Enable Banking adapter, the windowed fetch and de-duplication of §9.12 CP-7 and CP-8, poller, callback page); E-36 to E-44 in the server and `contracts/openapi.yaml` with examples; new error codes; the link clearing in §14.4.4 step 7; `contracts/test-vectors/match_card_purchase.json` and its Python reference in `tools/contracts-check.py`; config (§10.4), `infra/pi/compose.yaml`, `.env.example`, `infra/dev/**`, `infra/npm/kvitt.conf`, `infra/README.md` (step 4 block, card runbook); `web/src/api/schema.d.ts` regenerated | Server gate; `python3 tools/contracts-check.py`; the web gate (the regenerated types); an integration test suite against a local Enable Banking stub. At landing: server 356 tests (34 in `tests/cards_api/`), contracts-check 157 (17 `match_card_purchase` cases), web 609 (`match_card_purchase` listed as not ported until K3), the nginx check and both compose configs ok; the Android gate red by design until K2 lands (`VectorFilesTest`, `ExampleRoundTripTest` pin the contract's vector and example sets), so the commit bypassed the pre-commit hook (contract-first landing) | Copy the key (already on the Mac from K0) to `~/kvitt/secrets/cards/`, set the application id in `.env`, paste the new NPM block, deploy; once a client with the card screens runs: the partner's card, the next-day fetch without BankID and pending→booked (D-177) (PLAN-K1 §8) | SPEC v0.36 (K0's answers) | | K2 | Card purchases: Android (D-169, D-170, D-172, D-173) | Landed `a8f91ee` (2026-10-24; D-191 to D-200, SPEC v0.39). `:feature:cards` (S-23 to S-27 and the sheet), Room version 2 with its migration test, the `CardRepository` UI contract in `:core:model`, `matchCardPurchase` in `:domain` passing its vectors, `:data` (DTOs, card sync, queued link changes, Custom Tab, deep link), fakes and scenarios, the fourth bottom destination, S-5's "Från kortköp", S-7's prefill and link, S-8's card line, the committed card artwork mapped (D-180), goldens; strings in `docs/ux/strings.csv` (77 rows); out-of-path edits ratified by the orchestrator: `ArchitectureRules.kt` knows `cards`, `:core:design` gains `ic_credit_card` / `KvittIcon.CreditCard`, the card measures in `KvittDimens` (D-200), the nav-label shrink-to-fit (D-197), `StringsCatalogueTest` takes `cards_` as a feature prefix | Android gate. At landing: `BUILD SUCCESSFUL`, 791 actionable tasks, 1154 tests, 0 failures; the 11 card examples (E-36 to E-44) round-trip, the 17 `match_card_purchase` cases pass, `VectorFilesTest` and `ExampleRoundTripTest` green again on `main`; 150 goldens added (`:feature:cards` 135 for every state of S-23 to S-27 with `_sv_light_200` for the long ones; `S-5-account-menu-card`, `s7_from_card`, `s7_from_card_foreign`, `s8_from_card`, `s13_hub_card_connected`) and 147 re-recorded for the fourth bar destination (S-3, S-4, S-11, S-13, S-17 and the navigation-bar component; only the bar changed) | Connect the card on the phone with BankID; create an expense from a purchase; link one; disconnect and reconnect (PLAN-K2 §8 steps 1 to 7, pending: the phone was not attached; the release APK with the card screens is installed by the orchestrator once K1's image runs on the Pi) | SPEC v0.36; may start then on the spec and fakes; lands after K1 | -| K3 | Card purchases: web (D-174) | W-21 to W-23 in `web/src/features/cards/`, the sidebar entry, the S-7-style prefill in W-5 and the card line in W-6, the committed card artwork mapped (D-180), `matchCardPurchase` in TypeScript passing its vectors | Web gate | Connect the card from the laptop browser with BankID QR; create and link from W-21 (PLAN-K3 §8) | SPEC v0.36; lands after K1 and K2 (it takes the card strings K2 adds to `docs/ux/strings.csv`) | +| K3 | Card purchases: web (D-174) | Landed `faeb752` (2026-10-24; D-201 to D-207, SPEC v0.40). W-21 to W-23 in `web/src/features/cards/` (every §13.3 state but offline, the S-23 sheet actions, `?attempt=` polling, `?account=` as S-27), the Kortköp entry after Översikt in the sidebar and bottom bar, W-5's `?cardPurchase=` prefill (CP-9) and link on save, W-6's card line with "Ta bort koppling", W-3's "Från kortköp", W-13's "Anslut kort" row, the committed card artwork mapped (D-180), the card snapshot in `web/src/api/cards.ts`, `matchCardPurchase` in TypeScript passing its 17 vectors | Web gate. At landing: web 70 files, 684 tests, build ok; contracts-check 157 | Pending after image `v1.0.5` is deployed: connect the card from the laptop browser with BankID QR; create and link from W-21 (PLAN-K3 §8, three steps) | SPEC v0.36; lands after K1 and K2 (it takes the card strings K2 adds to `docs/ux/strings.csv`) | | 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); deployed 2026-10-24 as `v1.0.3` (D-211) | SPEC v0.28 | ### 11.3 Notes per milestone @@ -1511,7 +1511,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's * **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). 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". W0b landed `5305f37` (v0.34). All of W0 to W7 are landed and verified: `v1.0.3` (built on the Mac from `8fa9198`, D-211) runs on the Kvitt Pi and the owner checked the web client and the phone's Insights against it on 2026-10-24 (§11.4). -* **K0 to K3** (D-176, §11.1): the card-purchase programme. K0 runs first, on the Mac only, with the owner (a local test app for fast feedback before any Android work, owner's refinement 2026-10-24; the app landed `2ce317f`); K0 is done (run 2026-10-24, report `docs/spikes/K0-enable-banking.md`) and its answers are in SPEC v0.36 (D-177 to D-180), so K1 to K3 can start. The reports of K1 to K3 propose spec text and decisions that a docs agent records: K1's in SPEC v0.38 (landed `671c00d`, D-181 to D-188), K2's in v0.39 (landed `a8f91ee`, D-191 to D-200), K3's in v0.40 (landed `faeb752`) (decisions from D-191 to D-210, disjoint ranges per plan). Then K1, K2 and K3 run in parallel in their own worktrees (disjoint paths, §14.7): K1 owns the contract (`contracts/**`, the migration text, the vectors) and lands first; K2 and K3 build against the spec and fakes, and rebase on K1 before their gates (K2's DTO tests and K3's generated types need K1's contract); K3 lands after K2, whose `docs/ux/strings.csv` rows it imports (`npm run i18n:import`). K2 uses the phone only when its brief names it; K1 and K3 never use the phone. No plan connects to the owner's real card except through the owner's own BankID step; no plan stores, prints or commits a real purchase. +* **K0 to K3** (D-176, §11.1): the card-purchase programme. K0 runs first, on the Mac only, with the owner (a local test app for fast feedback before any Android work, owner's refinement 2026-10-24; the app landed `2ce317f`); K0 is done (run 2026-10-24, report `docs/spikes/K0-enable-banking.md`) and its answers are in SPEC v0.36 (D-177 to D-180), so K1 to K3 can start. The reports of K1 to K3 propose spec text and decisions that a docs agent records: K1's in SPEC v0.38 (landed `671c00d`, D-181 to D-188), K2's in v0.39 (landed `a8f91ee`, D-191 to D-200), K3's in v0.40 (landed `faeb752`, D-201 to D-207) (decisions from D-191 to D-210, disjoint ranges per plan). Then K1, K2 and K3 run in parallel in their own worktrees (disjoint paths, §14.7): K1 owns the contract (`contracts/**`, the migration text, the vectors) and lands first; K2 and K3 build against the spec and fakes, and rebase on K1 before their gates (K2's DTO tests and K3's generated types need K1's contract); K3 lands after K2, whose `docs/ux/strings.csv` rows it imports (`npm run i18n:import`). K2 uses the phone only when its brief names it; K1 and K3 never use the phone. No plan connects to the owner's real card except through the owner's own BankID step; no plan stores, prints or commits a real purchase. ### 11.4 Validation reports @@ -3422,7 +3422,7 @@ Ownership (an agent edits only paths its milestone owns; anything else needs the | K0 (D-176): `docs/spikes/k0/**` (the test app of `2ce317f`; its key, config and state in the git-ignored `docs/spikes/k0/.local/`, root `.gitignore`), `docs/spikes/K0-enable-banking.md` | K0 | docs agent (the report is a deliverable, kept) | | K1 (D-158 to D-175): `server/**`, `contracts/**`, `tools/contracts-check.py`, `infra/pi/compose.yaml`, `infra/pi/.env.example`, `infra/dev/**`, `infra/npm/kvitt.conf`, `infra/README.md` (step 4's block and the card runbook entry), and `web/src/api/schema.d.ts` for its regeneration only | K1 (contract-change plan for `contracts/**`) | the usual owners | | K2 (D-169, D-170, D-172, D-173): `android/feature/cards/**` (new module), and as a contract-change plan `android/core/model/**`, `android/domain/**` (`matchCardPurchase`), `android/data/**` (Room 2, exported `2.json`), `android/core/fakes/**`; `android/core/design/**` (the fourth destination, `nav_cards`), `android/feature/account/**` (S-5's action), `android/feature/expense/**` (S-7's prefill, S-8's line), `android/feature/settings/**` (S-13's row), `android/app/**` (navigation, deep link, bindings), `android/settings.gradle.kts` and `android/gradle/libs.versions.toml` (the module and `androidx.browser`), `docs/ux/strings.csv` (the card keys of §13.3) | K2 | the usual owners | -| K3 (D-174): `web/src/features/cards/**` (new), and in W0's shared code the route, the sidebar entry and `web/src/lib/domain/` (`matchCardPurchase`); `web/src/features/expenses/**` (W-5's prefill, W-6's card line); `web/src/features/accounts/**` (W-3's "Från kortköp"); `web/src/features/settings/**` (W-13's row); the regenerated i18n dictionaries | K3 | later web plans | +| K3 (D-174): `web/src/features/cards/**` (new), and in W0's shared code the route, the sidebar entry and `web/src/lib/domain/` (`matchCardPurchase`); `web/src/features/expenses/**` (W-5's prefill, W-6's card line); `web/src/features/accounts/**` (W-3's "Från kortköp"); `web/src/features/settings/**` (W-13's row); the regenerated i18n dictionaries; as built also `web/src/api/cards.ts` and its test, `web/src/test/cards.ts`, `web/vite.config.ts` (`/cards` in the dev proxy), `web/scripts/gen-icons.mjs` and `web/src/design/icons.generated.ts`, `web/src/app/Shell.test.tsx` (ratified by the orchestrator at landing, `faeb752`) | K3 | later web plans | | `docs/validation/` | 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. @@ -3462,7 +3462,7 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op 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. -**Status (2026-10-24, D-211).** W0 to W7 and W0b are landed; the web client is live at `https://kvitt.suorra.duckdns.org/app/` in image `v1.0.3`, paired from the owner's laptop browser and checked by the owner across its pages (§11.4). +**Status (2026-10-24, D-211).** W0 to W7 and W0b are landed; the web client is live at `https://kvitt.suorra.duckdns.org/app/` in image `v1.0.3`, paired from the owner's laptop browser and checked by the owner across its pages (§11.4). K3 (the card purchases of FR-22 on the web, W-21 to W-23) landed `faeb752` (D-201 to D-207, v0.40) and ships in image `v1.0.5`; its owner steps (PLAN-K3 §8) follow that deploy. ### 15.1 Scope (D-129) @@ -3489,9 +3489,10 @@ Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `w * **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. 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). +* **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), `matchCardPurchase` (§14.5.8, `web/src/lib/domain/`) passes `match_card_purchase.json` (K3, listed as ported). 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/cards` (K3, D-174), `/app/settings/*` (`/app/settings/cards` and `/app/settings/cards/:id` K3's); sub-routes and query parameters are the owning plan's. * **As built (W0b, `5305f37`).** Shared web pieces live once in `web/src/components` (AccountTile, PageStates, Timeline, OverflowMenu, SwitchRow, `editor/`), `lib` (categories, timeline, accountColors), `api` (errors, invites) and `app` (useToday). A feature never imports another feature (`catalog.test.ts` also fails when a feature uses another feature's `web_` key). Amount fields follow the page language: sv `,` or `.` decimal with space grouping, en `.` decimal with `,` grouping. Recharts is its own chunk loaded by the analytics pages only (vendor 516 → 132 kB). The Vitest cache is off. The web's version code is the git commit count at build time, else `KVITT_VERSION_CODE`, else 1. +* **As built (K3, `faeb752`).** Shared card pieces: `web/src/api/cards.ts` (the card snapshot of §15.5 and the E-44 write, with `cards.test.ts`; in `api/` because W-21 to W-23, W-5, W-6, W-3 and W-13 read it), `web/src/test/cards.ts` (fixtures and the fake server's card endpoints), `/cards` in the dev proxy of `web/vite.config.ts`, the icons `credit_card`, `link_off` and `visibility_off` in `web/scripts/gen-icons.mjs` and the generated table, the query names `cardPurchase`, `attempt` and `provider` in `routes.ts` `QUERY` with the builders `cardsPath`, `connectCardPath` and `expensesNewPath({ cardPurchase })` (D-202). Web-only strings `web_cards_*` (load and catalogue failures, the catalogue's loading line, "Filter", the never-fetched failure, `expense_already_linked`, `card_bank_unavailable`, the empty "Välj en annan utgift" list) and `web_expenses_card_load_failed`, `web_expenses_card_missing_title`, `web_expenses_card_link_failed`; the rest are the §13.3 keys. * **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) @@ -3503,6 +3504,7 @@ Owner interview 2026-10-21 (D-129 to D-134). Kvitt has a web client, built in `w ### 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. +* **Card purchases** (K3, D-201). The card snapshot (E-40) is held in memory beside the household snapshot, pulled from 0 on load, from its cursor on focus, every 60 s and after every card write; card writes are sent at once. A 409 `cursor_ahead` pulls it from 0; rows apply by `card_version`, tombstones included; only the viewer's own rows ever arrive (CP-11). W-21 re-reads it every 2 s while a connection is fetching or in its first fetch (CP-2's interval), otherwise at the shared 60 s (D-201). Refreshing it re-reads only the snapshot, not the catalogue (E-36) or an attempt (E-38) (`web/src/api/cards.ts`). * **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. @@ -3516,15 +3518,15 @@ The web's pages. "Mirrors" names the app screen whose content, rules and states | 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 ä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=`), Settle this account (`/app/settle?account=`, D-144), Edit, Archive / Unarchive, Delete; "Från kortköp" (`/app/cards?account=`, while the viewer has a card connection, K3, AC-22.9) | 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=` 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: