W0b web shared follow-ups: one copy of shared pieces, per-locale amounts, chunks
Build Kvitt images / build (push) Has been cancelled

- 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
This commit is contained in:
2026-10-24 11:19:58 +02:00
parent 07c36d24a2
commit 5305f37038
123 changed files with 1550 additions and 2022 deletions
+43 -15
View File
@@ -18,6 +18,10 @@ KVITT_DEV_API=http://localhost:8080 npm run dev # the default; point it at the
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`.
@@ -45,23 +49,28 @@ catalogs), `icons:import` (regenerate the icon table), `format` (Prettier write)
```
web/
index.html, vite.config.ts (base /app/, dev proxy, vendor chunks, Vitest), tsconfig.json, eslint.config.js
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, useDocumentTitle
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, pictures.ts,
push.ts (POST /sync), queryClient.ts, ApiProvider.tsx, useApi.ts
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
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
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
@@ -69,7 +78,9 @@ web/
## Routes
Fixed for the feature plans; always link with the builders in `src/app/routes.ts` (`paths.account(id)`, …). Each
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`
@@ -86,7 +97,7 @@ only spreads those arrays, so a feature plan changes its pages and sub-routes wi
| `/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`) |
| `/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) |
@@ -124,8 +135,11 @@ shown in groups of four, a pasted invite link gives its code and the inviter's n
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
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).
@@ -145,6 +159,11 @@ In components: `const { t, tp, language } = useI18n();` then `t('common_and', 'J
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
@@ -155,13 +174,18 @@ 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.
`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`. 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.
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
@@ -206,7 +230,11 @@ names. To add an icon: add its name to the list in the script and run `npm run i
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.
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