diff --git a/.gitignore b/.gitignore index 932b31c..cd4676b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ infra/dev/backups/ *.sqlite.gz *.sqlite-wal *.sqlite-shm +# Web client (web/): dependencies and build output +web/node_modules/ +web/dist/ diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..33dc180 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,6 @@ +dist +node_modules +src/api/schema.d.ts +src/design/icons.generated.ts +src/i18n/sv.json +src/i18n/en.json diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..dc8c710 --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "printWidth": 120, + "trailingComma": "all" +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..593000a --- /dev/null +++ b/web/README.md @@ -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 · ` 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-