Files
kvitt/web
jacobh2 7ce3029cb1
Build Kvitt images / build (push) Failing after 3h14m38s
SPEC v0.40: K3's report recorded (D-201 to D-207), web card pages as built
K3 (card purchases on the web, landed faeb752; web gate 70 files, 684 tests,
build ok; contracts-check 157):
- SPEC §15.6 W-21 to W-23 as built, with W-5's ?cardPurchase= prefill and link,
  W-6's card line, W-13's "Anslut kort" row; §15.5 the card snapshot; §15.3
  the shared card pieces (api/cards.ts, fixtures, dev proxy, three icons) and
  matchCardPurchase ported; §11.1, §11.2 K3 row, §11.3, §14.7, §15.9; v0.40 row.
- decisions D-201 to D-207 (refresh while fetching, query names, https-only
  auth_url, attempt outcome, W-5 purchase errors, date forms, S-27 on the web);
  D-208 to D-210 unused.
- HANDOFF: K0 to K3 landed, v1.0.4 deployed, v1.0.5 being built, kvitt-164 on
  the Pi; pending owner steps PLAN-K2 §8, PLAN-K3 §8, PLAN-K1 §8 steps 5 to 7;
  K3's open questions; next free D-212, v0.41.
- web/README.md: card routes, the card snapshot, query names, the new icons;
  prompts/README.md: K2 and K3 done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-24 17:04:37 +02:00
..

Kvitt web

The browser client of Kvitt: a single-page app with the same features as the Android app (SPEC §13.3 screens, §4 acceptance criteria), desktop first and usable on a phone (one column under 720 px), in Swedish and English. It is online-only (no PWA, no offline copy). kvitt-server serves the built files under /app/ (any unknown path under /app/ falls back to /app/index.html), and the API lives at the root of the same origin (/me, /sync, …), so there is no CORS and no server address to configure.

Run

Node 22.18 or later (the generators load src/lib/csv.ts with Node's built-in type stripping); this Mac has Node 25.

cd web
npm ci                     # dependencies into web/node_modules (git-ignored)
npm run dev                # http://localhost:5173/app/ ; API paths are proxied to KVITT_DEV_API
KVITT_DEV_API=http://localhost:8080 npm run dev   # the default; point it at the development stack (infra/dev)
npm run build              # web/dist/, what kvitt-server serves under /app/

The build splits libraries into react (React, React DOM), charts (Recharts and its d3 and Redux dependencies, loaded only with the analytics pages) and vendor (the rest), each under Vite's 500 kB warning. The dev proxy forwards every API prefix of contracts/openapi.yaml, /analytics and /cards included.

To sign in, create a "Pair a device" invite on the phone (Settings › Pair a device) and type the code on /app/login. The browser becomes a device named Web · <browser> and keeps its token in localStorage.

The gate

cd web && npm run check

check runs, in order and stopping at the first failure:

Step Script What it checks
Generated files generated:check src/api/schema.d.ts equals openapi-typescript ../contracts/openapi.yaml; src/i18n/{sv,en}.json equal what docs/ux/strings.csv + src/i18n/web-strings.csv give; src/design/icons.generated.ts equals the icon list
Types typecheck tsc --noEmit, strict, noUncheckedIndexedAccess
Lint lint ESLint (typescript-eslint strict type-checked, react-hooks), zero warnings
Format format:check Prettier
Tests test Vitest + Testing Library on jsdom: the contract vectors, formatters, components, auth flow, shell
Build build vite build

Other scripts: api:types (regenerate the API types after a contract change), i18n:import (regenerate the string catalogs), icons:import (regenerate the icon table), format (Prettier write), dev, preview.

Layout

web/
  index.html, vite.config.ts (base /app/, dev proxy, version code, chunks, Vitest), tsconfig.json, eslint.config.js
  scripts/   gen-i18n.mjs, gen-icons.mjs, check-generated.mjs
  src/
    main.tsx                 entry: theme, session, query client, router
    app/                     router.tsx (route table, lazy pages), routes.ts (path builders), providers.tsx,
                             Shell (sidebar / bottom bar), TopBar, UpdatedLine, RequireAuth, PlaceholderPage,
                             shortcuts.tsx (provider + useShortcut), theme.ts, PreferencesCard, SignOutButton,
                             NotFoundPage, buildInfo.ts, useSession, useNow, useToday (useToday, useTodayIso),
                             useDocumentTitle
    api/                     client.ts (openapi-fetch, headers, 401, ApiError, unwrap), schema.d.ts (generated),
                             types.ts (wire objects), session.ts, ledger.ts (sync-pull ledger), me.ts, invites.ts,
                             pictures.ts, push.ts (POST /sync), cards.ts (card snapshot, E-44), errors.ts (error and rejection texts),
                             queryClient.ts, ApiProvider.tsx, useApi.ts
    i18n/                    sv.json, en.json (generated), web-strings.csv, catalog.ts, I18nProvider, useI18n, language.ts
    design/                  tokens.css, global.css, Icon.tsx, icons.generated.ts (generated)
    components/              Button, TextField, AmountField, Dialog, Segmented, Avatar, CategoryIcon, Money,
                             LedgerRow (+ SegmentedGroup), Card, EmptyState, Toast, KeyboardShortcutsHelp,
                             AccountTile, PageStates (LoadingState, ErrorState), Timeline, OverflowMenu, SwitchRow;
                             editor/ (the expense-editor fields of design-spec §3.7, W2, also used by W5)
    lib/                     money, split, balances, settlement (kvitt-domain port), dates, effect, csv, ids,
                             shortcuts, storage, categories (categoryName), timeline (audit lines),
                             accountColors
    features/<name>/         auth (sign-in), home, accounts, expenses, settle, analytics (with insights), search,
                             export, settings, cards (K3: W-21 to W-23)
    test/                    setup.ts, fakeServer.ts (hand-written fetch fake), fixtures.ts, cards.ts (card fixtures),
                             render.tsx

Routes

Fixed for the feature plans; always link with the builders in src/app/routes.ts (paths.account(id), paths.drillDown('bucket', key), expensesNewPath({ account }), settlePath({ account }), settlementEditPath(id), cardsPath({ account }), connectCardPath({ provider }), expensesNewPath({ cardPurchase, account }), paths.settingsCardConnection(id); the query parameter names are QUERY.account and QUERY.settlement (D-144), QUERY.cardPurchase, QUERY.attempt and QUERY.provider (D-202)). Each feature exports its route objects from src/features/<name>/index.ts (homeRoutes, accountsRoutes, expensesRoutes, searchRoutes, settleRoutes, analyticsRoutes, settingsRoutes, cardsRoutes, authRoutes), paths relative to /app, pages loaded lazily with lazyPage(() => import('./Page')) (src/app/lazyPage.ts). src/app/router.tsx only spreads those arrays, so a feature plan changes its pages and sub-routes without editing src/app/.

Path under /app Page Screen
/ features/home/HomePage S-4
/login features/auth/LoginPage S-1 (web)
/accounts/new, /accounts/:id/edit features/accounts/AccountEditorPage S-6
/accounts/:id features/accounts/AccountDetailPage S-5
/expenses/new, /expenses/:id/edit features/expenses/ExpenseEditorPage S-7
/expenses/:id features/expenses/ExpenseDetailPage S-8
/settle features/settle/SettlePage S-9
/settlements/:id features/settle/SettlementDetailPage S-10
/analytics features/analytics/AnalyticsPage S-11
/analytics/:groupKind/:groupId features/analytics/DrillDownPage S-12 (groupKind category, account or bucket)
/insights features/analytics/InsightsPage web
/cards (?attempt=<id>, ?account=<id>) features/cards/CardsPage S-23, S-27 (W-21)
/settings/cards (?provider=<id>) features/cards/ConnectCardPage S-24, S-25 (W-22)
/settings/cards/:id features/cards/CardConnectionPage S-26 (W-23)
/search features/search/SearchPage web
/settings features/settings/SettingsPage S-13 (holds the web's language/theme card and sign-out)
/settings/categories, recurring, devices, profile, invite, about features/settings/*Page S-15, S-19, S-20, S-14, S-21, S-22

Data

The web has no local database. What it reads:

  • The ledger (users, categories, accounts, recurring templates, expenses, settlements, audit entries) comes only through sync pulls, because the API has no REST reads for them (SPEC §7). useLedger() (api/ledger.ts) holds the ledger in the TanStack Query cache: the first read pulls from cursor 0 in pages of 1000, later reads pull from the cursor it holds. A new epoch or 409 cursor_ahead starts again from 0. ledgerBalances(ledger) applies the ported §9.3 rules; live(map) drops tombstones. Pass a select to useLedger for derived views.
  • REST reads: useMe() (E-7), and the feature plans add devices (E-15), invites (E-13), health (E-1) the same way (useQuery + unwrap(api.GET(…))). Profile pictures: useUserPicture(userId, pictureVersion).
  • The card snapshot (SPEC §15.5, K3): the viewer's own card connections and purchases come through E-40 GET /cards/sync, never GET /sync. useCards(select?) (api/cards.ts) holds them in memory beside the ledger: the first read pulls from cursor 0 in pages of 1000, later reads (focus, every 60 s, after every card write) from the cursor it holds; 409 cursor_ahead starts again from 0; rows apply by card_version. W-21 re-reads every 2 s while a connection is fetching or in its first fetch (D-201). useCardWriter().refresh() re-reads only the snapshot (exact key), not the catalogue (E-36) or an attempt (E-38). Card writes (E-44 through useSetCardPurchase) are sent at once, never queued.
  • Refresh: every query refetches when the window gets focus and every 60 s while visible (api/queryClient.ts). UpdatedLine shows "Uppdaterad för 3 min sedan" with a button that reads now.

What it writes:

  • REST writes (accounts, categories, templates, profile, invites, devices) return the changed row; merge it with useLedgerWriter().apply({ accounts: [row] }), or call refresh() to pull.
  • Expenses and settlements go through POST /sync (api/push.ts): build the entity with ids from lib/ids.ts (uuidv7(), timestamp()), pushMutations(api, [expenseMutation(entity)]), then apply(rowsOfResults(results)). A rejected result carries its reason (SPEC §14.4.6) for the page to show; the web keeps no pending rows.
  • Errors are ApiError (status, code of SPEC §14.3.2 or network, field, retryAfterSeconds). Map codes to the error_* strings.

Auth

/app/login accepts an invite code (E-3) like the phone's pairing screen: the code is normalised per SPEC §8.3 and shown in groups of four, a pasted invite link gives its code and the inviter's name, and the server is this origin. The display name field appears only when the server asks for it (admin_setup and partner codes answer invalid_request on display_name). The token, user id, device id and invite kind are stored in localStorage under kvitt.session. Every call sends X-Kvitt-Protocol: 1, X-Kvitt-App-Version (package.json the version code below), X-Kvitt-App-Version-Name (package.json version), Accept-Language (the page language), and Authorization: Bearer <token> while signed in. The version code is the commit count of the checkout at build time (git rev-list --count HEAD in vite.config.ts, as the app's release builds count); where git or .git is missing (the image's Node stage) it is the environment variable KVITT_VERSION_CODE when set, else 1. vite build and Vitest both define it, so tests compare with BUILD.versionCode, never a literal. Any 401 signs out (unauthorized or device_revoked) and the router shows /app/login with S-18's text for device_revoked (D-130), "Du loggades ut" otherwise. After a partner invite the first page is /app/settings/profile (AC-2.10). Signing out from Settings forgets the token only; the device row stays on the server until it is removed under Devices on the phone (a device cannot revoke itself).

Strings

src/i18n/{sv,en}.json are generated, flat key → string, never edited by hand:

  • Every row of docs/ux/strings.csv (the app's strings, same keys) plus src/i18n/web-strings.csv (web-only keys, prefix web_, same columns key,en,sv,notes, so a docs agent can fold them into strings.csv).
  • Android placeholders become 1-based braces with the same numbers: %1$s and %1$d → {1}, a bare %d → {1} (then {2}, …), %% → %. Rows whose notes say formatted=false are copied as they are.
  • Plurals keep the app's keys key:one / key:other.

In components: const { t, tp, language } = useI18n(); then t('common_and', 'Jacob', 'Anna') and tp('sync_pending', count) (the count fills {1} unless other arguments are given). Keys are typed, so a missing key fails tsc. The language follows navigator.language (sv* is Swedish, anything else English, as the app's KvittLanguage.of); Settings stores an override in localStorage (kvitt.language).

Strings of a shared component (src/components, src/lib, src/api) live in the shared catalogs (web-strings.csv for web-only ones, e.g. web_editor_paid_by_toggle, web_reject_clock_skew); a component kept in a feature folder and rendered by another feature (export's ExportCsvButton on Search) keeps its strings in its own feature's i18n/. catalog.test.ts fails when a feature's source names another feature's web_ key.

To add a string in a feature: put it in src/features/<name>/i18n/sv.json and en.json (flat, keys web_<name>_…, the same keys in both, never a shared key; placeholders {1}, …). src/i18n/catalog.ts merges every feature's files at build time (import.meta.glob), so features never edit the shared files; t() accepts any web_ key, and src/i18n/catalog.test.ts fails when a feature's two files differ in keys or reuse a shared key. A string the app also has belongs in docs/ux/strings.csv (ask the docs agent; then npm run i18n:import). Shared web-only strings live in src/i18n/web-strings.csv (W0's).

Formatting and domain rules

  • lib/money.ts ports MoneyFormatter and AmountInput (:core:design): formatMoney(ore, language) gives 1 234,50 kr / SEK 1,234.50 with U+00A0; amount entry is string-parsed to öre in the page language (amountInputFromText(text, language)): Swedish takes , (or .) as the decimal separator and spaces as grouping, English . as the decimal point and , or spaces as grouping, so a field reopened as amountInputDisplay writes it (3 250,00, 3,250.00) can be edited. components/AmountField uses it; hideLabel keeps the label for assistive technology only, placeholder passes through.
  • lib/dates.ts ports DateFormatter with its own month and weekday tables.
  • lib/split.ts, lib/balances.ts, lib/settlement.ts port kvitt-domain (compute_split, compute_balances, propose_allocation, validate_settlement, over_settled_accounts); src/lib/vectors.test.ts runs every case of contracts/test-vectors/*.json against them, like server/crates/kvitt-domain/tests/vectors.rs, and against the ports that live in features: suggest_category (W2, features/expenses/suggest), resolve_range and spend_series (W4, features/analytics/domain/analytics), and match_card_purchase against lib/domain/matchCardPurchase (K3). Every vector file is listed as ported; a new file fails the test until it is.
  • lib/effect.ts is the §9.2 effect line.

Design tokens

src/design/tokens.css holds every token of docs/ux/design-spec.md §2 as a CSS custom property. Colours carry their light and dark value in one light-dark() declaration; the scheme follows prefers-color-scheme, and <html data-theme="light|dark"> (Settings › Theme, stored as kvitt.theme) overrides it.

Tokens Source Mapping
--primary … --outline-variant, --scrim §2.1 Material 3 colour roles role names 1:1 (on-primary-container → --on-primary-container)
--owed, --owing, --settled, --warning, --pending, --avatar-1/2, --card, --on-cat, --shadow, tints §2.2 Kvitt semantic roles and derived tints 1:1
--cat-<token>, --cat-<token>-container §2.3 (SPEC §9.7 tokens) the 14 colour tokens
--chart-cat-1…8, --chart-grid, --chart-axis-label §2.4 accounts use --chart-cat-(position mod 8 + 1)
--type-<style>, --tracking-* §2.7 type scale font shorthands; sp → rem (16 px root) so text follows the browser's font size; amounts add .amount (tabular figures)
--s1 … --s12, --gutter, --row-min, --ledger-row-min, --top-bar-height, --nav-height §2.8 spacing dp → px 1:1
--radius-* §2.9 shapes dp → px; the Expressive MaterialShapes use their stated fallbacks (category tile 30 %, avatars circles)
--elevation-card, --elevation-3 §2.10 light cards: two soft layers in --shadow; dark: none
--motion-* §2.11 the springs approximated as CSS durations and curves; all 0 under prefers-reduced-motion; navigation has no motion (D-108)

The typeface is the system UI face (D-020 bundles no font): Roboto where installed, else the platform's.

Icons

Material Symbols Rounded (design-spec §2.12), licence Apache License 2.0 (Google), from @material-symbols/svg-500 (outline, weight 500) and @material-symbols/svg-600 (filled, weight 600). No other icon set is used. scripts/gen-icons.mjs copies the path data of the listed icons into src/design/icons.generated.ts; <Icon name="home" /> or <Icon name="home" filled /> renders one inline. Five spec names have another file name in Material Symbols 0.48 (same glyph): card_giftcard → redeem, expand_more/expand_less → keyboard_arrow_down/up, smartphone → mobile, phonelink_erase → mobile_cancel; the tables keep the spec names. K3 added credit_card, link_off and visibility_off for the card pages. To add an icon: add its name to the list in the script and run npm run icons:import.

Adding a feature page (W1 to W5)

  1. Replace the placeholder in src/features/<name>/<Page>.tsx; keep the paths of src/app/routes.ts. New sub-routes go in the feature's index.ts.
  2. Start the page with <TopBar title={…} back={paths.…} actions={…} /> (sets the tab title too).
  3. Read with useLedger(select) / useMe() / a useQuery on a REST read; write as in "Data".
  4. Use the shared components and tokens; no literal colours, sizes or strings in feature code.
  5. Strings from t(); add missing ones in the feature's i18n/ (see Strings). Dates and money only through lib/dates.ts and lib/money.ts.
  6. Register page-level shortcuts with useShortcut(key, handler) (app/shortcuts.tsx); a later registration of a key wins, so a page's Esc overrides the shell's.
  7. Tests next to the page (*.test.tsx): renderApp(path, { server }) from src/test/render.tsx with a FakeServer answering the endpoints the page calls; no mocking libraries.
  8. Shared pieces live once in src/components, src/lib, src/api or src/app, and features import them; a feature never imports another feature's files (W0b moved the duplicates: tiles, page states, timeline, error texts, useToday, categoryName, the overflow menu, the editor helpers).
  9. npm run check must pass. Vitest runs without its file-system module cache (fsModuleCache: false in vite.config.ts): a stale cached transform once hid a feature catalog, and the suite takes seconds anyway.

Dependencies

Runtime (all MIT; the 51 production packages, transitive included, are MIT, ISC or BSD-3-Clause): react and react-dom 19.3.1, react-router 7.18.4, @tanstack/react-query 5.104.5, openapi-fetch 0.17.0, recharts 3.10.1 (with react-is 19.3.1, its peer).

Development: vite 8.3.5, @vitejs/plugin-react 6.1.2, typescript 5.9.3 (Apache-2.0; 5.9 because typescript-eslint supports TypeScript below 6.1 and openapi-typescript asks for 5.x), openapi-typescript 7.13.0, vitest 5.0.5, jsdom 30.1.3, @testing-library/react 16.3.3, @testing-library/dom 10.4.2, @testing-library/jest-dom 7.0.1, @testing-library/user-event 14.6.8, eslint 10.13.0, @eslint/js 10.0.1, typescript-eslint 8.72.0, eslint-plugin-react-hooks 7.1.1, eslint-config-prettier 10.1.8, globals 17.13.0, prettier 3.9.10, @types/react 19.3.0, @types/react-dom 19.3.0, @types/node 26.6.5, @material-symbols/svg-500 and svg-600 0.48.1 (Apache-2.0). None of them ship in the build except the icon path data.