diff --git a/web/README.md b/web/README.md index 593000a..21d0c4d 100644 --- a/web/README.md +++ b/web/README.md @@ -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 · ` 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// 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//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 ` 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 ` 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//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 @@ -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 diff --git a/web/package.json b/web/package.json index 3e19618..00a28a4 100644 --- a/web/package.json +++ b/web/package.json @@ -4,9 +4,6 @@ "version": "0.1.0", "description": "Kvitt web client: the SPA served by kvitt-server under /app/", "type": "module", - "kvitt": { - "versionCode": 1 - }, "engines": { "node": ">=22.18" }, diff --git a/web/src/api/errors.test.ts b/web/src/api/errors.test.ts new file mode 100644 index 0000000..08bba81 --- /dev/null +++ b/web/src/api/errors.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { ANNA, ANNA_ID } from '../test/fixtures'; +import { ApiError } from './client'; +import { allocationErrorKey, apiErrorKey, isNetworkError, rejectionMessage, type RejectedResult } from './errors'; + +describe('error texts', () => { + it('maps every settlement token on the allocations (§14.5.3)', () => { + expect(allocationErrorKey('allocation_sum_mismatch')).toBe('settle_error_allocation_sum'); + expect(allocationErrorKey('allocation_not_positive')).toBe('settle_error_allocation_positive'); + expect(allocationErrorKey('no_allocations')).toBe('settle_error_no_allocations'); + expect(allocationErrorKey('too_many_allocations')).toBe('settle_error_too_many_allocations'); + for (const token of ['duplicate_account', 'same_user', 'user_unknown', 'amount_out_of_range']) { + expect(allocationErrorKey(token)).toBe('error_generic_invalid'); + } + }); + + it('maps every rejection reason (§14.4.6)', () => { + const users = new Map([[ANNA_ID, ANNA]]); + const rejected = (reason: RejectedResult['reason'], message = '', actor = ANNA_ID) => ({ + reason, + message, + current: { updated_by_user_id: actor }, + }); + expect(rejectionMessage(rejected('stale'), users, 'Partnern')).toEqual({ + key: 'home_sync_reject_stale', + args: ['Anna'], + }); + expect(rejectionMessage(rejected('stale', '', 'unknown'), users, 'Partnern').args).toEqual(['Partnern']); + expect(rejectionMessage(rejected('deleted'), users, 'Anna').key).toBe('home_sync_reject_deleted'); + expect(rejectionMessage(rejected('reference_not_found'), users, 'Anna').key).toBe('home_sync_reject_reference'); + expect(rejectionMessage(rejected('clock_skew'), users, 'Anna').key).toBe('web_reject_clock_skew'); + expect(rejectionMessage(rejected('validation_failed', 'allocation_sum_mismatch'), users, 'Anna').key).toBe( + 'settle_error_allocation_sum', + ); + expect(rejectionMessage(rejected('validation_failed', 'same_user'), users, 'Anna').key).toBe( + 'home_sync_reject_validation', + ); + }); + + it('maps request failures (§14.3.2, §15.5)', () => { + expect(apiErrorKey(new ApiError(0, 'network', null, 'x'))).toBe('common_could_not_reach_server'); + expect(apiErrorKey(new ApiError(409, 'household_incomplete', null, 'x'))).toBe('error_household_incomplete'); + expect(apiErrorKey(new ApiError(409, 'account_not_empty', null, 'x'))).toBe('account_delete_blocked'); + expect(apiErrorKey(new ApiError(400, 'invalid_request', null, 'x'))).toBe('error_validation'); + expect(apiErrorKey(new ApiError(500, 'internal', null, 'x'))).toBe('error_server'); + expect(apiErrorKey(new Error('x'))).toBe('error_server'); + expect(isNetworkError(new ApiError(0, 'network', null, 'x'))).toBe(true); + expect(isNetworkError(new Error('x'))).toBe(false); + }); +}); diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 0000000..f70b168 --- /dev/null +++ b/web/src/api/errors.ts @@ -0,0 +1,106 @@ +/** + * What the web says when something fails (one mapping for every page): API error codes of SPEC §14.3.2 (and + * `network`, §15.5), the rejection reasons of `POST /sync` (§14.4.6) with S-16's copy, and the settlement-validity + * tokens a rejection or the allocation rows carry (§14.5.3). Keys of the catalogs; `apiErrorText` and + * `rejectionText` give the string. + */ +import type { FormatArg, StringKey } from '../i18n/catalog'; +import type { I18n } from '../i18n/I18nProvider'; +import { ApiError } from './client'; +import type { MutationResult, User } from './types'; + +export type RejectedResult = Extract; + +/** A string key with its arguments, for a message kept in state and rendered with `t(key, ...args)`. */ +export interface Message { + key: StringKey; + args: FormatArg[]; +} + +/** The string of a failed request: "Kunde inte nå servern" for a network error (§15.5), else the code's `error_*`. */ +export function apiErrorKey(error: unknown): StringKey { + if (!(error instanceof ApiError)) return 'error_server'; + switch (error.code) { + case 'network': + return 'common_could_not_reach_server'; + case 'forbidden': + return 'error_forbidden'; + case 'not_found': + return 'error_not_found'; + case 'household_full': + return 'error_household_full'; + case 'household_incomplete': + return 'error_household_incomplete'; + case 'rate_limited': + return 'error_rate_limited'; + case 'protocol_unsupported': + return 'error_app_too_old'; + case 'account_not_empty': + return 'account_delete_blocked'; + case 'validation_failed': + case 'invalid_request': + case 'batch_too_large': + case 'payload_too_large': + return 'error_validation'; + default: + return 'error_server'; + } +} + +export function apiErrorText(error: unknown, t: I18n['t']): string { + return t(apiErrorKey(error)); +} + +/** True when the request never reached the server: the page offers "Försök igen". */ +export function isNetworkError(error: unknown): boolean { + return error instanceof ApiError && error.code === 'network'; +} + +const ALLOCATION_KEYS: Readonly> = { + allocation_sum_mismatch: 'settle_error_allocation_sum', + allocation_not_positive: 'settle_error_allocation_positive', + no_allocations: 'settle_error_no_allocations', + too_many_allocations: 'settle_error_too_many_allocations', +}; + +/** A settlement-validity token (§14.5.3) on the allocations, as the app words it (design-spec S-9 "Invalid"). */ +export function allocationErrorKey(token: string): StringKey { + return ALLOCATION_KEYS[token] ?? 'error_generic_invalid'; +} + +/** + * S-16's explanation of a rejected push. `stale` names who saved the newer version: the stored row's + * `updated_by_user_id`, the partner when unknown (D-089). A `validation_failed` carrying a settlement token says which + * rule broke. `clock_skew` names the computer's clock (the web's wording). + */ +export function rejectionMessage( + rejection: Pick & { + current: { updated_by_user_id: string } | null; + message?: string; + }, + users: ReadonlyMap>, + partnerName: string, +): Message { + switch (rejection.reason) { + case 'stale': { + const actor = rejection.current === null ? undefined : users.get(rejection.current.updated_by_user_id); + return { key: 'home_sync_reject_stale', args: [actor?.display_name ?? partnerName] }; + } + case 'deleted': + return { key: 'home_sync_reject_deleted', args: [] }; + case 'reference_not_found': + return { key: 'home_sync_reject_reference', args: [] }; + case 'clock_skew': + return { key: 'web_reject_clock_skew', args: [] }; + case 'validation_failed': { + const key = rejection.message === undefined ? undefined : ALLOCATION_KEYS[rejection.message]; + return { key: key ?? 'home_sync_reject_validation', args: [] }; + } + } +} + +export function rejectionText(...args: [...Parameters, I18n['t']]): string { + const [rejection, users, partnerName, t] = args; + const message = rejectionMessage(rejection, users, partnerName); + return t(message.key, ...message.args); +} diff --git a/web/src/api/invites.ts b/web/src/api/invites.ts new file mode 100644 index 0000000..6736ef0 --- /dev/null +++ b/web/src/api/invites.ts @@ -0,0 +1,16 @@ +/** E-13 `GET /invites`: the household's pending invites (REST, not part of the sync ledger). */ +import { useQuery } from '@tanstack/react-query'; +import { unwrap } from './client'; +import { useApi } from './useApi'; + +export const INVITES_QUERY_KEY = ['invites'] as const; + +/** The pending invites; `enabled: false` for a member, whom the server answers `forbidden`. */ +export function useInvites({ enabled = true }: { enabled?: boolean } = {}) { + const api = useApi(); + return useQuery({ + queryKey: INVITES_QUERY_KEY, + enabled, + queryFn: ({ signal }) => unwrap(api.GET('/invites', { signal })), + }); +} diff --git a/web/src/app/buildInfo.ts b/web/src/app/buildInfo.ts index 4d25fe7..5b4c241 100644 --- a/web/src/app/buildInfo.ts +++ b/web/src/app/buildInfo.ts @@ -1,4 +1,7 @@ -/** The web build's identity, sent on every API call like the app's (SPEC §14.3.1, D-090). From package.json. */ +/** + * The web build's identity, sent on every API call like the app's (SPEC §14.3.1, D-090): package.json `version` and + * the git commit count at build time (vite.config.ts). + */ export const BUILD = { versionName: __KVITT_VERSION_NAME__, versionCode: __KVITT_VERSION_CODE__, diff --git a/web/src/app/routes.ts b/web/src/app/routes.ts index b7cb04f..e030b8b 100644 --- a/web/src/app/routes.ts +++ b/web/src/app/routes.ts @@ -4,7 +4,21 @@ */ export const BASENAME = '/app'; -export type DrillDownKind = 'category' | 'account'; +/** S-12's group kinds: a category, an account, or a whole bucket of the chart (`/analytics/bucket/`, D-151). */ +export type DrillDownKind = 'category' | 'account' | 'bucket'; + +/** + * Query parameters the pages read (D-144): `account` presets the account of W-5 and W-7 (`/expenses/new?account=`, + * `/settle?account=`); `settlement` opens W-7 to edit a saved payment (`/settle?settlement=`). + */ +export const QUERY = { account: 'account', settlement: 'settlement' } as const; + +function withQuery(path: string, query: Readonly>): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) if (value !== undefined) params.set(key, value); + const search = params.toString(); + return search === '' ? path : `${path}?${search}`; +} export const paths = { home: '/', @@ -30,5 +44,20 @@ export const paths = { settingsAbout: '/settings/about', } as const; +/** W-5 New expense, optionally with the account preset (S-5's FAB): `/expenses/new?account=`. */ +export function expensesNewPath({ account }: { account?: string } = {}): string { + return withQuery(paths.expenseNew, { [QUERY.account]: account }); +} + +/** W-7 Settle up, optionally for one account (AC-11.4, "Gör upp det här kontot"): `/settle?account=`. */ +export function settlePath({ account }: { account?: string } = {}): string { + return withQuery(paths.settle, { [QUERY.account]: account }); +} + +/** W-7 editing a saved payment (AC-11.6, W-8 "Redigera"): `/settle?settlement=`. */ +export function settlementEditPath(settlementId: string): string { + return withQuery(paths.settle, { [QUERY.settlement]: settlementId }); +} + /** Top-level destinations: Esc does not go back from these. */ export const TOP_LEVEL = new Set([paths.home, paths.analytics, paths.settings, paths.search, paths.insights]); diff --git a/web/src/app/useToday.ts b/web/src/app/useToday.ts new file mode 100644 index 0000000..c148a3b --- /dev/null +++ b/web/src/app/useToday.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { parseDate, todayIso, type LocalDate } from '../lib/dates'; +import { useNow } from './useNow'; + +const MINUTE_MS = 60_000; + +/** Today as `YYYY-MM-DD` in the browser's time zone, following midnight within a minute. */ +export function useTodayIso(): string { + return todayIso(new Date(useNow(MINUTE_MS))); +} + +/** Today's date in the browser's zone, refreshed every minute (list dates, month headers, this month's counts). */ +export function useToday(): LocalDate { + const iso = useTodayIso(); + return useMemo(() => parseDate(iso), [iso]); +} diff --git a/web/src/features/analytics/components/AccountTile.module.css b/web/src/components/AccountTile.module.css similarity index 54% rename from web/src/features/analytics/components/AccountTile.module.css rename to web/src/components/AccountTile.module.css index 76abff4..1e19f8b 100644 --- a/web/src/features/analytics/components/AccountTile.module.css +++ b/web/src/components/AccountTile.module.css @@ -1,8 +1,9 @@ +/* design-spec §3.4 AccountTile: Clover4Leaf fallback 16 px rounded, first letter in on-cat. */ .tile { display: inline-grid; place-items: center; flex: none; - border-radius: var(--radius-category-tile); + border-radius: var(--radius-large); color: var(--on-cat); font-weight: 700; line-height: 1; diff --git a/web/src/components/AccountTile.test.tsx b/web/src/components/AccountTile.test.tsx new file mode 100644 index 0000000..2c43346 --- /dev/null +++ b/web/src/components/AccountTile.test.tsx @@ -0,0 +1,23 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { accountColor, accountHeaderColor } from '../lib/accountColors'; +import { AccountTile, tileLetter } from './AccountTile'; + +describe('AccountTile', () => { + it('shows the first letter in the colour of the account position, decoratively', () => { + const { container } = render(); + const tile = container.firstElementChild as HTMLElement; + expect(tile).toHaveTextContent('Ö'); + expect(tile).toHaveAttribute('aria-hidden', 'true'); + expect(tile.style.backgroundColor).toBe('var(--chart-cat-2)'); + expect(tile).toHaveClass('muted'); + expect(tileLetter('')).toBe(''); + }); + + it('cycles the eight chart colours by position (design-spec §2.4)', () => { + expect(accountColor(0)).toBe('var(--chart-cat-1)'); + expect(accountColor(7)).toBe('var(--chart-cat-8)'); + expect(accountColor(8)).toBe('var(--chart-cat-1)'); + expect(accountHeaderColor(10)).toBe('var(--account-header-3)'); + }); +}); diff --git a/web/src/components/AccountTile.tsx b/web/src/components/AccountTile.tsx new file mode 100644 index 0000000..1ba007c --- /dev/null +++ b/web/src/components/AccountTile.tsx @@ -0,0 +1,40 @@ +import { accountColor } from '../lib/accountColors'; +import styles from './AccountTile.module.css'; + +/** The first grapheme of the name, uppercase (design-spec §3.4). */ +export function tileLetter(name: string): string { + return (Array.from(name.trim())[0] ?? '').toUpperCase(); +} + +/** + * design-spec §3.4 `AccountTile(name, colorIndex, size = 44, muted)`: a 16 px rounded tile (the Clover4Leaf fallback) + * in the account's `chart-cat-n` colour with the name's first letter in `on-cat`, weight 700 (18 at size 44, scaled + * with the tile); muted (70 %) for an archived account. `colorIndex` is the account's position (`lib/accountColors`). + * Always decorative: the name is printed beside it. + */ +export function AccountTile({ + name, + colorIndex, + size = 44, + muted = false, +}: { + name: string; + colorIndex: number; + size?: number; + muted?: boolean; +}) { + return ( + + ); +} diff --git a/web/src/components/AmountField.test.tsx b/web/src/components/AmountField.test.tsx index 9f13597..162cba5 100644 --- a/web/src/components/AmountField.test.tsx +++ b/web/src/components/AmountField.test.tsx @@ -6,12 +6,22 @@ import type { Language } from '../i18n/language'; import { renderWithProviders } from '../test/render'; import { AmountField } from './AmountField'; -function Controlled({ onOre }: { onOre: (ore: number | null) => void }) { - const [text, setText] = useState(''); +function Controlled({ + onOre, + initial = '', + hideLabel, +}: { + onOre: (ore: number | null) => void; + initial?: string; + hideLabel?: boolean; +}) { + const [text, setText] = useState(initial); return ( { setText(next); onOre(ore); @@ -21,16 +31,41 @@ function Controlled({ onOre }: { onOre: (ore: number | null) => void }) { } describe('AmountField', () => { - it.each<[Language, string]>([ - ['sv', 'kr'], - ['en', 'SEK'], - ])('converts typed text to öre and shows the unit (%s)', async (language, unit) => { + it.each<[Language, string, string]>([ + ['sv', 'kr', '1247,605-x'], + ['en', 'SEK', '1247.605-x'], + ])('converts typed text to öre and shows the unit (%s)', async (language, unit, typed) => { let ore: number | null = null; renderWithProviders( (ore = value)} />, { language }); const input = screen.getByLabelText('Belopp'); - await userEvent.type(input, '1247,605-x'); - expect(input).toHaveValue('1247,60'); + await userEvent.type(input, typed); + expect(input).toHaveValue(typed.slice(0, 7)); expect(ore).toBe(124760); expect(screen.getByText(unit)).toBeInTheDocument(); }); + + it('edits a grouped English amount with `.` as the decimal point and `,` as grouping', async () => { + let ore: number | null = null; + renderWithProviders( (ore = value)} />, { language: 'en' }); + const input = screen.getByLabelText('Belopp'); + await userEvent.type(input, '{backspace}5'); + expect(input).toHaveValue('3,250.05'); + expect(ore).toBe(325005); + await userEvent.clear(input); + await userEvent.type(input, '12,5'); + expect(ore).toBe(12500); + }); + + it('edits a grouped Swedish amount with `,` as the decimal separator', async () => { + let ore: number | null = null; + renderWithProviders( (ore = value)} />, { language: 'sv' }); + await userEvent.type(screen.getByLabelText('Belopp'), '{backspace}5'); + expect(ore).toBe(325005); + }); + + it('keeps a hidden label for assistive technology and shows the placeholder', () => { + renderWithProviders( undefined} />, { language: 'sv' }); + expect(screen.getByLabelText('Belopp')).toHaveAttribute('placeholder', '0'); + expect(screen.getByText('Belopp')).toHaveClass('visually-hidden'); + }); }); diff --git a/web/src/components/AmountField.tsx b/web/src/components/AmountField.tsx index 2772478..193adf0 100644 --- a/web/src/components/AmountField.tsx +++ b/web/src/components/AmountField.tsx @@ -1,7 +1,7 @@ import { useI18n } from '../i18n/useI18n'; import { amountInputFromText, amountInputOre, moneyUnit } from '../lib/money'; -import styles from './AmountField.module.css'; import { TextField } from './TextField'; +import styles from './AmountField.module.css'; interface Props { label: string; @@ -16,11 +16,16 @@ interface Props { disabled?: boolean; id?: string; className?: string; + /** The label is read by assistive technology but not shown (the row around the field names it). */ + hideLabel?: boolean; + placeholder?: string; } /** - * Amount entry (SPEC §9.6): digits, one decimal separator (`,` or `.`), at most two decimals, no sign, converted to öre - * by string parsing. Shows `kr` after (sv) or `SEK` before (en) the number, tabular figures. + * Amount entry (SPEC §9.6): digits, one decimal separator, at most two decimals, no sign, converted to öre by string + * parsing. Separators follow the page language (`amountInputFromText`): Swedish `1 234,50`, English `1,234.50`, so a + * value written by `amountInputDisplay` can be edited as shown. Shows `kr` after (sv) or `SEK` before (en) the number, + * tabular figures. */ export function AmountField({ label, value, onChange, maxOre, ...rest }: Props) { const { language } = useI18n(); @@ -36,7 +41,7 @@ export function AmountField({ label, value, onChange, maxOre, ...rest }: Props) prefix={language === 'en' ? unit : undefined} suffix={language === 'sv' ? unit : undefined} onChange={(text) => { - const input = amountInputFromText(text, maxOre); + const input = amountInputFromText(text, language, maxOre); if (input !== null) onChange(text, amountInputOre(input)); }} /> diff --git a/web/src/features/accounts/OverflowMenu.module.css b/web/src/components/OverflowMenu.module.css similarity index 100% rename from web/src/features/accounts/OverflowMenu.module.css rename to web/src/components/OverflowMenu.module.css diff --git a/web/src/components/OverflowMenu.test.tsx b/web/src/components/OverflowMenu.test.tsx new file mode 100644 index 0000000..2ad9023 --- /dev/null +++ b/web/src/components/OverflowMenu.test.tsx @@ -0,0 +1,36 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { renderWithProviders } from '../test/render'; +import { OverflowMenu } from './OverflowMenu'; + +describe('OverflowMenu', () => { + it('opens on the button, moves with the arrow keys, runs an item and closes; disabled items say why', async () => { + const edit = vi.fn(); + const remove = vi.fn(); + renderWithProviders( + , + { language: 'sv' }, + ); + const user = userEvent.setup(); + const button = screen.getByRole('button', { name: 'Fler alternativ' }); + await user.click(button); + expect(button).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('menuitem', { name: 'Redigera' })).toHaveFocus(); + await user.keyboard('{ArrowDown}'); + const disabled = screen.getByRole('menuitem', { name: 'Ta bort' }); + expect(disabled).toHaveFocus(); + expect(disabled).toHaveAccessibleDescription('Kontot har utgifter'); + await user.click(disabled); + expect(remove).not.toHaveBeenCalled(); + await user.keyboard('{ArrowDown}{Enter}'); + expect(edit).toHaveBeenCalledOnce(); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/features/accounts/OverflowMenu.tsx b/web/src/components/OverflowMenu.tsx similarity index 96% rename from web/src/features/accounts/OverflowMenu.tsx rename to web/src/components/OverflowMenu.tsx index 26b63e4..49e0379 100644 --- a/web/src/features/accounts/OverflowMenu.tsx +++ b/web/src/components/OverflowMenu.tsx @@ -1,7 +1,7 @@ import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'; -import { useShortcut } from '../../app/shortcuts'; -import { Icon } from '../../design/Icon'; -import type { OutlineIconName } from '../../design/icons.generated'; +import { useShortcut } from '../app/shortcuts'; +import { Icon } from '../design/Icon'; +import type { OutlineIconName } from '../design/icons.generated'; import styles from './OverflowMenu.module.css'; export interface MenuItem { diff --git a/web/src/features/accounts/PageStates.module.css b/web/src/components/PageStates.module.css similarity index 96% rename from web/src/features/accounts/PageStates.module.css rename to web/src/components/PageStates.module.css index 211e1ac..c51b5d4 100644 --- a/web/src/features/accounts/PageStates.module.css +++ b/web/src/components/PageStates.module.css @@ -40,6 +40,8 @@ color: var(--on-surface-variant); } .failed { + display: flex; + flex-direction: column; gap: var(--s4); } .failedInner { diff --git a/web/src/components/PageStates.test.tsx b/web/src/components/PageStates.test.tsx new file mode 100644 index 0000000..d0285ff --- /dev/null +++ b/web/src/components/PageStates.test.tsx @@ -0,0 +1,28 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { ApiError } from '../api/client'; +import { renderWithProviders } from '../test/render'; +import { ErrorState, LoadingState } from './PageStates'; + +describe('page states', () => { + it('announces the first read, with the first-sync line unless compact', () => { + const { unmount } = renderWithProviders(, { language: 'sv' }); + const status = screen.getByText('Hämtar era utgifter…').closest('[role="status"]'); + expect(status).toHaveTextContent('Första synken'); + unmount(); + renderWithProviders(, { language: 'sv' }); + expect(screen.getByText('Hämtar…').closest('[role="status"]')).toHaveTextContent(/^Hämtar…$/); + }); + + it('says what failed and why, and retries', async () => { + const retry = vi.fn(); + renderWithProviders(, { + language: 'sv', + }); + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('Kunde inte hämta hushållets utgifterKunde inte nå servern'); + await userEvent.click(within(alert).getByRole('button', { name: 'Försök igen' })); + expect(retry).toHaveBeenCalledOnce(); + }); +}); diff --git a/web/src/components/PageStates.tsx b/web/src/components/PageStates.tsx new file mode 100644 index 0000000..7c13d4e --- /dev/null +++ b/web/src/components/PageStates.tsx @@ -0,0 +1,52 @@ +import { apiErrorText } from '../api/errors'; +import { Icon } from '../design/Icon'; +import { useI18n } from '../i18n/useI18n'; +import { Button } from './Button'; +import { Card } from './Card'; +import styles from './PageStates.module.css'; + +/** + * A page's first read (SPEC §15.6 "loading"): design-spec §3.6 hero `Loading` content in a card, announced politely. + * The title defaults to "Hämtar era utgifter…" with the first-sync line under it; `compact` drops that line. + */ +export function LoadingState({ title, body, compact = false }: { title?: string; body?: string; compact?: boolean }) { + const { t } = useI18n(); + const line = compact ? undefined : (body ?? t('home_loading_body')); + return ( + +
+ +

{title ?? t('home_loading')}

+ {line === undefined ? null :

{line}

} +
+
+ ); +} + +/** + * A failed read with nothing to show yet (SPEC §15.6 "error, with retry"): what failed (by default "Kunde inte hämta + * hushållets utgifter"), why (`apiErrorText`: "Kunde inte nå servern" for a network error, §15.5) and "Försök igen". + */ +export function ErrorState({ error, onRetry, title }: { error: unknown; onRetry: () => void; title?: string }) { + const { t } = useI18n(); + return ( + +
+
+ +
+

{title ?? t('web_ledger_load_failed')}

+

{apiErrorText(error, t)}

+
+
+
+ +
+
+
+ ); +} diff --git a/web/src/components/SwitchRow.module.css b/web/src/components/SwitchRow.module.css new file mode 100644 index 0000000..d636988 --- /dev/null +++ b/web/src/components/SwitchRow.module.css @@ -0,0 +1,25 @@ +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); + min-height: var(--row-min); +} +.text { + display: flex; + flex-direction: column; +} +.label { + font: var(--type-body-large); +} +.supporting { + font: var(--type-body-small); + letter-spacing: var(--tracking-body-small); + color: var(--on-surface-variant); +} +.switch { + flex: none; + width: var(--touch-target); + height: var(--s6); + accent-color: var(--primary); +} diff --git a/web/src/components/SwitchRow.tsx b/web/src/components/SwitchRow.tsx new file mode 100644 index 0000000..676f03b --- /dev/null +++ b/web/src/components/SwitchRow.tsx @@ -0,0 +1,35 @@ +import { useId, type ReactNode } from 'react'; +import styles from './SwitchRow.module.css'; + +/** A switch row (design-spec S-19 "Pausad"): label, optional supporting line, the switch at the end. */ +export function SwitchRow({ + label, + checked, + onChange, + supporting, +}: { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + supporting?: ReactNode; +}) { + const id = useId(); + return ( +
+ + { + onChange(event.target.checked); + }} + /> +
+ ); +} diff --git a/web/src/components/TextField.tsx b/web/src/components/TextField.tsx index 6445e21..a1f69f8 100644 --- a/web/src/components/TextField.tsx +++ b/web/src/components/TextField.tsx @@ -5,6 +5,8 @@ type NativeProps = Omit, 'value' | 'onChan export interface TextFieldProps extends NativeProps { label: string; + /** The label is for assistive technology only (a field whose row already names it, e.g. S-9's allocation rows). */ + hideLabel?: boolean; value: string; onChange: (value: string) => void; /** Shown under the field in the error colour; also sets aria-invalid. */ @@ -23,6 +25,7 @@ export interface TextFieldProps extends NativeProps { /** design-spec §2.9 text field: filled, no indicator line, radius 16, label above the value. */ export function TextField({ label, + hideLabel = false, value, onChange, error, @@ -50,7 +53,7 @@ export function TextField({ }; return (
-