SPEC v0.27: D-128 S-12 line chart of a category or account over the range

New AC-14.11; §13.3 S-12 row with its own range and granularity, the line
chart and the empty-range text; §13.4 strings.csv 569 rows. design-spec
§3.10 SpendLineChart and LineLegend, AnalyticsFilterBar row 2 optional,
S-12 layout for a category or account over the range. strings.csv gains
the seven analytics keys as built in 12e2508. HANDOFF: D-128 and the
keyboard fix landed, web-client programme holds D-129+ and v0.28+.

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:34:07 +02:00
parent 12e2508934
commit 44c8cbb771
5 changed files with 46 additions and 15 deletions
+5 -3
View File
@@ -1,6 +1,6 @@
# Kvitt: specification
Version **0.26** (2026-10-21). Schema version **3**. Sync protocol version **1**. Push payload version **1**.
Version **0.27** (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.
@@ -266,6 +266,7 @@ The server starts with an empty database. The household is created from the comm
* AC-14.8 Tapping a bar or a breakdown row opens a list of the expenses behind that number.
* AC-14.9 Deleted expenses are excluded; archived accounts and categories are included.
* AC-14.10 Percentages are rounded half-up to one decimal and may not sum to exactly 100.
* AC-14.11 A drill-down for one category or account over the range shows its spend per bucket as a line chart (every bucket, empty ones as zero), the range average as a dashed reference line, a trailing moving average over 3 buckets, and a caption comparing the latest whole bucket with the average in whole percent; its range and granularity can be changed on the drill-down without changing S-11 (D-128).
### FR-15 Offline and sync
@@ -1387,7 +1388,7 @@ Each screen must be designed in these states where they apply: **loaded**, **emp
| S-9 | Settle up | Direction ("Anna pays Jacob" with a swap control); amount; allocation list: account name, account debt, editable allocated amount, over-settle warning per row (AC-11.5); remaining-to-allocate indicator; "Add account"; date; note; per-account variant with a single fixed row, which keeps the direction swap (§9.4: per-account settle-ups go either way) and an editable amount (AC-11.4 "defaults to") (D-052); when editing, the saved rows count as edited and "Föreslå igen" re-proposes; the info line on untouched accounts reads `SettleState.untouched` (nets without the settlement being edited, empty for a per-account settle-up, D-093, revising D-074's reading of Home balances); the discard dialog has no body text (D-080) | Confirm; Cancel |
| S-10 | Settlement detail | Payer, recipient, amount, date, note, allocations per account; timeline; "missing" state with "Synka nu" (`settle_detail_sync_now`), online only (§9.9, D-080) | Edit; Delete |
| S-11 | Analytics | Range selector (presets of AC-14.2 plus custom); granularity toggle month/week; measure toggle household / my share; group-by toggle category/account; total for range; chart 1 stacked bars over time with legend; chart 2 category breakdown; chart 3 account breakdown; empty state ("No expenses in this period"); a chart whose range has no expenses shows only the 0 baseline, with no gridline values (D-068). Total card (D-069): caption "<range> · Hushållet" for the household measure and "<range> · N veckor" in week granularity; for my share the label is "Totalt · Min del" so the caption does not repeat it; month presets show whole-month ranges ("maj–okt 2026"), custom ranges and week granularity show days (§9.8, D-077). Legend and stacking order: categories by `sortOrder`, accounts in `AccountRepository.all()` order (`AnalyticsState.groups` order is unspecified, §14.2.4). Loading means `SyncSummary.lastSuccessAt == null` and shows skeleton cards; offline looks like loaded; no error state (analytics are local, AC-14.1) | Change filters; tap a bar or a row to open S-12 |
| S-12 | Analytics drill-down | Title naming the filter ("Mat & hushåll, oktober 2026"; a week as in `docs/ux/strings.csv`, "Vardag, vecka 41"; a category or account over the whole range "<name>, <range caption>") (D-070); total; expense rows as in S-5. No loading and no error state (D-070) | Open expense |
| S-12 | Analytics drill-down | Title naming the filter ("Mat & hushåll, oktober 2026"; a week as in `docs/ux/strings.csv`, "Vardag, vecka 41"; a category or account over the whole range "<name>, <range caption>") (D-070); total; expense rows as in S-5. A category or account over the range adds, above the total, its own range selector (AC-14.2 presets plus custom) and month/week toggle, both starting from S-11's values and changing S-12 only; below the total, the line chart of AC-14.11; then the rows. A range with no expenses reads "No expenses in this period" (D-128). No loading and no error state (D-070) | Open expense; change range or granularity |
| S-13 | Settings hub | Own avatar and name; partner's avatar and name; rows: Profile, Categories, Recurring expenses, Devices, Invite partner or Pair a device (admin), Sync status, About | Navigate |
| S-14 | Profile | Avatar with change/remove; display name; "Needs connection" state | Save |
| S-15 | Categories | List: icon, colour, name, archived section; editor: name (with "Reset name" for defaults), icon grid (33 icons of §9.7), colour swatches (14 tokens); delete or archive with the reason when delete is not allowed | Add; Edit; Reorder (drag); Archive; Delete |
@@ -1441,7 +1442,7 @@ Suggested starting points the UX agent may use, replace or merge (it must delive
2. **"Skärgård"**: Material 3 Expressive with a cool sea-and-granite palette, large rounded shapes, bold balance header, playful but quiet.
3. **"Kontor"**: dense, utilitarian, data-forward. Neutral greys, strong typographic hierarchy, charts as the hero on Analytics, minimal decoration.
**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`. The owner picked direction 3 "Kalas" with the muted palette of `docs/ux/directions/3-kalas/`, typeface per D-020 (D-047). U2 landed `6da6b1b`, approved by the owner. `docs/ux/design-spec.md` is the binding design document for `:core:design` (§2 tokens, §3 components) and for every screen (§4, §5); this spec wins over it on rules. Amended after U2: chart bars have top corners 4 dp and bottom corners 2 dp (D-097, design-spec §2.9 and §3.10); S-7's account, date, payer and split options are four fixed rows, `EditorOptionRows`, and connected buttons have 8 dp side padding (D-102, design-spec §3.7 and §3.10); S-22 has no licences row (D-099, design-spec S-22); S-16's lost version (D-109) and S-22's "(schema <n>)" (D-110), design-spec S-16 and S-22; the mock-up PNGs are compressed losslessly (D-098). `docs/ux/strings.csv` (558 rows at U2, header included; 559 since v0.10 with `ledger_settlement_label`, D-068, and English "Dining" for `category_default_restaurants`, D-067; 578 since v0.11 with the shared keys `common_ok`, `common_undo` and `editor_split_full_on_you` and the module keys of M7b to M7f recorded as built, D-079; 577 since v0.16 without `settings_about_licences`, D-099; 562 since v0.18: M12f added `home_sync_lost_title`, `home_sync_lost_body`, `home_sync_lost_deleted`, `home_sync_lost_show_current` (D-109) and `settings_about_server_version_schema` (D-110) and removed the 19 keys (20 rows) that R8 found unused (`a0259ab`); keys with the D-017 prefixes; plurals as `key:one` / `key:other`) is the source of every string resource:
**Phase 2 (U2): design spec for the chosen direction.** After the owner picks (and comments), in `docs/ux/`. The owner picked direction 3 "Kalas" with the muted palette of `docs/ux/directions/3-kalas/`, typeface per D-020 (D-047). U2 landed `6da6b1b`, approved by the owner. `docs/ux/design-spec.md` is the binding design document for `:core:design` (§2 tokens, §3 components) and for every screen (§4, §5); this spec wins over it on rules. Amended after U2: chart bars have top corners 4 dp and bottom corners 2 dp (D-097, design-spec §2.9 and §3.10); S-7's account, date, payer and split options are four fixed rows, `EditorOptionRows`, and connected buttons have 8 dp side padding (D-102, design-spec §3.7 and §3.10); S-22 has no licences row (D-099, design-spec S-22); S-16's lost version (D-109) and S-22's "(schema <n>)" (D-110), design-spec S-16 and S-22; the mock-up PNGs are compressed losslessly (D-098). `docs/ux/strings.csv` (558 rows at U2, header included; 559 since v0.10 with `ledger_settlement_label`, D-068, and English "Dining" for `category_default_restaurants`, D-067; 578 since v0.11 with the shared keys `common_ok`, `common_undo` and `editor_split_full_on_you` and the module keys of M7b to M7f recorded as built, D-079; 577 since v0.16 without `settings_about_licences`, D-099; 562 since v0.18: M12f added `home_sync_lost_title`, `home_sync_lost_body`, `home_sync_lost_deleted`, `home_sync_lost_show_current` (D-109) and `settings_about_server_version_schema` (D-110) and removed the 19 keys (20 rows) that R8 found unused (`a0259ab`); 569 since v0.27 with the seven S-12 line-chart keys `analytics_average`, `analytics_moving_average`, `analytics_vs_average_above`, `analytics_vs_average_below`, `analytics_vs_average_on`, `analytics_summary_line`, `analytics_summary_line_single` (D-128); keys with the D-017 prefixes; plurals as `key:one` / `key:other`) is the source of every string resource:
* `design-spec.md`: final tokens (colour roles light and dark, category colour hex values, typography scale, shapes, spacing scale in dp, elevation, motion durations and easing), component inventory (each with anatomy, states, sizes), and for every screen S-1 to S-22: layout description, all states of §13.3, interaction details, TalkBack labels.
* `strings.csv`: every user-visible string of the app with columns `key,en,sv,notes`, keys in `snake_case` prefixed by the feature module that holds them (`home_balance_owes_you`, `expense_save`; shared strings for `:core:design` never start with a feature name; the app name is the only `:app` string; §6.2, D-017), placeholders as Android format arguments (`%1$s`), plurals marked in `notes` with their quantities. This becomes `values/strings.xml` and `values-sv/strings.xml` in M6 and M7; feature agents add no strings that are not in this file without a decisions entry. Keys recorded by D-079 (v0.11): shared `common_ok`, `common_undo`, `editor_split_full_on_you` ("All on you" / "Allt på dig"; `ledger_settlement_label` was D-068); module keys as built: `analytics_granularity`, `analytics_measure`, `analytics_group_by`, `analytics_range_apply`; `settle_row_amount`, `settle_detail_delete`, `settle_detail_sync_now`; `expense_detail_sync_now`; `home_archived_expanded`, `home_archived_collapsed`; `settings_category_archived_snackbar`, `settings_category_unarchived_snackbar`, `settings_recurring_summary_none`, `settings_expanded`, `settings_collapsed`, `settings_partner_missing`.
@@ -2888,3 +2889,4 @@ Rule: a change to any other contract file, or a semantic change to `contracts/op
| 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.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). |
| 0.27 | 2026-10-21 | S-12 line chart for a category or account over the range (D-128, owner request 2026-10-21; Android `12e2508`). S-12 opened from a breakdown row shows its own period chip (AC-14.2 presets plus custom) and Month/Week toggle, both starting from S-11's values and changing chart, total and list on S-12 only (S-11 keeps its state; measure and group-by come from S-11 and are fixed on S-12), the header total, the "Utgifter över tid" card and the expense rows; a drill-down from a bar is unchanged. One point per bucket, empty buckets as zero (AC-14.7), from the group's bucket totals of `spendSeries` (§14.5.4); an empty range shows only the 0 baseline (D-068) and "Inga utgifter i den här perioden". Overlays: the range average (total ÷ buckets, half-up to öre) as a dashed line in the group colour with its amount at the right end; a trailing moving average over 3 buckets (`AnalyticsRules.MOVING_AVERAGE_WINDOW`; the first buckets use the 1 or 2 that exist) as a thinner neutral line; legend "Snitt", "Glidande snitt (3)"; caption "<Bucket>: N % över snittet" / "under snittet" / "på snittet" for the latest bucket the range does not cut short, N a whole percent half-up. Overlays and caption omitted when the average is 0 or the range has one bucket; caption omitted when no bucket is whole. Not interactive; one TalkBack summary. `:core:design` gains `SpendLineChart` and `LineLegend` (`chart/LineChart.kt`), axis code shared with `StackedBarChart` in `Charts.kt`; `AnalyticsFilterBar` measure and group-by toggles optional. Seven `strings.csv` keys (569 rows). New goldens `s12_category_groceries`, `s12_category_week`, `s12_category_empty_range`, `line_chart`, `line_chart_variants`; `s12_category`, `s12_account`, `s12_long` re-recorded; 977 Android tests. No change to `:core:model`, `:domain`, `:core:fakes`, Room, the server or the contracts (new AC-14.11; §13.3 S-12, §13.4; design-spec §3.10, S-12). |
+13 -10
View File
@@ -3,7 +3,7 @@
Living document, rewritten in place (never a second copy). A new orchestrator session reads CLAUDE.md, then this.
## 1. Where things stand
- 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).
- State as of 2026-10-21. `main` at `12e2508` (S-12 line chart, D-128), after `b0a90da` (SPEC v0.26, D-127), `05ec3f3` (editor keyboard fix) and `3778f5f` (pull-to-refresh, D-127); `origin/main` (Gitea) at `12e2508`; plus the SPEC v0.27 docs commit from `agent-catline-docs` once it lands. The web-client programme started 2026-10-21: its architect agent holds D-129 onward and SPEC v0.28 onward. Landed since SPEC v0.26: `05ec3f3`, `12e2508` (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.
- **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.
@@ -12,6 +12,9 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- **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 (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.26 (recorded in v0.27):
- S-12 line chart (`12e2508`, D-128, owner request 2026-10-21): S-12 opened from a category or account breakdown row has its own period chip and Month/Week toggle (starting from S-11's, changing S-12 only; measure and group-by fixed from S-11), the header total, a "Utgifter över tid" card with `SpendLineChart` (one point per bucket, empty as zero), the dashed range average with its amount, a trailing 3-bucket moving average, `LineLegend` and a caption comparing the latest whole bucket with the average ("<Bucket>: N % över snittet" / "under snittet" / "på snittet"), then the rows; a bar drill-down is unchanged. New AC-14.11; seven `strings.csv` keys (569 rows); no model, domain, fakes, server or contract change. Goldens `s12_category_groceries`, `s12_category_week`, `s12_category_empty_range`, `line_chart`, `line_chart_variants` new, `s12_category`, `s12_account`, `s12_long` re-recorded; 977 Android tests.
- Editor keyboard fix (`05ec3f3`, no decision): S-19, S-15 and S-6 editor bodies apply the scaffold padding, consume it and add `imePadding()`, so the keyboard no longer covers a focused field.
- 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):
@@ -46,21 +49,21 @@ 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`).
- 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:
- 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).
- A parallel feature with D-128 and SPEC v0.27 (orchestrator's brief).
- This docs pass (`agent-catline-docs`: SPEC v0.27, D-128, AC-14.11, §13.3 S-12, §13.4, design-spec §3.10 and S-12, `docs/ux/strings.csv`, this file).
- The web-client programme (started 2026-10-21; architect agent, holds D-129 onward and SPEC v0.28 onward).
- 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.
- 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 959 tests at `3778f5f`; 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 977 tests at `12e2508`; 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 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`.
- 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.
## 2. Open work (one line each, with who owns it: agent or owner)
- 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).
- Land `agent-catline-docs` (orchestrator): SPEC v0.27, D-128, design-spec, `strings.csv`, HANDOFF; run the Android gate on landing (`strings.csv` changed). Push it to `origin` (it touches no `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).
- Pushed: through `7521c01` (SPEC v0.25, D-126, `infra/npm/kvitt.conf`).
- Pushed: through `12e2508` (S-12 line chart, D-128).
- 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).
- 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).
@@ -74,7 +77,7 @@ Living document, rewritten in place (never a second copy). A new orchestrator se
- Unverified risk: the workflow pushes a git tag; whether that re-triggers the path-filtered workflow is not known (watch the next runs).
- `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`:
- 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), pull-to-refresh (D-127), the editor keyboard fix, the S-12 line chart (D-128): landed.
- 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)
@@ -92,9 +95,9 @@ Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers health
- Nothing.
## 6. Numbering
- 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.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.26.
- Decisions: D-001 to D-128 used, except D-008 and D-009 (never used). Next free: **D-129**, taken by the web-client architect agent (it holds D-129 onward).
- Spec version: v0.27 written. Next **v0.28**, taken by the web-client architect agent (it holds v0.28 onward).
- 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.27.
- Sync protocol **1**, push payload **1**.
## 7. How the work is run
+1
View File
@@ -132,3 +132,4 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
| 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-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` |
| D-128 | 2026-10-21 | S-12 line chart for a category or account over the range (new AC-14.11). S-12 opened from a category or account breakdown row shows, in order: its own period chip (AC-14.2 presets plus custom) and Month/Week toggle, both starting from S-11's values; the header total; the "Utgifter över tid" card; then the expense rows. Changing the period or granularity recomputes chart, total and list on S-12 only; S-11 keeps its state. Measure and group-by come from S-11 and cannot be changed on S-12. A drill-down opened from a bar keeps today's header and list. The chart has one point per bucket, empty buckets as zero (AC-14.7), computed on the phone from the per-group bucket totals of `spendSeries` (§14.5.4); an empty range shows only the 0 baseline (D-068) and "Inga utgifter i den här perioden". Overlays: the range average (total ÷ buckets, rounded half-up to öre) as a dashed line in the group colour at reduced opacity, its amount at the right end in axis typography; a trailing moving average over 3 buckets (the bucket and the two before it; the first buckets use the 1 or 2 that exist; constant `AnalyticsRules.MOVING_AVERAGE_WINDOW`) as a thinner neutral line; the legend names the series, "Snitt", "Glidande snitt (3)"; the caption compares the latest bucket not cut short by the range (a whole month or ISO week) with the average: "<Bucket>: N % över snittet" / "under snittet" / "på snittet" when N rounds to 0, N a whole percent rounded half-up. Overlays and caption are omitted when the average is 0 or the range has one bucket; the caption is omitted when no bucket is whole. The chart is not interactive; its TalkBack summary gives the first and last bucket, the range total and the average. Consequences: no change to `:core:model`, `:domain`, `:core:fakes`, Room, the server or the contracts; `AnalyticsFilterBar`'s measure and group-by toggles are optional; `:core:design` gains `SpendLineChart` and `LineLegend` (`chart/LineChart.kt`), with the axis code shared with `StackedBarChart` in `Charts.kt` (bar chart pixels unchanged); seven `strings.csv` keys (`analytics_average`, `analytics_moving_average`, `analytics_vs_average_above`, `analytics_vs_average_below`, `analytics_vs_average_on`, `analytics_summary_line`, `analytics_summary_line_single`; 569 rows); new goldens `s12_category_groceries`, `s12_category_week`, `s12_category_empty_range`, `line_chart`, `line_chart_variants`; re-recorded `s12_category`, `s12_account`, `s12_long`; 977 Android tests. Landed `12e2508`. | Owner request 2026-10-21: "When clicking on a category in the analytics tab, the top should show total expense over time as a line graph. This time period should have similar config as the main analytics page. Underneath there can be all expenses listed as it is today." Follow-up: "I also want to see, per category what my average is, so I can see if we are above or below … some moving average to see if we are spending more or less." The series is the group's column of the bucket totals S-11 already computes, so no contract changes. | SPEC AC-14.11, §13.3 (S-12), §13.4; design-spec §3.10, S-12; `docs/ux/strings.csv`; `android/core/design/.../chart/LineChart.kt`, `Charts.kt`, `:feature:analytics` (`AnalyticsModel.kt`, `DrillDownScreen.kt`, `DrillDownViewModel.kt`) |
+20 -2
View File
@@ -592,7 +592,7 @@ TalkBack: "Konto: Vardag. Dubbeltryck för att byta konto"; "Datum: idag"; "Beta
* `pendingLocal`: hollow dot (2 dp `pending` ring), text in `pending` weight 600 with a 16 dp `cloud_upload` before it: "Din ändring väntar på synk" (AC-12.5); always last.
* Semantics: each item one node; the header is a heading.
### 3.10 Charts (S-11; Vico or the Canvas fallback, §6.2)
### 3.10 Charts (S-11, S-12; Vico or the Canvas fallback, §6.2)
**`ChartCard(title, content)`**: card, padding 18 / 16 / 16, title title-medium; an invisible summary sentence as `contentDescription` of the chart area.
@@ -605,9 +605,19 @@ TalkBack: "Konto: Vardag. Dubbeltryck för att byta konto"; "Datum: idag"; "Beta
**`ChartLegend(groups)`**: wrapping row, gap 4 / 12; 10 dp swatch (radius 3) + label (12 / 16 sp weight 500 `on-surface-variant`). Only groups with data. Decorative for TalkBack (the bars speak their totals).
**`SpendLineChart(points, color, summary, averageOre, movingAverage, xCaption)`** (S-12, AC-14.11, D-128; `chart/LineChart.kt`)
* Plot, y-axis gutter, gridlines, x labels, the "Vecka" caption and horizontal scroll as `StackedBarChart` (the axis code is shared in `Charts.kt`); one point per bucket (every bucket of the range, empty ones as zero, AC-14.7).
* Series: a 2.5 dp line in `color` (category colour or account chart colour) with round joins, 3 dp dots at the points, and a vertical fill under it from 22 % to 2 % opacity of that colour.
* Range average (`averageOre`): a 1.5 dp dashed line (6 dp on, 4 dp off) across the plot at 75 % opacity of `color`, with its amount in whole kronor at the right end in `chartAxis` typography and the same colour.
* Moving average (`movingAverage`, one value per point): a 1.5 dp line in `chart-axis-label` at 85 %.
* A single bucket shows one centred point and no average lines; a range without spending shows only the 0 baseline (D-068).
* Not interactive. TalkBack: the chart is one node reading `summary` (`analytics_summary_line`, or `analytics_summary_line_single` for one bucket).
**`LineLegend(items, color)`**: wrapping row as `ChartLegend`; each item a 16 × 10 dp line swatch in its line's style (solid series in `color`, dashed average, thin neutral moving average) + label (12 / 16 sp weight 500 `on-surface-variant`): the series name, "Snitt" (`analytics_average`), "Glidande snitt (3)" (`analytics_moving_average`). Decorative for TalkBack (the summary names the lines).
**`BreakdownList(rows, total, onRow)`** (AC-14.6 charts 2 and 3): rows sorted by amount descending, padding 8 / 0, gap 6: tile 32 (`CategoryTile` or `AccountTile`) + name (title-small, 1 line, ellipsis; "Arkiverat" badge for archived accounts) + right column `MoneyText(Small)` over the percent (body-small muted, one decimal, half-up, "34,8 %", AC-14.10); below, a 10 dp bar track (`surface-container-highest`, radius full) indented 44 dp, filled to amount / largest amount in the group colour. Row is one node "Boende, 62 000 kronor, 34,8 procent"; click → `onRow(id)`.
**`AnalyticsFilterBar(filter, onChange)`**: row 1: period chip (`date_range`, label "Senaste 6 mån" / "Anpassad · 1–30 nov.", `expand_more`; TalkBack "Period: senaste 6 månaderna") + `ButtonGroup` "Månad | Vecka" (min width 170, fills the rest); row 2: `ButtonGroup` "Hushållet | Min del" and `ButtonGroup` "Kategori | Konto", half width each. Connected buttons: min height 40, label-large weight 600, unselected `surface-container-high` / `on-surface-variant`, selected `secondary` / `on-secondary` with full radius; connected buttons: 8 dp side padding (D-102). 200 %: one control per row. Semantics: each group a radio group named "Indelning", "Mått", "Gruppera efter".
**`AnalyticsFilterBar(filter, onChange)`**: row 1: period chip (`date_range`, label "Senaste 6 mån" / "Anpassad · 1–30 nov.", `expand_more`; TalkBack "Period: senaste 6 månaderna") + `ButtonGroup` "Månad | Vecka" (min width 170, fills the rest); row 2: `ButtonGroup` "Hushållet | Min del" and `ButtonGroup` "Kategori | Konto", half width each; both optional, so S-12 shows row 1 only (D-128). Connected buttons: min height 40, label-large weight 600, unselected `surface-container-high` / `on-surface-variant`, selected `secondary` / `on-secondary` with full radius; connected buttons: 8 dp side padding (D-102). 200 %: one control per row. Semantics: each group a radio group named "Indelning", "Mått", "Gruppera efter".
**`TotalCard(totalOre, caption)`**: card, padding 16 / 20: "Totalt" (label-large muted; "Totalt · Min del" for my share) over `MoneyText(Large)` over the range caption (body-small muted, "maj–okt 2026 · Hushållet").
@@ -971,6 +981,14 @@ Full screen (§13.2), works offline (AC-8.2). The flow and the category rule are
**TalkBack**: header one node "Mat & hushåll, oktober 2026, totalt 3 910,00 kronor, 4 utgifter".
**Category or account over the range (D-128).** Opened from a breakdown row of S-11 (a drill-down from a bar keeps the layout above). Layout:
1. Period chip and "Månad | Vecka" as row 1 of `AnalyticsFilterBar` (no measure or group-by; custom range on its own row; 200 %: one control per row). Both start from S-11's values; changing them recomputes the chart, the total and the list on S-12 only, and S-11 keeps its own. Measure and group-by come from S-11 and cannot be changed here.
2. Header card as above.
3. `ChartCard` "Utgifter över tid" with `SpendLineChart` (§3.10): the series is a 2.5 dp line in the group colour (category colour, or account chart colour) with round joins and 3 dp dots, and a fill from 22 % to 2 % opacity of that colour; the plot, y gutter, gridlines, x labels and scroll are as `StackedBarChart`. The range average (total ÷ buckets, rounded half-up to öre) is a 1.5 dp dashed line (6 on, 4 off) at 75 % opacity of the group colour, its whole-kronor amount at the right end in `chartAxis` and the group colour. The moving average (trailing, 3 buckets: the bucket and the two before it; the first buckets use the 1 or 2 that exist) is a 1.5 dp line in `chart-axis-label` at 85 %. Then `LineLegend` (16 × 10 dp line swatches: series, "Snitt", "Glidande snitt (3)"), and the caption in body-small `on-surface-variant`: the latest bucket the range does not cut short (a whole month or ISO week) against the average, "Oktober 2026: 12 % över snittet" / "… under snittet" / "…: på snittet" when the whole percent (half-up) is 0 (`analytics_vs_average_*`). Overlays and caption are omitted when the average is 0 or the range has one bucket; the caption is omitted when no bucket is whole. A single bucket shows one centred point and no average lines; an empty range shows only the 0 baseline and "Inga utgifter i den här perioden" (`analytics_empty`). TalkBack: the chart is one node, "Resor över tid, maj–okt 2026: från 860 kronor i maj 2026 till 9 600 kronor i oktober 2026, totalt 37 238 kronor, snitt 6 206 kronor." (`analytics_summary_line`; one bucket `analytics_summary_line_single`).
4. `SegmentedGroup` of `ExpenseRow`s, newest first.
Goldens (no catalogue frame): `s12_category`, `s12_category_groceries`, `s12_category_week`, `s12_category_empty_range`, `s12_account`, `s12_long`; components `line_chart`, `line_chart_variants`.
### S-13 Settings hub (`:feature:settings`)
**Layout** (app bar "Inställningar"; column gap 12; bottom navigation with Inställningar selected):
+7
View File
@@ -438,6 +438,13 @@ analytics_drilldown_expenses:other,%d expenses,%d utgifter,module=analytics; plu
analytics_granularity,Interval,Indelning,module=analytics; radio-group name of the Månad | Vecka toggle (design-spec §3.10); M7e
analytics_measure,Measure,Mått,module=analytics; radio-group name of the Hushållet | Min del toggle; M7e
analytics_group_by,Group by,Gruppera efter,module=analytics; radio-group name of the Kategori | Konto toggle; M7e
analytics_average,Average,Snitt,module=analytics; S-12 line chart legend for the range average (D-128)
analytics_moving_average,Moving average (%1$d),Glidande snitt (%1$d),module=analytics; S-12 line chart legend; %1$d is the window in buckets (D-128)
analytics_vs_average_above,%1$s: %2$s above average,%1$s: %2$s över snittet,module=analytics; S-12 caption: latest whole bucket and the whole percent above the range average (D-128)
analytics_vs_average_below,%1$s: %2$s below average,%1$s: %2$s under snittet,module=analytics; S-12 caption: latest whole bucket and the whole percent below the range average (D-128)
analytics_vs_average_on,%1$s: on average,%1$s: på snittet,module=analytics; S-12 caption when the percent rounds to 0 (D-128)
analytics_summary_line,"%1$s over time, %2$s: from %3$s in %4$s to %5$s in %6$s, %7$s in total, average %8$s.","%1$s över tid, %2$s: från %3$s i %4$s till %5$s i %6$s, totalt %7$s, snitt %8$s.",module=analytics; TalkBack summary of the S-12 line chart: group; range; first and last bucket; total; average (D-128)
analytics_summary_line_single,"%1$s over time, %2$s: %3$s in total.","%1$s över tid, %2$s: totalt %3$s.",module=analytics; TalkBack summary of the S-12 line chart with one bucket (D-128)
analytics_range_apply,Apply,Använd,module=analytics; confirm button of the custom range picker; M7e
settings_profile_offline_banner,Needs connection. Your profile can only be changed online.,Kräver anslutning. Profilen kan bara ändras när telefonen är online.,module=settings
settings_title,Settings,Inställningar,module=settings
Can't render this file because it contains an unexpected character in line 156 and column 79.