Files
kvitt/web/README.md
T
jacobh2 5305f37038
Build Kvitt images / build (push) Has been cancelled
W0b web shared follow-ups: one copy of shared pieces, per-locale amounts, chunks
- Shared once, features import them: components/AccountTile (+ lib/accountColors),
  components/PageStates (LoadingState, ErrorState), components/Timeline + lib/timeline,
  components/OverflowMenu, components/SwitchRow, app/useToday (useToday, useTodayIso),
  lib/categories (categoryName), api/errors (apiErrorKey/Text, isNetworkError,
  rejectionMessage/Text, allocationErrorKey), api/invites (useInvites),
  components/editor peopleOf, editorPeopleOf, accountBpOf. Local copies deleted.
- AmountField: separators follow the page language (en "3,250.00" editable),
  hideLabel and placeholder props; settle's ungrouped amountText and CSS label
  hiding removed.
- W5's recurring editor uses the shared editor fields (D-148); recurring/fields deleted.
- routes.ts: 'bucket' in DrillDownKind, QUERY, expensesNewPath, settlePath,
  settlementEditPath; accounts/links.ts and settle/links.ts deleted.
- vectors.test.ts runs resolve_range and spend_series (analytics port); the
  duplicate runner is gone.
- vite.config.ts: /analytics in the dev proxy, Recharts in a charts chunk (vendor
  516 kB -> 132 kB), version code from git rev-list --count HEAD (else
  KVITT_VERSION_CODE, else 1), Vitest fsModuleCache off.
- ids.ts: random typed (bytes: Uint8Array<ArrayBuffer>) => void.
- test/setup.ts: findBy and waitFor wait up to 5 s (first lazy renders time out at 1 s
  when parallel agents load the Mac).
- Strings: web_editor_paid_by_toggle (click wording), web_reject_clock_skew shared;
  catalog.test.ts fails when a feature uses another feature's web_ key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
2026-10-24 11:26:17 +02:00

253 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/
```
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` 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
```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, 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), 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, 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)`,
`paths.drillDown('bucket', key)`, `expensesNewPath({ account })`, `settlePath({ account })`,
`settlementEditPath(id)`; the query parameter names are `QUERY.account` and `QUERY.settlement`, D-144). 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`, `account` or `bucket`) |
| `/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`
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`). 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. 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.