W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s

web/ is the browser client's scaffold: Vite 8, React 19, TypeScript 5.9 strict, React Router 7 (basename /app, every
fixed route with a lazy placeholder page), TanStack Query 5 (refetch on focus and every 60 s), openapi-typescript
types generated from contracts/openapi.yaml with openapi-fetch. Reads go through GET /sync (no REST reads exist for
the ledger, SPEC §7): useLedger() pulls from cursor 0 and then incrementally, restarting on a new epoch or
cursor_ahead. Login pairs the browser as "Web · <browser>" with an invite code (E-3); every call sends the SPEC
§14.3.1 headers, a 401 signs out. kvitt-domain's split, balances, allocation and settlement rules are ported to
web/src/lib and pass every case of contracts/test-vectors. Design tokens from design-spec §2 in tokens.css
(light-dark()), Material Symbols Rounded (Apache-2.0) as generated inline SVG, sv/en catalogs generated from
docs/ux/strings.csv plus web-strings.csv. Gate: cd web && npm run check.

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 21:00:03 +02:00
parent 3fbd393082
commit 2ae65a5a9f
154 changed files with 15651 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
# 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.
```bash
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
```bash
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.