Files
kvitt/web
jacobh2 f9a9bfb472
Build Kvitt images / build (push) Failing after 14m12s
W3 web settle up: W-7 settle up and W-8 settlement detail
W-7 (/app/settle, mirrors S-9): global, per account (?account=<id>) and
editing (?settlement=<id>). Defaults per AC-11.3/11.4/11.6, proposal from
proposeAllocation over all non-deleted accounts with nets excluding the
edited payment, rows follow the proposal until one is edited, "Föreslå
igen", swap, over-settle warnings and confirm dialog (AC-11.5), remaining
indicator, add account, untouched info line (D-093), date and note,
§14.5.3 errors live, one POST /sync mutation, §14.4.6 rejections shown
with a re-pull, discard dialog on leaving with changes (D-080).

W-8 (/app/settlements/:id, mirrors S-10): hero, date, note, allocations,
audit timeline (AC-12.4), deleted and missing states, edit link and soft
delete with confirmation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-21 21:28:43 +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/

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, vendor 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, 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, pictures.ts,
                             push.ts (POST /sync), 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
    lib/                     money, split, balances, settlement (kvitt-domain port), dates, effect, csv, ids,
                             shortcuts, storage
    features/<name>/         auth (sign-in, built), home, accounts, expenses, settle, analytics (with insights), search,
                             settings (placeholders for W1 to W5)
    test/                    setup.ts, fakeServer.ts (hand-written fetch fake), fixtures.ts, render.tsx

Routes

Fixed for the feature plans; always link with the builders in src/app/routes.ts (paths.account(id), …). Each feature exports its route objects from src/features/<name>/index.ts (homeRoutes, accountsRoutes, expensesRoutes, searchRoutes, settleRoutes, analyticsRoutes, settingsRoutes, 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 or account)
/insights features/analytics/InsightsPage web
/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).
  • 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 kvitt.versionCode), X-Kvitt-App-Version-Name (package.json version), Accept-Language (the page language), and Authorization: Bearer <token> while signed in. 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).

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.
  • 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. The app-only functions (spend_series, resolve_range, suggest_category) are listed as not ported; the plan that ports one moves it to PORTED in that test so its vectors run.
  • 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. 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. npm run check must pass.

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.