From 6f637514e698a5e079861b9da3d6c8111d1bb48d Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 24 Oct 2026 17:00:56 +0200 Subject: [PATCH] SPEC v0.39: K2's report recorded (D-191 to D-200), card design-spec S-23 to S-27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPEC: §11.2 K2 row landed a8f91ee with its gate, §11.3, §12 OQ-9, §13.2 the destination enum as built, §13.3 S-5, S-13, S-23 to S-27 as built and K2's four added keys, §14.2.1 (D-192, D-193), §14.2.2 (card_providers, D-194), §14.2.3 (Room 2 landed), §14.2.4 readings fixed in v0.39 (D-191 to D-196, OQ-9), change log v0.39. - docs/decisions.md: D-191 to D-200. - docs/ux/design-spec.md: S-23 to S-27 from K2's goldens, §3.11 four destinations and the label shrink-to-fit (D-197), §3.12 card artwork, credit_card and card icons, the card states of S-5, S-7, S-8, S-13, cards_ prefix. - docs/HANDOFF.md: K2 and K3 landed, Android gate green, PLAN-K2 §8 steps pending, numbering. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- SPEC.md | 43 +++++++---- docs/HANDOFF.md | 26 ++++--- docs/decisions.md | 10 +++ docs/ux/design-spec.md | 166 +++++++++++++++++++++++++++++++++++++++-- 4 files changed, 212 insertions(+), 33 deletions(-) diff --git a/SPEC.md b/SPEC.md index 52d4d00..c2f0605 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,6 +1,6 @@ # Kvitt: specification -Version **0.38** (2026-10-24). Schema version **4**. Sync protocol version **1**. Push payload version **1**. +Version **0.39** (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. @@ -1477,7 +1477,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's | 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); done 2026-10-24 against `v1.0.3` (D-211) | W7 landed, its image deployed | | 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) | `: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` | Android gate | Connect the card on the phone with BankID; create an expense from a purchase; link one; disconnect and reconnect (PLAN-K2 §8) | SPEC v0.36; may start then on the spec and fakes; lands after K1 | +| 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`) | | 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 | @@ -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, K3's in v0.40 (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`) (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 @@ -1536,6 +1536,7 @@ Only things that cannot be decided without the owner. Everything else in this sp | OQ-6 | Answered 2026-10-24 (D-156): no confirmation step on S-1 for an invite link that prefills a server other than the release default (`RELEASE_SERVER_URL`, AC-2.6) (security review L-6); the default stands: S-1 opens its server row expanded for a non-default server so the address is visible (AC-2.6, D-116). | M10 | answered | | OQ-7 | Should a new card purchase raise a notification on the holder's phone (a local one, built from the holder's own data; never an FCM payload with content)? | K2 | Not in this version: purchases appear on the next sync without a notification (D-171) | | OQ-8 | Should several purchases be turned into expenses at once ("Lägg till i konto…", one expense each with the account's default split)? | after K2 | Not in this version (D-171); one purchase at a time through S-7 | +| OQ-9 | `CardPurchase.linked` (§14.2.4) is null both when a purchase is unlinked and while its linked expense is not yet on the phone (for example a link made in the web client to an expense the phone has not pulled yet), so the purchase is listed under "Kopplade" without its "Kopplad till" line and its sheet briefly offers "Skapa utgift" and the matches. Add `linkedExpenseId` to `CardPurchase` so the sheet can say "Kopplad" without the expense's details (a UI-contract change, §14.8)? | next Android card pass | Not now: the window lasts until the next household sync brings the expense | --- @@ -1562,7 +1563,7 @@ A brief for one UX agent (Opus) who has read this spec and nothing else. It runs * A floating action button "Add expense" (Lägg till utgift) on Home and on Account detail. * Account detail, expense editor, expense detail, settle up, settlement detail and all settings sub-screens are pushed screens with a top app bar and back. * The expense editor is a full-screen screen, not a bottom sheet (the numeric keyboard needs the room). -* Wiring (D-071, as built by M7a to M7f and connected by M8): the top-level screens S-3, S-4, S-11 and S-13 render their own `KvittNavigationBar` and take `onNavigate: (KvittDestination) -> Unit`; the `:app` scaffold draws no second bar, and the `NavHost` switches top-level destinations from those lambdas. Since D-172 S-23 is the fourth top-level screen that draws the bar, and `KvittDestination` (`:core:design`) gains `Cards` between `Home` and `Analytics`. Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID` and `ARG_ACCOUNT_ID`; `SettleArgs.SETTLEMENT_ID` and `SettleArgs.ACCOUNT_ID`; `DrillDownArgs.KEYS`; settings `"categoryId"` and `"templateId"`), read through `SavedStateHandle`. +* Wiring (D-071, as built by M7a to M7f and connected by M8): the top-level screens S-3, S-4, S-11 and S-13 render their own `KvittNavigationBar` and take `onNavigate: (KvittDestination) -> Unit`; the `:app` scaffold draws no second bar, and the `NavHost` switches top-level destinations from those lambdas. Since D-172 S-23 is the fourth top-level screen that draws the bar, and `KvittDestination` (`:core:design`) gains `Cards` between `Home` and `Analytics` (as built by K2, `a8f91ee`: `enum class KvittDestination { Home, Cards, Analytics, Settings }`, icon `credit_card`, label `nav_cards`; a label that does not fit its slot at a large font scale shrinks to fit, down to 70 %, D-197). Navigation-argument keys are constants in each feature (`ARG_EXPENSE_ID` and `ARG_ACCOUNT_ID`; `SettleArgs.SETTLEMENT_ID` and `SettleArgs.ACCOUNT_ID`; `DrillDownArgs.KEYS`; settings `"categoryId"` and `"templateId"`), read through `SavedStateHandle`. * Result snackbars (D-071): the result of S-7 ("Utgiften sparades"), of S-9 and of archiving an account is shown by the screen returned to; the account-archived snackbar has an undo action (`common_undo`, "Ångra"). As built by M12e (D-095): six screens host `:app`'s shared `snackbarHostState` in their own scaffold (Home, AccountDetail, ExpenseDetail, SettlementDetail, DrillDown, SyncStatus), so a snackbar never overlaps a FAB or the bottom bar; `:app` has no overlay host. Features hand `:app` string resource ids, never resolved strings: `ExpenseEditorScreen.onSaved(ExpenseId, @StringRes Int?)`, and `onDeleted()` on S-7 and S-8 takes no argument, `:app` then shows `snackbar_expense_deleted`. * Session gate (as built by M8): S-1 for an unpaired or unrecognised session, S-2 for the profile step after a `partner` pairing only (AC-2.10, D-086; a member pairing a second phone with a `device` invite goes to Home), S-18 for a revoked device (back on S-18 closes the app). Home shows S-3 while the household has no partner (`partner == null`). @@ -1576,7 +1577,7 @@ Each screen must be designed in these states where they apply: **loaded**, **emp | S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a `partner` pairing (`Pairing.inviteKind`, D-086) | Continue | | S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") | Invite partner (goes to S-21); Settings | | S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when `SyncSummary.lastAttemptFailed`; `SyncStatusPill` takes no `failed` parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account; Pull to refresh (sync now, D-127) | -| S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed); Pull to refresh (sync now, D-127); "Från kortköp" (overflow menu, only while the viewer has a card connection, opens S-27, AC-22.9) | +| S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed); Pull to refresh (sync now, D-127); "Från kortköp" (overflow menu, first item, only while the viewer has a card connection and not on an archived account, D-199; opens S-27, AC-22.9) | | S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state; the discard dialog has a title and no body text (D-080) | Save; Cancel | | S-7 | Expense editor | Amount entry (large, numeric keyboard, `kr`); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); account, date, payer and split shown as four fixed full-width rows in that order, which do not move when a value changes (D-102); split section: mode chooser (`default` "Account default 60/40", `ratio` "Custom ratio", `exact` "Exact amounts", `full` "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode; opened from a card purchase (S-23's sheet or S-27, CP-9, D-169) it starts as a new expense prefilled from the purchase, and "Spara" also links the purchase (a foreign-currency purchase leaves the amount empty); after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071), `onSaved` handing `:app` the string resource id (D-095); the quick row is filled by `quickRowCategories` (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) | Save; Cancel; Delete (edit mode) | | S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: