SPEC v0.28: web client programme (D-129 to D-134), plans W0 to W7
New SPEC §15 Web client and FR-21; §1, §2, G-5, AC-14.1 revised; §8.5 and §10.5.2 gate lines for /app/, the invite accept and bearer requests (except /health, /setup/, /i/, /apk/) plus the /app/ CSP via $kvitt_csp, identical in infra/npm/kvitt.conf and infra/README.md step 4 (checked with nginx 1.31.7); §10.3, §10.6, §11.1 to §11.3 (rows W0 to W7), §14.7 ownership; change log is now §16. PLAN-W0..W7 and prompts/W0..W7; HANDOFF numbering D-139 / v0.30. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+11
-8
@@ -3,12 +3,13 @@
|
||||
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 `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).
|
||||
- State as of 2026-10-21. `main` at `44c8cbb` (SPEC v0.27, D-128), after `12e2508` (S-12 line chart, D-128), `b0a90da` (SPEC v0.26, D-127), `05ec3f3` (editor keyboard fix) and `3778f5f` (pull-to-refresh, D-127); `origin/main` (Gitea) at `12e2508`. The web-client programme started 2026-10-21: SPEC v0.28 (D-129 to D-134, this docs pass `agent-web-arch`) and the W0 and W7 implementation agents are in flight. 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.
|
||||
- **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): 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).
|
||||
- **Web client programme** (owner interview 2026-10-21, SPEC v0.28, D-129 to D-134, SPEC §15, FR-21): a React web client in `web/`, served by `kvitt-server` under `/app/` from the server image, with the app's features except the phone-only ones, plus search, CSV export, keyboard shortcuts and a language choice; login with an invite code (device `Web · <browser>`); the NPM gate also passes `/app/`, `POST /invites/accept` and bearer-token requests except `/health`, `/setup/`, `/i/`, `/apk/` (new block in `infra/npm/kvitt.conf`, README step 4, SPEC §10.5.2; checked with nginx 1.31.7, 17 requests); advanced analytics on the server (`/analytics/*`, D-133, forecast = recurring templates due + 3-month average of non-recurring spend). Plans `PLAN-W0.md` to `PLAN-W7.md`, prompts `prompts/W0.md` to `prompts/W7.md`. W0 (`web/**`) and W7 (`server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`; D-135 to D-138, SPEC v0.29) are running; W1 to W5 start in parallel when W0 has landed (each in its own `web/src/features/<name>/`); W6 (Android Insights) after W7 lands and is deployed. The web gate is `npm run check` in `web/` (third gate; `CLAUDE.md` "Build and verify" still to be updated by the orchestrator).
|
||||
- **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`.
|
||||
@@ -49,19 +50,19 @@ 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-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).
|
||||
- This docs pass (`agent-web-arch`: SPEC v0.28, D-129 to D-134, §15 Web client, FR-21, `infra/npm/kvitt.conf`, `infra/README.md` steps 4 and 4a, `PLAN-W*.md`, `prompts/W*.md`, this file).
|
||||
- W0 web foundation (`web/**`) and W7 server web serving and analytics endpoints (D-135 to D-138, SPEC v0.29), in parallel.
|
||||
- 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 977 tests at `12e2508`; 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`). A third gate comes with W0 (D-131): `npm run check` in `web/` (`tsc --noEmit`, `eslint .`, `vitest run`, `vite build`, generated-types freshness); a change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` in the same landing.
|
||||
- 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-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).
|
||||
- `agent-catline-docs` landed as `44c8cbb` (SPEC v0.27, D-128); push it to `origin` if not done (it touches no `server/**`, `infra/backup/**` or `.gitea/**`, so no workflow run). Land `agent-web-arch` (orchestrator): SPEC v0.28, D-129 to D-134, `infra/npm/kvitt.conf`, `infra/README.md`, `PLAN-W*.md`, `prompts/W*.md`, HANDOFF; docs only, no gate but `python3 tools/contracts-check.py`; pushing it triggers 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 `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).
|
||||
@@ -78,12 +79,14 @@ 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`.
|
||||
- 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), pull-to-refresh (D-127), the editor keyboard fix, the S-12 line chart (D-128): landed.
|
||||
- Web client programme (D-134, SPEC §11.2 rows W0 to W7): W0 and W7 running; then W1 to W5 in parallel after W0 (briefs from `prompts/W1.md` to `prompts/W5.md` with W0's module map in the notes; if W0 lacks a shared piece the plans name, a W0b follow-up first); W4's W-11 and W6 after W7. Docs agent afterwards: record W0's and W7's reports (D-135 to D-138, SPEC v0.29, `KVITT_WEB_DIR` in §10.4, §7 rows for `/analytics/*`, the README's workflow and build-images sections for the repository-root context).
|
||||
- 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)
|
||||
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. 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. 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. Web client gate lines (D-130, SPEC v0.28): when the NPM block is pasted (item 2, or again if the D-126 block is already in NPM), paste the current `infra/npm/kvitt.conf` content (step 4), which also lets the web client through and sets its Content-Security-Policy; Save (row Online, yes/no). After the first image with the web client is deployed (W7, §10.10), acceptance test with Wi-Fi off on the phone: `https://kvitt.suorra.duckdns.org/app/` shows the Kvitt login page (yes/no) and `https://kvitt.suorra.duckdns.org/health` still gives a connection error (yes/no) (step 4a item 5 d and e).
|
||||
|
||||
## 4. Owner questions (asked, unanswered)
|
||||
- OQ-6 (SPEC §12): a confirm step on S-1 for an unusual server; default "not in v1".
|
||||
@@ -95,9 +98,9 @@ Done 2026-10-21: the Pi install (stack running, `v1.0.1`, both containers health
|
||||
- Nothing.
|
||||
|
||||
## 6. Numbering
|
||||
- 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.
|
||||
- Decisions: D-001 to D-134 used, except D-008 and D-009 (never used); D-135 to D-138 reserved for W7 (the server agent); D-139 taken by `infra/publish-apk.sh --deploy` (`70cb25c`, cited in `infra/README.md`, not yet in `docs/decisions.md` or SPEC: a docs agent records it). Next free: **D-140**.
|
||||
- Spec version: v0.28 written (web client programme, after v0.27 for D-128); v0.29 reserved for W7's contract change. Next **v0.30** (D-139's recording may take it).
|
||||
- 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 and v0.28 (W7 expects no migration).
|
||||
- Sync protocol **1**, push payload **1**.
|
||||
|
||||
## 7. How the work is run
|
||||
|
||||
@@ -133,3 +133,9 @@ entry and the old one a strike-through plus a pointer. The spec is updated in th
|
||||
| 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`) |
|
||||
| D-129 | 2026-10-21 | Web client in scope (revises §2's "A web client", "CSV or any other export" and, for the web only, the language-picker and layout non-goals). Kvitt gets a web client with the Android app's features except the phone-only ones: the phone pairing flow (S-1's server field, `kvitt://` links), push notifications, offline use and the sync screen (S-16), the in-app update (S-17). In: Home balances, account detail and editor, expense add, edit, delete and detail with the audit timeline, settle up and settlement detail, analytics (S-11, S-12 with D-128's line chart), categories, recurring templates, devices, profile, invite partner and pair a device (admin), about. Extras the app lacks: free-text search across descriptions and notes with amount, date, account, category and payer filters; CSV export of the filtered expense list (format chosen by W2 for Numbers and Excel in a Swedish locale, proposed in its report); keyboard shortcuts `N` new expense, `/` search, `Esc` close, `?` help; a language choice overriding the browser's language. Desktop first, one column with a bottom bar under 720 px; sv and en; light and dark by `prefers-color-scheme`; not a PWA (no service worker, no manifest, no offline). Pages W-1 to W-20 mirror the S-n screens (§15.6). No receipt picture (none in the app). | Owner interview 2026-10-21: the owner and the partner want the household on a computer too, with the bigger screen used for search, export and analytics; the phone-only parts have no meaning in a browser. | SPEC §1, §2, §15.1, §15.6, §15.7, FR-21 |
|
||||
| D-130 | 2026-10-21 | Web access and login (extends D-126). The web client is served by `kvitt-server` under `/app/`. A browser logs in with an invite code exactly like a phone pairs: E-3 `POST /invites/accept` with a `device` invite (or `partner`/`admin_setup`), device name `Web · <browser>`; the token kept in `localStorage`, sent as `Authorization: Bearer` on API calls only; the browser is listed and revocable on Devices like any device; a 401 forgets the token (with S-18's message for `device_revoked`); Log out forgets the token in the browser, and the device stays valid until revoked from another device (a device cannot revoke itself, AC-3.2; a sign-out endpoint would be a contract change, not taken). The NPM gate also passes, from outside the LAN: a path `/app` or under `/app/`; `/invites/accept`; a request with an `Authorization: Bearer` header whose path does not start with `/health`, `/setup/`, `/i/` or `/apk/` (a helper variable `$kvitt_bearer`, because nginx's `if` takes one condition). Everything else still gets 444; `/i/`, `/health`, `/setup/status` and `/apk/*` stay LAN-or-key only, with or without a bearer header. Lines in `infra/npm/kvitt.conf`, `infra/README.md` step 4 and SPEC §10.5.2 (three identical copies, D-126's rule); step 4a's acceptance test gains: with Wi-Fi off `/app/` shows the login page, `/health` is still a connection error. Checked with nginx 1.31.7 (nginx:alpine) on the Mac: `nginx -t` and 17 requests as intended. Consequence, accepted: the server's authenticated routes are reachable from the internet with a wrong token (401, at most 60 per minute per address by §8.6 and the H-1 fix), and the invite accept by anyone (AC-2.12's lockout, 60-bit codes), which the security review's model already assumed before D-126. | Owner interview 2026-10-21: the web client must work from outside the home; a browser cannot carry the app key, so the gate admits what the web needs and nothing more. The bearer exemption excludes the four public paths so that sending any bearer header does not reopen what D-126 closed. | SPEC §8.5, §10.5, §10.5.2, §15.2, FR-21; `infra/npm/kvitt.conf`; `infra/README.md` steps 4 and 4a |
|
||||
| D-131 | 2026-10-21 | Web stack. `web/` at the repository root: Vite, React 19, TypeScript strict, React Router 7, TanStack Query 5, `openapi-typescript` generating `web/src/api/schema.d.ts` from `contracts/openapi.yaml` (generated, committed, checked for freshness) with `openapi-fetch` as the client, Recharts, Vitest with Testing Library, ESLint and Prettier, plain CSS modules with tokens from `docs/ux/design-spec.md` §2 (same colour roles, category colours, type scale, the 4/2 radii; light and dark by `prefers-color-scheme`; system font stack, tabular figures; Material Symbols bundled as SVG), JSON dictionaries `sv.json` and `en.json` with `docs/ux/strings.csv`'s keys where a string exists there and `web_` keys otherwise. Money in integer öre with the same rules as `kvitt-domain` and `:domain`: the web's ports of §14.5.2's functions pass their `contracts/test-vectors/*.json` (Vitest), `resolveRange`/`spendSeries` and the §9.11 suggestion pass theirs. Node 25 and npm on the Mac. The web gate, the third gate: `npm run check` in `web/` = `tsc --noEmit`, `eslint .`, `vitest run`, `vite build` and the generated-types freshness check. A change to `contracts/openapi.yaml` regenerates `web/src/api/schema.d.ts` in the same landing. Source layout `web/src/{app,api,i18n,design,components,lib,features/<name>}`. | Owner interview 2026-10-21. One typed contract for three clients; the vectors keep the money rules equal on phone, server and web (G-1). | SPEC §11 intro, §14.7, §15.3, FR-21; `CLAUDE.md` "Build and verify" (orchestrator) |
|
||||
| D-132 | 2026-10-21 | Web build and serving. A Node stage in `server/Dockerfile` builds `web/` and the runtime image carries `dist/` at `/app`; `kvitt-server` serves it when `KVITT_WEB_DIR` (default `/app`) exists, with every non-file path under `/app/` answered by `index.html` (SPA fallback), and `/app/*` is 404 otherwise. The build context becomes the repository root (`-f server/Dockerfile`); the workflow (sparse checkout adds `web`, and `contracts` for files the web build reads), its path triggers, `infra/build-images.sh` and `infra/dev/**` follow (W7). The development stack serves it the same way. Hashed assets immutable, `index.html` `no-store`. CSP: NPM sets `Content-Security-Policy` from a variable `$kvitt_csp`, the D-117 policy everywhere and for `/app/` `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`, still one server-level `add_header`; the server's own `/app/` header and the exact cache values are the server agent's (see D-135+). No separate publish: a server deploy ships the web client. | Owner interview 2026-10-21: one image, one deploy, nothing new on the Pi; a variable keeps D-117's no-`add_header`-in-locations rule. | SPEC §10.3, §10.5.2, §10.6, §15.4; `infra/npm/kvitt.conf`; `server/Dockerfile`, `.gitea/workflows/build-images.yaml`, `infra/build-images.sh` (W7) |
|
||||
| D-133 | 2026-10-21 | Advanced analytics in the backend (revises AC-14.1's "the server has no reporting endpoint" and §2's "Server-side analytics or reporting endpoints"). New authenticated read endpoints under `/analytics/*`, designed by the server agent (D-135 to D-138, SPEC v0.29): per-category averages and trends per month, overall and per category, and a forecast of the next 2 calendar months per account. Forecast rule: for each account and each of the next two calendar months, forecast = the sum of the account's recurring templates due in that month (known future expenses) + the average per month of the account's non-recurring spend over the last 3 whole months; the 3-month window is the constant of D-128's trailing moving average (`AnalyticsRules.MOVING_AVERAGE_WINDOW`, 3), shared. The phone keeps computing S-11 and S-12 offline; the advanced analytics come from the server, online only (§9.9), on the web (W-11) and in the app (W6, an Insights section on S-11). | Owner, 2026-10-21: "These calculations should be put in the backend because I will want to see this in the app as well." | SPEC §1 G-5, §2, AC-14.1, §15.8 |
|
||||
| D-134 | 2026-10-21 | Plans for the web client programme, disjoint paths so Opus agents run in parallel after the foundation: W0 web foundation (`web/**` except feature folders; scaffold, tokens, shell, router with every route and a placeholder page per feature folder, login, token store, API client and household snapshot, i18n, formatting, shared components, money functions, the gate; running); W1 home and accounts (`features/home`, `accounts`); W2 expenses, search and export (`features/expenses`, `search`, `export`); W3 settle up (`features/settle`); W4 analytics and insights (`features/analytics`; W-11 stubbed until W7); W5 settings (`features/settings`); W6 Android Insights (`android/**`, a UI-contract change, after W7); W7 server web serving and analytics endpoints (`server/**`, `contracts/**`, `tools/contracts-check.py`, `.gitea/**`, `infra/build-images.sh`, `infra/dev/**`; running). Routes `/app/`, `/app/login`, `/app/accounts/:id`, `/app/expenses/new`, `/app/expenses/:id`, `/app/settle`, `/app/settlements/:id`, `/app/analytics`, `/app/analytics/:groupKind/:groupId`, `/app/insights`, `/app/search`, `/app/settings/*`. One `PLAN-W<n>.md` and `prompts/W<n>.md` each. | Owner interview 2026-10-21: run every independent task in parallel. | SPEC §11.1, §11.2, §11.3, §14.7, §15.9; `PLAN-W0.md` to `PLAN-W7.md`; `prompts/W0.md` to `prompts/W7.md` |
|
||||
|
||||
Reference in New Issue
Block a user