# 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 · ` 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// 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//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 ` 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//i18n/sv.json` and `en.json` (flat, keys `web__…`, 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 `` (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-`, `--cat--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-