SPEC v0.26: D-127 pull-to-refresh on Home and account detail

AC-15.3, §9.9 and §13.3 (S-4, S-5) name the pull as a sync now, not
disabled offline. design-spec: S-4's "Pull-to-refresh is not offered"
replaced; S-4/S-5 refreshing frames, interactions and TalkBack;
KvittPullToRefresh in §3.6; ContainedLoadingIndicator row in §3.1;
spatial fast and the named motion in §2.11. HANDOFF: D-127 landed
(3778f5f), Pi live, both phones paired, import done; next free D-129 /
v0.28 after the parallel D-128 (v0.27).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 20:23:20 +02:00
parent 05ec3f3f16
commit b0a90da630
4 changed files with 47 additions and 32 deletions
+6 -5
View File
@@ -1,6 +1,6 @@
# Kvitt: specification # Kvitt: specification
Version **0.25** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**. Version **0.26** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
This file is the single source of truth for Kvitt. It is written for implementation agents that have no other context. If code, a plan or `CLAUDE.md` disagrees with this file, this file is right. Words in `code` are literal identifiers, paths, enum values or error codes and must be used exactly as written. Anything not decided here is listed in §12 as `TODO(spec)`; do not invent a value for it. 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.
@@ -271,7 +271,7 @@ The server starts with an empty database. The household is created from the comm
* AC-15.1 The phone holds a full local copy of: users, accounts, categories, recurring templates, expenses, settlements and audit entries (Room, §14.2). * AC-15.1 The phone holds a full local copy of: users, accounts, categories, recurring templates, expenses, settlements and audit entries (Room, §14.2).
* AC-15.2 Expenses and settlements can be created, edited and deleted offline. Accounts, categories, recurring templates, profile, pictures, invites and devices need the server; their actions are disabled offline with "Needs connection". * AC-15.2 Expenses and settlements can be created, edited and deleted offline. Accounts, categories, recurring templates, profile, pictures, invites and devices need the server; their actions are disabled offline with "Needs connection".
* AC-15.3 Sync runs on app start and every return to the foreground, 2 seconds after the last local write (debounced), every 15 minutes in the background through WorkManager (network constraint), and on every FCM data message (expedited one-off work, waited for up to 20 seconds, AC-13.5). Only one sync runs at a time. WorkManager names in §14.4.1 (D-028). * AC-15.3 Sync runs on app start and every return to the foreground, 2 seconds after the last local write (debounced), every 15 minutes in the background through WorkManager (network constraint), and on every FCM data message (expedited one-off work, waited for up to 20 seconds, AC-13.5). A pull-to-refresh on Home (S-4) or account detail (S-5) also runs a sync, the same call as S-16 "Synka nu" (D-127). Only one sync runs at a time. WorkManager names in §14.4.1 (D-028).
* AC-15.4 The app shows a "Last synced" relative time and a pending-changes count on Home, and a sync status screen (§13). * AC-15.4 The app shows a "Last synced" relative time and a pending-changes count on Home, and a sync status screen (§13).
* AC-15.5 Conflicts resolve by last-write-wins per row on the client `updated_at`, deletes are final, and rejected changes are shown to the user (§14.4). * AC-15.5 Conflicts resolve by last-write-wins per row on the client `updated_at`, deletes are final, and rejected changes are shown to the user (§14.4).
* AC-15.6 A phone offline for 14 days with 200 local changes syncs all of them on reconnect (integration test with a scripted clock). * AC-15.6 A phone offline for 14 days with 200 local changes syncs all of them on reconnect (integration test with a scripted clock).
@@ -800,7 +800,7 @@ The server validates icon and colour against these lists (`validation_failed`).
### 9.9 Online-only actions ### 9.9 Online-only actions
When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or the last request failed with a network error less than 30 seconds ago, these actions are disabled with "Needs connection" ("Kräver anslutning", `common_needs_connection`): create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update; "Sync now" ("Synka nu") on S-8 and S-10 (their missing states) and on S-16 (D-080). A REST call that fails with a network error shows "Could not reach the server" and changes nothing locally. When the phone has no validated network (Android `NET_CAPABILITY_VALIDATED`) or the last request failed with a network error less than 30 seconds ago, these actions are disabled with "Needs connection" ("Kräver anslutning", `common_needs_connection`): create, edit, archive, unarchive, delete of accounts, categories and recurring templates; profile name and picture; invites; device revoke; in-app update; "Sync now" ("Synka nu") on S-8 and S-10 (their missing states) and on S-16 (D-080). A REST call that fails with a network error shows "Could not reach the server" and changes nothing locally. Pull-to-refresh on S-4 and S-5 is not disabled offline; it ends at once without a sync (D-127).
### 9.10 Other edge cases ### 9.10 Other edge cases
@@ -1379,8 +1379,8 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
| S-1 | Pairing | App name and one-line purpose; server URL field (prefilled; in release builds with `RELEASE_SERVER_URL`, which `:feature:onboarding` receives through the Hilt qualifier `@ReleaseServerUrl` for the prefill and for the non-default-server check of AC-2.6, D-121); 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 (`onboarding_code_help` with the admin's name when the invite link carries `from`, else the generic help line `onboarding_code_help_generic`; `PairingScreen(initialAdminName)` and `PairingViewModel.prefill` carry the name from `:app`'s `AppLink.Invite`, and a pasted link fills it too, D-087); 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; in release builds with `RELEASE_SERVER_URL`, which `:feature:onboarding` receives through the Hilt qualifier `@ReleaseServerUrl` for the prefill and for the non-default-server check of AC-2.6, D-121); 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 (`onboarding_code_help` with the admin's name when the invite link carries `from`, else the generic help line `onboarding_code_help_generic`; `PairingScreen(initialAdminName)` and `PairingViewModel.prefill` carry the name from `:app`'s `AppLink.Invite`, and a pasted link fills it too, D-087); progress while pairing; errors `invite_invalid`, network error, `rate_limited` | Pair; paste code |
| S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a `partner` pairing (`Pairing.inviteKind`, D-086) | Continue | | S-2 | Partner profile step | Display name field (prefilled; a blank name shows `onboarding_error_name_required` "Skriv ditt namn"), avatar with "Add picture"; the owner's name and avatar ("You are joining Jacob"). Shown only after a `partner` pairing (`Pairing.inviteKind`, D-086) | Continue |
| S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") | Invite partner (goes to S-21); Settings | | S-3 | Waiting for partner | Explanation that the partner must join first; the pending invite's expiry if any; error state when fetching the invite failed (snackbar with "Försök igen") | Invite partner (goes to S-21); Settings |
| S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when `SyncSummary.lastAttemptFailed`; `SyncStatusPill` takes no `failed` parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account | | S-4 | Home | Global balance header: both avatars, wording per §9.3, amount; "Settle up" button when non-zero; sync line: "Last synced …" and pending count when > 0, tap opens S-16 ("Sync failed" when `SyncSummary.lastAttemptFailed`; `SyncStatusPill` takes no `failed` parameter, D-088); update banner (S-17) when available ("Later" hides it for the ViewModel's lifetime only, so it returns on the next start); list of active accounts, each with name, balance wording and amount, number of expenses this month; collapsed "Archived (n)" section; empty state when no accounts ("Create your first account") | Add expense (FAB); Settle up; open account; New account; Pull to refresh (sync now, D-127) |
| S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed) | | S-5 | Account detail | Account name, default split with names (ratios written "60/40", `common_ratio`), balance header; list of expenses and settlements interleaved by date, grouped by month with month totals (household spend; month headers are not sticky), each expense row per AC-8.10, each settlement row per AC-11.7 (its title may wrap to 2 lines, 3 at 200 % font scale, and its second line is the type label `ledger_settlement_label` "Betalning" from `:core:design`, D-068); archived badge if archived (an archived account hides the add-expense FAB, AC-6.4, and keeps "Settle this account", D-052); pending-sync marker per row | Add expense (not on an archived account); Settle this account; Edit account; Archive / Unarchive; Delete (only if allowed); Pull to refresh (sync now, D-127) |
| S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state; the discard dialog has a title and no body text (D-080) | Save; Cancel | | S-6 | Account editor | Name field; default split control (one slider or stepper plus two percent fields that always sum to 100, two decimals allowed); preview "Of 100,00 kr: Jacob 60,00 kr, Anna 40,00 kr"; "Needs connection" state; the discard dialog has a title and no body text (D-080) | Save; Cancel |
| S-7 | Expense editor | Amount entry (large, numeric keyboard, `kr`); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); account, date, payer and split shown as four fixed full-width rows in that order, which do not move when a value changes (D-102); split section: mode chooser (`default` "Account default 60/40", `ratio` "Custom ratio", `exact` "Exact amounts", `full` "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode; after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071), `onSaved` handing `:app` the string resource id (D-095); the quick row is filled by `quickRowCategories` (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) | Save; Cancel; Delete (edit mode) | | S-7 | Expense editor | Amount entry (large, numeric keyboard, `kr`); description; account picker (active accounts); category picker (grid of icon + colour + name, active categories; for a new expense the category is selected from the description per §9.11, with no marker); date (today default, date picker); payer toggle (two avatars: "Paid by"); account, date, payer and split shown as four fixed full-width rows in that order, which do not move when a value changes (D-102); split section: mode chooser (`default` "Account default 60/40", `ratio` "Custom ratio", `exact` "Exact amounts", `full` "All on one person") and its controls; live preview of each person's share; notes (collapsed by default); validation errors inline; delete in edit mode; after saving, the snackbar "Utgiften sparades" without "Ångra" (an undo would be an audited create and delete that also notifies the partner, D-052), shown by the screen returned to (D-071), `onSaved` handing `:app` the string resource id (D-095); the quick row is filled by `quickRowCategories` (D-075) and category labels take up to two centred lines, a longer custom name ending the second line in "…" (D-076); the discard dialog has no body text (D-080) | Save; Cancel; Delete (edit mode) |
| S-8 | Expense detail | Amount, description, category, account, date, payer, each user's share and the split mode, notes, "Recurring: <template>" when generated; timeline (AC-12.4, AC-12.5); "deleted" state when opened from a notification of a deletion; "missing" state (not yet on this phone) with "Synka nu" (`expense_detail_sync_now`), online only (§9.9, D-080); after a delete `:app` shows `snackbar_expense_deleted` on the screen returned to (D-095) | Edit; Delete | | 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; "missing" state (not yet on this phone) with "Synka nu" (`expense_detail_sync_now`), online only (§9.9, D-080); after a delete `:app` shows `snackbar_expense_deleted` on the screen returned to (D-095) | Edit; Delete |
@@ -2887,3 +2887,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 0.23 | 2026-10-19 | Port 443 (`a86ad24` server and contracts, `4852803` bundle, `038c603` Android, `ad35231` portless Android fixtures, 30 S-21/S-22 goldens re-recorded) and the pinned-root template (`038c603`, no decision). D-124 (owner decision 2026-10-19): Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, no 8443 rule. Server and contracts: OpenAPI server `https://{host}`; E-7 and E-12 examples without a port; `tools/contracts-check.py` expects the portless header. Bundle: `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}`; health checks through NPM on 443; README router step "no new rule"; the APK-abuse stop-gap is disabling Kvitt's proxy host in NPM. Android: `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 intent filter has no port and matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443`, `:80`); S-1 goldens on `https://kvitt.example.org` (28 re-recorded) (§1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §11.4, §14.2.4, §14.3, E-7, E-12). Without a decision id: the pinned-root config is generated at build time from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost`, only when `kvitt.tls.pinnedRoot=true`, and the build stops while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (§8.5, §10.5.3). | | 0.23 | 2026-10-19 | Port 443 (`a86ad24` server and contracts, `4852803` bundle, `038c603` Android, `ad35231` portless Android fixtures, 30 S-21/S-22 goldens re-recorded) and the pinned-root template (`038c603`, no decision). D-124 (owner decision 2026-10-19): Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, no 8443 rule. Server and contracts: OpenAPI server `https://{host}`; E-7 and E-12 examples without a port; `tools/contracts-check.py` expects the portless header. Bundle: `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}`; health checks through NPM on 443; README router step "no new rule"; the APK-abuse stop-gap is disabling Kvitt's proxy host in NPM. Android: `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 intent filter has no port and matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443`, `:80`); S-1 goldens on `https://kvitt.example.org` (28 re-recorded) (§1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §11.4, §14.2.4, §14.3, E-7, E-12). Without a decision id: the pinned-root config is generated at build time from the template `android/app/src/releasePinnedRoot/network_security_config.xml` with the domain `kvitt.publicHost`, only when `kvitt.tls.pinnedRoot=true`, and the build stops while `android/app/src/release/res/raw/kvitt_root_ca.crt` is not committed (§8.5, §10.5.3). |
| 0.24 | 2026-10-20 | The self-contained Pi compose project (`6e5981d`), the `.env` without informational keys (`200c99f`) and the sparse-checkout workflow (`3820f9c`, no decision), with the workflow's first results; the compose deploy hint in the workflow and `infra/build-images.sh` (`28fa71b`, no decision). D-125 (owner decision 2026-10-20, revises D-103, D-105, D-117 point 5, D-120 point 6): the Kvitt Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project (`compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`); both containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi) with a read-only root and a 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; each mounts only its own secret file read-only (`create_host_path: false`), secret files the owner's, mode 0600; `.env` holds exactly the seven keys compose reads, `RCLONE_REMOTE` fixed to `gdrive:`; no install script, no `/opt`, no Docker daemon or system changes (Docker and the registry trust are prerequisites; private packages mean a one-time `docker login gitea.lan`); the scripts `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh` removed, their equivalents plain `docker compose` commands (backup `exec backup /backup.sh`, bootstrap and partner invite through `exec server kvitt-server`, deploy = backup, set `KVITT_TAG`, `pull`, `up -d`, rollback = the previous tag, APK = `scp` of the APK and then `latest.json` into `data/apk/`, old APKs removed by hand, digests with `docker image inspect`, no digest record, health checks by hand); restore moves files on the host, only rclone and sqlite3 run in the backup container; the compose default network; the development stack unchanged (uid 10001); rehearsed with Podman as uid 501 (§8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 OQ-3, §14.7). Without a decision id: the `.env` keys `KVITT_PI_HOST` and `KVITT_PI_SSH_USER` dropped (`200c99f`, revises D-103), `<KVITT_PI_HOST>` is now `<kvitt-pi-address>` (§8.9, §10.1, §10.5.1, §10.5.3, OQ-3); the Gitea workflow's results on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (a misnamed user-level secret, fixed by the owner 2026-10-19), run 69 built `v1.0.0` (`kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min, first build with `CARGO_BUILD_JOBS=1`), the sparse-checkout push built `v1.0.1`, the tag the Pi uses; the checkout is a cone-mode sparse checkout of `server` and `infra/backup` with `fetch-depth: 0` and `--filter=blob:none`; the diagnostic step removed (§10.3, §11.2). | | 0.24 | 2026-10-20 | The self-contained Pi compose project (`6e5981d`), the `.env` without informational keys (`200c99f`) and the sparse-checkout workflow (`3820f9c`, no decision), with the workflow's first results; the compose deploy hint in the workflow and `infra/build-images.sh` (`28fa71b`, no decision). D-125 (owner decision 2026-10-20, revises D-103, D-105, D-117 point 5, D-120 point 6): the Kvitt Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project (`compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`); both containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi) with a read-only root and a 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; each mounts only its own secret file read-only (`create_host_path: false`), secret files the owner's, mode 0600; `.env` holds exactly the seven keys compose reads, `RCLONE_REMOTE` fixed to `gdrive:`; no install script, no `/opt`, no Docker daemon or system changes (Docker and the registry trust are prerequisites; private packages mean a one-time `docker login gitea.lan`); the scripts `install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh` removed, their equivalents plain `docker compose` commands (backup `exec backup /backup.sh`, bootstrap and partner invite through `exec server kvitt-server`, deploy = backup, set `KVITT_TAG`, `pull`, `up -d`, rollback = the previous tag, APK = `scp` of the APK and then `latest.json` into `data/apk/`, old APKs removed by hand, digests with `docker image inspect`, no digest record, health checks by hand); restore moves files on the host, only rclone and sqlite3 run in the backup container; the compose default network; the development stack unchanged (uid 10001); rehearsed with Podman as uid 501 (§8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 OQ-3, §14.7). Without a decision id: the `.env` keys `KVITT_PI_HOST` and `KVITT_PI_SSH_USER` dropped (`200c99f`, revises D-103), `<KVITT_PI_HOST>` is now `<kvitt-pi-address>` (§8.9, §10.1, §10.5.1, §10.5.3, OQ-3); the Gitea workflow's results on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (a misnamed user-level secret, fixed by the owner 2026-10-19), run 69 built `v1.0.0` (`kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min, first build with `CARGO_BUILD_JOBS=1`), the sparse-checkout push built `v1.0.1`, the tag the Pi uses; the checkout is a cone-mode sparse checkout of `server` and `infra/backup` with `fetch-depth: 0` and `--filter=blob:none`; the diagnostic step removed (§10.3, §11.2). |
| 0.25 | 2026-10-21 | The app gate (D-126, owner decision 2026-10-21; Android `34e5cad`): outside the home LAN `192.168.1.0/24` NPM closes the connection (`return 444`) of every request without `X-Kvitt-App: <key>`, every route included (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*`); only `/.well-known/acme-challenge/` passes (Let's Encrypt renewal), tested on `$uri`; the LAN check reads `$realip_remote_addr`, because NPM's `real_ip_header X-Real-IP` for private peers makes `$remote_addr` forgeable; the server and `contracts/openapi.yaml` are unchanged, the bearer token stays the authentication. The app sends the header in `KvittApi`'s common headers and `PictureAuth` when built with `kvitt.appKey` (Gradle property, else `android/local.properties`, else empty = no header; 16 to 128 printable ASCII characters without white space; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey`; 952 Android tests); the key is the owner's `openssl rand -hex 32`, only in `local.properties` and NPM; rotation by a second `if` line, a new APK through FR-16, then the old line removed. Accepted: landing page and browser use only on the LAN, outside health check through the app's About screen or `curl -H`, a leaked APK leaks the key until rotation, no flood protection. The gate rehearsed in NPM v2.16.0's openresty with its includes (AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1). Without a decision id: `infra/README.md` step 4a (key, APK, gate, acceptance test), its `CARGO_BUILD_JOBS` text (in effect since `b4d9467`; run 69: `kvitt-server` 2 h 42 min) and its owner decisions marked answered. | | 0.25 | 2026-10-21 | The app gate (D-126, owner decision 2026-10-21; Android `34e5cad`): outside the home LAN `192.168.1.0/24` NPM closes the connection (`return 444`) of every request without `X-Kvitt-App: <key>`, every route included (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*`); only `/.well-known/acme-challenge/` passes (Let's Encrypt renewal), tested on `$uri`; the LAN check reads `$realip_remote_addr`, because NPM's `real_ip_header X-Real-IP` for private peers makes `$remote_addr` forgeable; the server and `contracts/openapi.yaml` are unchanged, the bearer token stays the authentication. The app sends the header in `KvittApi`'s common headers and `PictureAuth` when built with `kvitt.appKey` (Gradle property, else `android/local.properties`, else empty = no header; 16 to 128 printable ASCII characters without white space; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey`; 952 Android tests); the key is the owner's `openssl rand -hex 32`, only in `local.properties` and NPM; rotation by a second `if` line, a new APK through FR-16, then the old line removed. Accepted: landing page and browser use only on the LAN, outside health check through the app's About screen or `curl -H`, a leaked APK leaks the key until rotation, no flood protection. The gate rehearsed in NPM v2.16.0's openresty with its includes (AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1). Without a decision id: `infra/README.md` step 4a (key, APK, gate, acceptance test), its `CARGO_BUILD_JOBS` text (in effect since `b4d9467`; run 69: `kvitt-server` 2 h 42 min) and its owner decisions marked answered. |
| 0.26 | 2026-10-21 | Pull-to-refresh on Home and account detail (D-127, owner request 2026-10-21; Android `3778f5f`; revises design-spec S-4 "Pull-to-refresh is not offered"): pulling S-4 or S-5 past the threshold runs `SyncRepository.syncNow()`, the call behind S-16 "Synka nu"; the indicator stays while the sync runs and goes when it returns, success or failure; a failure shows only in the sync pill (S-5 has none, so a failed pull there shows nothing until Home's pill); offline the pull ends at once without a sync; a pull while a sync runs starts no second one. Shared `KvittPullToRefresh` in `:core:design` (`component/Refresh.kt`): Material 3 `PullToRefreshBox` with a Kvitt indicator (48 dp `primary-container` disc, the `sync` icon 18 dp turning 270° with the pull and popping from 80 % at the threshold on the new `KvittMotion.spatialFast()` with a `GestureThresholdActivate` haptic, a 32 dp `primary` arc that fills to the threshold and turns indeterminate while refreshing); `ContainedLoadingIndicator` replaces the arc once the pinned Material 3 (1.4.0) ships it. TalkBack reads "Synkar…" (`sync_syncing`, reused) as a polite live region; S-16 stays the TalkBack path. New goldens `S-4-home-refreshing` and `S-5-account-refreshing`; 959 Android tests. No change to `:core:model`, Room, the sync protocol, the server or `docs/ux/strings.csv` (AC-15.3, §9.9, §13.3; design-spec §2.11, §3.1, §3.6, S-4, S-5). |
+23 -23
View File
@@ -3,16 +3,18 @@
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this. Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
## 1. Where things stand ## 1. Where things stand
- State as of 2026-10-21. `main` at `34e5cad` (the Android side of D-126: `X-Kvitt-App` on every request), after `d9e189a` (SPEC v0.24, D-125); `origin/main` (Gitea) at `d9e189a`, so `34e5cad` is not pushed yet; plus the SPEC v0.25 docs commit from `agent-gate-docs` once it lands. Landed since SPEC v0.24: `34e5cad` (below). - State as of 2026-10-21. `main` at `3778f5f` (pull-to-refresh on Home and account detail, D-127), after `7521c01` (`infra/npm/kvitt.conf`, D-126) and `2acfc34` (SPEC v0.25, D-126); `origin/main` (Gitea) at `7521c01`, so `3778f5f` is not pushed yet; plus the SPEC v0.26 docs commit from `agent-ptr-docs` once it lands. A parallel feature holds D-128 and SPEC v0.27. Landed since SPEC v0.25: `3778f5f` (below).
- **Kvitt host name** (owner decision 2026-10-19): `kvitt.suorra.duckdns.org`, a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`; NPM issues its Let's Encrypt certificate; `KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org`, so `KVITT_PUBLIC_URL=https://kvitt.suorra.duckdns.org` (port 443, D-124). It is in `infra/pi/.env.example` and `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`, git-ignored). SPEC §10.1 and §10.5.1 point here. - **Kvitt host name** (owner decision 2026-10-19): `kvitt.suorra.duckdns.org`, a subdomain of the owner's DuckDNS family domain `suorra.duckdns.org`; NPM issues its Let's Encrypt certificate; `KVITT_PUBLIC_HOST=kvitt.suorra.duckdns.org`, so `KVITT_PUBLIC_URL=https://kvitt.suorra.duckdns.org` (port 443, D-124). It is in `infra/pi/.env.example` and `infra/pi/.env` (`KVITT_PUBLIC_HOST`) and `android/local.properties` (`kvitt.publicHost`, git-ignored). SPEC §10.1 and §10.5.1 point here.
- **NPM proxy host** for `kvitt.suorra.duckdns.org` is Online with a Let's Encrypt certificate (owner, 2026-10-19). The router needs no new rule: external TCP 443 already forwards to the Home Assistant Pi (owner confirmed, D-124). - **NPM proxy host** for `kvitt.suorra.duckdns.org` is Online with a Let's Encrypt certificate (owner, 2026-10-19). The router needs no new rule: external TCP 443 already forwards to the Home Assistant Pi (owner confirmed, D-124).
- **Images**: `gitea.lan/jacobh2/kvitt-server` and `gitea.lan/jacobh2/kvitt-backup`, tags `v1.0.0` (run 69) and `v1.0.1` (the sparse-checkout push; the tag the Pi uses), plus `latest`; the packages are public, so the Pi pulls without `docker login`. Gitea Actions on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (the user-level secret was misnamed on the owner's side, fixed 2026-10-19; a user-level secret of the repository owner reaches the repository); run 69 succeeded: login ok, `v1.0.0`, `kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min on the Pi 3 (first build, `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache); the tag push works with the `write:package` token. SPEC §10.3. - **Images**: `gitea.lan/jacobh2/kvitt-server` and `gitea.lan/jacobh2/kvitt-backup`, tags `v1.0.0` (run 69) and `v1.0.1` (the sparse-checkout push; the tag the Pi uses), plus `latest`; the packages are public, so the Pi pulls without `docker login`. Gitea Actions on the owner's Pi runner (act_runner 0.6.1, Alpine job container): the first failures were an empty secret (the user-level secret was misnamed on the owner's side, fixed 2026-10-19; a user-level secret of the repository owner reaches the repository); run 69 succeeded: login ok, `v1.0.0`, `kvitt-backup` 5 min 20 s, `kvitt-server` 2 h 42 min on the Pi 3 (first build, `CARGO_BUILD_JOBS=1`; later runs reuse the builder's cargo cache); the tag push works with the `write:package` token. SPEC §10.3.
- **Kvitt Pi** (D-125): Kvitt runs from `/home/jacobhagstedt/kvitt` as the owner's user `jacobhagstedt`, uid and gid 1000:1000 (`KVITT_UID`/`KVITT_GID`); Docker and the `gitea.lan` registry trust were already on the Pi; nothing outside that folder is installed or changed. **The stack is running** (owner, 2026-10-21): image `v1.0.1`, `server` and `backup` both healthy after a `chown` of `data/` (it had been created as root); compose's `mem_limit` warning (the Pi kernel has no memory cgroup, so the 160 MiB and 96 MiB limits are not enforced) is accepted. `/health` through NPM from outside answered `"status":"ok"` (public health OK). - **Kvitt Pi** (D-125): Kvitt runs from `/home/jacobhagstedt/kvitt` as the owner's user `jacobhagstedt`, uid and gid 1000:1000 (`KVITT_UID`/`KVITT_GID`); Docker and the `gitea.lan` registry trust were already on the Pi; nothing outside that folder is installed or changed. **The stack is running** (owner, 2026-10-21): image `v1.0.1`, `server` and `backup` both healthy after a `chown` of `data/` (it had been created as root); compose's `mem_limit` warning (the Pi kernel has no memory cgroup, so the 160 MiB and 96 MiB limits are not enforced) is accepted. `/health` through NPM from outside answered `"status":"ok"` (public health OK). **Live** (2026-10-21): both phones are paired against it and the Splitwise import (M11) is done.
- **App gate** (D-126, owner decision 2026-10-21, in progress): outside the home LAN `192.168.1.0/24` NPM closes (`return 444`) every request without `X-Kvitt-App: <key>`, every route included; the key is the build property `kvitt.appKey` (`android/local.properties`, git-ignored), generated by the owner with `openssl rand -hex 32` and pasted into NPM's block; the server and `contracts/openapi.yaml` are unchanged. Android side landed (`34e5cad`); the docs (SPEC v0.25, `infra/README.md` step 4a with the block and the acceptance test) are `agent-gate-docs`. Not yet in NPM: `android/local.properties` has no `kvitt.appKey` yet (checked 2026-10-21), so no keyed APK exists. - **App gate** (D-126, owner decision 2026-10-21): outside the home LAN `192.168.1.0/24` NPM closes (`return 444`) every request without `X-Kvitt-App: <key>`, every route included; the key is the build property `kvitt.appKey` (`android/local.properties`, git-ignored), generated by the owner with `openssl rand -hex 32` and pasted into NPM's block (`infra/npm/kvitt.conf`, `7521c01`); the server and `contracts/openapi.yaml` are unchanged. Android side `34e5cad`, docs `2acfc34` (SPEC v0.25, `infra/README.md` step 4a). `android/local.properties` has a `kvitt.appKey` and `infra/pi/out/apk/` holds `kvitt-125.apk` (checked 2026-10-21); whether both phones run a keyed APK and the gate is in NPM is not recorded here (orchestrator confirms with the owner, §3).
- **First release APK**: `apk/kvitt-123.apk` (10.0 MB, git-ignored by `*.apk`) handed to the owner from the main checkout for sideloading on both phones; it was built before any `kvitt.appKey` existed, so it sends no `X-Kvitt-App`: it works only until the gate is pasted (on the home Wi-Fi it keeps working afterwards). The phones need a keyed APK before the gate goes in (§3). - **First release APK**: `apk/kvitt-123.apk` (10.0 MB, git-ignored by `*.apk`) handed to the owner from the main checkout for sideloading on both phones (both paired, 2026-10-21); it was built before any `kvitt.appKey` existed, so it sends no `X-Kvitt-App`: it works only until the gate is pasted (on the home Wi-Fi it keeps working afterwards). The phones need a keyed APK before the gate goes in (§3).
- History rewrite and first push, 2026-10-18 (owner authorised): superseded PNG versions were dropped from the whole history (no LFS), the current goldens and UX mock-ups restored in `18c527f`; pack 45 MiB; `origin/main` pushed at `18c527f`. A bundle of the pre-rewrite history (`kvitt-pre-rewrite.bundle`) is kept outside the repository, in the orchestrator's scratchpad. Every commit from M2b on got a new hash; the oxipng pass (`7c453d8` before the rewrite) no longer exists as a commit (it only touched PNGs). SPEC, `docs/decisions.md`, this file and `prompts/README.md` cite the rewritten hashes since v0.18 (`7c453d8` stays as the oxipng pass's name; `docs/validation/ac-coverage.md` keeps its pre-rewrite hashes verbatim); since v0.19 the plans cite rewritten hashes too (eight pre-rewrite hashes in `PLAN-*.md` replaced by the commit with the same subject); `docs/spikes/pi-image-arm64.md` and the READMEs had none stale. For any other old hash, find the commit by its subject with `git log --oneline --grep`. - History rewrite and first push, 2026-10-18 (owner authorised): superseded PNG versions were dropped from the whole history (no LFS), the current goldens and UX mock-ups restored in `18c527f`; pack 45 MiB; `origin/main` pushed at `18c527f`. A bundle of the pre-rewrite history (`kvitt-pre-rewrite.bundle`) is kept outside the repository, in the orchestrator's scratchpad. Every commit from M2b on got a new hash; the oxipng pass (`7c453d8` before the rewrite) no longer exists as a commit (it only touched PNGs). SPEC, `docs/decisions.md`, this file and `prompts/README.md` cite the rewritten hashes since v0.18 (`7c453d8` stays as the oxipng pass's name; `docs/validation/ac-coverage.md` keeps its pre-rewrite hashes verbatim); since v0.19 the plans cite rewritten hashes too (eight pre-rewrite hashes in `PLAN-*.md` replaced by the commit with the same subject); `docs/spikes/pi-image-arm64.md` and the READMEs had none stale. For any other old hash, find the commit by its subject with `git log --oneline --grep`.
- History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f, `8103dae` to `b985ce9` server overhaul, `48fe119` SPEC v0.18, `e4e6e5d` to `c582363` Android overhaul, `3ef2c15` SPEC v0.19, `96e4cbd` APK download rate limit (D-119), `481889a` SPEC v0.20, `f1abb0d` `cargo audit` in the server gate, `4f69ae0` Gitea login in the run line, `d05f13a` Pi stack behind NPM, `62f5279` and `9231a1d` infra README for NPM (D-120), `d090c6c` Android host property (D-121), `4bcff2c` SPEC v0.21, `dd07ca4` temporary workflow diagnostic step, `4a4e45f` S-22's release server and `4b1ae0c` `publish-apk.sh` host (D-122), `c9abd24` and `e381e37` server host defaults and OpenAPI `{host}` (D-123), `ac17b41` `install.sh` step number, `cce3e4e` SPEC v0.22, `a86ad24` server and contracts on port 443, `4852803` bundle on port 443 (D-124), `038c603` Android on port 443 and the pinned-root template, `ad35231` portless Android fixtures, `b1f4c10` SPEC v0.23, `3820f9c` sparse-checkout workflow, `200c99f` `.env` without informational keys, `6e5981d` self-contained Pi compose project (D-125), `28fa71b` compose deploy hint in the workflow and `infra/build-images.sh`. - History (rewritten hashes): `5e1be38` plans and prompts, `c885aba` M2c-dev, `76ee957` SPEC v0.3, `c277ee3` M2b, `06f48ef` M2a, `6dce4c7` SPEC v0.4, `7869ec4` M3, `8f0e156` M5, `853c038` SPEC v0.5, `15056dc` M4, `d9dd584` SPEC v0.6, `50339ae` M9, `86e08a1` SPEC v0.7, `aa765bb` M12a, `d39eb2c` SPEC v0.8, `b09f322` M12c, `6eaccec` M12b, `0006bd7` UX Kalas categories and tokens, `6da6b1b` UX sprint U2, `e676812` SPEC v0.9, `557af7e` M12d, `7730a31` M6, `9ee67d4` SPEC v0.10, `a41f240` and `6453932` app wiring before M8, `1ea752c` M7a, `ea7d494` M7e, `7380e50` M7d, `0e1d99d` M7b, `0b140aa` M7c, `c4ab35f` M7f, `b24c8b5` M6b, `f4dc10c` SPEC v0.11, `3dda414` M7g, `cb832a7` and `3d9eea8` M8, `6159e18` SPEC v0.12, `8c60715` M12e, `b7b7483` M12e launcher icon, `51d6ff8` SPEC v0.13, `a312758` Vico removal, `f56dcbe` FCM `fid`, `3e6c337` Pi image arm64 check, `e2dba84` SPEC v0.14, `328c466` M8b, `6eb3f85` `FcmMessageBody.fid`, `bfde12c` SPEC v0.15, `75b549f` M8c goldens, `a0c25b0` M8c licences row, `c30a49e` R8 release build, `bacbd18` M8d, `bfc3acb` SPEC v0.16, `2476667` M2c-pi bundle, `a59abeb` M8e, `b4d9467` Dockerfile `CARGO_BUILD_JOBS`, `956908a` M8f, `1328afe` chips cleanup, `400eac9` SPEC v0.17, `18c527f` goldens restored after the rewrite; then `2fac1ec` to `e70fe0d` infra overhaul, `8660b7f` server gap pass, `7fb86f5` infra security, `dc7a296` to `a0259ab` M12f, `8103dae` to `b985ce9` server overhaul, `48fe119` SPEC v0.18, `e4e6e5d` to `c582363` Android overhaul, `3ef2c15` SPEC v0.19, `96e4cbd` APK download rate limit (D-119), `481889a` SPEC v0.20, `f1abb0d` `cargo audit` in the server gate, `4f69ae0` Gitea login in the run line, `d05f13a` Pi stack behind NPM, `62f5279` and `9231a1d` infra README for NPM (D-120), `d090c6c` Android host property (D-121), `4bcff2c` SPEC v0.21, `dd07ca4` temporary workflow diagnostic step, `4a4e45f` S-22's release server and `4b1ae0c` `publish-apk.sh` host (D-122), `c9abd24` and `e381e37` server host defaults and OpenAPI `{host}` (D-123), `ac17b41` `install.sh` step number, `cce3e4e` SPEC v0.22, `a86ad24` server and contracts on port 443, `4852803` bundle on port 443 (D-124), `038c603` Android on port 443 and the pinned-root template, `ad35231` portless Android fixtures, `b1f4c10` SPEC v0.23, `3820f9c` sparse-checkout workflow, `200c99f` `.env` without informational keys, `6e5981d` self-contained Pi compose project (D-125), `28fa71b` compose deploy hint in the workflow and `infra/build-images.sh`.
- Landed since SPEC v0.24 (recorded in v0.25): - Landed since SPEC v0.25 (recorded in v0.26):
- Pull-to-refresh (`3778f5f`, D-127, owner request 2026-10-21): S-4 and S-5 run `SyncRepository.syncNow()` (the S-16 "Synka nu" call) on a pull; the indicator stays until the sync returns, success or failure (failure only in the sync pill); offline the pull ends at once. `KvittPullToRefresh` in `:core:design` (`component/Refresh.kt`): `PullToRefreshBox` with a `primary-container` disc, the `sync` icon turning with the pull and popping at the threshold (`KvittMotion.spatialFast()`, `GestureThresholdActivate` haptic), a `primary` arc that turns indeterminate; `ContainedLoadingIndicator` swaps in when the pinned Material 3 ships it. TalkBack "Synkar…" (`sync_syncing`, reused). Goldens `S-4-home-refreshing`, `S-5-account-refreshing`; 959 Android tests.
- Landed since SPEC v0.24 (recorded in v0.25, plus `infra/npm/kvitt.conf` `7521c01`, no decision of its own):
- App key header (`34e5cad`, D-126): `kvitt.appKey` (`-Pkvitt.appKey`, else `android/local.properties`, else empty = no header; 16 to 128 printable ASCII characters without white space, else the build stops giving the length only), `BuildConfig.APP_KEY`, `AppBuildInfo.appKey` (`:data`, `toString` hides it); `X-Kvitt-App` in `KvittApi`'s common headers and `PictureAuth`; 952 Android tests. - App key header (`34e5cad`, D-126): `kvitt.appKey` (`-Pkvitt.appKey`, else `android/local.properties`, else empty = no header; 16 to 128 printable ASCII characters without white space, else the build stops giving the length only), `BuildConfig.APP_KEY`, `AppBuildInfo.appKey` (`:data`, `toString` hides it); `X-Kvitt-App` in `KvittApi`'s common headers and `PictureAuth`; 952 Android tests.
- Landed since SPEC v0.23 (recorded in v0.24): - Landed since SPEC v0.23 (recorded in v0.24):
- Sparse-checkout workflow (`3820f9c`, no decision): cone-mode `sparse-checkout` of `server` and `infra/backup` (the two build contexts), so the action fetches with `--filter=blob:none` and no PNG is checked out; `fetch-depth: 0` stays because `fetch-tags` with a shallow fetch only brings tags on the tip; the temporary "Diagnose secrets" step removed. Its push built `v1.0.1`. - Sparse-checkout workflow (`3820f9c`, no decision): cone-mode `sparse-checkout` of `server` and `infra/backup` (the two build contexts), so the action fetches with `--filter=blob:none` and no PNG is checked out; `fetch-depth: 0` stays because `fetch-tags` with a shallow fetch only brings tags on the tip; the temporary "Diagnose secrets" step removed. Its push built `v1.0.1`.
@@ -44,22 +46,22 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4). Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 is fixed (`725901c`). - Validation reports (SPEC §11.4): `docs/validation/ac-coverage.md` (taken at `956908a`, `ed1b130` before the rewrite): 270 rows, 260 Done, 5 Partial, 1 Missing, 4 device-only; feature complete in code, not yet against §4's acceptance rule; its S-16 and S-22 mismatches are fixed by M12f. `docs/validation/security-review.md` (taken at `18c527f`): Critical 0, High 1, Medium 3, Low 10, Info 8; L-6 kept (D-116), L-7 fixed (D-116), L-1, L-2, L-5, L-9, M-2 fixed (D-117), L-8 fixed (`e70fe0d`); H-1 (`725901c`, D-115), M-3 (`baa5806`) and L-4 (`b985ce9`, D-114) fixed by the server overhaul; L-10 left as the spec says, decision pending (§4). Owner answers of 2026-10-19: M-1 no (backups stay unencrypted, rclone keeps the `drive` scope; accepted risk in SPEC §8.1 and §8.7), L-3 accepted and fixed (`96e4cbd`, D-119), the 16 MP and 512-character limits (D-114) confirmed; M-2's remaining question (the Home Assistant SSH key form) is moot with the NPM rework (D-120) and closed; D-120 also moves L-1 and the headers to NPM, makes L-2 moot, reverses the L-8 fix and replaces I-5's check with a conntrack check (dated notes in the review, SPEC §11.4). H-1 is fixed (`725901c`).
- Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18. - Server overhaul (landed on `main` `8103dae` to `b985ce9`): shared helpers and one test harness, clippy pedantic wins; H-1 rate limiter (`725901c`, D-115), pictures at most 16 MP and 64 MB decoded (`baa5806`, D-114), `fcm_token` at most 512 characters (`b985ce9`, D-114); shared row inserts, invite creation and CLI helpers, `tests/common/` harness, synchronous `tasks::start`; 268 server tests; all crates at their latest versions. Recorded in SPEC v0.18.
- In flight: - In flight:
- This docs pass (`agent-gate-docs`: SPEC v0.25, D-126, `infra/README.md` step 4a and the gate block, its stale `CARGO_BUILD_JOBS` text and owner decisions fixed, this file). - This docs pass (`agent-ptr-docs`: SPEC v0.26, D-127, design-spec S-4, S-5, §2.11, §3.1, §3.6, this file).
- The owner: first backup, bootstrap and release pairing; then the app key and the gate (§3). - A parallel feature with D-128 and SPEC v0.27 (orchestrator's brief).
- Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2). - Earlier outcomes (details in SPEC §11.2, §11.3 and `docs/decisions.md`): M8 end-to-end test passed on the owner's S26 against the dev server on 2026-10-18 (pairing, scripted partner, offline expense synced, push by `fid` delivered); M8b to M8f UI fixes; the M2c-pi bundle `infra/pi/` (D-103 to D-106, owner checklist in `infra/README.md` "Pi: what the owner does"); release builds R8-shrunk and signed (alias `kvitt`, RSA 4096, v2).
- Compatibility: an APK from M12e on cannot pair or list devices against a pre-M12e server, so the server is rebuilt and redeployed before a new APK is installed (SPEC §14.8). The dev server already runs schema 3. - Compatibility: an APK from M12e on cannot pair or list devices against a pre-M12e server, so the server is rebuilt and redeployed before a new APK is installed (SPEC §14.8). The dev server already runs schema 3.
- Remote: `origin` on Gitea, SSH key works; pushed at `6e5981d`. The Gitea secret `kvitt_cicd` (a user-level secret of `jacobh2`, renamed by the owner 2026-10-19) works; the Drive token is `infra/secrets/rclone.conf` (`[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted). - Remote: `origin` on Gitea, SSH key works; pushed at `7521c01`. The Gitea secret `kvitt_cicd` (a user-level secret of `jacobh2`, renamed by the owner 2026-10-19) works; the Drive token is `infra/secrets/rclone.conf` (`[gdrive]`; folder id in `.env` as `RCLONE_FOLDER_ID`). Unused on the Mac since D-120: `infra/secrets/ha_ssh_key` and `ha_ssh_key.pub` (git-ignored; may be deleted).
- Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 952 tests at `34e5cad`; server 276 tests at `c9abd24`). - Both gates exist: Android `tools/gradle-serial.sh check` (from `android/`), server `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo audit && cargo test` (from `server/`, `cargo audit` since `f1abb0d`). Both green on `main` as far as reported (Android 959 tests at `3778f5f`; server 276 tests at `c9abd24`).
- Importer: the dry run of 2026-10-18 reconciled on both real files (Vardag 1746 rows, Hemma 37 rows, 0 unmapped). The CSV files stay in `export/` (git-ignored). - Importer: the dry run of 2026-10-18 reconciled on both real files (Vardag 1746 rows, Hemma 37 rows, 0 unmapped). The production import on the Kvitt Pi is done (owner, 2026-10-21). The CSV files stay in `export/` (git-ignored).
- Development stack (M2c-dev): running on the Mac at `http://192.168.1.210:8080`, database at schema 3, data kept; backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` and `kvitt-20261018T130443Z.sqlite.gz` (git-ignored). On this Podman setup run `infra/dev/up.sh --down`, then `infra/dev/up.sh`, to swap in a rebuilt image. Since `7fb86f5` `up.sh` copies only `fcm-service-account.json` into `infra/dev/secrets/` (git-ignored). The owner's phone is paired with it; a scripted partner exists (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`. - Development stack (M2c-dev): running on the Mac at `http://192.168.1.210:8080`, database at schema 3, data kept; backups `infra/dev/backups/kvitt-20261018T093143Z.sqlite.gz` and `kvitt-20261018T130443Z.sqlite.gz` (git-ignored). On this Podman setup run `infra/dev/up.sh --down`, then `infra/dev/up.sh`, to swap in a rebuilt image. Since `7fb86f5` `up.sh` copies only `fcm-service-account.json` into `infra/dev/secrets/` (git-ignored). The owner's phone is paired with it; a scripted partner exists (tokens held by the orchestrator outside the repo). Firebase project `kvitt-1337` with apps `se.hagstedt.kvitt` and `se.hagstedt.kvitt.debug`; service account `kvitt-fcm`.
- Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the release app `se.hagstedt.kvitt`, rebuilt with the host `kvitt.suorra.duckdns.org` and no port (D-124) and installed 2026-10-19; release pairing against the Kvitt Pi is next (§3 item 1, with `apk/kvitt-123.apk`). - Installed on the owner's phone (SM-S942B): the debug app `se.hagstedt.kvitt.debug`, paired against the dev server; the release app `se.hagstedt.kvitt`, rebuilt with the host `kvitt.suorra.duckdns.org` and no port (D-124) and installed 2026-10-19; paired against the Kvitt Pi 2026-10-21. The partner's phone is paired too.
- Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB. - Disk: the MacBook had about 72 GB free during the v0.20 docs pass (2026-10-19); run `tools/clean-builds.sh` when it drops under about 5 GB.
## 2. Open work (one line each, with who owns it: agent or owner) ## 2. Open work (one line each, with who owns it: agent or owner)
- Land `agent-gate-docs` (orchestrator): SPEC v0.25, D-126, `infra/README.md`, HANDOFF. Push `34e5cad` and it to `origin` (neither touches `server/**`, `infra/backup/**` or `.gitea/**`, so no workflow run). - Land `agent-ptr-docs` (orchestrator): SPEC v0.26, D-127, design-spec, HANDOFF. Push `3778f5f` and it to `origin` (neither touches `server/**`, `infra/backup/**` or `.gitea/**`, so no workflow run).
- Closed by `28fa71b`: the `/opt/kvitt` leftovers in `.gitea/workflows/build-images.yaml` and `infra/build-images.sh` (compose deploy hint, `docker image inspect`). Still stale in the workflow header (agent, next infra pass): "NOT YET RUN" and "First-build duration on the Pi: unknown" (now 2 h 42 min). - Closed by `28fa71b`: the `/opt/kvitt` leftovers in `.gitea/workflows/build-images.yaml` and `infra/build-images.sh` (compose deploy hint, `docker image inspect`). Still stale in the workflow header (agent, next infra pass): "NOT YET RUN" and "First-build duration on the Pi: unknown" (now 2 h 42 min).
- Pushed: `28fa71b` and the v0.24 docs commit (`origin/main` at `d9e189a`). - Pushed: through `7521c01` (SPEC v0.25, D-126, `infra/npm/kvitt.conf`).
- Closed by `agent-gate-docs`: `infra/README.md`'s stale `CARGO_BUILD_JOBS` text and its "Owner decisions (open)" (now answered). - Closed by `2acfc34` (`agent-gate-docs`): `infra/README.md`'s stale `CARGO_BUILD_JOBS` text and its "Owner decisions (open)" (now answered).
- `infra/backup/Dockerfile`: stale comment "on the Pi the container runs as uid 10001" (since D-125 the owner's uid); left as is, because editing it triggers a 5-minute image build (agent, with the next change there). - `infra/backup/Dockerfile`: stale comment "on the Pi the container runs as uid 10001" (since D-125 the owner's uid); left as is, because editing it triggers a 5-minute image build (agent, with the next change there).
- Historical `:8443` and `icehack.asuscomm.com` mentions in `docs/ux/` and `docs/validation/` are left as the record of their time (no action unless the owner wants them rewritten). - Historical `:8443` and `icehack.asuscomm.com` mentions in `docs/ux/` and `docs/validation/` are left as the record of their time (no action unless the owner wants them rewritten).
- Restore drill AC-18.2 (`docs/validation/restore-drill.md`, M10; the one Missing row of the AC-coverage audit); `infra/RESTORE.md` is the runbook since D-125. - Restore drill AC-18.2 (`docs/validation/restore-drill.md`, M10; the one Missing row of the AC-coverage audit); `infra/RESTORE.md` is the runbook since D-125.
@@ -73,14 +75,12 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- `PLAN-M2c-pi.md` is superseded where it names Caddy, cert-sync, scripts or `/opt/kvitt` (note at its top, D-120 and D-125); SPEC §10 and `infra/README.md` are current. `PLAN-M11.md` has a note that `/opt/kvitt/import` is now `~/kvitt/import`. - `PLAN-M2c-pi.md` is superseded where it names Caddy, cert-sync, scripts or `/opt/kvitt` (note at its top, D-120 and D-125); SPEC §10 and `infra/README.md` are current. `PLAN-M11.md` has a note that `/opt/kvitt/import` is now `~/kvitt/import`.
- Plans (SPEC §11.2 v0.24), in dependency order; prompt `prompts/<id>.md`: - Plans (SPEC §11.2 v0.24), in dependency order; prompt `prompts/<id>.md`:
- M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12f, U1, U2, R8 release build, M2c-pi bundle with its NPM rework and the self-contained compose project (D-125), server gap pass, infra overhaul, infra security, the server overhaul, the Android overhaul, the APK download rate limit, `cargo audit`, the Android host property and its follow-ups (D-122), the server host defaults (D-123), port 443 (D-124): landed. - M1, M2a, M2b, M2c-dev, M3, M4, M5, M6, M6b, M7a to M7g, M8 (phone test passed), M8b to M8f, M9, M12a to M12f, U1, U2, R8 release build, M2c-pi bundle with its NPM rework and the self-contained compose project (D-125), server gap pass, infra overhaul, infra security, the server overhaul, the Android overhaul, the APK download rate limit, `cargo audit`, the Android host property and its follow-ups (D-122), the server host defaults (D-123), port 443 (D-124): landed.
- Next: the owner pairs the release app against the Kvitt Pi (stack running, `v1.0.1`), then the app key and gate (D-126, §3); M10 First release after it (keystore per §10.6, D-101); M11 Import history after M9 and M10 (procedure SPEC §10.8 "Production run", now in `~/kvitt/import/`, D-025, D-125; each run must reconcile, D-045). - Next: the app key and gate if not yet done (D-126, §3); M10 First release (keystore per §10.6, D-101; the reports `first-release.md` and `restore-drill.md`). Done 2026-10-21: release pairing of both phones against the Kvitt Pi and M11 Import history (SPEC §10.8 "Production run").
## 3. HUMAN steps pending (relay in chat) ## 3. HUMAN steps pending (relay in chat)
Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers healthy) and the public health check through NPM. Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers healthy), the public health check through NPM, bootstrap, release pairing of both phones, the Splitwise import.
1. First backup, bootstrap, release pairing. On the Pi in `~/kvitt`: `docker compose exec backup /backup.sh` ends with `backup: ok <stamp>` (yes/no); then `docker compose exec server kvitt-server bootstrap` prints the admin invite code, link and expiry; sideload `apk/kvitt-123.apk` and pair the release app `se.hagstedt.kvitt` with it (the orchestrator relays the exact screen labels then). 1. App key (D-126; `infra/README.md` step 4a items 1 to 3; the key and `kvitt-125.apk` exist on the Mac, the rest is unconfirmed): generate `kvitt.appKey` in `android/local.properties` on the Mac (an agent may run the one-line `openssl` command; the value is never printed); build and publish a keyed APK (`infra/publish-apk.sh`, copy to `data/apk/`); both phones on it (update banner, or sideload).
2. Partner phone, after the owner's release app is paired: `docker compose exec server kvitt-server invite-partner` on the Pi; sideload the release APK on the partner's phone and pair with that invite (relayed with screen labels when the time comes). 2. Gate (step 4a items 4 and 5), only after both phones run the keyed APK: NPM, Hosts, Proxy Hosts, Kvitt row, Edit, Advanced: paste the step 4 block with the key in place of `KVITT_APP_KEY`, Save (row Online, yes/no). Acceptance test: on home Wi-Fi Chrome opens `https://kvitt.suorra.duckdns.org/i/TEST` and the invite page shows (yes/no); with Wi-Fi off the same URL gives a connection error (yes/no); with Wi-Fi off Kvitt's About screen shows the server version (yes/no).
3. App key (D-126; `infra/README.md` step 4a items 1 to 3), after `agent-gate-docs` lands: generate `kvitt.appKey` in `android/local.properties` on the Mac (an agent may run the one-line `openssl` command; the value is never printed); build and publish a keyed APK (`infra/publish-apk.sh`, copy to `data/apk/`); both phones on it (update banner, or sideload).
4. Gate (step 4a items 4 and 5), only after both phones run the keyed APK: NPM, Hosts, Proxy Hosts, Kvitt row, Edit, Advanced: paste the step 4 block with the key in place of `KVITT_APP_KEY`, Save (row Online, yes/no). Acceptance test: on home Wi-Fi Chrome opens `https://kvitt.suorra.duckdns.org/i/TEST` and the invite page shows (yes/no); with Wi-Fi off the same URL gives a connection error (yes/no); with Wi-Fi off Kvitt's About screen shows the server version (yes/no).
## 4. Owner questions (asked, unanswered) ## 4. Owner questions (asked, unanswered)
- OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1". - OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1".
@@ -92,9 +92,9 @@ Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers health
- Nothing. - Nothing.
## 6. Numbering ## 6. Numbering
- Decisions: D-001 to D-126 used, except D-008 and D-009 (never used); nothing reserved. Next free: **D-127**. - Decisions: D-001 to D-127 used, except D-008 and D-009 (never used); D-128 reserved for the parallel feature. Next free: **D-129**.
- Spec version: v0.25 written; next **v0.26**. - Spec version: v0.26 written; v0.27 reserved for the parallel feature; next **v0.28**.
- Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.25. - Server schema version **3** (migration 0003); next **4**. Room schema version **1** (`android/data/schemas/se.hagstedt.kvitt.data.db.KvittDatabase/1.json`); next **2**. Both unchanged by v0.26.
- Sync protocol **1**, push payload **1**. - Sync protocol **1**, push payload **1**.
## 7. How the work is run ## 7. How the work is run
+1
View File
@@ -131,3 +131,4 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| D-124 | 2026-10-19 | Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects the proxy host by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, and there is no 8443 rule (revises D-120 point 2 and the `:8443` of D-120 to D-123). (1) Server and contracts (`a86ad24`): the OpenAPI server is `https://{host}` (`contracts/openapi.yaml`, `openapi.rs` `PUBLIC_SERVER_URL`); examples E-7 `public_url` and E-12 `link` use `https://kvitt.example.invalid` without a port; `tools/contracts-check.py` expects the portless header; the server's tests, configuration message and README name no port. (2) Bundle (`4852803`): `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` in `infra/pi/compose.yaml`; the health checks through NPM use `https://<KVITT_PUBLIC_HOST>/health` on 443 (`infra/pi/lib.sh`); `infra/publish-apk.sh` prints the release server without a port; `infra/README.md` step 7 is "Router: no new rule" (verify the existing TCP 443 rule to `<HA_HOST>`); the APK-abuse stop-gap is now "disable Kvitt's proxy host in NPM", because the 443 forward also serves Home Assistant. (3) Android (`038c603`): `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 `https` intent filter carries no `android:port`, which matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443` for `https`, `:80` for `http`); the S-1 fixture `GOLDEN_SERVER` is `https://kvitt.example.org` (28 S-1 goldens re-recorded, only the server row changed). | Owner decision 2026-10-19: NPM serves every proxy host on 443 and picks Kvitt's by name, so a second external port adds a router rule and nothing else; the owner confirmed that external 443 already forwards to the Home Assistant Pi. | SPEC §1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §14.2.4, §14.3, E-7, E-12 | | D-124 | 2026-10-19 | Kvitt is served on port 443 (`https://<host>`, no explicit port) because NPM listens on 443 and selects the proxy host by host name; the router's existing external-443 forward to the Home Assistant Pi serves Kvitt, and there is no 8443 rule (revises D-120 point 2 and the `:8443` of D-120 to D-123). (1) Server and contracts (`a86ad24`): the OpenAPI server is `https://{host}` (`contracts/openapi.yaml`, `openapi.rs` `PUBLIC_SERVER_URL`); examples E-7 `public_url` and E-12 `link` use `https://kvitt.example.invalid` without a port; `tools/contracts-check.py` expects the portless header; the server's tests, configuration message and README name no port. (2) Bundle (`4852803`): `KVITT_PUBLIC_URL=https://${KVITT_PUBLIC_HOST}` in `infra/pi/compose.yaml`; the health checks through NPM use `https://<KVITT_PUBLIC_HOST>/health` on 443 (`infra/pi/lib.sh`); `infra/publish-apk.sh` prints the release server without a port; `infra/README.md` step 7 is "Router: no new rule" (verify the existing TCP 443 rule to `<HA_HOST>`); the APK-abuse stop-gap is now "disable Kvitt's proxy host in NPM", because the 443 forward also serves Home Assistant. (3) Android (`038c603`): `releaseServerUrl(host)` = `https://<host>` (`RELEASE_SERVER_PORT` removed); the AC-2.6 `https` intent filter carries no `android:port`, which matches every port of the host, so an old `:8443` link still opens S-1 with the server it names; invite-link parsing drops an explicit default port (`:443` for `https`, `:80` for `http`); the S-1 fixture `GOLDEN_SERVER` is `https://kvitt.example.org` (28 S-1 goldens re-recorded, only the server row changed). | Owner decision 2026-10-19: NPM serves every proxy host on 443 and picks Kvitt's by name, so a second external port adds a router rule and nothing else; the owner confirmed that external 443 already forwards to the Home Assistant Pi. | SPEC §1, AC-1.1, AC-2.6, §6.1, §7, §8.1, §8.5, §10.1, §10.2, §10.4, §10.5, §10.5.3, §10.6, §10.10, §11.2, §11.3, §14.2.4, §14.3, E-7, E-12 |
| D-125 | 2026-10-20 | The Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project: `compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`; containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi); no install script, no `/opt`, no Docker daemon or system changes (registry trust and Docker are prerequisites); operations are plain `docker compose` commands. Details: `.env` holds exactly the seven keys compose reads (`KVITT_REGISTRY`, `KVITT_TAG`, `KVITT_PUBLIC_HOST`, `HA_HOST`, `KVITT_UID`, `KVITT_GID`, `RCLONE_FOLDER_ID`); `RCLONE_REMOTE` fixed to `gdrive:` in compose; private packages → one-time `docker login gitea.lan`; each container mounts only its own secret file read-only (`create_host_path: false`); read-only root, 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; secret files the owner's, mode 0600. Equivalents of the removed scripts (`install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh`): backup `docker compose exec backup /backup.sh`; bootstrap `docker compose exec server kvitt-server bootstrap`; partner invite `docker compose exec server kvitt-server invite-partner`; deploy = backup, set `KVITT_TAG`, `pull`, `up -d`; rollback = the previous tag; APK = `scp` the APK, then `latest.json`, into `data/apk/` (old APKs removed by hand); digests via `docker image inspect`. Restore moves files on the host; only rclone and sqlite3 run in the backup container. Compose default network. The development stack is unchanged (uid 10001). Revises D-103 (scripts, `/opt/kvitt`, `.env` keys), D-105 (uid and `rclone.conf` owner on the Pi), D-117 point 5 (the digest record is dropped), D-120 point 6 (health checks via scripts dropped). Landed `6e5981d`. Rehearsed locally with Podman as uid 501: both containers healthy, `/health` 200, bootstrap, APK served, backup in three modes (no folder id, a local remote, a failing remote), the restore steps. | Owner decision 2026-10-20: 'It should be a self contained docker compose thing', 'do not install anything in any other place'. | SPEC §8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 (OQ-3), §14.7; `infra/pi/compose.yaml`, `infra/pi/.env.example`, `infra/README.md` "Pi: what the owner does", `infra/RESTORE.md`, `PLAN-M11.md` and `PLAN-M2c-pi.md` notes | | D-125 | 2026-10-20 | The Pi runs Kvitt from `/home/jacobhagstedt/kvitt`, a self-contained compose project: `compose.yaml`, `.env`, `secrets/`, relative bind mounts `./data`, `./backups`, `./import`; containers run as the owner's uid/gid (`KVITT_UID`/`KVITT_GID`, 1000 on the Pi); no install script, no `/opt`, no Docker daemon or system changes (registry trust and Docker are prerequisites); operations are plain `docker compose` commands. Details: `.env` holds exactly the seven keys compose reads (`KVITT_REGISTRY`, `KVITT_TAG`, `KVITT_PUBLIC_HOST`, `HA_HOST`, `KVITT_UID`, `KVITT_GID`, `RCLONE_FOLDER_ID`); `RCLONE_REMOTE` fixed to `gdrive:` in compose; private packages → one-time `docker login gitea.lan`; each container mounts only its own secret file read-only (`create_host_path: false`); read-only root, 16 MB tmpfs `/tmp`, backup `HOME=/tmp`; secret files the owner's, mode 0600. Equivalents of the removed scripts (`install.sh`, `deploy.sh`, `bootstrap.sh`, `backup-now.sh`, `install-apk.sh`, `lib.sh`): backup `docker compose exec backup /backup.sh`; bootstrap `docker compose exec server kvitt-server bootstrap`; partner invite `docker compose exec server kvitt-server invite-partner`; deploy = backup, set `KVITT_TAG`, `pull`, `up -d`; rollback = the previous tag; APK = `scp` the APK, then `latest.json`, into `data/apk/` (old APKs removed by hand); digests via `docker image inspect`. Restore moves files on the host; only rclone and sqlite3 run in the backup container. Compose default network. The development stack is unchanged (uid 10001). Revises D-103 (scripts, `/opt/kvitt`, `.env` keys), D-105 (uid and `rclone.conf` owner on the Pi), D-117 point 5 (the digest record is dropped), D-120 point 6 (health checks via scripts dropped). Landed `6e5981d`. Rehearsed locally with Podman as uid 501: both containers healthy, `/health` 200, bootstrap, APK served, backup in three modes (no folder id, a local remote, a failing remote), the restore steps. | Owner decision 2026-10-20: 'It should be a self contained docker compose thing', 'do not install anything in any other place'. | SPEC §8.5, §8.9, §10 intro, §10.1, §10.2, §10.3, §10.4, §10.5, §10.5.1, §10.6, §10.7, §10.8, §10.9, §10.10, §11.2, §11.3, §12 (OQ-3), §14.7; `infra/pi/compose.yaml`, `infra/pi/.env.example`, `infra/README.md` "Pi: what the owner does", `infra/RESTORE.md`, `PLAN-M11.md` and `PLAN-M2c-pi.md` notes |
| D-126 | 2026-10-21 | App gate in NPM. Outside the home LAN, Nginx Proxy Manager closes the connection (`return 444`, nothing sent) of every request that does not carry the header `X-Kvitt-App: <key>` with the app key; requests from the home LAN `192.168.1.0/24` pass without it. Every route is covered (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*` included); only `/.well-known/acme-challenge/` passes, served by NPM from its own webroot, so Let's Encrypt can renew (Force SSL redirects the challenge to 443). The gate is five server-level rewrite lines at the top of the Custom Nginx Configuration (`set $kvitt_gate 0`; an `if` per accepted key; `if ($realip_remote_addr ~ "^192\.168\.1\.")`; `if ($uri ~ "^/\.well-known/acme-challenge/")`; `if ($kvitt_gate = 0) { return 444; }`), which run before location selection. The LAN check reads `$realip_remote_addr` (the TCP peer), not `$remote_addr`, because NPM's `nginx.conf` applies `real_ip_header X-Real-IP` to 10/8, 172.16/12 and 192.168/16 peers, so `$remote_addr` can be set by the client (by every client if NPM saw a Docker bridge address, security review I-5); the ACME exemption tests the normalised `$uri`, so `../` cannot reach Kvitt through it. The Rust server neither sees nor checks the header; `contracts/openapi.yaml` is unchanged (a proxy-level header, §14.3.1); the per-device bearer token stays the authentication (§8.2), and the gate is a perimeter. The app (`34e5cad`) sends `X-Kvitt-App` in `KvittApi`'s common headers (every API call, `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` when built with the property `kvitt.appKey` (`-Pkvitt.appKey`, else `kvitt.appKey` in `android/local.properties`, else empty = no header; set, it must be 16 to 128 printable ASCII characters without white space, or the build stops with a message giving the length, never the value), modelled on D-121; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey` in `:data` (`toString` hides it); `infra/publish-apk.sh` unchanged (it builds on the Mac). The key: the owner's `openssl rand -hex 32`, only in `android/local.properties` and NPM's block; never in git, `.env`, docs or logs. Rotation: a second `if` line with the new key, the new APK through the update path (FR-16), then the old line removed. Both phones must run a keyed APK before the gate goes into NPM (`infra/README.md` step 4a). Accepted consequences: the invite landing page and any browser use work only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; port 80 still answers NPM's 301, Block Common Exploits' patterns NPM's 403, and the TLS handshake shows the certificate; nothing protects against a bandwidth flood (not winnable at home). Caveat: the LAN check needs NPM to see real client addresses (a bridge address matches nothing, a router-rewritten address everything; NAT hairpin's `192.168.1.1` is inside the /24); the owner's acceptance test is the landing page on home Wi-Fi (shows) and with Wi-Fi off (connection error). Rehearsed on the Mac in NPM v2.16.0's openresty 1.31.1.1 with its includes. | Owner decision 2026-10-21: "have auth on everything", the key "is the app itself", the owner controls the APK's distribution (first install on both phones by hand), and "the landing page should work if you are on the same LAN, that is OK. In this case, the 'secret' is that you have my wifi password." An NPM access list cannot check a header, and its basic auth would take the `Authorization` header the bearer token needs. | SPEC AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1; `infra/README.md` steps 4, 4a, 5, 6, Operations; `android/app/build.gradle.kts`, `KvittApi.kt`, `PictureAuth.kt`, `DataModule.kt`, `android/README.md` | | D-126 | 2026-10-21 | App gate in NPM. Outside the home LAN, Nginx Proxy Manager closes the connection (`return 444`, nothing sent) of every request that does not carry the header `X-Kvitt-App: <key>` with the app key; requests from the home LAN `192.168.1.0/24` pass without it. Every route is covered (`/health`, `/setup/status`, `/invites/accept`, `/i/{code}`, `/apk/*` included); only `/.well-known/acme-challenge/` passes, served by NPM from its own webroot, so Let's Encrypt can renew (Force SSL redirects the challenge to 443). The gate is five server-level rewrite lines at the top of the Custom Nginx Configuration (`set $kvitt_gate 0`; an `if` per accepted key; `if ($realip_remote_addr ~ "^192\.168\.1\.")`; `if ($uri ~ "^/\.well-known/acme-challenge/")`; `if ($kvitt_gate = 0) { return 444; }`), which run before location selection. The LAN check reads `$realip_remote_addr` (the TCP peer), not `$remote_addr`, because NPM's `nginx.conf` applies `real_ip_header X-Real-IP` to 10/8, 172.16/12 and 192.168/16 peers, so `$remote_addr` can be set by the client (by every client if NPM saw a Docker bridge address, security review I-5); the ACME exemption tests the normalised `$uri`, so `../` cannot reach Kvitt through it. The Rust server neither sees nor checks the header; `contracts/openapi.yaml` is unchanged (a proxy-level header, §14.3.1); the per-device bearer token stays the authentication (§8.2), and the gate is a perimeter. The app (`34e5cad`) sends `X-Kvitt-App` in `KvittApi`'s common headers (every API call, `/health`, `/apk/latest`, `/apk/files/{file_name}`, `/invites/accept` included) and in `PictureAuth` when built with the property `kvitt.appKey` (`-Pkvitt.appKey`, else `kvitt.appKey` in `android/local.properties`, else empty = no header; set, it must be 16 to 128 printable ASCII characters without white space, or the build stops with a message giving the length, never the value), modelled on D-121; `BuildConfig.APP_KEY`, `AppBuildInfo.appKey` in `:data` (`toString` hides it); `infra/publish-apk.sh` unchanged (it builds on the Mac). The key: the owner's `openssl rand -hex 32`, only in `android/local.properties` and NPM's block; never in git, `.env`, docs or logs. Rotation: a second `if` line with the new key, the new APK through the update path (FR-16), then the old line removed. Both phones must run a keyed APK before the gate goes into NPM (`infra/README.md` step 4a). Accepted consequences: the invite landing page and any browser use work only on the LAN; `/health` from outside without the header gets a closed connection, so the outside check is the app's About screen (S-22) or `curl -H "X-Kvitt-App: $KEY"`; a leaked APK leaks the key until rotation; port 80 still answers NPM's 301, Block Common Exploits' patterns NPM's 403, and the TLS handshake shows the certificate; nothing protects against a bandwidth flood (not winnable at home). Caveat: the LAN check needs NPM to see real client addresses (a bridge address matches nothing, a router-rewritten address everything; NAT hairpin's `192.168.1.1` is inside the /24); the owner's acceptance test is the landing page on home Wi-Fi (shows) and with Wi-Fi off (connection error). Rehearsed on the Mac in NPM v2.16.0's openresty 1.31.1.1 with its includes. | Owner decision 2026-10-21: "have auth on everything", the key "is the app itself", the owner controls the APK's distribution (first install on both phones by hand), and "the landing page should work if you are on the same LAN, that is OK. In this case, the 'secret' is that you have my wifi password." An NPM access list cannot check a header, and its basic auth would take the `Authorization` header the bearer token needs. | SPEC AC-2.5, §8.1, §8.5, §10.1, §10.5, §10.5.1, §10.5.2, §10.6, §10.10, §14.3.1; `infra/README.md` steps 4, 4a, 5, 6, Operations; `android/app/build.gradle.kts`, `KvittApi.kt`, `PictureAuth.kt`, `DataModule.kt`, `android/README.md` |
| D-127 | 2026-10-21 | Pull-to-refresh on Home and account detail (revises design-spec S-4 "Pull-to-refresh is not offered (sync runs on its own triggers, AC-15.3)"). S-4 (Home) and S-5 (account detail) offer pull-to-refresh; pulling past the threshold runs `SyncRepository.syncNow()`, the call behind S-16 "Synka nu". The indicator stays while the sync runs and goes when it returns, success or failure. A failure is reported only by the sync pill (design-spec §3.6); there is no new error UI. S-5 has no pill, so a failed pull there shows nothing until Home's pill. Offline (§9.9) the pull animates and ends at once without a sync. A pull while a sync runs starts no second sync. Shared `KvittPullToRefresh(refreshing, onRefresh, content)` in `:core:design` (`component/Refresh.kt`): Material 3 `PullToRefreshBox` with a Kvitt indicator: a 48 dp `primary-container` disc (Cookie9 shape, circle fallback); the `sync` icon at 18 dp in `on-primary-container`, turning 270° with the pull and popping from 80 % to full size at the threshold on a new `KvittMotion.spatialFast()` spring (0.6 / 800), with a `GestureThresholdActivate` haptic; a 32 dp `primary` arc (stroke 3, track `primary` at 24 %) that fills to the threshold and turns indeterminate while refreshing. The pinned Material 3 1.4.0 has no `LoadingIndicator`, `ContainedLoadingIndicator` or `LinearWavyProgressIndicator`; `ContainedLoadingIndicator` replaces the arc when the pinned Material 3 ships it, in `Refresh.kt` only. TalkBack reads "Synkar…" (`sync_syncing`, reused; `docs/ux/strings.csv` unchanged) as a polite live region while refreshing; S-16 "Synka nu" remains the TalkBack path. Consequences: `HomeUi.Ready.refreshing`, `AccountDetailUi.Ready.refreshing`, `HomeActions.onRefresh`, `AccountDetailActions.onRefresh`; `HomeViewModel` and `AccountDetailViewModel` take `SyncRepository`; test fixture `GatedSyncRepository` in `:core:design` testFixtures; no change to `:core:model`, Room, the sync protocol, the server or `strings.csv`; new goldens `S-4-home-refreshing` and `S-5-account-refreshing` (three variants each: sv light, sv dark, en light); 959 Android tests. Landed `3778f5f`. | Owner request 2026-10-21: pull-to-refresh on Home and the account screen "with some nice fitting design when you pull and then some nice loading that fits the UX theme of the app". The indicator reuses the hero's first-sync tile look (design-spec §3.6 `Loading`) and the `sync` icon of the pill and "Synka nu", so it reads as Kvitt's own; reporting failure only in the pill keeps sync never blocking (§3.6). | SPEC AC-15.3, §9.9, §13.3 (S-4, S-5); design-spec §2.11, §3.1, §3.6, S-4, S-5; `android/core/design/.../component/Refresh.kt`, `KvittMotion.kt`, `KvittDimens.kt`, `:feature:home`, `:feature:account` |
+17 -4
View File
@@ -279,7 +279,7 @@ Tonal first. Light: content cards (`card` #FFFFFF on surface #FAF7F2) carry a so
| Spring | Damping ratio | Stiffness | Use | | Spring | Damping ratio | Stiffness | Use |
|---|---|---|---| |---|---|---|---|
| spatial fast | 0.6 | 800 | chip and tile selection, check badge pop (scale 0 → 1), keypad key press | | spatial fast | 0.6 | 800 | chip and tile selection, check badge pop (scale 0 → 1), keypad key press, pull-to-refresh icon pop (D-127) |
| spatial default | 0.8 | 380 | expanding the split section, collapsible sections, sheet open, FAB extend/collapse | | spatial default | 0.8 | 380 | expanding the split section, collapsible sections, sheet open, FAB extend/collapse |
| spatial slow | 0.8 | 200 | screen-level layout changes | | spatial slow | 0.8 | 200 | screen-level layout changes |
| effects fast | 1.0 | 3800 | state-layer and colour of pressed controls | | effects fast | 1.0 | 3800 | state-layer and colour of pressed controls |
@@ -291,6 +291,7 @@ Named motions:
* **Seesaw re-tilt** when the global balance changes: spring damping 0.55, stiffness 300; the hero amount counts from the old to the new value over 400 ms, `EmphasizedDecelerate`. * **Seesaw re-tilt** when the global balance changes: spring damping 0.55, stiffness 300; the hero amount counts from the old to the new value over 400 ms, `EmphasizedDecelerate`.
* **Settling to zero**: the plank levels (re-tilt spring), then the bunting drops one pennant at a time, 40 ms stagger, each on spatial fast; the card cross-fades to `settled-container` (effects slow). * **Settling to zero**: the plank levels (re-tilt spring), then the bunting drops one pennant at a time, 40 ms stagger, each on spatial fast; the card cross-fades to `settled-container` (effects slow).
* **Category selection**: the chosen tile cross-fades container → solid (effects default) and the check badge pops (spatial fast); a suggestion change (CS-7) uses the same motion without haptics. * **Category selection**: the chosen tile cross-fades container → solid (effects default) and the check badge pops (spatial fast); a suggestion change (CS-7) uses the same motion without haptics.
* **Pull-to-refresh** (D-127): the `sync` icon follows the pull (270°) and pops to full size at the threshold on spatial fast, with a `GestureThresholdActivate` haptic.
* **Saved expense**: the editor closes; the new row is in its month group on return (no extra animation required). * **Saved expense**: the editor closes; the new row is in its month group on return (no extra animation required).
* **Navigation**: none. Destination changes, tab switches and editors (S-7, S-9) appear at once, with no transition (D-108). * **Navigation**: none. Destination changes, tab switches and editors (S-7, S-9) appear at once, with no transition (D-108).
* **Reduced motion** (system "Ta bort animationer", `Settings.Global.ANIMATOR_DURATION_SCALE == 0`): all springs become critically damped and immediate, the bunting appears at once, the hero amount does not count. * **Reduced motion** (system "Ta bort animationer", `Settings.Global.ANIMATOR_DURATION_SCALE == 0`): all springs become critically damped and immediate, the bunting appears at once, the hero amount does not count.
@@ -392,6 +393,7 @@ Every default category has its own colour; `teal` is shared with the archived ex
| `ButtonGroup` (connected) | analytics toggles, ratio presets, full-split person picker | `SingleChoiceSegmentedButtonRow` | | `ButtonGroup` (connected) | analytics toggles, ratio presets, full-split person picker | `SingleChoiceSegmentedButtonRow` |
| Expressive slider look (thick track, thin handle) | `SplitSlider` | `Slider` with custom track and thumb | | Expressive slider look (thick track, thin handle) | `SplitSlider` | `Slider` with custom track and thumb |
| `LoadingIndicator` (shape morph) | first sync on Home | `CircularProgressIndicator` | | `LoadingIndicator` (shape morph) | first sync on Home | `CircularProgressIndicator` |
| `ContainedLoadingIndicator` | pull-to-refresh indicator (S-4, S-5; D-127) | `CircularProgressIndicator` in `primary` on a `primary-container` disc |
| `LinearWavyProgressIndicator` | update download | `LinearProgressIndicator` | | `LinearWavyProgressIndicator` | update download | `LinearProgressIndicator` |
| Medium extended FAB (56 dp, radius 20) | "Lägg till utgift", "Ny återkommande" | `ExtendedFloatingActionButton` | | Medium extended FAB (56 dp, radius 20) | "Lägg till utgift", "Ny återkommande" | `ExtendedFloatingActionButton` |
| Button sizes small (40) and medium (56) | all buttons | `Button` with `heightIn` | | Button sizes small (40) and medium (56) | all buttons | `Button` with `heightIn` |
@@ -496,6 +498,14 @@ Every default category has its own colour; `teal` is shared with the archived ex
* Variants: synced: `surface-container-high` fill, `cloud_done` (filled) in `owed`, "Synkad för 3 min sedan" (`sync_synced_relative`, §9.8 relative time, refreshed every minute) · running: `sync` icon, "Synkar…" · pending: suffix " · 2 ändringar väntar på synk" (`sync_pending`, plural) · offline: `sync-pill-pending` fill, `cloud_off` in `pending`, "Offline · 2 ändringar väntar på synk" (or "Offline" alone) · warning: `warning-container` fill, `warning` icon, "1 ändring synkades inte" (`sync_rejected`, plural) or "Telefonens klocka går fel" (`sync_clock_wrong_short`) · failed: "Synk misslyckades · försök igen om en stund" in the warning style. * Variants: synced: `surface-container-high` fill, `cloud_done` (filled) in `owed`, "Synkad för 3 min sedan" (`sync_synced_relative`, §9.8 relative time, refreshed every minute) · running: `sync` icon, "Synkar…" · pending: suffix " · 2 ändringar väntar på synk" (`sync_pending`, plural) · offline: `sync-pill-pending` fill, `cloud_off` in `pending`, "Offline · 2 ändringar väntar på synk" (or "Offline" alone) · warning: `warning-container` fill, `warning` icon, "1 ändring synkades inte" (`sync_rejected`, plural) or "Telefonens klocka går fel" (`sync_clock_wrong_short`) · failed: "Synk misslyckades · försök igen om en stund" in the warning style.
* Never a blocking banner. Whole pill is one button: TalkBack "Synkstatus: <visible text>". * Never a blocking banner. Whole pill is one button: TalkBack "Synkstatus: <visible text>".
**`KvittPullToRefresh(refreshing: Boolean, onRefresh, content)`** (S-4, S-5; D-127)
* Material 3 `PullToRefreshBox` around the screen's scrolling content; pulling past the threshold calls `onRefresh` (a sync now, the call behind S-16 "Synka nu"); the indicator stays while `refreshing` is true and slides away when it turns false, success or failure. A failure is reported only by `SyncStatusPill`; no other error UI.
* Indicator, centred at the top: a 48 dp disc in `primary-container` (Cookie9 shape, circle fallback, the look of the hero's `Loading` tile); inside it the `sync` icon at 18 dp in `on-primary-container` and a 32 dp `primary` arc, stroke 3, round caps.
* Pulling: the arc fills from 0 to the threshold over a track of `primary` at 24 %; the icon sits at 80 % and turns 270° with the pull. At the threshold the icon pops to full size (spatial fast, §2.11) with a `GestureThresholdActivate` haptic.
* Refreshing: the arc turns into an indeterminate ring (no track); the icon stays at full size.
* Expressive: `ContainedLoadingIndicator` replaces the arc when the pinned Material 3 ships it (not in 1.4.0), in `component/Refresh.kt` only (§3.1).
* Semantics: the indicator is cleared while pulling; while refreshing it reads "Synkar…" (`sync_syncing`) as a polite live region. The pull is not a TalkBack path: S-16 "Synka nu" is.
**`Banner(kind: Lantern | Error | Warning | Neutral, icon, title, body, actions)`**: radius 24, padding 14 / 16 / 10, gap 6; icon 24 (filled) + title (title-small) + body (body-medium); actions right-aligned, text buttons in the banner's content colour. Lantern = `tertiary-container`, Error = `error-container`, Warning = `warning-container`, Neutral = `surface-container-high`. `role` status (alert for Error). **`Banner(kind: Lantern | Error | Warning | Neutral, icon, title, body, actions)`**: radius 24, padding 14 / 16 / 10, gap 6; icon 24 (filled) + title (title-small) + body (body-medium); actions right-aligned, text buttons in the banner's content colour. Lantern = `tertiary-container`, Error = `error-container`, Warning = `warning-container`, Neutral = `surface-container-high`. `role` status (alert for Error).
**`UpdateBanner(state: UpdateState, onUpdate, onLater, onRetry)`** (S-4, S-22): `Available`: Lantern, `system_update`, "Version 1.2 finns", "Nyheter och rättningar · 8,4 MB" (size from `UpdateInfo.sizeBytes`), actions "Senare" (text) and "Uppdatera" (tertiary filled, `download`); offline: "Uppdatera" disabled and the body reads "Kräver anslutning". `Downloading`: Lantern, `downloading`, "Laddar ner… 64 %", `LinearWavyProgressIndicator` in `tertiary` (determinate, track `tertiary` at 28 %), no actions; TalkBack progress "Laddar ner version 1.2, 64 procent". `Failed`: Error, `error`, "Nedladdningen misslyckades. Försök igen." with "Senare" and "Försök igen" (error filled, `refresh`). `NeedsInstallPermission`: Available look; "Uppdatera" opens the S-17 dialog. **`UpdateBanner(state: UpdateState, onUpdate, onLater, onRetry)`** (S-4, S-22): `Available`: Lantern, `system_update`, "Version 1.2 finns", "Nyheter och rättningar · 8,4 MB" (size from `UpdateInfo.sizeBytes`), actions "Senare" (text) and "Uppdatera" (tertiary filled, `download`); offline: "Uppdatera" disabled and the body reads "Kräver anslutning". `Downloading`: Lantern, `downloading`, "Laddar ner… 64 %", `LinearWavyProgressIndicator` in `tertiary` (determinate, track `tertiary` at 28 %), no actions; TalkBack progress "Laddar ner version 1.2, 64 procent". `Failed`: Error, `error`, "Nedladdningen misslyckades. Försök igen." with "Senare" and "Försök igen" (error filled, `refresh`). `NeedsInstallPermission`: Available look; "Uppdatera" opens the S-17 dialog.
@@ -732,6 +742,7 @@ Each screen: layout top to bottom, the **binding states** (exactly the frames of
| `S-4-home-loading` | loading (first sync only) | hero `Loading`, a 40 × 200 dp pill skeleton, "Konton" header without action, three skeleton account cards; no FAB | | `S-4-home-loading` | loading (first sync only) | hero `Loading`, a 40 × 200 dp pill skeleton, "Konton" header without action, three skeleton account cards; no FAB |
| `S-4-home-offline` | offline + update banner | pill "Offline · 2 ändringar väntar på synk"; "Nytt konto" disabled with "Kräver anslutning" as its supporting text; banner `Available` with "Uppdatera" disabled ("Kräver anslutning") | | `S-4-home-offline` | offline + update banner | pill "Offline · 2 ändringar väntar på synk"; "Nytt konto" disabled with "Kräver anslutning" as its supporting text; banner `Available` with "Uppdatera" disabled ("Kräver anslutning") |
| `S-4-home-200` | long, 200 %, light only | app bar `surface-container` (scrolled 90 dp), hero amount ~1.5× with "kr" on its own line, account amounts under the names, FAB icon-only | | `S-4-home-200` | long, 200 %, light only | app bar `surface-container` (scrolled 90 dp), hero amount ~1.5× with "kr" on its own line, account amounts under the names, FAB icon-only |
| `S-4-home-refreshing` | variant refreshing | the indicator at rest, centred about 32 dp below the top of the list over the hero; the list does not move |
| `S-4-home-en` | English, light only | "You owe Anna", "SEK 3,250.00" (unit before the number), "Synced 3 min ago", "Accounts", "New account", "Add expense" | | `S-4-home-en` | English, light only | "You owe Anna", "SEK 3,250.00" (unit before the number), "Synced 3 min ago", "Accounts", "New account", "Add expense" |
**Interactions** **Interactions**
@@ -739,9 +750,9 @@ Each screen: layout top to bottom, the **binding states** (exactly the frames of
* "Gör upp" → S-9 global. Account card → S-5. "Nytt konto" → S-6 (offline: disabled). Pill → S-16. Banner per S-17. * "Gör upp" → S-9 global. Account card → S-5. "Nytt konto" → S-6 (offline: disabled). Pill → S-16. Banner per S-17.
* Returning from S-7 shows the S-7 snackbar here; from S-9 "Betalningen sparades" (`snackbar_payment_saved`). * Returning from S-7 shows the S-7 snackbar here; from S-9 "Betalningen sparades" (`snackbar_payment_saved`).
* After the first full sync after pairing, the notification permission is asked once (AC-2.9). * After the first full sync after pairing, the notification permission is asked once (AC-2.9).
* Pull-to-refresh is not offered (sync runs on its own triggers, AC-15.3). * Pull-to-refresh (D-127): pulling the list down at its top runs a sync now (`KvittPullToRefresh`, §3.6). The indicator stays while the sync runs and goes when it ends, success or failure; a failure shows only in the sync pill. Offline the pull ends at once.
**TalkBack**: hero as §3.6; pill as §3.6; account cards as §3.4; "Arkiverade (1), hopfälld"; FAB "Lägg till utgift"; navigation items "Översikt, flik 1 av 3, vald". **TalkBack**: hero as §3.6; pill as §3.6; "Synkar…" while refreshing (polite live region, §3.6); account cards as §3.4; "Arkiverade (1), hopfälld"; FAB "Lägg till utgift"; navigation items "Översikt, flik 1 av 3, vald".
**200 %**: see `S-4-home-200`; every element reflows, nothing truncates except account names (two lines then ellipsis). **200 %**: see `S-4-home-200`; every element reflows, nothing truncates except account names (two lines then ellipsis).
@@ -765,13 +776,15 @@ Overflow menu (`KvittMenu`): "Ändra konto" (`edit`) → S-6; "Arkivera" (`inven
| `S-5-account-offline` | offline | menu open: all three items disabled, each with "Kräver anslutning" as its second line; rows, "Gör upp det här kontot" and FAB work | | `S-5-account-offline` | offline | menu open: all three items disabled, each with "Kräver anslutning" as its second line; rows, "Gör upp det här kontot" and FAB work |
| `S-5-account-error` | error | after "Arkivera" while the server is unreachable: snackbar "Kunde inte nå servern" with "Försök igen"; account unchanged | | `S-5-account-error` | error | after "Arkivera" while the server is unreachable: snackbar "Kunde inte nå servern" with "Försök igen"; account unchanged |
| `S-5-account-long` | long | "Sommarstugan i Österåker 2027" ellipsized in the app bar; rows with long descriptions ellipsized at one line; an amount of 1 234 567,89 kr and a month total of 7 digits shown in full | | `S-5-account-long` | long | "Sommarstugan i Österåker 2027" ellipsized in the app bar; rows with long descriptions ellipsized at one line; an amount of 1 234 567,89 kr and a month total of 7 digits shown in full |
| `S-5-account-refreshing` | variant refreshing | the indicator over the account header |
**Interactions** **Interactions**
* Row → S-8 / S-10. "Gör upp det här kontot" → S-9 per account. FAB → S-7 with this account. * Row → S-8 / S-10. "Gör upp det här kontot" → S-9 per account. FAB → S-7 with this account.
* "Arkivera" / "Återställ från arkiv": no confirmation (reversible); success → back to S-4 with snackbar "Vardag arkiverades" + "Ångra" (`account_archived_snackbar`; "Ångra" unarchives) / stays with "Vardag återställdes"; network failure → `S-5-account-error`. * "Arkivera" / "Återställ från arkiv": no confirmation (reversible); success → back to S-4 with snackbar "Vardag arkiverades" + "Ångra" (`account_archived_snackbar`; "Ångra" unarchives) / stays with "Vardag återställdes"; network failure → `S-5-account-error`.
* "Ta bort konto": `ConfirmDialog` (destructive) "Ta bort kontot Vardag?" / "Det går inte att ångra." → "Ta bort"; success → S-4; `account_not_empty` → snackbar "Kontot har fortfarande utgifter, betalningar eller återkommande utgifter". * "Ta bort konto": `ConfirmDialog` (destructive) "Ta bort kontot Vardag?" / "Det går inte att ångra." → "Ta bort"; success → S-4; `account_not_empty` → snackbar "Kontot har fortfarande utgifter, betalningar eller återkommande utgifter".
* Pull-to-refresh (D-127): pulling the list down at its top runs a sync now (`KvittPullToRefresh`, §3.6). The indicator stays while the sync runs and goes when it ends, success or failure; a failure shows only in Home's sync pill (S-5 has none). Offline the pull ends at once.
**TalkBack**: overflow "Fler alternativ"; header one node (§3.6); month headers are headings; rows §3.5; disabled menu items read with their reason. **TalkBack**: overflow "Fler alternativ"; header one node (§3.6); month headers are headings; rows §3.5; disabled menu items read with their reason; "Synkar…" while refreshing (polite live region, §3.6).
**200 %**: header wording and amount wrap; rows per §3.5 (amount under the text). **200 %**: header wording and amount wrap; rows per §3.5 (amount under the text).