SPEC v0.8: UX-session decisions (D-047 to D-054), category contract v2, M12 plans

Records the owner's UX-sprint decisions UX-1 to UX-8 of docs/ux/proposed-decisions.md:
D-047 direction Kalas muted, D-048 three tabs, D-049 category suggestion (§9.11,
suggestCategory, contracts/category-keywords.csv, suggest_category.json), D-050 S-1
display-name field, D-051 colour token values from Kalas (slate, amber TODO(U2)),
D-052 smaller UX rules, D-053 finer Splitwise mapping, D-054 fourteen default
categories with server schema 2 (migration 0002, §14.1.1). Adds milestone group M12
(PLAN-M12a/b/c, prompts), the CLAUDE.md UX-sprint note, and rewrites HANDOFF.

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-18 08:06:45 +02:00
parent aa765bbb48
commit d39eb2c79b
15 changed files with 553 additions and 68 deletions
+1
View File
@@ -45,6 +45,7 @@ The owner talks to one **orchestrator** session. The orchestrator plans, briefs,
* **Agents' reports are not shown to the owner.** The orchestrator extracts the decision: one line per finding, fixed or not, and relays every owner step in chat.
* **Subagents are Opus.** Haiku or Sonnet only for a read-only scan the owner explicitly asks for that way.
* **Numbering** (decisions, spec versions, schema versions, contract requests) is handed out by the orchestrator in the brief. Two parallel agents get disjoint ranges; the second to land renumbers to be contiguous on rebase. **Implementation agents never edit `SPEC.md` or `docs/decisions.md`:** they put proposed decision entries and spec text, numbered from their range, in their report, and the orchestrator has them recorded by a docs agent, landed together with the implementation.
* **The UX sprint** (U1, U2) runs in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`, with no orchestrator. Its results reach the main line only through `docs/ux/proposed-decisions.md` (owner answers, proposed decisions and spec text, no numbers): the orchestrator numbers them and has a docs agent record them in `SPEC.md` and `docs/decisions.md` (SPEC v0.8 recorded UX-1 to UX-8 as D-047 to D-054).
* **Parallel agents** each have their own worktree, build through `tools/gradle-serial.sh` (one Gradle build at a time on this machine), and take turns on the phone: **one agent on the phone at a time**, named in the brief.
---
+72
View File
@@ -0,0 +1,72 @@
# PLAN-M12a: Category contract v2, contracts
On record only: M12a ran in parallel with the SPEC v0.8 docs change, from the UX-session documents, and landed on `main` as `6b60ef7`. What landed wins where this plan differs: the suggestion records and the `UnicodeText` interface live in `:domain` (M12a also wrote `:domain`'s `suggestCategory`), the vectors include non-NFC strings, and the OpenAPI `DefaultCategoryKey` extension and the new category examples were held back for M12b (SPEC §11.2, §14.5.6).
## 1. Goal
Write the contract files of the category contract v2 (D-049, D-054) so that M12b (server) and M12c (Android) can work in parallel against them: the keyword table, the locked text of server migration 0002, the `suggest_category` test vectors, the fourteen-key enum in the OpenAPI document, the examples that change, and the checker extended to verify all of it, including a stdlib-only Python reference of §9.11.
## 2. Read first
1. SPEC §9.11 Category suggestion (CS-1 to CS-7), all of it.
2. SPEC §14.5.5 Test-vector format (the `suggest_category` keys, output and minimum cases) and §14.5.6 Category suggestion.
3. SPEC §14.8 (the `contracts/category-keywords.csv` rules and the contract-file list), §14.1 and §14.1.1 Migration 0002.
4. SPEC AC-7.2 (fourteen defaults), §9.7 (33 icons, 14 colours), §14.3.3 Category, §14.3.4 E-1.
5. SPEC §11.2 (row M12a), §11.3 (note M12), §14.7.
6. `docs/decisions.md` D-049 and D-054.
7. Files: `tools/contracts-check.py` (whole file: how vectors, the reference functions and the §14.1 byte check are organised), `contracts/openapi.yaml` (`DefaultCategoryKey`), `contracts/examples/E-1-health/response.json`, the examples under `contracts/examples/` that hold categories (`E-23` to `E-27`, `E-31-sync-pull`, `sync/pull-page.json`), and the source table `docs/ux/category-keywords.csv` (148 entries) as it is on `main`. If `docs/ux/category-keywords.csv` is not on `main` yet, take it from the branch `UX-sprint` with `git show UX-sprint:docs/ux/category-keywords.csv` (read only; never edit that branch).
8. `CLAUDE.md`.
## 3. Already established
- Fourteen default keys in AC-7.2 order: `groceries`, `restaurants`, `housing`, `home`, `utilities`, `insurance`, `transport`, `travel`, `entertainment`, `health`, `children`, `shopping`, `fees`, `other` (D-054).
- New ids `01920000-0000-7000-8000-000000000011` (`housing`), `…012` (`insurance`), `…013` (`children`), `…014` (`fees`); `created_at` = `updated_at` = `2026-10-18T00:00:00.000Z`; `server_version` taken from the global sequence by the migration (§14.1.1).
- New icons `key`, `shield`, `account_balance` (appended, 33 in all); new colour tokens `slate`, `amber` (14 in all); their hex values are `TODO(U2)` and are not needed by this plan (D-051).
- `contracts/schema/server-v1.sql` stays byte-locked; `contracts/schema/migrations/0002_categories_v2.sql` is the SQL block of §14.1.1 byte for byte (§14.1.1, §14.8).
- `contracts/category-keywords.csv`: columns `keyword,match,default_key,source`, the rules of §14.8, seeded from `docs/ux/category-keywords.csv` unchanged (that draft already satisfies the rules: normalised, unique, sorted, phrase rule, no `other`).
- `suggest_category.json` input keys: `description`, `history` (`description`, `category_id`, `expense_date`, `created_at`), `keywords` (`keyword`, `match`, `default_key`), `active_categories` (`id`, `default_key`), `fallback`; output `{"ok": id}` or `{"ok": null}`; every string NFC (§14.5.5).
- E-1's `schema_version` becomes 2 (§14.3.4).
- The Room schema is unchanged (§14.1.1).
## 4. Owned paths
From SPEC §14.7: `contracts/**`, `tools/contracts-check.py` (owner M1; after landing "contract-change plans only (§14.8)"); M12a is that contract-change plan (§14.7 row M12a, D-054). Concretely: `contracts/category-keywords.csv`, `contracts/schema/migrations/0002_categories_v2.sql`, `contracts/test-vectors/suggest_category.json`, `contracts/openapi.yaml` (the `DefaultCategoryKey` enum only), `contracts/examples/**` (only where §2 item 7 shows a change is needed), `tools/contracts-check.py`.
Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator. Never edit `docs/ux/**`.
## 5. Deliverables
- `contracts/category-keywords.csv`: the 148 entries of the draft, byte-identical to it unless a §14.8 rule fails (report any change).
- `contracts/schema/migrations/0002_categories_v2.sql`: the §14.1.1 SQL block, byte for byte.
- `contracts/test-vectors/suggest_category.json`: format 1, `"function": "suggest_category"`, at least the §14.5.5 minimum cases.
- `contracts/openapi.yaml`: `DefaultCategoryKey` with the fourteen keys (a semantic change, recorded by D-054).
- `contracts/examples/E-1-health/response.json`: `schema_version` 2; other examples only if they enumerate the default categories.
- `tools/contracts-check.py` extended (stdlib only): (a) the §14.8 rules for `category-keywords.csv`; (b) `0002_categories_v2.sql` byte-identical to the second SQL block of SPEC §14.1 (the one under `#### 14.1.1`); (c) in an in-memory SQLite with `PRAGMA foreign_keys = ON`: load `server-v1.sql`, insert a user, an account, a custom category and expenses referencing a default and the custom category, run 0002 inside `BEGIN`/`COMMIT`; assert fourteen default rows with the AC-7.2 ids, keys, icons, colours and sort orders, the custom category and every expense unchanged, `PRAGMA foreign_key_check` empty, `meta.schema_version` = `2`, the four new `server_version`s strictly greater than every earlier one; (d) a Python reference of §9.11 and §14.5.6 (NFC via `unicodedata`, general categories via `unicodedata.category`) recomputing every `suggest_category` case, plus a check that every string in the file is NFC; (e) the vector file is listed with the other Kotlin-only files.
## 6. Steps
1. Worktree. From the main checkout `/Users/jhag/projects/kvitt`: `git worktree add .claude/worktrees/agent-m12a -b agent-m12a main`, then in `.claude/worktrees/agent-m12a`: `git reset --hard main`. No git-ignored files are needed. Run `tools/install-git-hooks.sh`.
2. Read §2 in order. Confirm SPEC.md on `main` is v0.8 (header) with §9.11 and §14.1.1; if not, stop and report.
3. Copy the keyword draft into `contracts/category-keywords.csv`; write checker part (a) and run it.
4. Extract the §14.1.1 block into `contracts/schema/migrations/0002_categories_v2.sql`; write checker parts (b) and (c); run.
5. Write the Python reference of §9.11 (part d) first, then the vector cases one group at a time, running the checker after each group. Hand-check every expected value against §9.11 before trusting the reference.
6. Update `DefaultCategoryKey` in `contracts/openapi.yaml` and the E-1 example; scan the other examples (§2 item 7) and change only those that enumerate the defaults.
7. Run the gate (§7). Commit on `agent-m12a`, `git rebase main`, run the gate again. Never merge, push or remove the worktree.
## 7. Gate
From SPEC §11.2, verbatim: "`python3 tools/contracts-check.py` passes".
Command: `python3 tools/contracts-check.py` from the repository root. Green means exit code 0 and its summary line reporting zero failures, with the new checks (a) to (e) listed in its output.
## 8. HUMAN steps
From SPEC §11.2, verbatim: "none".
## 9. Out of scope
- Any Rust or Kotlin code: the server migration file, `SCHEMA_VERSION`, token lists and importer table (M12b); `:core:model`, `:domain`, `:data`, fakes (M12c).
- The colour hex values of `slate` and `amber` (U2, then a docs agent).
- `contracts/schema/server-v1.sql` (byte-locked).
- `SPEC.md`, `docs/`, `CLAUDE.md`; a §9.11 ambiguity is a gap for the report, not something to resolve in the vectors.
## 10. Report
- Branch `agent-m12a`, head commit hash after rebasing on `main`.
- Gate: the checker's summary lines from this session, with the new checks named.
- `suggest_category` case count and the list of case names; whether the keyword file is byte-identical to the draft.
- Examples changed, and why each other category example needed no change.
- Proposed decision entries (never written to `SPEC.md` or `docs/decisions.md` by this agent) or `TODO(spec)` occurrences.
- Open questions and spec gaps with section numbers (any §9.11 case the text does not decide).
## 11. Numbering
Decision ids, spec version and schema version are assigned by the orchestrator in the start prompt; use `TODO(spec)` if none was given. This plan's own changes are already recorded as D-049 and D-054 in SPEC v0.8; the server schema version it describes is 2.
+72
View File
@@ -0,0 +1,72 @@
# PLAN-M12b: Category contract v2, server
## 1. Goal
Bring the landed server and importer to the category contract v2 (D-053, D-054): migration 0002 adds the four new default categories to every database and moves the server to schema version 2; the token lists, the default-key enum and the importer's category table follow the spec. Existing databases, the development stack included, upgrade in place without losing a row.
## 2. Read first
1. SPEC §14.1 and §14.1.1 Migration 0002 (all of it), §14.8 (contract files, migration texts).
2. SPEC AC-7.2 (fourteen defaults), §9.7 (33 icons, 14 colour tokens), §14.3.3 Category, §14.3.4 E-1.
3. SPEC §10.8 Splitwise import (the schema check and the new category table), D-044, D-046, D-053.
4. SPEC §8.9 (pragmas), §14.4.2 (pull: the new rows reach phones by `server_version`).
5. SPEC §11.2 (row M12b), §11.3 (note M12), §14.7 (row M12b).
6. `docs/decisions.md` D-053, D-054.
7. Files: `git show 6b60ef7` (M12a's commit message lists what it held back), `contracts/schema/migrations/0002_categories_v2.sql` and `tools/contracts-check.py` (from M12a), `server/crates/kvitt-server/migrations/0001_init.sql`, `src/db/mod.rs`, `src/api/validate.rs`, `src/wire/category.rs`, `src/wire/mod.rs`, `src/openapi.rs`, `tests/skeleton/migration.rs`, `tests/skeleton/routes.rs` (E-1 `schema_version`), `tests/openapi_contract.rs`, the tests in `tests/core_api/` and `tests/sync_api/` that count or list categories, `server/crates/kvitt-import/src/categories.rs`, `data/splitwise_categories.csv`, `tests/import.rs` (`category_table_matches_the_spec`), `contracts/examples/E-31-sync-pull/response.json`.
8. `CLAUDE.md` (server gate, no `unwrap` outside tests, every schema change gets a migration and a migration test).
## 3. Already established
- Migration 0002 is the SQL of §14.1.1 byte for byte; `server-v1.sql` and `0001_init.sql` stay unchanged (§14.1.1).
- It rebuilds `categories` under `PRAGMA defer_foreign_keys = ON` inside sqlx's per-migration transaction (foreign keys stay ON), inserts four rows with `server_version` from `sync_sequence`, sets `meta.schema_version` to `2` (§14.1.1). M12a's checker (`6b60ef7`) loads it on a fresh and on an in-use v1 database with foreign keys on, the way sqlx does; the docs agent's own run of an equivalent text with SQLite 3.50 also committed with `foreign_key_check` empty.
- `SCHEMA_VERSION` = 2; E-1 reports it; the importer's check reads that constant, so it follows (§10.8, §14.1.1; D-046 revised by D-054).
- Icons: the 30 of v1 plus `key`, `shield`, `account_balance`; colours: the 12 of v1 plus `slate`, `amber` (§9.7). Hex values are irrelevant to the server.
- `DefaultCategoryKey` has the fourteen keys in both `contracts/openapi.yaml` and the generated document. M12a (`6b60ef7`) held back the contract side, so this plan edits `contracts/openapi.yaml` (that enum only) and adds the category examples M12a held back (see its commit message), then the OpenAPI match test compares the two.
- Importer table per §10.8 (D-053): Rent/Mortgage/Hyra/Avbetalning/Amortering `housing`; Insurance/Försäkringar `insurance`; Childcare/Barnomsorg `children`; Taxes/Skatter `fees`; Swedish El, Värme/gas, Vatten, Sophämtning (`utilities`) and Husdjur (`home`) added. `category_table_matches_the_spec` parses SPEC §10.8, so it fails on `main` from SPEC v0.8 until this plan lands (the orchestrator lands them close together).
- The E-31 example lists two categories, not the defaults; change it only if it enumerates the defaults.
## 4. Owned paths
From SPEC §14.7 (row M12b, a contract-change plan): `contracts/openapi.yaml` (`DefaultCategoryKey` only) and the category examples under `contracts/examples/` that M12a held back (with `python3 tools/contracts-check.py` still passing); `server/crates/kvitt-server/migrations/0002_categories_v2.sql`, `src/db/mod.rs` (`SCHEMA_VERSION`), `src/api/validate.rs` (token lists), `src/wire/category.rs` and `src/wire/mod.rs` (key enum), and the tests of these (`tests/skeleton/**`, and the category-counting lines of `tests/core_api/**` and `tests/sync_api/**`); `server/crates/kvitt-import/data/splitwise_categories.csv`, `src/categories.rs` and its tests.
Edit nothing outside these paths; a needed change elsewhere (including `contracts/**`) is a contract request to the orchestrator.
## 5. Deliverables
- `server/crates/kvitt-server/migrations/0002_categories_v2.sql`: byte-identical to `contracts/schema/migrations/0002_categories_v2.sql`.
- An identity test asserting that equality (next to the existing 0001 identity test).
- `SCHEMA_VERSION` = 2 and the E-1 tests updated.
- A seed test: on a fresh database, the fourteen default rows exactly per AC-7.2 and §14.1.1 (ids, keys, icons, colours, sort orders, null names, `server_version` 11 to 14 after the ten seeds); on a v1 database (built from `0001_init.sql` alone, then data inserted: a custom category, a renamed and an archived default, expenses and a recurring template referencing categories) upgraded through the migrator: every earlier row unchanged, the four new rows with `server_version`s above the old sequence value, `PRAGMA foreign_key_check` empty, `meta.schema_version` `2`; a pull from cursor = the old sequence value returns exactly the four new categories.
- `validate.rs`: icon list of 33 and colour list of 14 (§9.7), with tests for the new tokens.
- `contracts/openapi.yaml` `DefaultCategoryKey` with the fourteen keys and the held-back category examples; the default-key enum in `src/wire/` with the fourteen keys; the OpenAPI match test and `python3 tools/contracts-check.py` green.
- `splitwise_categories.csv` per §10.8; `src/categories.rs` key list of fourteen; `category_table_matches_the_spec` green; an importer test that a Försäkringar / Insurance row maps to `insurance`.
- Existing tests that assumed ten defaults updated, each with the reason in the report.
## 6. Steps
1. Worktree. From the main checkout `/Users/jhag/projects/kvitt`: `git worktree add .claude/worktrees/agent-m12b -b agent-m12b main`, then in `.claude/worktrees/agent-m12b`: `git reset --hard main`. No git-ignored files are needed. Run `tools/install-git-hooks.sh`.
2. Read §2 in order. Confirm M12a has landed (`contracts/schema/migrations/0002_categories_v2.sql` exists and `python3 tools/contracts-check.py` passes on `main`); if not, stop and report.
3. Copy the migration file; add the identity test; bump `SCHEMA_VERSION`; run the server gate and list every failing test that counts categories or checks the schema version.
4. Write the seed and upgrade tests (§5), then fix the lists, the enum and the failing tests until green.
5. Update the importer table and key list; run `category_table_matches_the_spec`; add the insurance mapping test.
6. Run the gate (§7). Commit on `agent-m12b`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
7. Do not migrate the running development stack's database yourself: report the command for the orchestrator (`kvitt-server migrate` runs at start, so a restart with the new image upgrades it; take a backup first with the dev backup script).
## 7. Gate
From SPEC §11.2, verbatim: "Server gate; `python3 tools/contracts-check.py` passes".
Command, from `server/`: `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`; and `python3 tools/contracts-check.py` from the repository root (this plan edits `contracts/`).
Green means: all three exit 0 in this session; the `test result:` lines show 0 failed and include the identity, seed, upgrade, OpenAPI match and importer parity tests.
## 8. HUMAN steps
From SPEC §11.2, verbatim: "none".
## 9. Out of scope
- `contracts/**` beyond the OpenAPI enum and the held-back examples, Android (M12c), `server-v1.sql` and `0001_init.sql` (byte-locked).
- Any change to sync, push or the audit writer; the four new rows get no audit entries and no pushes (they are seed data like the v1 seeds).
- Running the importer against the real exports, or migrating the development or production database.
## 10. Report
- Branch `agent-m12b`, head commit hash after rebasing on `main`.
- Gate output: fmt and clippy exit status, every `test result:` line.
- Tests changed because they assumed ten defaults or schema 1, each with its reason.
- SQLite version of the bundled `libsqlite3-sys` and confirmation that the migration ran under sqlx's transaction with foreign keys on.
- The upgrade steps for the development stack, worded for the orchestrator.
- Proposed decision entries (never written to `SPEC.md` or `docs/decisions.md` by this agent) or `TODO(spec)` occurrences.
- Contract requests, open questions, spec gaps (with section numbers).
## 11. Numbering
Decision ids, spec version and schema version are assigned by the orchestrator in the start prompt; use `TODO(spec)` if none was given. This plan implements server schema version 2 (D-054); any further schema change would be 3.
+73
View File
@@ -0,0 +1,73 @@
# PLAN-M12c: Category contract v2, Android
## 1. Goal
Bring the landed Android contract and data layer to the category contract v2 (D-049, D-054): fourteen default categories, two more colour tokens and three more icons in `:core:model`; `ExpenseRepository.suggestCategory` in `:data`, calling the pure `suggestCategory` that M12a already landed in `:domain` (`6b60ef7`) with a platform `UnicodeText` binding and the keyword table bundled; fakes with the fourteen defaults. After this lands, M6 and M7c build on it.
## 2. Read first
1. SPEC §9.11 Category suggestion (CS-1 to CS-7), all of it.
2. SPEC §14.5.6 Category suggestion (types and signatures in `:domain`, `UnicodeText`), and the landed `android/domain/src/main/kotlin/se/hagstedt/kvitt/domain/CategorySuggestion.kt` with its test binding `android/domain/src/test/kotlin/se/hagstedt/kvitt/domain/JvmUnicodeText.kt`.
3. SPEC §14.2.4: the enums, `CategoryIcons`, `ExpenseRepository` and "Changes in v0.8"; §14.2.2 (DataStore preferences: last used category per account).
4. SPEC AC-7.2, AC-8.6, AC-8.10 (list order used for ties), §9.7, §6.2 (layering, pure modules, fakes), §14.8 (`category-keywords.csv`).
5. SPEC §11.2 (row M12c), §11.3 (note M12), §14.7 (row M12c).
6. `docs/decisions.md` D-049, D-054.
7. Files: `contracts/category-keywords.csv` and `contracts/test-vectors/suggest_category.json` (from M12a), `android/core/model/src/main/kotlin/se/hagstedt/kvitt/model/Enums.kt` and the repository interfaces, `android/core/model/src/test/.../ContractTest.kt`, the `:domain` vector tests (how they load `contracts/test-vectors/`), `android/data/` (the expense repository, DAOs, the DataStore preferences for the last-used category, the test harness), `android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/FakeScenarios.kt` and the fake expense repository, `android/README.md`.
8. `CLAUDE.md` (Android gate, layering, no mocking libraries, no silencing).
## 3. Already established
- `DefaultCategoryKey` in AC-7.2 order (14), `ColorToken` + `SLATE`, `AMBER`, `CategoryIcons.ALL` + `key`, `shield`, `account_balance` at the end (33) (§14.2.4). Wire value = lowercase name.
- New default ids `01920000-0000-7000-8000-000000000011` to `…014` (housing, insurance, children, fees), sort orders 25, 45, 85, 95 (AC-7.2, §14.1.1).
- Landed in `:domain` by M12a (`6b60ef7`), not to be rewritten: `SuggestionHistoryEntry`, `KeywordMatch`, `CategoryKeyword`, `ActiveCategory`, `UnicodeText`, `normalizeDescription`, `suggestCategory(description, history, keywords, activeCategories, fallback, unicode): CategoryId?`, passing `suggest_category.json` (§14.5.6).
- `:domain` may not use `java.*`, so `:data` provides the production `UnicodeText` (`java.text.Normalizer` NFC, `Character.getType` for L and Nd), the same behaviour as the `:domain` test binding (§14.5.6).
- `ExpenseRepository.suggestCategory(accountId: AccountId?, description: String): CategoryId?`: history = every non-deleted expense in Room (pending included); active categories = non-archived, non-deleted; fallback = AC-8.6 (last used in that account on this phone if active, else `other`; `accountId` null gives `other`); null only while the phone holds no categories (§14.2.4).
- The keyword table is bundled as the Java resource `android/data/src/main/resources/category-keywords.csv`, byte-identical to `contracts/category-keywords.csv`, checked by a test; parsed once and cached (§14.2.4, §14.8).
- The Room schema stays at version 1; the new default rows arrive through sync (§14.1.1). No Room migration.
- Fakes: the empty scenario has exactly the fourteen defaults (§6.2); the fake `suggestCategory` follows §9.11 over the fake's own data by calling the same `:domain` function (`:core:fakes` may depend on `:domain`, §6.2).
- Default category names (AC-7.2) are shown from string resources. At `e8ee518` no module holds default category name strings; `:core:design` will hold them (M6, from `docs/ux/strings.csv`). If, when this plan runs, a module already holds the ten default names as string resources, add the four new names there in English and Swedish (`values/strings.xml`, `values-sv/strings.xml`); otherwise add none and say so in the report (M6 then takes all fourteen from `strings.csv`).
## 4. Owned paths
From SPEC §14.7 (row M12c, a contract-change plan): `android/core/model/**` (enums, icons, `ExpenseRepository`), `android/data/**` (`UnicodeText` binding, `suggestCategory`, the bundled keyword resource, tests), `android/core/fakes/**` (defaults, fake `suggestCategory`); plus the default-name string files of §3 last item, only if they already exist.
Edit nothing outside these paths; a needed change elsewhere is a contract request to the orchestrator.
## 5. Deliverables
- `:core:model`: the enum, token and icon changes; `suggestCategory` on `ExpenseRepository`; `ContractTest` updated (33 icons, 14 colours, 14 keys).
- `:data`: the production `UnicodeText` with a test against the `:domain` test binding's behaviour (NFC, letters, Nd digits, a supplementary code point); the resource file, a test that it equals `contracts/category-keywords.csv` byte for byte and satisfies the §14.8 rules; `suggestCategory` in the expense repository (history query, active categories, fallback per AC-8.6, the `UnicodeText` binding); repository tests over Room: exact match, first token, keyword, fallback, archived category skipped, deleted expenses ignored, pending expenses counted, `accountId` null.
- `:core:fakes`: fourteen defaults in the empty scenario with the AC-7.2 ids and values; fake `suggestCategory`.
- Every existing test that assumed ten defaults or 30 icons updated, each with the reason in the report.
## 6. Steps
1. Worktree. From the main checkout `/Users/jhag/projects/kvitt`: `git worktree add .claude/worktrees/agent-m12c -b agent-m12c main`, then in `.claude/worktrees/agent-m12c`: `git reset --hard main`. Copy `android/local.properties` from the main checkout (add `sdk.dir=/opt/homebrew/share/android-commandlinetools` if missing; never overwrite or commit). Run `tools/install-git-hooks.sh`.
2. Read §2 in order. Confirm M12a has landed (`6b60ef7`: `contracts/category-keywords.csv` and `:domain`'s `CategorySuggestion.kt` on `main`); if not, stop and report.
3. Run `python3 tools/dependency-report/latest.py`; record the lines. No new library is expected.
4. `:core:model` changes and `ContractTest`; build.
5. `:domain`: nothing to write; confirm the `suggest_category` vector test passes in the gate.
6. `:core:fakes`: fourteen defaults, fake `suggestCategory`.
7. `:data`: resource and its test, then `suggestCategory` and its repository tests.
8. String resources per §3, if they exist.
9. Run the gate (§7). Commit on `agent-m12c`, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
## 7. Gate
From SPEC §11.2, verbatim: "Android gate".
Command, from `android/`: `PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ../tools/gradle-serial.sh check`.
Green means: `BUILD SUCCESSFUL`; test counts from fresh XML show 0 failures and include the `:domain` `suggest_category` vector test (from M12a), the `UnicodeText` and resource identity tests and the repository tests.
## 8. HUMAN steps
From SPEC §11.2, verbatim: "none".
## 9. Out of scope
- Any UI: the editor's "picked by hand" flag and the calls on description and account changes (M7c), category tiles and colours (M6), the categories screen (M7f).
- Colour hex values (U2, M6).
- A Room migration (none is needed); `contracts/**` (M12a); the server (M12b).
- Applying the suggestion to the recurring template editor (CS-1).
## 10. Report
- Branch `agent-m12c`, head commit hash after rebasing on `main`.
- Gate: BUILD line, actionable tasks, test counts from fresh XML.
- Dependency-report lines.
- Whether default-name string resources existed and what was added (§3).
- Tests changed because they assumed ten defaults or 30 icons, each with its reason.
- Proposed decision entries (never written to `SPEC.md` or `docs/decisions.md` by this agent) or `TODO(spec)` occurrences.
- Contract requests, open questions, spec gaps (with section numbers).
## 11. Numbering
Decision ids, spec version and schema version are assigned by the orchestrator in the start prompt; use `TODO(spec)` if none was given. Room schema stays 1 in this plan (next would be 2).
+1 -1
View File
@@ -20,7 +20,7 @@ Implement `:core:design` from the approved `docs/ux/design-spec.md`: theme, colo
- Typography uses the Material 3 default typeface; no font file is bundled and no licence step is needed (D-020).
- Shared strings in `:core:design` include the notification copy of §13.3 and the channel name "Activity"/"Aktivitet" of §14.6, which `:app` uses; their keys never start with a feature name (D-017).
- Money: sv `1 234,50 kr` (U+00A0 thousands and before `kr`, comma decimal, two decimals); other locales `SEK 1,234.50` (U+00A0 after `SEK`); never a minus sign; dates in lists `3 okt.` / `3 Oct`, with year when not the current year; detail `lördag 3 oktober 2026` / `Saturday, 3 October 2026`; times `14:02`; relative "last synced" per §9.8 (§9.8). Formatting lives in `:core:design`, never in `:domain` (§9.8).
- Colour token names are the 12 of §9.7 (`ColorToken`); icon tokens the 30 of §9.7 (`CategoryIcons.ALL`) (§9.7, §14.2.4).
- Colour token names are the 14 of §9.7 (`ColorToken`, `slate` and `amber` added by D-054, hex values from U2 per D-051); icon tokens the 33 of §9.7 (`CategoryIcons.ALL`); fourteen default categories (AC-7.2), whose localised default names `:core:design` holds as shared strings (§9.7, §14.2.4, D-054). Direction "Kalas", muted (D-047). M12c must have landed (§11.2).
- Initials: first letter of the first two words, uppercase, on a colour derived from the user id (AC-4.5).
- Accessibility: 48 dp targets, 4.5:1 contrast, TalkBack labels, reflow at 200 % (§13.1).
- No hard-coded colours, sizes or strings in features: tokens come from one design file; shared strings live in `:core:design` with keys from `docs/ux/strings.csv`; lint `MissingTranslation` is an error in this module (CLAUDE.md, §14.7 Strings, D-017).
+2 -2
View File
@@ -6,7 +6,7 @@ Build `:feature:expense`: the full-screen expense editor (S-7) and the expense d
## 2. Read first
1. `docs/ux/design-spec.md`: tokens, component inventory, and the sections for S-7, S-8.
2. `docs/ux/strings.csv` (keys for these screens) and `docs/ux/screens/S-7-*`, `docs/ux/screens/S-8-*`.
3. SPEC §13.2 Navigation model and §13.3 Screens (rows S-7, S-8 and the states paragraph).
3. SPEC §13.2 Navigation model and §13.3 Screens (rows S-7, S-8 and the states paragraph); §9.11 Category suggestion (D-049). M12c must have landed (§11.2). After saving, the snackbar "Utgiften sparades" has no undo (S-7, D-052).
4. SPEC §14.2.4 UI contract: `ExpenseRepository`, `ExpenseDraft`, `ExpenseEditorOptions`, `ExpenseDetailState`, `TimelineLine`, `FieldChange`, `Share`, `SaveResult`, `FieldError`, `SplitMode`, `Account`, `Category`, `User`.
5. SPEC §4 FR-8 Expenses (AC-8.1 to AC-8.11); FR-12 AC-12.4, AC-12.5; §9.1 Splits; §9.6 (limits, amount input parsing); §9.10 (exact-mode edit); §14.5.3 error tokens.
6. SPEC §6.2 (module table), §9.8 Formatting, §9.9 Online-only actions, §11.2 (row M7c), §11.3 (note M7x), §14.7.
@@ -28,7 +28,7 @@ Build `:feature:expense`: the full-screen expense editor (S-7) and the expense d
- No placeholder text in previews or screenshots; goldens are committed; open every changed PNG before committing (CLAUDE.md).
- The add flow needs at most: open app, tap add, type amount, type description, tap save; account, category, payer and split are defaulted (§13.1, AC-8.6).
- Editor is a full screen, not a bottom sheet (§13.2). Amount input accepts digits, one decimal separator (`,` or `.`), at most two decimals, no sign, converted to öre by string parsing, never floating point (§9.6).
- Defaults: account = last used on this phone if active else oldest active; category = last used in that account if active else `other`; split mode `default`; payer = current user; date = today in the phone's zone (AC-8.3, AC-8.4, AC-8.6). Defaults come from `ExpenseEditorOptions`.
- Defaults: account = last used on this phone if active else oldest active; category for a new expense = the suggestion of §9.11 (D-049): the editor keeps a "picked by hand" flag (CS-2) and, while it is false, calls `ExpenseRepository.suggestCategory(accountId, description)` on every description and account change (CS-3), showing the result with no marker (CS-7); before any description it is `ExpenseEditorOptions.defaultCategoryFor` (last used in that account if active else `other`); editing never changes the category (CS-1); split mode `default`; payer = current user; date = today in the phone's zone (AC-8.3, AC-8.4, AC-8.6). Defaults come from `ExpenseEditorOptions`.
- Split modes `default` ("Account default 60/40"), `ratio`, `exact` (type one share, the other is amount minus it, read-only; a share larger than the amount blocks saving inline), `full` (one bearer); changing account in `default` re-copies the ratio; live preview from `ExpenseRepository.preview` (AC-8.5, AC-8.7, AC-8.8, §13.3 S-7).
- Validation errors come back as `SaveResult.Invalid` with §14.5.3 tokens, `required`, or `validation_failed` for a §9.6 limit without a domain token (lengths, dates) (§9.6, §14.2.4); deleting asks for confirmation (AC-8.9).
- Detail shows amount, description, category, account, date, payer, shares and mode, notes, "Recurring: <template>" when generated, and the timeline: newest last, one line per entry, field names in the UI language, ids resolved, pending line "Your change is waiting to sync"; a deleted state when opened from a deletion notification (AC-8.11, AC-12.4, AC-12.5, §13.3 S-8).
+1 -1
View File
@@ -28,7 +28,7 @@ Build `:feature:settings`: the settings hub (S-13), profile (S-14), categories (
- No placeholder text in previews or screenshots; goldens are committed; open every changed PNG before committing (CLAUDE.md).
- Hub rows: Profile, Categories, Recurring expenses, Devices, Invite partner or Pair a device (admin only), Sync status (opens S-16 via `:app`), About; own and partner's avatar and name (§13.3 S-13, §8.4).
- Profile: own name and picture only, from the Android photo picker, online only (AC-4.2, AC-4.3, §9.9).
- Categories: list with icon, colour, name and an archived section; editor with "Reset name" for defaults, the 30 icons and 12 colour swatches of §9.7; reorder by drag; delete blocked with the reason (`category_in_use`, `category_is_default`), archive offered instead; all online only (FR-7, §13.3 S-15).
- Categories: list with icon, colour, name and an archived section; editor with "Reset name" for defaults, the 33 icons and 14 colour swatches of §9.7 (D-054); reorder by drag; delete blocked with the reason (`category_in_use`, `category_is_default`), archive offered instead; all online only (FR-7, §13.3 S-15).
- Recurring: list with description, amount, account, "Every month on day 25", next date, paused badge; editor with the expense fields of S-7 except date, plus day of month, first month (default per AC-9.8, up to 12 months back), optional last month, pause switch; online only (FR-9, §13.3 S-19).
- Every settings write is online: a refused value comes back as `OnlineResult.Failed` with the §14.3.2 code and `field` (for example `validation_failed` on `name`), shown inline at that field (§9.6, §14.2.4, §14.3.2).
- Devices: rows per AC-3.1 ("This device" marked); revoke with confirmation; the current device cannot be revoked; admin sees all, member own (AC-3.1, AC-3.2).
+1 -1
View File
@@ -37,7 +37,7 @@ Edit nothing outside these paths; a needed change elsewhere is a contract reques
- `docs/ux/design-spec.md`: final tokens (colour roles light and dark, category colour hex values, typography scale, shapes, spacing scale in dp, elevation, motion durations and easing); component inventory (anatomy, states, sizes per component); for every screen S-1 to S-22: layout description, all states of §13.3 that apply (and an explicit "not applicable" for the ones that do not), interaction details, TalkBack labels.
- `docs/ux/strings.csv`: every user-visible string of the app, `key,en,sv,notes`, including notifications, rejection reasons, error codes the app shows, enum labels, category default names, relative-time phrases.
- `docs/ux/screens/<S-id>-<slug>.png` (one file per screen and state, light and dark, Swedish; name the state in the file name, for example `S-4-home-empty-dark.png`).
- `docs/ux/colour-tokens-proposal.md`: the proposed light and dark hex values for the 12 tokens of §9.7 with contrast ratios against both surfaces, for the orchestrator's spec change.
- `docs/ux/colour-tokens-proposal.md`: the proposed light and dark hex values for the 14 tokens of §9.7 (the muted Kalas values, `slate` and `amber` included, D-051) with contrast ratios against both surfaces, for the orchestrator's spec change.
## 6. Steps
1. Worktree. The owner runs the UX sprint in a separate session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`; work there and `git rebase main`. No git-ignored files are needed; copy none.
+223 -43
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.7** (2026-10-18). Schema version **1**. Sync protocol version **1**. Push payload version **1**.
Version **0.8** (2026-10-18). Schema version **2**. 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.
@@ -118,7 +118,7 @@ The server starts with an empty database. The household is created from the comm
* AC-2.1 The admin creates a `partner` invite from Settings. It is refused with `household_full` when two users exist. Creating a new `partner` invite cancels any pending one.
* AC-2.2 The admin creates a `device` invite for either user from Settings (to pair a second phone).
* AC-2.3 An invite is single-use and expires 72 hours after creation. An expired, cancelled, used or unknown code is refused with `invite_invalid`; the response does not reveal which.
* AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the user, the device and the household's users. For a `partner` invite it creates the `member` user.
* AC-2.4 Accepting an invite takes the code, a device name and (for `admin_setup` and `partner`) a display name. It returns a bearer token, the user, the device and the household's users. For a `partner` invite it creates the `member` user. On a first pairing the app asks for the display name on S-1 (§13.3); a partner confirms it on S-2 (AC-2.10) (D-050).
* AC-2.5 `GET /i/<CODE>` serves an HTML landing page in Swedish if the request's `Accept-Language` prefers `sv`, English otherwise. It shows: the app name, "Download Kvitt" linking to the latest APK (FR-16), an "Open in Kvitt" button linking to `kvitt://invite?server=<urlencoded KVITT_PUBLIC_URL>&code=<CODE>`, the code in groups of 4, and one sentence of instructions (copy in §13.3, "Invite landing page"). It does not reveal whether the code is valid and contains no personal data. While no APK is published (E-5 would answer `not_found`) the page omits the download button. The page is sent with `Referrer-Policy: no-referrer` and `Cache-Control: no-store`. The 404 page (E-4 with a code outside 1 to 32 characters) shows only "Kvitt" and "404".
* AC-2.6 The app opens from `kvitt://invite?server=…&code=…` and from `https://icehack.asuscomm.com:8443/i/<CODE>` (unverified link, the user may get a chooser) directly into the pairing screen with both fields filled.
* AC-2.7 The pairing screen also accepts manual entry: a server URL (prefilled with `https://icehack.asuscomm.com:8443` in release builds) and the code. Codes are normalised per §8.3 before sending.
@@ -142,7 +142,7 @@ The server starts with an empty database. The household is created from the comm
* AC-4.2 A user edits their own display name in Settings (online only). The change reaches the other phone through sync.
* AC-4.3 A user sets their own picture from the Android photo picker (no camera capture in v1) or removes it. Upload is online only.
* AC-4.4 The server accepts JPEG, PNG and WebP up to 5 MiB and at most 8000 × 8000 pixels, applies EXIF orientation, centre-crops to a square, re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128, strips all metadata, and increments the user's `picture_version` (the first upload is version 1; after a removal the next version follows the highest one ever used, §10.4, D-030). PNG and WebP transparency is flattened onto white (D-034). Other input is refused with `image_invalid`, `unsupported_media_type` or `payload_too_large`.
* AC-4.5 Where no picture exists, the app shows the user's initials (first letter of the first two words of the display name, uppercase) on a colour derived from the user id (UX sprint defines the two colours).
* AC-4.5 Where no picture exists, the app shows the user's initials (first letter of the first two words of the display name, uppercase) on one of two colour tokens, `avatar-1` and `avatar-2` (values in U2's `docs/ux/design-spec.md`). A user's slot is a stable function of the user id, so the same user has the same colour on both phones (D-052).
* AC-4.6 The app caches pictures on disk (Coil) keyed by user id and `picture_version`, so they show offline.
### FR-5 Household gate
@@ -163,21 +163,27 @@ The server starts with an empty database. The household is created from the comm
### FR-7 Categories
* AC-7.1 One global, editable category list. A category has a name, an icon token (§9.7) and a colour token (§9.7).
* AC-7.2 The database is seeded with exactly these ten default categories (fixed ids, §14.1), each shown with its localised default name until a user renames it:
* AC-7.2 The database is seeded with exactly these fourteen default categories (fixed ids, §14.1; the four with `sort_order` 25, 45, 85 and 95 were added by migration 0002, §14.1.1, D-054), each shown with its localised default name until a user renames it:
| `default_key` | English name | Swedish name | `icon` | `color` | `sort_order` |
|---|---|---|---|---|---|
| `groceries` | Groceries | Mat & hushåll | `shopping_cart` | `green` | 10 |
| `restaurants` | Restaurants | Restaurang & café | `restaurant` | `orange` | 20 |
| `housing` | Housing | Boende | `key` | `indigo` | 25 |
| `home` | Home | Hem | `home` | `brown` | 30 |
| `utilities` | Utilities | Räkningar | `bolt` | `yellow` | 40 |
| `insurance` | Insurance | Försäkringar | `shield` | `slate` | 45 |
| `transport` | Transport | Transport | `directions_car` | `blue` | 50 |
| `travel` | Travel | Resor | `flight` | `teal` | 60 |
| `entertainment` | Entertainment | Nöje | `movie` | `purple` | 70 |
| `health` | Health | Hälsa | `medical_services` | `red` | 80 |
| `children` | Children | Barn | `child_care` | `lime` | 85 |
| `shopping` | Shopping | Shopping | `shopping_bag` | `pink` | 90 |
| `fees` | Fees & taxes | Avgifter & skatter | `account_balance` | `amber` | 95 |
| `other` | Other | Övrigt | `category` | `grey` | 100 |
`home` keeps furniture, supplies, maintenance, electronics and services; rent and mortgage belong to `housing` (D-054).
* AC-7.3 Either user creates a category (online) with a name, icon and colour. New categories get `sort_order` = the maximum `sort_order` over non-deleted categories + 10 (D-034).
* AC-7.4 Either user renames, re-icons, recolours or reorders a category (online). Renaming a default category stores the custom name; "Reset name" (only for default categories) clears it so the localised name shows again.
* AC-7.5 Either user archives or unarchives a category. Archived categories are not offered for new expenses or templates but stay on existing ones and in analytics.
@@ -191,11 +197,11 @@ 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 last used in that account on this phone if still active, else `other`; 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); editing an existing expense keeps its category; split mode `default`.
* 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.
* AC-8.10 Expense lists show, per row: category icon and colour, description, date, amount, and a one-line effect from the viewer's side ("You lent 205,00 kr", "You borrowed 205,00 kr", "Not involved" for `full` on the payer, matching §13 copy). Rows are ordered by date descending, then `created_at` descending.
* AC-8.10 Expense lists show, per row: category icon and colour, description, date, amount, and a one-line effect from the viewer's side ("You lent 205,00 kr", "You borrowed 205,00 kr", "Not involved" for `full` on the payer, matching §13 copy; Swedish "Du lånade ut 205,00 kr", "Du lånade 205,00 kr", "Inte inblandad", D-052). Rows are ordered by date descending, then `created_at` descending.
* AC-8.11 An expense created from a recurring template is an ordinary expense in every way (editable, deletable) and shows "Recurring" with the template name in the detail view.
### FR-9 Recurring expenses
@@ -437,7 +443,7 @@ Features never call `:domain` directly: repositories return computed state (bala
* Shared UI that two features need is built in `:core:design` by M6 (D-019); a feature never copies another feature's component.
* Strings: `:app` holds only the app name; `:core:design` holds the shared strings (including the notification copy and channel name of §13.3 and §14.6, which `:app` uses); each feature module holds its own. Lint `MissingTranslation` is an error in every module. Keys of a feature module are prefixed by the feature name (`home_`, `account_`, `expense_`, ...); keys in `:core:design` never start with a feature name. This rule wins over any other key convention (§13.4, §14.7).
**Fakes.** Every fake in `:core:fakes` has at least the empty scenario (D-018): the household is complete (two named users, no pictures), there are no accounts, the categories are exactly the ten defaults of AC-7.2, and there are no expenses, settlements, templates, rejections or pending changes. Feature agents add their own scenarios (§11.3).
**Fakes.** Every fake in `:core:fakes` has at least the empty scenario (D-018): the household is complete (two named users, no pictures), there are no accounts, the categories are exactly the fourteen defaults of AC-7.2 (D-054), and there are no expenses, settlements, templates, rejections or pending changes. Feature agents add their own scenarios (§11.3).
### 6.3 Server
@@ -739,11 +745,11 @@ The phone input field for amounts accepts digits, one decimal separator (`,` or
### 9.7 Icon and colour tokens
Category icon tokens are Material Symbols names. The allowed set (exhaustive, v1):
Category icon tokens are Material Symbols names. The allowed set (exhaustive, 33 tokens; `key`, `shield` and `account_balance` added by D-054):
`shopping_cart`, `restaurant`, `local_cafe`, `home`, `bolt`, `water_drop`, `wifi`, `directions_car`, `train`, `local_gas_station`, `flight`, `hotel`, `luggage`, `movie`, `music_note`, `sports_esports`, `fitness_center`, `medical_services`, `medication`, `shopping_bag`, `checkroom`, `pets`, `child_care`, `school`, `card_giftcard`, `celebration`, `build`, `chair`, `subscriptions`, `category`.
`shopping_cart`, `restaurant`, `local_cafe`, `home`, `bolt`, `water_drop`, `wifi`, `directions_car`, `train`, `local_gas_station`, `flight`, `hotel`, `luggage`, `movie`, `music_note`, `sports_esports`, `fitness_center`, `medical_services`, `medication`, `shopping_bag`, `checkroom`, `pets`, `child_care`, `school`, `card_giftcard`, `celebration`, `build`, `chair`, `subscriptions`, `category`, `key`, `shield`, `account_balance`.
Colour tokens (exhaustive, v1) with provisional values; the UX sprint may change the hex values and must keep the token names:
Colour tokens (exhaustive, 14 tokens; `slate` and `amber` added by D-054) with provisional values. The direction is U1's "Kalas" with its muted palette (D-047); U2 replaces these hex values with the muted Kalas values and must keep the token names (D-051). `slate` and `amber` have no value until U2 reports (`TODO(U2)`); M6 starts only after the values have landed here (§11.1).
| Token | Light | Dark |
|---|---|---|
@@ -759,6 +765,8 @@ Colour tokens (exhaustive, v1) with provisional values; the UX sprint may change
| `grey` | `#546E7A` | `#B0BEC5` |
| `indigo` | `#283593` | `#9FA8DA` |
| `lime` | `#827717` | `#DCE775` |
| `slate` | `TODO(U2)` | `TODO(U2)` |
| `amber` | `TODO(U2)` | `TODO(U2)` |
The server validates icon and colour against these lists (`validation_failed`).
@@ -797,6 +805,46 @@ When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or
| Time zones | Dates (`expense_date`, `settlement_date`) are calendar dates chosen by the user; analytics never convert them. Only "today" defaults use the phone's zone; server validation and scheduling use Europe/Stockholm. |
| A day with a DST change | Scheduling uses local 06:00, which exists on every day in Europe/Stockholm. |
### 9.11 Category suggestion
The expense editor (S-7) selects the category from the description while the user types (D-049, owner 2026-10-18). The rule is a pure function in `:domain` (§14.5.6, with platform Unicode services through `UnicodeText`), fed by `ExpenseRepository.suggestCategory` in `:data` (§14.2.4) from Room and the keyword table `contracts/category-keywords.csv` (§14.8).
**CS-1 Scope.** The suggestion runs only in the editor for a **new** expense. It never runs when an existing expense is edited and never changes the category of a saved expense. The recurring template editor (S-19) does not use it in v1.
**CS-2 Manual choice wins.** The editor keeps a flag "category picked by hand", false when the editor opens. Picking any category in the category grid sets it to true, also when the user picks the category already shown. While the flag is true the suggestion never changes the category, for the rest of this editor session (until the editor closes). Nothing resets the flag.
**CS-3 When it runs.** While the flag is false, the category is recomputed on every change of the description and on every change of the account. It is a local computation over Room data; no network.
**CS-4 Normalisation.** Descriptions (typed and stored) and keywords are compared after this normalisation:
1. Unicode NFC, then lowercase without locale rules (Kotlin `lowercase()`, which uses `Locale.ROOT`).
2. Delete every `'` (U+0027), `’` (U+2019) and `:` (so "Willy:s" becomes "willys" and "McDonald's" becomes "mcdonalds").
3. Replace every remaining code point that is not a Unicode letter (general category L), a decimal digit (general category Nd) or `&` with a space. Whitespace is therefore replaced too.
4. Collapse runs of spaces to one space and trim.
Diacritics are kept (å, ä, ö and é stay distinct; the keyword table lists both spellings where it matters). Digits are kept. The **tokens** are the normalised description split on spaces; the **first token** is the first of them. An empty normalised description matches no history and no keyword.
**CS-5 Order.** The suggested category is the first that applies of:
1. **History, exact:** among the non-deleted expenses on the phone (every account, either user, pending, imported and recurring ones included) whose normalised description equals the normalised typed description, the category used most often; on a tie, the one used most recently.
2. **History, first token:** among the non-deleted expenses whose first token equals the typed description's first token, the category used most often; on a tie, the one used most recently.
3. **Keywords:** the category of the winning entry of the keyword table (CS-6).
4. **Default:** the AC-8.6 fallback: the category last used in the selected account on this phone if still active, else `other`.
"Used most often" counts the matching expenses per category. "Most recently" compares the latest matching expense of each tied category by (`expense_date`, then `created_at`), the expense-list order of AC-8.10; if that still ties, the lower category id (string order) wins. Archived and deleted categories are left out of the count; when no category is left, the next step applies (owner, 2026-10-18: most often, not most recent).
**CS-6 Keyword table.** Each entry has a `keyword` (already normalised per CS-4), a `match` and a `default_key`. An entry matches when:
* `token`: some token equals the keyword;
* `prefix`: some token starts with the keyword;
* `phrase`: the keyword's tokens occur consecutively in the tokens.
When several entries match, the longest keyword (in code points, spaces included, a surrogate pair counting as one) wins; on equal length, the entry earlier in the table wins (the table is sorted by `default_key`, then `keyword`, both in code-point order). The winning entry gives the active category with that `default_key` (a renamed default included, since the key stays). If that category is archived, the next matching entry in the same order is tried; when none is left, step 4 applies. The table is fixed data shipped with the app; users do not edit it. No entry maps to `other`.
**CS-7 What the user sees.** The suggestion simply replaces the category shown in the Kategori field: no caption, no marker, no confirmation (owner, 2026-10-18: a wrong guess is corrected with one tap). Clearing the description returns the field to the step-4 default.
Evidence (D-049): a chronological replay of the household's 1 775 Splitwise expenses with the fourteen categories of AC-7.2 and the §10.8 mapping put 79.7 % of rows in the right category, against 30.5 % for the step-4 default alone; with no history, the keywords cover 77 % of rows at 96 % accuracy. The replay is in `docs/ux/category-suggestion.md`.
---
## 10. Deployment & ops
@@ -981,7 +1029,7 @@ kvitt-import --db /data/kvitt.sqlite --file <csv> --account "<Kvitt account name
* One CSV file per Splitwise group (Splitwise's group "Export as spreadsheet"). The account is created with default 50/50 if no non-deleted account with that name exists; otherwise rows go into the existing one, and the existing account's stored name (not the spelling given to `--account`) is the account name used in the import key (D-046).
* `--user` (exactly two, distinct Splitwise names, one per household user) maps a member column to a Kvitt user given as a user id or as a display name that exactly one user has; anything else is a usage error (D-046).
* The importer never creates or migrates a database: `--db` must name an existing file whose `meta.schema_version` is `1`, else it exits 1 (run `kvitt-server migrate` first) (D-046).
* The importer never creates or migrates a database: `--db` must name an existing file whose `meta.schema_version` is the version the server's migrations produce (`2` since D-054), else it exits 1 (run `kvitt-server migrate` first) (D-046, D-054).
* The whole run is one `BEGIN IMMEDIATE` transaction: find or create the account, skip rows whose `import_key` exists, validate and insert the rest with the server's own push checks, write the audit entries, then reconcile (below). It commits only when every row is valid, the reconciliation is accepted and `--dry-run` is absent; otherwise it rolls back and nothing is written. A dry run runs the same transaction and rolls it back (D-046).
* Exit status (D-046): `0` imported (or, with `--dry-run`, would import); `1` database or file error; `2` usage (bad arguments, `--user` not resolving to the two household users); `3` the file cannot be imported (header, invalid rows, non-SEK rows without `--fx`); `4` reconciliation mismatch or missing totals row. Nothing is written unless the status is `0` and `--dry-run` is absent.
* Header (confirmed against both real exports, D-003; the exact header lines are recorded in PLAN-M9, not here): Splitwise localises the export, so two variants exist and the importer must accept both. English: `Date`, `Description`, `Category`, `Cost`, `Currency`; Swedish: `Datum`, `Beskrivning`, `Kategori`, `Kostnad`, `Valuta`. Then one column per group member, named by the member's full Splitwise name, holding that member's net effect for the row (positive = paid more than their share). The member columns appear in different orders in different files, so the importer reads member names from the header and matches them to the `--user` arguments; it never relies on column positions. Any other header fails the import with a clear message, writing nothing.
@@ -994,22 +1042,27 @@ kvitt-import --db /data/kvitt.sqlite --file <csv> --account "<Kvitt account name
* `import_key` = lowercase hex SHA-256 of `splitwise|v1|<account name>|<Date>|<Description>|<Category>|<Cost>|<Currency>|<member nets in header order, joined with |>|<n>` where `<n>` is the 0-based occurrence index of an identical line earlier in the same file. Rows whose key exists are skipped. Idempotency uses the existing `import_key` columns of `expenses` and `settlements` (§14.1); no new column or table (D-046).
* Every created row: `created_by_user_id` = `updated_by_user_id` = the owner; `updated_by_device_id` null; `created_at` = `updated_at` = the import time; one audit entry `create` with `source` = `import`; new `server_version`s from the global sequence; no pushes.
* Description is truncated to 100 code points; the full original goes into `notes` when truncated. An empty description, or one over 1000 code points (the `notes` limit, §9.6), makes the row invalid (exit `3`, nothing written) (D-046).
* Category mapping (case-insensitive exact match on the `Category` column, English and Swedish names, D-003). The importer commits this table as the data file `server/crates/kvitt-import/data/splitwise_categories.csv` (columns `splitwise_name,language,default_key`) and reads it from there; it is the only mapping. The Swedish names are those observed in the real Swedish export, each placed with its English Splitwise equivalent (D-044 added the nine found unmapped in the real run):
* Category mapping (case-insensitive exact match on the `Category` column, English and Swedish names, D-003). The importer commits this table as the data file `server/crates/kvitt-import/data/splitwise_categories.csv` (columns `splitwise_name,language,default_key`) and reads it from there; it is the only mapping. Splitwise categories are mapped as finely as the fourteen categories of AC-7.2 allow (D-053); the Swedish names are those observed in the real Swedish export, each placed with its English Splitwise equivalent (D-044, D-053):
| Splitwise category (English) | Splitwise category (Swedish) | Kvitt `default_key` |
|---|---|---|
| Groceries, Food and drink - Other, Liquor | Livsmedel, Mat och dryck - Övrigt, Alkohol | `groceries` |
| Dining out | Restaurangbesök | `restaurants` |
| Electronics, Furniture, Household supplies, Maintenance, Mortgage, Rent, Services, Pets, Home - Other | Elektronik, Möbler, Förbrukningsvaror, Skötsel/underhåll, Avbetalning/Amortering, Hyra, Tjänster, Hem - Övrigt | `home` |
| Electricity, Heat/gas, Water, Trash, TV/Phone/Internet, Cleaning, Utilities - Other | TV/telefon/internet, Städning, Verktyg - Övrigt | `utilities` |
| Rent, Mortgage | Hyra, Avbetalning/Amortering | `housing` |
| Electronics, Furniture, Household supplies, Maintenance, Services, Pets, Home - Other | Elektronik, Möbler, Förbrukningsvaror, Skötsel/underhåll, Tjänster, Husdjur, Hem - Övrigt | `home` |
| Electricity, Heat/gas, Water, Trash, TV/Phone/Internet, Cleaning, Utilities - Other | El, Värme/gas, Vatten, Sophämtning, TV/telefon/internet, Städning, Verktyg - Övrigt | `utilities` |
| Insurance | Försäkringar | `insurance` |
| Bicycle, Bus/train, Car, Gas/fuel, Parking, Taxi, Transportation - Other | Cykel, Buss/tåg, Bil, Bensin/bränsle, Parkering, Taxi, Transport - Övrigt | `transport` |
| Hotel, Plane | Hotell, Flyg | `travel` |
| Games, Movies, Music, Sports, Entertainment - Other | Spel, Musik, Filmer, Sport, Underhållning - Övrigt | `entertainment` |
| Games, Movies, Music, Sports, Entertainment - Other | Spel, Filmer, Musik, Sport, Underhållning - Övrigt | `entertainment` |
| Medical expenses | Sjukvård/medicin | `health` |
| Childcare | Barnomsorg | `children` |
| Clothing, Gifts | Kläder, Presenter | `shopping` |
| General, Childcare, Education, Insurance, Taxes, Life - Other, Other | Allmänt, Barnomsorg, Utbildning, Försäkringar, Skatter, Livet - Övrigt | `other` |
| Taxes | Skatter | `fees` |
| General, Education, Life - Other, Other | Allmänt, Utbildning, Livet - Övrigt | `other` |
| any other value | any other value | `other`, counted as unmapped |
The Swedish names El, Värme/gas, Vatten, Sophämtning and Husdjur were not seen in the export; they are Splitwise's Swedish labels by analogy (D-053). A wrong guess only leaves such a row unmapped (`other`, counted), which the summary shows.
* The summary lists each unmapped category name with its row count, and the total count of unmapped rows as a warning.
Production run (M11, D-025), in this order:
@@ -1071,9 +1124,13 @@ M0 spec ──┬─► U1 directions ─(owner picks)─► U2 design spec ─
M2c-pi infra on the Pi (Pi, TLS, Gitea, router, Drive) ◄─────┤
M10 first release ◄── M2c-pi, M8 ◄──┘
M11 import real history ◄── M9, M10
Category contract v2 (D-054), after SPEC v0.8:
M12a contracts + :domain suggestCategory (landed 6b60ef7) ─┬─► M12b server (migration 0002, importer table)
└─► M12c android (:core:model, :domain, :data, fakes) ─► M6, M7c
```
After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001).
After M1, M2a and M2b land, these run in parallel without touching each other's files (§14.7): M3, M4 (lands after M3, D-015), M5, M2c-dev, M6 (once U2 is approved and the §9.7 colour change from U2's proposal has landed in this spec), M7a to M7f (once M6 lands), M9 (once M4 lands). M2c-pi runs after M8 and before M10 (D-001). Group M12 "Category contract v2" (D-049, D-054): M12a first; then M12b and M12c in parallel; M6 and M7c wait for M12c.
### 11.2 Milestone table
@@ -1090,10 +1147,13 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
| M3 | Server core API | Landed `503cee5` (D-030 to D-034). E-3 to E-30 and E-33 implemented (§14.3; E-2 is M2a's, M3 tests it), invite landing page, pictures, rate limiting (`src/api/rate_limit.rs`), `bootstrap`, `pair-device`, the recurring generation function of §6.3 (D-015) | Server gate; integration tests per endpoint and per error code listed for it; every generation case of §9.5 (catch-up, clamping, split resolution, idempotency, resume without backfill, `next_occurrence_date`) | none | M2a |
| M4 | Server sync, audit, push, scheduler | Landed `bd6f10a` (D-035 to D-039). E-31, E-32, the real `AuditWriter` (writer and diff) and `PushQueue` (FCM sender), `src/tasks/**` starting them, `sync_mutations` pruning daily at 04:00 (D-021), scheduler, `rotate-epoch` | Server gate; integration tests for every sync rule of §14.4 and the scheduler cases of §9.5 (run at start, daily 06:00 on DST days, catch-up through M3's generation function); FCM sender tested against a local stub HTTP server | none | M2a; lands after M3 (D-015) |
| M5 | Android data layer | Landed `c28c52b` (D-026 to D-029, D-040 to D-043). DAOs on M2b's Room 3 entities and `KvittDatabase` v1 (§14.2, D-012; any change to the entities or the exported schema is a contract change, §14.8), Ktor client and DTOs, token store, sync engine and workers, real repositories, APK updater | Android gate; DTO round-trip tests over every file in `contracts/examples/`; sync engine tests with Ktor `MockEngine` covering §14.4 including the 14-day offline case (AC-15.6) | none | M1, M2b |
| M6 | Design system | `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts, formatters (§9.8), shared strings (including the notification copy) per `docs/ux/design-spec.md` and `docs/ux/strings.csv`, screenshot tests of every component in light and dark, Swedish and English | Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b, the §9.7 colour change |
| M12a | Category contract v2: contracts | Landed `6b60ef7`. `contracts/category-keywords.csv` (148 entries), `contracts/schema/migrations/0002_categories_v2.sql` (§14.1.1), `contracts/test-vectors/suggest_category.json` (65 cases), E-1 example `schema_version` 2, `tools/contracts-check.py` (keyword-table rules, 0002 loaded on a fresh and an in-use v1 database, Python reference of §9.11, new icon and colour tokens), and `:domain`'s `suggestCategory` with `UnicodeText` passing the vectors (§14.5.6). The OpenAPI `DefaultCategoryKey` extension and the new category examples were held back for M12b | `python3 tools/contracts-check.py` passes | none | M1, SPEC v0.8 |
| M12b | Category contract v2: server | Migration `server/crates/kvitt-server/migrations/0002_categories_v2.sql` byte-identical to the contract file with its identity test; `SCHEMA_VERSION` 2; seed test of the fourteen defaults (ids, keys, icons, colours, sort orders, `server_version` from the sequence) on a fresh database and on a v1 database holding expenses (rows and references kept); icon and colour lists of §9.7 in `validate.rs`; `DefaultCategoryKey` with the fourteen keys in `contracts/openapi.yaml` (held back by M12a) and in the utoipa wire types, so the OpenAPI match test passes; the category examples M12a held back; importer table `splitwise_categories.csv` per §10.8 with its spec-parity test; importer schema check 2 (D-054); E-31 and other examples only where they enumerate the defaults | Server gate; `python3 tools/contracts-check.py` passes | none | M12a, M3, M4, M9 |
| M12c | Category contract v2: Android | `:core:model`: `DefaultCategoryKey` (14), `ColorToken` (`SLATE`, `AMBER`), `CategoryIcons.ALL` (33), `ExpenseRepository.suggestCategory`; `:data`: the `UnicodeText` binding (`java.text.Normalizer`, `Character`), `suggestCategory` from Room, DataStore and the keyword table bundled as the resource `android/data/src/main/resources/category-keywords.csv` (byte-identical to the contract file, with a test); `:core:fakes`: fourteen defaults in the empty scenario and a fake `suggestCategory`; default-name string resources for the four new categories in English and Swedish (AC-7.2) where default names already live | Android gate | none | M12a, M2b, M5 |
| M6 | Design system | `:core:design` theme, tokens, typography (Material 3 default typeface, D-020), shared components including the ledger rows and expense editor fields (D-019), charts, formatters (§9.8), shared strings (including the notification copy) per `docs/ux/design-spec.md` and `docs/ux/strings.csv`, screenshot tests of every component in light and dark, Swedish and English | Android gate | Look at the component screenshots the agent opens and posts (yes/no) | U2, M2b, M12c, the §9.7 colour change |
| M7a | Feature: onboarding | `:feature:onboarding` screens S-1, S-2, S-3, S-18 | Android gate with screenshot tests of every state, sv and en, light and dark | none | M6 |
| M7b | Feature: home and account | `:feature:home`, `:feature:account` screens S-4, S-5, S-6, S-16, S-17 | as M7a | none | M6 |
| M7c | Feature: expense | `:feature:expense` screens S-7, S-8 | as M7a | none | M6 |
| M7c | Feature: expense | `:feature:expense` screens S-7, S-8, with the category suggestion of §9.11 (CS-2, CS-3, CS-7) | as M7a | none | M6, M12c |
| M7d | Feature: settle | `:feature:settle` screens S-9, S-10 | as M7a | none | M6 |
| M7e | Feature: analytics | `:feature:analytics` screens S-11, S-12 | as M7a | none | M6 |
| M7f | Feature: settings | `:feature:settings` screens S-13, S-14, S-15, S-19, S-20, S-21, S-22 | as M7a | none | M6 |
@@ -1115,6 +1175,7 @@ After M1, M2a and M2b land, these run in parallel without touching each other's
* **M10**: the orchestrator relays every HUMAN step in chat. The keystore is created with the command of §10.6 (D-024).
* **M2c-pi**: the allowances to act on the Kvitt Pi, the Home Assistant Pi, Gitea and Google Drive are requested from the owner at the start of that sprint (D-023).
* **M9** (landed `ab298ab`) reuses the server's own push validation (`kvitt_server::api::validate`, `rows`) and the `SqlAuditWriter`, so imported rows pass the same checks as synced ones (§14.4.4). Its tests are one binary, `crates/kvitt-import/tests/import.rs`, over synthetic fixtures only; one test checks the §10.8 category table against `data/splitwise_categories.csv`.
* **M12a, M12b, M12c** (D-049 to D-054) carry the owner's UX-session decisions into the contracts and the landed code: M3, M5 and M9 were built with ten defaults, so this is a contract change against `main`, not only the spec. M12a (landed `6b60ef7`) wrote `contracts/`, the checker and `:domain`'s `suggestCategory`; M12b and M12c now run in parallel (disjoint paths, §14.7). The Room schema stays at version 1: category rows reach the phone through sync. The colour hex values of `slate` and `amber` are not needed by M12 (`:core:model` holds token names only); they arrive with U2 and M6.
* **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 and 0 are expected after it, to be confirmed by the next dry run.
---
@@ -1148,9 +1209,9 @@ A brief for one UX agent (Opus) who has read this spec and nothing else. It runs
* Category icons are Material Symbols from the set in §9.7; category colours are the tokens of §9.7 (the UX agent may change hex values, not token names, and must give light and dark values with contrast checked against both surfaces).
* Users are shown by avatar (picture or initials, AC-4.5) and display name. The viewer is always "you" / "du".
### 13.2 Navigation model (binding unless a direction proposes a better one with reasons)
### 13.2 Navigation model (binding, D-048)
* Bottom navigation with three destinations: **Home** (Översikt), **Analytics** (Statistik), **Settings** (Inställningar).
* Bottom navigation with three destinations: **Home** (Översikt), **Analytics** (Statistik), **Settings** (Inställningar). An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth destination and not taken for v1 (D-048).
* 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).
@@ -1161,21 +1222,21 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
| Id | Screen | Must show | Actions |
|---|---|---|---|
| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); invite code field (12 characters, shown in groups of 4); explanation of where the code comes from; progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code |
| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled); invite code field (12 characters, shown in groups of 4); display name field ("Ditt namn", 1 to 40 code points, §9.6), required on a first pairing and hidden in the "Para ihop telefonen igen" variant (D-050); explanation of where the code comes from; progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code |
| S-2 | Partner profile step | Display name field (prefilled), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob") | Continue |
| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any | 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; update banner (S-17) when available; 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 |
| S-5 | Account detail | Account name, default split ("50 / 50" with names), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend), each expense row per AC-8.10, each settlement row per AC-11.7; archived badge if archived; pending-sync marker per row | Add expense; Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed) |
| S-5 | Account detail | Account name, default split ("50 / 50" with names), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend), each expense row per AC-8.10, each settlement row per AC-11.7; 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) |
| 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 | 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); date (today default, date picker); payer toggle (two avatars: "Paid by"); 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 | Save; Cancel; Delete (edit mode) |
| 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"); 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; after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052) | 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: <template>" when generated; timeline (AC-12.4, AC-12.5); "deleted" state when opened from a notification of a deletion | Edit; Delete |
| S-9 | Settle up | Direction ("Anna pays Jacob" with a swap control); amount; allocation list: account name, account debt, editable allocated amount, over-settle warning per row (AC-11.5); remaining-to-allocate indicator; "Add account"; date; note; per-account variant with a single fixed row | Confirm; Cancel |
| S-9 | Settle up | Direction ("Anna pays Jacob" with a swap control); amount; allocation list: account name, account debt, editable allocated amount, over-settle warning per row (AC-11.5); remaining-to-allocate indicator; "Add account"; date; note; per-account variant with a single fixed row, which keeps the direction swap (§9.4: per-account settle-ups go either way) and an editable amount (AC-11.4 "defaults to") (D-052) | Confirm; Cancel |
| S-10 | Settlement detail | Payer, recipient, amount, date, note, allocations per account; timeline | Edit; Delete |
| S-11 | Analytics | Range selector (presets of AC-14.2 plus custom); granularity toggle month/week; measure toggle household / my share; group-by toggle category/account; total for range; chart 1 stacked bars over time with legend; chart 2 category breakdown; chart 3 account breakdown; empty state ("No expenses in this period") | Change filters; tap a bar or a row to open S-12 |
| S-12 | Analytics drill-down | Title naming the filter ("Mat & hushåll, oktober 2026"); total; expense rows as in S-5 | Open expense |
| S-13 | Settings hub | Own avatar and name; partner's avatar and name; rows: Profile, Categories, Recurring expenses, Devices, Invite partner or Pair a device (admin), Sync status, About | Navigate |
| 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 (30 icons of §9.7), colour swatches (12 tokens); delete or archive with the reason when delete is not allowed | Add; Edit; Reorder (drag); Archive; Delete |
| 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; "Changes not synced" list from `sync_rejections` with what was lost and why (copy per §14.4.6 reasons); clock-skew warning; "Sync now" | 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-18 | Device removed | Explanation that this phone's access was removed and local data deleted | Pair again (goes to S-1) |
@@ -1226,12 +1287,12 @@ Suggested starting points the UX agent may use, replace or merge (it must delive
2. **"Skärgård"**: Material 3 Expressive with a cool sea-and-granite palette, large rounded shapes, bold balance header, playful but quiet.
3. **"Kontor"**: dense, utilitarian, data-forward. Neutral greys, strong typographic hierarchy, charts as the hero on Analytics, minimal decoration.
**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`:
**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`. The owner picked direction 3 "Kalas" with the muted palette of `docs/ux/directions/3-kalas/`, typeface per D-020 (D-047):
* `design-spec.md`: final tokens (colour roles light and dark, category colour hex values, typography scale, shapes, spacing scale in dp, elevation, motion durations and easing), component inventory (each with anatomy, states, sizes), and for every screen S-1 to S-22: layout description, all states of §13.3, interaction details, TalkBack labels.
* `strings.csv`: every user-visible string of the app with columns `key,en,sv,notes`, keys in `snake_case` prefixed by the feature module that holds them (`home_balance_owes_you`, `expense_save`; shared strings for `:core:design` never start with a feature name; the app name is the only `:app` string; §6.2, D-017), placeholders as Android format arguments (`%1$s`), plurals marked in `notes` with their quantities. This becomes `values/strings.xml` and `values-sv/strings.xml` in M6 and M7; feature agents add no strings that are not in this file without a decisions entry.
* `screens/<S-id>-<slug>.png` for every screen and state, light and dark, Swedish.
* Updated colour hex values for §9.7, proposed as a spec change (decisions entry, spec version bump) by the orchestrator.
* Updated colour hex values for the 14 tokens of §9.7 (the muted Kalas values, `slate` and `amber` included), proposed as a spec change (decisions entry, spec version bump) by the orchestrator (D-051).
### 13.5 Out of scope for the UX sprint
@@ -1243,9 +1304,9 @@ New features, changes to flows required by §4, changes to enums or limits, app
Contracts are what lets agents work in parallel. Each subsection is binding. The files that M1 writes from this section are listed in §14.8. A change to any contract follows §14.8.
### 14.1 SQLite schema v1 (server)
### 14.1 SQLite schema v1 (server) and migrations
This DDL is the content of `contracts/schema/server-v1.sql` and of the server's first migration `server/crates/kvitt-server/migrations/0001_init.sql`, byte for byte (a test asserts equality). Conventions: ids `TEXT` (UUIDv7, see the conventions at the top of this file); money `INTEGER` öre; timestamps `TEXT` in the fixed RFC 3339 form; booleans `INTEGER` 0/1; `server_version` from `sync_sequence`. Enum and token lists that may grow (icons, colours) are checked in code, not in DDL. Every connection sets the pragmas of §8.9.
The server schema is at version **2**: the v1 DDL below (migration 0001) plus migration 0002 (§14.1.1, D-054). This DDL is the content of `contracts/schema/server-v1.sql` and of the server's first migration `server/crates/kvitt-server/migrations/0001_init.sql`, byte for byte (a test asserts equality). Conventions: ids `TEXT` (UUIDv7, see the conventions at the top of this file); money `INTEGER` öre; timestamps `TEXT` in the fixed RFC 3339 form; booleans `INTEGER` 0/1; `server_version` from `sync_sequence`. Enum and token lists that may grow (icons, colours) are checked in code, not in DDL. Every connection sets the pragmas of §8.9.
```sql
-- Kvitt server schema v1. Do not edit; add a new migration and bump the schema version (SPEC §14.8).
@@ -1488,6 +1549,83 @@ Server-side rules on top of the DDL:
* No row of `users`, `devices`, `accounts`, `categories`, `recurring_templates`, `expenses`, `settlements` or `audit_log` is ever hard-deleted. Child rows (`account_default_shares`, `recurring_template_splits`, `expense_splits`, `settlement_allocations`) are replaced (delete and insert) when their parent is updated.
* `invites` rows are never deleted; `sync_mutations` rows are pruned after 90 days by a daily job at 04:00 Europe/Stockholm (DST-aware, always local 04:00) that deletes rows whose `received_at` is strictly older than 90 days; a row exactly 90 days old is kept (D-021, D-039).
#### 14.1.1 Migration 0002: schema version 2 (D-054)
Migration 0002 adds the four default categories `housing`, `insurance`, `children` and `fees` of AC-7.2 to every database, existing ones (the development stack included) on upgrade and new ones right after 0001. The SQL below is the content of the contract file `contracts/schema/migrations/0002_categories_v2.sql` as M12a committed it (`6b60ef7`; the file wins over this copy) and of the server migration `server/crates/kvitt-server/migrations/0002_categories_v2.sql`, byte for byte (a test asserts equality, as for 0001); `contracts/schema/server-v1.sql` stays unchanged. Every later migration follows the same pattern: `contracts/schema/migrations/<NNNN>_<name>.sql` is its locked text.
* The v1 `CHECK` on `categories.default_key` lists ten keys and SQLite cannot alter a `CHECK`, so the migration rebuilds `categories` with the fourteen keys (the ten of v1 followed by the four new ones) and otherwise the same columns and constraints, keeping every row unchanged (custom categories, renames, archive state, `server_version`).
* sqlx runs the migration in one transaction on a connection with `foreign_keys = ON` (§8.9), where `PRAGMA foreign_keys` cannot change. `PRAGMA defer_foreign_keys = ON` defers the checks to the commit; the rows are copied out, the table dropped and recreated under its own name, and the rows copied back, so every reference from `expenses` and `recurring_templates` resolves again before the commit.
* Each new row takes its `server_version` from the global sequence (§14.1 rules), so phones receive the four rows in their next pull like any other change. Ids `01920000-0000-7000-8000-000000000011` to `…014`, `created_at` = `updated_at` = `2026-10-18T00:00:00.000Z`, `name` null.
* `meta.schema_version` becomes `2`; the server's `SCHEMA_VERSION` constant (E-1) becomes 2, and the importer's check (§10.8), which reads that constant, follows.
* The Room schema is unchanged (version 1): category rows reach the phone through sync (§14.2).
```sql
-- Kvitt server schema v2: four new default categories (housing, insurance, children, fees). Do not edit; add a new
-- migration and bump the schema version (SPEC §14.8). Applied on top of server-v1.sql (0001_init.sql).
--
-- The default_key CHECK of categories cannot be altered in place in SQLite, so the table is rebuilt: copy the rows
-- aside, drop the table, recreate it with the new CHECK and copy the rows back, ids and server_version unchanged.
-- The migration runs inside one transaction (sqlx wraps every migration in one) with foreign_keys = ON (SPEC §8.9).
-- defer_foreign_keys = ON lets expenses and recurring_templates point at categories while the table is briefly
-- absent; the deferred check at commit still fails the migration if any reference is left dangling. It resets
-- itself when the transaction ends. No other table, index or trigger refers to categories by anything but name.
PRAGMA defer_foreign_keys = ON;
CREATE TABLE categories_v1_copy AS SELECT * FROM categories;
DROP TABLE categories;
CREATE TABLE categories (
id TEXT PRIMARY KEY NOT NULL,
default_key TEXT UNIQUE CHECK (default_key IS NULL OR default_key IN
('groceries', 'restaurants', 'home', 'utilities', 'transport',
'travel', 'entertainment', 'health', 'shopping', 'other',
'housing', 'insurance', 'children', 'fees')),
name TEXT CHECK (name IS NULL OR length(name) BETWEEN 1 AND 40),
icon TEXT NOT NULL,
color TEXT NOT NULL,
sort_order INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived_at TEXT,
deleted_at TEXT,
server_version INTEGER NOT NULL UNIQUE,
CHECK (name IS NOT NULL OR default_key IS NOT NULL)
) STRICT;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version
FROM categories_v1_copy;
DROP TABLE categories_v1_copy;
-- Each new row takes the next server_version from the global sequence, as every server write does (SPEC §14.1), so
-- phones receive the rows on their next pull whatever their cursor.
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000011', 'housing', NULL, 'key', 'indigo', 25,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000012', 'insurance', NULL, 'shield', 'slate', 45,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000013', 'children', NULL, 'child_care', 'lime', 85,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE sync_sequence SET value = value + 1 WHERE id = 1;
INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, server_version)
SELECT '01920000-0000-7000-8000-000000000014', 'fees', NULL, 'account_balance', 'amber', 95,
'2026-10-18T00:00:00.000Z', '2026-10-18T00:00:00.000Z', NULL, NULL, value
FROM sync_sequence WHERE id = 1;
UPDATE meta SET value = '2' WHERE key = 'schema_version';
```
### 14.2 Room entities (phone)
Database class `KvittDatabase` in `:data`, file name `kvitt.db`, version **1**, `exportSchema = true` with schemas in `android/data/schemas/` (committed). Package `se.hagstedt.kvitt.data.db`. Room 3 (`androidx.room3`, KSP only) on an `SQLiteDriver` with `sqlite-bundled` (D-012). M2b created the entities and `KvittDatabase` version 1; the exported `android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json` is a contract file (§14.8).
@@ -1556,15 +1694,15 @@ enum class InviteKind { ADMIN_SETUP, PARTNER, DEVICE }
enum class EntityType { EXPENSE, SETTLEMENT }
enum class AuditAction { CREATE, UPDATE, DELETE }
enum class AuditSource { APP, RECURRING, IMPORT }
enum class DefaultCategoryKey { GROCERIES, RESTAURANTS, HOME, UTILITIES, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, SHOPPING, OTHER }
enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME }
enum class DefaultCategoryKey { GROCERIES, RESTAURANTS, HOUSING, HOME, UTILITIES, INSURANCE, TRANSPORT, TRAVEL, ENTERTAINMENT, HEALTH, CHILDREN, SHOPPING, FEES, OTHER } // AC-7.2 order (D-054)
enum class ColorToken { GREEN, ORANGE, BROWN, YELLOW, BLUE, TEAL, PURPLE, RED, PINK, GREY, INDIGO, LIME, SLATE, AMBER }
enum class RangePreset { THIS_MONTH, LAST_3_MONTHS, LAST_6_MONTHS, LAST_12_MONTHS, THIS_YEAR, ALL_TIME, CUSTOM }
enum class Granularity { MONTH, WEEK }
enum class Measure { HOUSEHOLD, MY_SHARE }
enum class GroupBy { CATEGORY, ACCOUNT }
enum class SyncRejectionReason { STALE, DELETED, VALIDATION_FAILED, REFERENCE_NOT_FOUND, CLOCK_SKEW }
// Wire value of every enum = lowercase name (SplitMode.DEFAULT <-> "default"). Icon tokens are Strings from CategoryIcons.ALL (§9.7).
object CategoryIcons { val ALL: List<String> /* the 30 tokens of §9.7, in that order */ }
object CategoryIcons { val ALL: List<String> /* the 33 tokens of §9.7, in that order */ }
// Plain records
data class User(val id: UserId, val role: Role, val displayName: String, val pictureVersion: Int?)
@@ -1643,6 +1781,7 @@ interface ExpenseRepository {
suspend fun save(draft: ExpenseDraft): SaveResult<ExpenseId>
suspend fun delete(id: ExpenseId)
fun detail(id: ExpenseId): Flow<ExpenseDetailState?>
suspend fun suggestCategory(accountId: AccountId?, description: String): CategoryId? // §9.11, D-049; null only while the phone holds no categories
}
// Settlements (S-9, S-10)
@@ -1737,6 +1876,12 @@ Readings fixed in v0.4 (no type changes):
* **Settlement rows in an account's list** carry the allocation to that account (AC-11.7).
* **`Share` names.** `se.hagstedt.kvitt.model.Share` (a split share) and `se.hagstedt.kvitt.model.analytics.AnalyticsMeasure.Share` (the measure) share a simple name in different packages. Accepted; code that needs both imports one with an alias or qualifies it.
Changes in v0.8 (D-049, D-054):
* **Category suggestion types** live in `:domain`, not here (§14.5.6); `:core:model` gains only `ExpenseRepository.suggestCategory`.
* **Fourteen defaults.** `DefaultCategoryKey` gains `HOUSING`, `INSURANCE`, `CHILDREN`, `FEES` (wire values `housing`, `insurance`, `children`, `fees`), `ColorToken` gains `SLATE` and `AMBER`, `CategoryIcons.ALL` gains `key`, `shield`, `account_balance` at the end. The localised default names are those of AC-7.2.
* **`ExpenseRepository.suggestCategory`.** Computed in `:data` from Room (every non-deleted expense on the phone, pending ones included, and the active categories), the per-phone last-used category of the account (DataStore, §14.2.2) and the keyword table bundled as the resource `android/data/src/main/resources/category-keywords.csv`, by calling `:domain`'s `suggestCategory` (§14.5.6). `:data` binds `:domain`'s `UnicodeText` with `java.text.Normalizer` and `Character`, since `:domain` may not use `java.*` (§14.5.6). `accountId` null (no active account) gives the fallback `other`. The result is null only while the phone holds no categories (before the first sync). The editor (S-7) calls it on every change of the description and of the account while the category was not picked by hand (§9.11 CS-2, CS-3) and never when editing an existing expense (CS-1). `ExpenseEditorOptions.defaultCategoryFor` stays: it is the step-4 default before any description is typed.
Readings fixed in v0.5 (no type changes):
* **`OnlineResult.Failed("network", null)`.** A REST call that fails on the network (no answer from the server) returns code `network`, the one token outside §14.3.2, so the UI can show "Could not reach the server" (§9.9) rather than the `Offline` state, which means the call was not attempted (D-029).
@@ -1824,8 +1969,8 @@ All server-produced objects. `?` marks nullable (always present). Types: `string
"created_at": ts, "updated_at": ts, "archived_at": ts?, "deleted_at": ts?, "server_version": int }
// Category
{ "id": uuid, "default_key": "groceries" | "restaurants" | "home" | "utilities" | "transport" | "travel" |
"entertainment" | "health" | "shopping" | "other" | null, "name": string?, "icon": string, "color": string,
{ "id": uuid, "default_key": "groceries" | "restaurants" | "housing" | "home" | "utilities" | "insurance" | "transport" |
"travel" | "entertainment" | "health" | "children" | "shopping" | "fees" | "other" | null, "name": string?, "icon": string, "color": string,
"sort_order": int, "created_at": ts, "updated_at": ts, "archived_at": ts?, "deleted_at": ts?, "server_version": int }
// Split (of an expense): share_bp null for mode exact
@@ -1871,7 +2016,7 @@ Arrays `splits` are sorted by `user_id`, `allocations` by `account_id`, `default
**E-1 `GET /health`** (none). 200:
```json
{ "status": "ok", "server_version": "0.1.0", "schema_version": 1, "db_ok": true }
{ "status": "ok", "server_version": "0.1.0", "schema_version": 2, "db_ok": true }
```
`schema_version` comes from a constant in the server code (the version its embedded migrations produce), not from the database, so `/health` answers with the database down. When the database check (`SELECT 1`) fails: 503 with the same shape, `status` `"degraded"`, `db_ok` false. The OpenAPI document declares the 503 body as `oneOf` the health object or Error (an `unavailable` error from a shutting-down server is also possible).
@@ -2149,7 +2294,7 @@ With these values a phone offline for 14 days loses nothing: tokens do not expir
### 14.5 Domain rules as pure functions
Implemented twice: Kotlin in `:domain` (package `se.hagstedt.kvitt.domain`, input and output types in `:core:model` package `se.hagstedt.kvitt.model.money`) and Rust in `kvitt-domain`. Both are pure (no I/O, no clock, no randomness, no locale) and must pass every vector in `contracts/test-vectors/`. Analytics exists only in Kotlin; `next_occurrence_date` and the day clamping exist only in Rust (D-015). The rules themselves are §9.1, §9.3 and §9.4; this section fixes signatures, error tokens and the vector format.
Implemented twice: Kotlin in `:domain` (package `se.hagstedt.kvitt.domain`, input and output types in `:core:model` package `se.hagstedt.kvitt.model.money`) and Rust in `kvitt-domain`. Both are pure (no I/O, no clock, no randomness, no locale) and must pass every vector in `contracts/test-vectors/`. Analytics and the category suggestion (§14.5.6) exist only in Kotlin; `next_occurrence_date` and the day clamping exist only in Rust (D-015). The rules themselves are §9.1, §9.3 and §9.4; this section fixes signatures, error tokens and the vector format.
#### 14.5.1 Types
@@ -2253,7 +2398,7 @@ fun resolveRange(preset: RangePreset, today: LocalDate, earliestExpense: LocalDa
#### 14.5.5 Test-vector format
One file per function in `contracts/test-vectors/`: `compute_split.json`, `compute_balances.json`, `propose_allocation.json`, `validate_settlement.json`, `over_settled_accounts.json`, `spend_series.json` (Kotlin only), `resolve_range.json` (Kotlin only).
One file per function in `contracts/test-vectors/`: `compute_split.json`, `compute_balances.json`, `propose_allocation.json`, `validate_settlement.json`, `over_settled_accounts.json`, `spend_series.json` (Kotlin only), `resolve_range.json` (Kotlin only), `suggest_category.json` (Kotlin only, D-049).
```json
{
@@ -2280,7 +2425,7 @@ One file per function in `contracts/test-vectors/`: `compute_split.json`, `compu
}
```
Input keys are the snake_case parameter names of the Rust signatures. For the Kotlin-only analytics functions they are the snake_case names of the Kotlin parameters and fields: `spend_series` takes `expenses` (`[{"date", "account_id", "category_id", "amount_ore", "shares"}]`), `range` (`{"start", "end_inclusive"}`), `granularity`, `measure`, `group_by`; `resolve_range` takes `preset`, `today`, `earliest_expense`, `custom_start`, `custom_end`. `spec` is `{"mode":"ratio","bp":{…}}`, `{"mode":"exact","ore":{…}}` or `{"mode":"full","bearer_user_id":"…"}`. Outputs: `compute_split` `{"ok": {user: öre}}`; `compute_balances` `{"ok": {"per_account": {...}, "global": {...}}}`; `propose_allocation` `{"ok": {"allocations": [{"account_id","amount_ore"}], "excess_ore": n, "over_settled_account_ids": [...]}}`; `validate_settlement` `{"ok": null}`; `over_settled_accounts` `{"ok": [ids]}`; `spend_series` `{"ok": {"buckets": [{"key","start","end_inclusive","totals","total"}], "group_totals": {...}, "grand_total": n}}` with `measure` input `"household"` or `{"share": "a"}`, and dates as strings; `resolve_range` `{"ok": {"start","end_inclusive"}}`. Errors: `{"error": "<token>"}`.
Input keys are the snake_case parameter names of the Rust signatures. For the Kotlin-only analytics functions they are the snake_case names of the Kotlin parameters and fields: `spend_series` takes `expenses` (`[{"date", "account_id", "category_id", "amount_ore", "shares"}]`), `range` (`{"start", "end_inclusive"}`), `granularity`, `measure`, `group_by`; `resolve_range` takes `preset`, `today`, `earliest_expense`, `custom_start`, `custom_end`; `suggest_category` takes `description` (string), `history` (`[{"description", "category_id", "expense_date", "created_at"}]`), `keywords` (`[{"keyword", "match", "default_key"}]` in table order, `match` one of `token`, `prefix`, `phrase`), `active_categories` (`[{"id", "default_key"}]`, `default_key` null for a custom category) and `fallback` (category id or null), exactly as in `contracts/test-vectors/suggest_category.json` (`6b60ef7`); the `unicode` parameter has no key (the test binds the platform implementation). `spec` is `{"mode":"ratio","bp":{…}}`, `{"mode":"exact","ore":{…}}` or `{"mode":"full","bearer_user_id":"…"}`. Outputs: `compute_split` `{"ok": {user: öre}}`; `compute_balances` `{"ok": {"per_account": {...}, "global": {...}}}`; `propose_allocation` `{"ok": {"allocations": [{"account_id","amount_ore"}], "excess_ore": n, "over_settled_account_ids": [...]}}`; `validate_settlement` `{"ok": null}`; `over_settled_accounts` `{"ok": [ids]}`; `spend_series` `{"ok": {"buckets": [{"key","start","end_inclusive","totals","total"}], "group_totals": {...}, "grand_total": n}}` with `measure` input `"household"` or `{"share": "a"}`, and dates as strings; `resolve_range` `{"ok": {"start","end_inclusive"}}`; `suggest_category` `{"ok": "<category id>"}` or `{"ok": null}` (it has no error case). Errors: `{"error": "<token>"}`.
Minimum cases M1 must include (more are welcome):
@@ -2291,6 +2436,34 @@ Minimum cases M1 must include (more are welcome):
* `over_settled_accounts`: none, one, an unknown account.
* `spend_series`: monthly and weekly over a range crossing a year boundary (ISO week 2026-W53 exists: 28 Dec 2026 to 3 Jan 2027), household and share measures, category and account grouping, empty buckets, edge-clipped buckets.
* `resolve_range`: every preset with `today` = `2026-10-17`, `ALL_TIME` with and without an earliest expense, `CUSTOM` swapped.
* `suggest_category` (M12a): empty and punctuation-only descriptions (fallback); exact history match across case, `'`, `’`, `:` and other punctuation ("Willy:s" and "WILLYS"); most often beats most recent; ties broken by `expense_date`, then `created_at`, then category id; first-token match when no exact match; exact match taking precedence over first token; an archived category in history skipped (the next category, then the next step); `token`, `prefix` and `phrase` keywords; longest keyword wins; equal length, earlier entry wins; a keyword whose category is archived falls through to the next matching entry; a renamed default category found by its key; diacritics kept (`entre` and `entré` distinct); `&` and digits kept (`h&m`, `okq8`); NFC applied to non-NFC input; surrogate pairs counted as one code point; no match gives `fallback`; `fallback` null gives null. M12a's file has 65 cases.
#### 14.5.6 Category suggestion (Kotlin only)
The rule is §9.11 (D-049). Types and functions in `:domain`, package `se.hagstedt.kvitt.domain` (landed with M12a, `6b60ef7`). The records live in `:domain`, not in `:core:model`: the keyword table is data, not a UI contract, and default keys are open strings there.
```kotlin
data class SuggestionHistoryEntry(val description: String, val categoryId: CategoryId, val expenseDate: LocalDate, val createdAt: String)
enum class KeywordMatch { TOKEN, PREFIX, PHRASE }
data class CategoryKeyword(val keyword: String, val match: KeywordMatch, val defaultKey: String)
data class ActiveCategory(val id: CategoryId, val defaultKey: String?)
interface UnicodeText {
fun nfc(text: String): String
fun isLetter(codePoint: Int): Boolean // general category Lu, Ll, Lt, Lm or Lo
fun isDigit(codePoint: Int): Boolean // general category Nd
}
fun normalizeDescription(text: String, unicode: UnicodeText): String // CS-4
fun suggestCategory(description: String, history: List<SuggestionHistoryEntry>, keywords: List<CategoryKeyword>, activeCategories: List<ActiveCategory>, fallback: CategoryId?, unicode: UnicodeText): CategoryId?
```
(`CategoryId` is `se.hagstedt.kvitt.model.CategoryId`; `LocalDate` is `kotlinx.datetime.LocalDate`.)
* `UnicodeText` exists because pure modules may not use `java.*` and `kotlin.*` has neither NFC nor general categories. `:data` binds it with `java.text.Normalizer` (NFC) and `java.lang.Character` (`getType` for L and Nd); the `:domain` tests bind the same. `normalizeDescription` applies `nfc`, then `lowercase()`, then walks the string by code points (a surrogate pair is one code point), deleting `'`, `’` and `:`, keeping `&`, letters and digits, and turning every other code point into a space; then it collapses spaces and trims. Inputs need not be NFC; the vectors include non-NFC strings.
* `history` holds non-deleted expenses only (the caller's job, as for every domain function). Expenses whose `categoryId` is not in `activeCategories` are ignored (CS-5).
* History ranking (CS-5 steps 1 and 2): the highest count wins; on equal count, the category whose latest matching expense is latest by (`expenseDate`, then `createdAt`); then the lower category id (string order).
* Keyword ranking (CS-6): among the matching entries, the longest keyword in code points wins, then the earlier entry in table order (`keywords` are passed in table order, §14.8). An entry resolves to the active category with that `defaultKey`; none means the next entry in that ranking is tried.
* `fallback` is the step-4 category id (AC-8.6), computed by the caller; it is returned as given when no earlier step applies, also when the normalised description is empty.
### 14.6 Push payload v1
@@ -2350,7 +2523,8 @@ SPEC.md CLAUDE.md README-agents.md PLAN-*.md
.gitea/workflows/ (Gitea Actions image build, M2c-pi)
contracts/
openapi.yaml
schema/server-v1.sql
category-keywords.csv
schema/server-v1.sql schema/migrations/0002_categories_v2.sql
test-vectors/*.json
examples/<E-id>-<name>/{request,response}.json, examples/errors/<code>.json, examples/sync/*.json, examples/push/*.json
docs/ HANDOFF.md decisions.md ux/ spikes/ validation/
@@ -2359,7 +2533,7 @@ tools/ gradle-serial.sh clean-builds.sh install-git-hooks.sh git-hooks/ dep
server/
Cargo.toml (workspace) Cargo.lock Dockerfile rust-toolchain.toml
crates/kvitt-domain/ src/{lib.rs, split.rs, balance.rs, allocation.rs, settlement.rs, recurring.rs} tests/vectors.rs
crates/kvitt-server/ migrations/0001_init.sql
crates/kvitt-server/ migrations/{0001_init.sql, 0002_categories_v2.sql}
src/{main.rs, lib.rs, config.rs, error.rs, router.rs, openapi.rs, auth.rs, time.rs}
src/db/{mod.rs, pool.rs, sequence.rs}
src/wire/{mod.rs, user.rs, device.rs, invite.rs, account.rs, category.rs, expense.rs, settlement.rs, recurring.rs, audit.rs, sync.rs}
@@ -2407,6 +2581,9 @@ Ownership (an agent edits only paths its milestone owns; anything else needs the
| `.gitea/workflows/**` | M2c-pi (D-023) | later infra plans |
| git-ignored `infra/secrets/**` in the main checkout | M2c-dev (D-022), then M2c-pi | M8 copies `google-services.json` from it (§10.7) |
| `docs/ux/**` | U1, U2 | |
| M12 (D-054): `contracts/category-keywords.csv`, `contracts/schema/migrations/**`, `contracts/test-vectors/suggest_category.json` and the other `contracts/**` edits of §11.2 row M12a, `tools/contracts-check.py`, `android/domain/**` (`suggestCategory`, `UnicodeText`) | M12a (contract-change plan, landed `6b60ef7`) | contract-change plans only |
| M12 (D-054): `contracts/openapi.yaml` (`DefaultCategoryKey`) and the category examples M12a held back; `kvitt-server` `migrations/0002_categories_v2.sql`, `src/db/mod.rs` (`SCHEMA_VERSION`), `src/api/validate.rs` (token lists), `src/wire/category.rs` and `src/wire/mod.rs` (key enum), their tests; `kvitt-import` `data/splitwise_categories.csv`, `src/categories.rs` and its tests | M12b (contract-change plan) | the usual owners |
| M12 (D-054): `android/core/model/**` (enums, icons, `ExpenseRepository`), `android/data/**` (`UnicodeText` binding, `suggestCategory`, the bundled keyword resource), `android/core/fakes/**` (defaults, fake `suggestCategory`) | M12c (contract-change plan) | the usual owners |
| `docs/validation/<file>` | the milestone that names the file | |
Module shells (D-004): a shell file or module created by M2a or M2b in a path owned by a later milestone holds only what makes the build and the 501 stubs work; the later milestone owns it from the moment the shell lands and writes the body.
@@ -2415,7 +2592,9 @@ Strings: each feature module has its own `src/main/res/values/strings.xml` and `
### 14.8 Contract files and the change rule
Contract files (exhaustive): `contracts/openapi.yaml`, `contracts/schema/server-v1.sql` and any later `server-vN.sql`, `contracts/test-vectors/*.json`, `contracts/examples/**`, the Room exported schema `android/data/schemas/**` (version 1 exists since M2b, D-012), `android/core/model/**` (the UI contract and domain types), the sync protocol (§14.4) and the push payload (§14.6).
Contract files (exhaustive): `contracts/openapi.yaml`, `contracts/schema/server-v1.sql`, the locked migration texts `contracts/schema/migrations/*.sql` (0002 since D-054, §14.1.1), `contracts/category-keywords.csv`, `contracts/test-vectors/*.json`, `contracts/examples/**`, the Room exported schema `android/data/schemas/**` (version 1 exists since M2b, D-012), `android/core/model/**` (the UI contract and domain types), the sync protocol (§14.4) and the push payload (§14.6).
`contracts/category-keywords.csv` (D-049) is the keyword table of §9.11 CS-6: UTF-8 without BOM, `\n` line ends, header `keyword,match,default_key,source`, no quoting. `keyword` is non-empty and already normalised (CS-4 applied to it gives it back), unique in the file; `match` is `token`, `prefix` or `phrase`; a `token` or `prefix` keyword has no space and a `phrase` keyword has at least one; `default_key` is a key of AC-7.2 other than `other`; `source` is `data` (learned from the household's export) or `curated`, documentation only. Rows are sorted by `default_key`, then `keyword`, in code-point order. The phone bundles a byte-identical copy (`android/data/src/main/resources/category-keywords.csv`, §14.2.4); M12a seeds the file from `docs/ux/category-keywords.csv` (148 entries). Changing an entry is a contract change like any other.
Representational edits to `contracts/openapi.yaml` (key order, `$ref` naming, description wording) do not change meaning and need no spec bump or decision entry; they are listed in the report of the plan that made them (D-016). The OpenAPI equality test compares parsed values, not text.
@@ -2432,7 +2611,7 @@ The equality test (`server/crates/kvitt-server/tests/openapi_contract.rs`, D-005
The document-wide rules of §14.3.1 and §14.3.2 live in one place on the generated side, the `CommonRules` modifier in `server/crates/kvitt-server/src/openapi.rs`, never in a handler annotation: the common request headers and the `X-Kvitt-Server-Version` response header on every operation, the always-possible error responses of §14.3.2 (authenticated and unauthenticated sets), the document-level `device` security, and response headers marked required.
Rule: a change to any other contract file, or a semantic change to `contracts/openapi.yaml` or to §14 of this spec, requires, landed together by the orchestrator: a spec version bump (in the header and the change log), a `docs/decisions.md` entry, and the matching implementation changes on both sides, or a plan that schedules them. A schema change also bumps the schema version (`meta.schema_version` and a new migration on the server; a new Room version and migration on the phone). A sync protocol change bumps `X-Kvitt-Protocol`; a push payload change bumps `v`. Numbers come from the orchestrator's brief. Implementation agents never edit `SPEC.md` or `docs/decisions.md`: they put the proposed spec text and decision entry in their report, and the orchestrator has a docs agent record them (CLAUDE.md › Orchestration).
Rule: a change to any other contract file, or a semantic change to `contracts/openapi.yaml` or to §14 of this spec, requires, landed together by the orchestrator: a spec version bump (in the header and the change log), a `docs/decisions.md` entry, and the matching implementation changes on both sides, or a plan that schedules them. A schema change also bumps the schema version (`meta.schema_version` and a new migration on the server, whose locked text is the contract file `contracts/schema/migrations/<NNNN>_<name>.sql`, §14.1.1; there is no `server-v2.sql`, and `server-v1.sql` stays the text of 0001; a new Room version and migration on the phone). A sync protocol change bumps `X-Kvitt-Protocol`; a push payload change bumps `v`. Numbers come from the orchestrator's brief. Implementation agents never edit `SPEC.md` or `docs/decisions.md`: they put the proposed spec text and decision entry in their report, and the orchestrator has a docs agent record them (CLAUDE.md › Orchestration).
---
@@ -2447,3 +2626,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 0.5 | 2026-10-17 | Decisions from the M3 (`503cee5`) and M5 (`c28c52b`) implementation reports. M3: D-030 picture version after a removal, `-removed` marker, next version = highest on disk + 1, first upload 1 (AC-4.4, §10.4, E-10); D-031 failed accept = any 4xx but 429, refused until 60 minutes after the first failure, fixed windows (AC-2.12); D-032 rate-limit keying by token hash, 401 counts against the IP, never-accepted tokens refused when the IP is over, `KVITT_TRUSTED_PROXY` re-resolved every 60 s, last `X-Forwarded-For` entry, `into_make_service_with_connect_info` added by M4 (§8.6, §10.4, §11.3); D-033 resume moves `last_generated_period` only to a period at or after `start_period` (§9.5); D-034 strings stored trimmed, transparency flattened onto white, `fcm_token` non-empty or null, revoke clears `fcm_token`, idempotent archive keeps `server_version`, new category `sort_order` over non-deleted (§8.6, §9.6, AC-3.5, AC-4.4, AC-7.3, E-17, E-20, E-21, E-25, E-26). M5 (renumbered: its D-030 to D-033 are D-040 to D-043): D-026 `cursor_ahead` is epoch recovery (§14.4.2); D-027 `clock_skew` gives a new `pending_mutation_id`, and the phone re-stamps `updated_at` on the next attempt (orchestrator decision, follow-up open) (§14.4.5); D-028 periodic work `kvitt-sync-periodic` (§14.4.1); D-029 `OnlineResult.Failed("network", null)` (§14.2.4); D-040 whole-request push refusals count as no progress, 429 retries, 426 stops (§14.4.5); D-041 `DataBindingsModule` (§6.2); D-042 Room tests on Robolectric, migrations from the exported JSON (§6.2); D-043 pairing status and 426 version code in DataStore (§8.2, §14.2.2). D-035 to D-039 reserved for M4. Clarifications without a decision id: control characters restricted only in `notes`, newlines accepted in names and descriptions (§8.6); landing page without download button while no APK is published, 404 page, `Referrer-Policy` and `Cache-Control` headers (AC-2.5, §13.3); generation sets the template's `updated_at` (§9.5); E-28 accepts archived accounts and categories (AC-6.4); E-14 on an accepted invite is 204 with no change; `/health` from the healthcheck counts against loopback (§8.6); M3's tests in one binary `tests/core_api/` (§14.7); sync triggers, batch and page sizes (AC-15.3, §14.4.1); 426 stops syncing until the installed version changes and `HomeState` has no update-required field (§14.2.4, §14.3.1); pictures always fetched at 512; `NeedsInstallPermission` once (AC-16.3); `Rejection.summary`, `FieldChange` display and `RecurringTemplate.draft.date` as documented in `:data` (§14.2.4); M8 contract requests (§11.3). |
| 0.6 | 2026-10-18 | Decisions from the M4 (`bd6f10a`) implementation report. D-035: a mutation whose `mutation_id`, `entity_type` or `entity.id` is unusable fails the whole request with 400 `invalid_request`; every other shape error is a per-mutation `validation_failed` (§14.4.4 step 3). D-036: `POST /sync` with 0 mutations is 422 `batch_too_large`, the code now reads "outside 1 to 100" (§9.6, §14.3.2, §14.4.4). D-037: a delete of a never-synced row runs the full step-8 validation; every rejection carries the stored row or null as `current`, also at steps 3 and 4; step 11 keeps the stored `created_at`; pushed ids must be canonical lowercase hyphenated UUIDs (§14.4.3, §14.4.4). D-038: FCM retries are one send plus up to three retries at 1 s, 5 s, 25 s; 401 drops the cached access token and is not retried, other 4xx are not retried; retries block the single sender task up to 31 s per device (§14.6). D-039: pruning deletes `received_at` strictly older than 90 days; seam change to D-015: `tasks::start` returns the `AppState` with the real push queue and `serve` builds the router from it, `AppState::new` defaults to `SqlAuditWriter`, `NoopAuditWriter` removed, `NoopPushQueue` kept for push disabled (§6.3, §14.1, §14.4.9; PLAN-M4). Clarifications without a decision id: §14.4.4 step 8 order is field rules, domain rules, references; a negative `since` is `invalid_request` and a pull reads one page in a single read transaction (§9.6, §14.3.4, §14.4.2); push runs in one `BEGIN IMMEDIATE` with a savepoint per mutation; the push queue is bounded at 100 and drops the oldest with a warning; the FCM JWT is RS256 with `kid` = `private_key_id`, the access token cached until 5 minutes before expiry, messages data-only with string values; `UNREGISTERED` or 404 clears the device token only if unchanged; an unusable credentials file disables push like a missing one (§6.3, §14.6); the scheduler reads the injected clock at least every 30 s, runs a missed job once after a forward jump and re-plans after a backward jump of more than a day (§9.5); `rotate-epoch` prints the new epoch, exit 0 or 1 on a database error (§10.4); `serve` uses `into_make_service_with_connect_info` (D-032 fulfilled, §8.6, §11.3); `GET /i/` with an empty code is the HTML 404 (E-4); reqwest (rustls, aws-lc-rs, platform verifier) and jsonwebtoken added, so the arm64 image needs a C toolchain stage and the distroless image CA roots, to be confirmed by M2c-pi or M10; the dev image was rebuilt with them on the Mac on 2026-10-18 (§6.3, §10.3); M4's test binaries and scheduler path (§11.3, §14.7). |
| 0.7 | 2026-10-18 | Decisions from the M9 (`ab298ab`) implementation report and the importer rehearsal on the real exports. D-044: nine Swedish Splitwise category names added to the §10.8 table and to `splitwise_categories.csv` with the key of their English equivalent (Städning and Verktyg - Övrigt `utilities`, Tjänster `home`, Cykel `transport`, Flyg `travel`, Spel `entertainment`, Kläder `shopping`, Livet - Övrigt and Utbildning `other`). D-045: reconciliation: the run commits only if each user's balance of the whole account equals the sum of the file's totals rows (one per currency, converted with `--fx`); a mismatch or a missing totals row rolls back and exits 4, a dry run reports the same, an account already holding other rows rolls back, a mismatch after any `--fx` conversion is reported but accepted (§10.8, AC-17.5). D-046: CLI semantics: `--user` takes a user id or an exact unique display name; no database is created or migrated, `meta.schema_version` must be 1; a dry run runs the full transaction and rolls back; exit codes 0 ok, 1 database or file error, 2 usage, 3 cannot import, 4 reconciliation mismatch; the import key uses an existing account's stored name; an empty description or one over 1000 code points makes the row invalid; descriptions over 100 code points are cut with the full text in `notes`; settlement notes cut to 200; both nets zero is mode `full` on the owner; idempotency through the existing `import_key` columns (§10.8, AC-17.5). M9 marked landed, M11 rehearsal recorded (§11.2, §11.3). |
| 0.8 | 2026-10-18 | Decisions from the owner's UX session (`docs/ux/proposed-decisions.md` UX-1 to UX-8 on branch `UX-sprint`). D-047: direction 3 "Kalas" with the muted palette, typeface per D-020 (§9.7, §13.4). D-048: navigation stays at three destinations, an "Aktivitet" tab not taken for v1; §13.2 binding. D-049: category suggestion from the description, new §9.11 (CS-1 to CS-7: new expenses only, manual choice wins, history most often then first token, keyword table, AC-8.6 default, no marker), AC-8.6, `ExpenseRepository.suggestCategory` (§14.2.4), Kotlin-only `suggestCategory` in `:domain` with the `UnicodeText` interface bound by `:data` and `suggest_category.json` as landed by M12a (`6b60ef7`) (§14.5.5, §14.5.6), contract file `contracts/category-keywords.csv` (§14.8). D-050: S-1 display-name field on a first pairing (AC-2.4, §13.3). D-051: §9.7 hex values become the muted Kalas values from U2, token names fixed, `slate` and `amber` `TODO(U2)`. D-052: "Utgiften sparades" without undo (S-7), archived account hides the FAB (S-5), per-account settle-up keeps the swap and an editable amount (S-9), avatar tokens `avatar-1` and `avatar-2` (AC-4.5), Swedish effect copy (AC-8.10). D-053: finer Splitwise mapping (§10.8). D-054: fourteen default categories (`housing`, `insurance`, `children`, `fees` added; AC-7.2), icons `key`, `shield`, `account_balance` (33), colours `slate`, `amber` (14), server schema 2 through migration 0002 rebuilding `categories` (§14.1.1, locked as `contracts/schema/migrations/0002_categories_v2.sql`, copied from M12a's file; no `server-v2.sql`, §14.8), importer schema check 2 (§10.8), enums in §14.2.4 and §14.3.3, E-1 `schema_version` 2; Room schema unchanged. New milestone group M12 "Category contract v2" (M12a contracts, then M12b server and M12c Android in parallel; M6 and M7c wait for M12c) (§11). |
+18 -15
View File
@@ -3,27 +3,30 @@
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
## 1. Where things stand
- `main` at `ab298ab` (M9) plus the SPEC v0.7 docs commit from `agent-spec7` once it lands. History: `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `fad6fc9` M2b, `fdf2093` M2a, `040bf41` SPEC v0.4, `503cee5` M3, `c28c52b` M5, `982451d` SPEC v0.5, `bd6f10a` M4, `23a2f1e` SPEC v0.6, `ab298ab` M9.
- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2a server skeleton and `kvitt-domain` (`fdf2093`), M2b Android skeleton and `:domain` (`fad6fc9`), M2c-dev infra for development (`c885aba`), M3 server core API (`503cee5`), M4 server sync, audit, push, scheduler (`bd6f10a`), M5 Android data layer (`c28c52b`), M9 Splitwise importer (`ab298ab`), SPEC v0.6 (`23a2f1e`). With `agent-spec7`: SPEC v0.7 with M9's D-044 to D-046 and D-044's nine Swedish names in `server/crates/kvitt-import/data/splitwise_categories.csv`.
- `main` at `6b60ef7` (M12a) plus the SPEC v0.8 docs commit from `agent-spec8` once it lands. History: `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `fad6fc9` M2b, `fdf2093` M2a, `040bf41` SPEC v0.4, `503cee5` M3, `c28c52b` M5, `982451d` SPEC v0.5, `bd6f10a` M4, `23a2f1e` SPEC v0.6, `ab298ab` M9, `e8ee518` SPEC v0.7, `6b60ef7` M12a.
- Landed: M0 spec, M1 contracts and vectors (`c6df2ab`), M2a server skeleton and `kvitt-domain` (`fdf2093`), M2b Android skeleton and `:domain` (`fad6fc9`), M2c-dev infra for development (`c885aba`), M3 server core API (`503cee5`), M4 server sync, audit, push, scheduler (`bd6f10a`), M5 Android data layer (`c28c52b`), M9 Splitwise importer (`ab298ab`), SPEC v0.7 (`e8ee518`), M12a category contract v2 contracts and `:domain` `suggestCategory` (`6b60ef7`; OpenAPI `DefaultCategoryKey` and the new category examples held back for M12b). With `agent-spec8`: SPEC v0.8, the owner's UX-session decisions UX-1 to UX-8 as D-047 to D-054 (Kalas muted, three tabs, category suggestion §9.11, S-1 name field, colour tokens, smaller UX rules, finer import mapping, fourteen default categories with server schema 2), and the milestone group M12 with PLAN-M12a/b/c and prompts, reconciled with what M12a landed (§14.1.1 copied from the contract file, `:domain` types with `UnicodeText`, vector keys).
- **Known red test:** from the landing of SPEC v0.8 until M12b lands, `main`'s server gate has exactly one failing test, the importer's `category_table_matches_the_spec`, which parses the §10.8 table that v0.8 changed (D-053). M12b fixes it; land M12b next.
- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test` (from `server/`).
- Worktrees in flight under `.claude/worktrees/`: `agent-spec7` (this docs change: SPEC v0.7).
- The owner runs the UX sprint in a separate Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`; it commits only under `docs/ux/` and rebases on `main` before landing. U1 is committed on `UX-sprint` at `210ab5c` (`docs/ux/`: directions, ia, screens, flows, components, tokens, `strings.csv`); the owner has not yet picked a direction.
- Importer rehearsal (M11 preparation), 2026-10-18: the real run reconciled on both real files in a throwaway database (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044, 0 are expected after it (confirm with the next dry run). The CSV files stay in `export/` (git-ignored).
- Development stack (M2c-dev): running on the Mac with the M4 image, reachable at `http://192.168.1.210:8080`; the owner confirmed `/health` from the phone on the home Wi-Fi on 2026-10-17. Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`; `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` in the main checkout (git-ignored).
- Worktrees in flight under `.claude/worktrees/`: `agent-spec8` (this docs change: SPEC v0.8).
- The owner runs the UX sprint in a separate Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`; it commits only under `docs/ux/` and rebases on `main` before landing; its results reach the main line through `docs/ux/proposed-decisions.md` and a docs agent (CLAUDE.md › Orchestration). Direction 3 "Kalas" with the muted palette is picked and approved (D-047); U2 (design spec, renamed `strings.csv`, per-screen PNGs, the §9.7 hex values including `slate` and `amber`) is in progress there.
- Importer rehearsal (M11 preparation), 2026-10-18: the real run reconciled on both real files in a throwaway database (Vardag 1746 rows, Hemma 37 rows); 21 rows were unmapped before D-044, 0 are expected after it. After M12b the mapping is finer (D-053); the next dry run confirms it. The CSV files stay in `export/` (git-ignored).
- Development stack (M2c-dev): running on the Mac with the M4 image, reachable at `http://192.168.1.210:8080`; the owner confirmed `/health` from the phone on the home Wi-Fi on 2026-10-17. Its database is at schema 1; an image with M12b upgrades it to 2 on start (back it up first). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`; `infra/secrets/fcm-service-account.json` and `infra/secrets/google-services.json` in the main checkout (git-ignored).
- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug` built from `main` at `c28c52b`, as of 2026-10-18.
## 2. Open work (one line each, with who owns it: agent or owner)
- Land `agent-spec7` (orchestrator): SPEC v0.7, decisions D-044 to D-046, the category data file, `server/README.md`, prompts/README status.
- Land `agent-spec8` (orchestrator): SPEC v0.8, decisions D-047 to D-054, PLAN-M12a/b/c, prompts M12a/b/c, CLAUDE.md UX-sprint note, plan touch-ups (M6, M7c, M7f, U2).
- Next: M12b (server; also the OpenAPI enum and the held-back examples) and M12c (Android) in parallel.
- Image build for the Pi (M2c-pi or M10): since M4 the arm64 server image needs a C toolchain in the build stage (aws-lc-rs) and CA roots in the distroless image (§10.3); confirm both and one FCM send from the shipped image.
- D-027 re-stamp (orchestrator assigns: M8 as a contract request on `:data`, or a small `:data` agent): on the first sync attempt after a `clock_skew` rejection the phone re-stamps `updated_at` of each such pending row to its current clock before pushing. Not in `:data` at `c28c52b`.
- D-027 re-stamp (orchestrator assigns: M8 as a contract request on `:data`, a small `:data` agent, or fold into M12c): on the first sync attempt after a `clock_skew` rejection the phone re-stamps `updated_at` of each such pending row to its current clock before pushing. Not in `:data` at `c28c52b`.
- Picture size (M6, if needed): `:data` always fetches E-11 at 512; a size parameter for the 128 variant is a contract request on `:core:model` and `:data`.
- Plans (SPEC §11.2 v0.7), in dependency order; prompt `prompts/<id>.md`:
- After U2 reports: a docs agent records the §9.7 hex values (muted Kalas, `slate` and `amber`), U2's other spec proposals, and the four new default names in `docs/ux/strings.csv` (D-051, D-054).
- Plans (SPEC §11.2 v0.8), in dependency order; prompt `prompts/<id>.md`:
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M9: landed.
- U1 UX directions: committed on `UX-sprint` (`210ab5c`); waiting for the owner's pick.
- Next waves: U2 UX design spec (owner's session, after the pick; owner approves; then the §9.7 colour change goes into the spec by a docs agent), then M6 Design system, M7a to M7f Features after M6 in parallel, M8 App integration after M7, M2c-pi Infra on the Pi after M8 (owner: answer OQ-3, grant the Pi, Home Assistant Pi, Gitea and Drive allowances at the start, D-023), M10 First release after M2c-pi (owner: keystore per §10.6, D-024), M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045).
- M12a Category contract v2, contracts: landed (`6b60ef7`).
- Next waves: M12b (server) and M12c (Android) in parallel now; U2 (owner's session; owner approves); then M6 Design system after U2, the §9.7 values and M12c; M7a to M7f after M6 in parallel (M7c also after M12c); M8 App integration after M7; M2c-pi Infra on the Pi after M8 (owner: answer OQ-3, grant the Pi, Home Assistant Pi, Gitea and Drive allowances at the start, D-023); M10 First release after M2c-pi (owner: keystore per §10.6, D-024); M11 Import history after M9 and M10 (procedure §10.8 "Production run", D-025; each run must reconcile, D-045).
## 3. HUMAN steps pending (relay in chat)
- UX direction pick from U1's directions in `docs/ux/` on `UX-sprint`, with comments (owner's UX-sprint session).
- U2 approval: the owner approves U2's design spec in the UX-sprint session.
## 4. Owner questions (asked, unanswered)
- OQ-3 LAN addresses and SSH users of the Kvitt Pi and the Home Assistant Pi; SSH port of the HA Pi; certificate directory there; which SSH add-on (SPEC §12). Needed by M2c-pi only. The only open question (OQ-1, OQ-2, OQ-4 answered: D-001, D-003, §10.9).
@@ -32,9 +35,9 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Nothing.
## 6. Numbering
- Decisions: D-001 to D-046 used (D-008 and D-009 unused). Next free: **D-047**.
- Spec version: v0.7 written; next **v0.8**.
- Server schema version **1**; next **2**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**.
- Decisions: D-001 to D-054 used (D-008 and D-009 unused). Next free: **D-055**.
- Spec version: v0.8 written; next **v0.9**.
- Server schema version **2** (migration 0002, implemented by M12b); next **3**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`, unchanged by M12); next **2**.
- Sync protocol **1**, push payload **1**.
## 7. How the work is run
+9 -1
View File
@@ -50,4 +50,12 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| D-043 | 2026-10-17 | Two extra DataStore keys: the pairing status in `auth.preferences_pb`, and the installed version code that received a 426 in `prefs.preferences_pb` (syncing stops until the installed version changes). The token is encrypted with Keystore key `kvitt_token_key`, AES-256-GCM (as §8.2). (M5's D-033, renumbered.) | M5 report. | SPEC §8.2, §14.2.2, §14.4.5 |
| D-044 | 2026-10-18 | Nine Swedish Splitwise category names, found unmapped in the importer rehearsal on the real Swedish export, are added to the §10.8 table and to `server/crates/kvitt-import/data/splitwise_categories.csv` with the key of their English equivalent: Städning → `utilities` (Cleaning); Verktyg - Övrigt → `utilities` (Utilities - Other); Tjänster → `home` (Services); Cykel → `transport` (Bicycle); Flyg → `travel` (Plane); Spel → `entertainment` (Games); Kläder → `shopping` (Clothing); Livet - Övrigt → `other` (Life - Other); Utbildning → `other` (Education). The test `category_table_matches_the_spec` keeps the two in step. | The rehearsal of 2026-10-18 left 21 rows of the Vardag file as `other`, counted unmapped, only because these names were missing from D-003's table. | SPEC §10.8; `server/crates/kvitt-import/data/splitwise_categories.csv` |
| D-045 | 2026-10-18 | Import reconciliation. Inside the import transaction, after the rows are written, each user's balance of the whole account (`net(u)`, §9.3) must equal the sum of that member's values in the file's totals rows (`Total balance` / `Totalsumma`), one totals row allowed per currency, a non-SEK one converted with its `--fx` rate. A mismatch or a missing totals row rolls the run back and exits 4; a dry run reports the same. The comparison covers the whole account, so importing into an account that already holds other rows rolls back. When any `--fx` conversion took place, a mismatch is reported in the summary but accepted (per-value rounding can make an exact match impossible). | M9 report (`ab298ab`): v0.6 ignored the totals row, and M11's gate ("imported balances equal Splitwise's totals") is cheapest checked by the importer itself, before anything is committed. | SPEC §10.8, AC-17.5, §11.2 (M11) |
| D-046 | 2026-10-18 | Importer CLI semantics. `--user <Splitwise name>=<user>` takes a user id or an exact display name that exactly one user has; the two arguments must name the two household users. The importer never creates or migrates a database and requires `meta.schema_version` = `1`. A dry run runs the full transaction and rolls it back. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import (header, invalid rows, non-SEK without `--fx`), 4 reconciliation mismatch. The import key uses an existing account's stored name, so a re-run with another spelling finds its rows. An empty description or one over 1000 code points makes the row invalid; a description over 100 code points is cut with the full text in `notes`; settlement notes are cut to 200 code points; both nets zero means the owner pays in mode `full`. Idempotency uses the existing `import_key` columns; no schema change. | M9 report (`ab298ab`): §10.8 left the user lookup, database handling, dry-run mechanics, exit codes and these edge cases open. | SPEC §10.8, AC-17.5; server/README.md |
| D-046 | 2026-10-18 | Importer CLI semantics. `--user <Splitwise name>=<user>` takes a user id or an exact display name that exactly one user has; the two arguments must name the two household users. The importer never creates or migrates a database and requires `meta.schema_version` = `1` (revised by D-054: the schema version the server's migrations produce, now `2`). A dry run runs the full transaction and rolls it back. Exit codes: 0 ok, 1 database or file error, 2 usage, 3 cannot import (header, invalid rows, non-SEK without `--fx`), 4 reconciliation mismatch. The import key uses an existing account's stored name, so a re-run with another spelling finds its rows. An empty description or one over 1000 code points makes the row invalid; a description over 100 code points is cut with the full text in `notes`; settlement notes are cut to 200 code points; both nets zero means the owner pays in mode `full`. Idempotency uses the existing `import_key` columns; no schema change. | M9 report (`ab298ab`): §10.8 left the user lookup, database handling, dry-run mechanics, exit codes and these edge cases open. | SPEC §10.8, AC-17.5; server/README.md |
| D-047 | 2026-10-18 | Visual direction: U1's direction 3 "Kalas" (cards, very round shapes, a solid category-colour tile on every expense, the seesaw balance) with the muted palette of `docs/ux/directions/3-kalas/` (owner approved 2026-10-18). Typeface per D-020 (Material 3 default); Kalas's preview font Nunito is dropped. U2's `design-spec.md` carries the tokens. | Owner's pick (UX-1); muted because bright colours tire over years of daily use. | SPEC §9.7, §13.4 |
| D-048 | 2026-10-18 | Navigation stays at three destinations (Översikt, Statistik, Inställningar); §13.2 is binding. An "Aktivitet" feed (the other person's changes across accounts) was considered as a fourth tab and not taken for v1. | Owner's answer (UX-2). | SPEC §13.2 |
| D-049 | 2026-10-18 | Category suggestion (§9.11 CS-1 to CS-7): for a new expense, while the category was not picked by hand in this editor session, the editor selects (1) the category used most often among expenses with the same normalised description, (2) else most often among expenses with the same first token, ties by the most recent expense in list order, then category id, (3) else the longest matching entry of the fixed keyword table `contracts/category-keywords.csv`, (4) else the AC-8.6 default. Recomputed on every description and account change; no marker; editing never changes a category. `ExpenseRepository.suggestCategory(accountId, description)` in `:data`; pure `suggestCategory(description, history, keywords, activeCategories, fallback, unicode: UnicodeText): CategoryId?` in `:domain` (Kotlin only) with `suggest_category.json`. `UnicodeText` (`nfc`, `isLetter(codePoint)`, `isDigit(codePoint)` meaning Nd) is an interface in `:domain` that `:data` binds with `java.text.Normalizer` and `Character`, because pure modules cannot use `java.*`; the records `SuggestionHistoryEntry(description, categoryId, expenseDate, createdAt)`, `CategoryKeyword(keyword, match, defaultKey: String)`, `ActiveCategory(id, defaultKey: String?)` and the enum `KeywordMatch` live in `:domain`, with default keys as strings. History ties: count, then the latest (`expense_date`, `created_at`), then the lower category id; code points walked by surrogate pairs; keywords rank by code-point length, then table order. As landed by M12a (`6b60ef7`). | Owner (UX-3): most expenses repeat the same shops; fewer taps in the ten-second flow (§13.1). Replay of the household's 1 775 Splitwise expenses: 79.7 % right versus 30.5 % for the AC-8.6 default alone; "most often" beats "most recent" by about one point (`docs/ux/category-suggestion.md`). | SPEC AC-8.6, §9.11, §13.3 (S-7), §14.2.4, §14.5.5, §14.5.6, §14.7, §14.8 |
| D-050 | 2026-10-18 | S-1 has a required "Ditt namn" display-name field (1 to 40 code points, §9.6) on a first pairing, hidden in the "Para ihop telefonen igen" variant; a partner confirms it again on S-2 (AC-2.10). | UX-4: AC-2.4 sends a display name with the accept call, but S-1 listed no field for it. Owner OK. | SPEC AC-2.4, §13.3 (S-1) |
| D-051 | 2026-10-18 | The §9.7 colour hex values are replaced by the muted Kalas values when U2 reports; the token names are fixed now, the two new ones being `slate` (insurance) and `amber` (fees), whose values are `TODO(U2)`. M6 starts only after the values have landed in §9.7. | UX-5: several provisional values (light orange, yellow, lime) fail contrast as icon colours on tinted containers; UX-8 needs two more tokens. | SPEC §9.7, §13.4 |
| D-052 | 2026-10-18 | Smaller UX rules: saving an expense shows "Utgiften sparades" without "Ångra" (S-7); an archived account's detail hides the add-expense FAB and keeps settling (S-5); the per-account settle-up keeps the direction swap and an editable amount, its one account row fixed (S-9); avatar initials use two colour tokens `avatar-1`, `avatar-2`, the slot a stable function of the user id (AC-4.5). The owner approved the Swedish wording "Ni är kvitt", "Gör upp", "Du lånade ut / Du lånade", "Inte inblandad" (AC-8.10; the copy lives in `docs/ux/strings.csv`). U2 renames the `strings.csv` keys to the feature prefixes of D-017. | UX-6, found during U1: an undo would be an audited create and delete that also notifies the partner; archived accounts take no new expenses (AC-6.4); §9.4 allows per-account settle-ups either way and AC-11.4 says the amount "defaults to". | SPEC AC-4.5, AC-8.10, §13.3 (S-5, S-7, S-9) |
| D-053 | 2026-10-18 | Splitwise categories are mapped as finely as the fourteen categories allow: Rent, Mortgage, Hyra, Avbetalning/Amortering → `housing`; Insurance, Försäkringar → `insurance`; Childcare, Barnomsorg → `children`; Taxes, Skatter → `fees`; the Swedish labels El, Värme/gas, Vatten, Sophämtning (`utilities`) and Husdjur (`home`) added by analogy, to be checked against Splitwise (an unseen name only stays unmapped). The §10.8 table and `server/crates/kvitt-import/data/splitwise_categories.csv` change together (M12b). | UX-7 (owner, 2026-10-18): §10.8 collapsed Försäkringar (26 rows), Barnomsorg (73) and Skatter (19) into `other` and Hyra (106) and Avbetalning/Amortering (48) into `home`; "Hemförsäkring" must not end up in Övrigt. | SPEC §10.8 |
| D-054 | 2026-10-18 | Fourteen default categories: `housing` Boende (`key`, `indigo`, 25), `insurance` Försäkringar (`shield`, `slate`, 45), `children` Barn (`child_care`, `lime`, 85), `fees` Avgifter & skatter (`account_balance`, `amber`, 95) join the ten; `home` keeps furniture, supplies, maintenance, electronics and services. Ids `01920000-0000-7000-8000-000000000011` to `…014`, `created_at` `2026-10-18T00:00:00.000Z`, `server_version` from the global sequence. Server schema 2 through migration 0002, which rebuilds `categories` (the v1 `CHECK` lists ten keys) under `PRAGMA defer_foreign_keys`, inserts the four rows and sets `schema_version` 2; its text is locked as `contracts/schema/migrations/0002_categories_v2.sql` (§14.1.1); `server-v1.sql` stays byte-locked. Icons 33 (`key`, `shield`, `account_balance`), colours 14, `DefaultCategoryKey` and `ColorToken` extended, OpenAPI enum, E-1 `schema_version` 2; the importer requires schema 2 (revises D-046's "1"). Room schema unchanged (rows arrive through sync). Carried out by milestone group M12 (M12a contracts, then M12b server and M12c Android in parallel; M6 and M7c after M12c). | UX-8 (owner, 2026-10-18): the household's history has these as large separate groups (Hyra + Amortering 154 rows, Barnomsorg 73, Försäkringar 26, Skatter 19) that were collapsed into Hem and Övrigt; separate categories make analytics and the suggestion meaningful. M3, M5 and M9 landed with ten defaults, so this is a contract change against `main`. | SPEC AC-7.2, §6.2, §9.7, §10.8, §11, §13.3 (S-15), §14.1, §14.1.1, §14.2.4, §14.3.3, §14.3.4 (E-1), §14.7, §14.8 |
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: M12a Category contract v2, contracts
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), milestone M12a: Category contract v2, contracts. You have no context other than this prompt and the files it names.
Read PLAN-M12a.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both.
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
(To fill: Say whether `docs/ux/category-keywords.csv` is on `main` or must be read from the branch `UX-sprint`; agent-m12a may already have run from the UX-session documents, in which case this prompt is for a re-run against SPEC v0.8.)
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-m12a -b agent-m12a main`; work only there, on branch `agent-m12a`; then copy the git-ignored files the plan names.
- Edit only `contracts/**` (the files named in the plan) and `tools/contracts-check.py`; anything else is a contract request to the orchestrator.
- Gate: `python3 tools/contracts-check.py` from the repository root. Report only results you ran in this session.
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-M12a.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), contract requests, open questions and spec gaps with section numbers.
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: M12b Category contract v2, server
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), milestone M12b: Category contract v2, server. You have no context other than this prompt and the files it names.
Read PLAN-M12b.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both.
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
(To fill: Confirm M12a has landed; say whether the agent may restart the development stack on the Mac to upgrade its database (default: no, report the steps).)
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-m12b -b agent-m12b main`; work only there, on branch `agent-m12b`; then copy the git-ignored files the plan names.
- Edit only the server paths of PLAN-M12b §4 (the OpenAPI `DefaultCategoryKey` and the category examples M12a held back, migration 0002, `SCHEMA_VERSION`, token lists, the key enum, the importer table and key list, their tests); anything else is a contract request to the orchestrator.
- Gate: `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test` in `server/`. Report only results you ran in this session.
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-M12b.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), contract requests, open questions and spec gaps with section numbers.
+24
View File
@@ -0,0 +1,24 @@
# Start prompt: M12c Category contract v2, Android
You are an Opus implementation agent on Kvitt (repository `/Users/jhag/projects/kvitt`), milestone M12c: Category contract v2, Android. You have no context other than this prompt and the files it names.
Read PLAN-M12c.md and follow it; CLAUDE.md rules apply. SPEC.md wins over both.
Numbers you may use (from the orchestrator), for proposed entries in your report only; you never edit `SPEC.md` or `docs/decisions.md` (CLAUDE.md › Orchestration):
- Next free decision id: {{NEXT_DECISION}}
- Spec version a proposed spec change would get: {{SPEC_VERSION}}
- Schema version for any schema change: {{SCHEMA_VERSION}}
If a value is still a `{{…}}` placeholder, write `TODO(spec)` wherever the number would go and say so in the report.
Orchestrator notes for this run:
(To fill: Confirm M12a has landed. No phone is needed.)
{{ORCHESTRATOR_NOTES}}
Essentials (the plan has the detail):
- First step: from the main checkout, `git worktree add .claude/worktrees/agent-m12c -b agent-m12c main`; work only there, on branch `agent-m12c`; then copy the git-ignored files the plan names.
- Edit only the Android paths of PLAN-M12c §4 (`:core:model`, `:data`, `:core:fakes`, and existing default-name string files if any; `:domain`'s suggestion landed with M12a); anything else is a contract request to the orchestrator.
- Gate: `PATH=/opt/homebrew/opt/openjdk@25/bin:$PATH ../tools/gradle-serial.sh check` in `android/`. Report only results you ran in this session.
- Stop and ask when the plan says so, when a step could touch the owner's real data, or when the spec is ambiguous in a way that changes a decision.
- Commit on your branch when the gate is green, `git rebase main`, rerun the gate. Never merge, push or remove the worktree.
Report: end with the report described in PLAN-M12c.md §10: branch, head commit hash, gate result lines, versions chosen with sources, numbering used, how the orchestrator notes were applied, HUMAN steps outstanding (worded for relay), contract requests, open questions and spec gaps with section numbers.
+8 -4
View File
@@ -2,7 +2,7 @@
One ready-to-paste start prompt per plan (`PLAN-<id>.md` at the repository root), plus `ORCHESTRATOR-prompt.md`. Before pasting, the orchestrator fills `{{NEXT_DECISION}}`, `{{SPEC_VERSION}}`, `{{SCHEMA_VERSION}}` (disjoint ranges for agents that run in parallel, CLAUDE.md › Numbering) and `{{ORCHESTRATOR_NOTES}}` (answers to the plan's open points, phone assignment, overrides). `U2.md` also takes `{{OWNER_PICK_AND_COMMENTS}}`. U1 and U2 run in the owner's own Claude session in `~/orca/workspaces/kvitt/UX-sprint` on branch `UX-sprint`. The numbers are for proposed entries in agents' reports: implementation agents never edit `SPEC.md` or `docs/decisions.md`; a docs agent records what the orchestrator accepts (CLAUDE.md › Orchestration).
Dependencies follow SPEC §11.1 and §11.2 (v0.7). Status as of 2026-10-18: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`fdf2093`), M2b landed (`fad6fc9`), M3 landed (`503cee5`), M5 landed (`c28c52b`), M4 landed (`bd6f10a`), M9 landed (`ab298ab`); U1 committed on `UX-sprint` (owner's direction pick pending). The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043); v0.6 the M4 decisions (D-035 to D-039); v0.7 the M9 decisions (D-044 to D-046).
Dependencies follow SPEC §11.1 and §11.2 (v0.8). Status as of 2026-10-18: M0 done, M1 landed (`c6df2ab`), M2c-dev landed (`c885aba`), M2a landed (`fdf2093`), M2b landed (`fad6fc9`), M3 landed (`503cee5`), M5 landed (`c28c52b`), M4 landed (`bd6f10a`), M9 landed (`ab298ab`); SPEC v0.7 landed (`e8ee518`); M12a landed (`6b60ef7`); U1 done on `UX-sprint`, direction 3 "Kalas" muted picked (D-047), U2 in progress there. The plans' open points are resolved in SPEC v0.3 and recorded in each plan's §4; v0.4 records the M2a and M2b decisions (D-005 to D-014); v0.5 the M3 and M5 decisions (D-026 to D-034, D-040 to D-043); v0.6 the M4 decisions (D-035 to D-039); v0.7 the M9 decisions (D-044 to D-046); v0.8 the owner's UX-session decisions (D-047 to D-054) and the milestone group M12.
## Dependency order and waves
@@ -19,10 +19,13 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7
| 2 | M3 Server core API | `M3.md` | M2a | done (`503cee5`) |
| 2 | M4 Server sync, audit, push, scheduler | `M4.md` | M2a; lands after M3 (D-015) | done (`bd6f10a`) |
| 2 | M5 Android data layer | `M5.md` | M1, M2b | done (`c28c52b`) |
| 2 | M6 Design system | `M6.md` | U2 approved, M2b, the §9.7 colour spec change | M3, M4, M5 |
| 2 | M12a Category contract v2: contracts | `M12a.md` | M1, SPEC v0.8 | done (`6b60ef7`) |
| 2 | M12b Category contract v2: server | `M12b.md` | M12a (M3, M4, M9 landed) | M12c, U2 |
| 2 | M12c Category contract v2: Android | `M12c.md` | M12a (M2b, M5 landed) | M12b, U2 |
| 2 | M6 Design system | `M6.md` | U2 approved, M2b, M12c, the §9.7 colour spec change | M12b |
| 3 | M7a Feature onboarding | `M7a.md` | M6 | M7b to M7f, M9 |
| 3 | M7b Feature home and account | `M7b.md` | M6 | M7a, M7c to M7f, M9 |
| 3 | M7c Feature expense | `M7c.md` | M6 | M7a, M7b, M7d to M7f, M9 |
| 3 | M7c Feature expense | `M7c.md` | M6, M12c | M7a, M7b, M7d to M7f, M9 |
| 3 | M7d Feature settle | `M7d.md` | M6 | M7a to M7c, M7e, M7f, M9 |
| 3 | M7e Feature analytics | `M7e.md` | M6 | M7a to M7d, M7f, M9 |
| 3 | M7f Feature settings | `M7f.md` | M6 | M7a to M7e, M9 |
@@ -37,6 +40,7 @@ Plans in the same wave can run in parallel (they own disjoint paths, SPEC §14.7
- **M3 and M4** (D-015): M2a provides `next_occurrence_date`, the `AuditWriter`/`PushQueue` traits with no-op implementations and `tasks::start`; M3 writes the recurring generation function and calls the traits; M4 replaces the implementations, its scheduler calls M3's function, and M4 lands after M3.
- **M2a and M2b** create shell files and modules in later milestones' paths (D-004); ownership passes on when they land.
- **M7a to M7f** follow the feature convention of SPEC §6.2 (D-017) and take the ledger rows and expense editor fields from M6's `:core:design` (D-019).
- **M6** starts only after the §9.7 colour change from U2's proposal has landed in SPEC (§11.1).
- **M6** starts only after the §9.7 colour change from U2's proposal (the muted Kalas values, `slate` and `amber` included, D-051) has landed in SPEC and M12c has landed (§11.1).
- **M12** (D-054): SPEC v0.8 changes the §10.8 table that the importer test `category_table_matches_the_spec` parses, so the server gate on `main` is red from SPEC v0.8 until M12b lands; land M12b soon after (or land SPEC v0.8 together with it).
- **Phone:** M2b (one `installDebug`), M5 (only if instrumented tests), M8 and M10 use the phone; only one at a time, named in the notes.
- **Outside the repository:** M2c-dev (D-022, done), M2c-pi (the Kvitt Pi, the Home Assistant Pi, Gitea, Drive: allowances requested at the start of that sprint, D-023), M8 (dev server on the Mac), M10 and M11 (production) need the owner's allowance in the notes.