2ae65a5a9f
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
225 lines
20 KiB
Markdown
225 lines
20 KiB
Markdown
# 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.
|