W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s
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
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo, type ReactNode } from 'react';
|
||||
import { languageOf } from '../i18n/language';
|
||||
import { createApi, type ApiOptions } from './client';
|
||||
import type { SessionStore } from './session';
|
||||
import { ApiContext } from './useApi';
|
||||
|
||||
interface Props {
|
||||
session: SessionStore;
|
||||
queryClient: QueryClient;
|
||||
baseUrl?: string;
|
||||
fetch?: ApiOptions['fetch'];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Provides the API client and the query cache; signing out clears every cached query. */
|
||||
export function ApiProvider({ session, queryClient, baseUrl, fetch, children }: Props) {
|
||||
const api = useMemo(
|
||||
() =>
|
||||
createApi({
|
||||
baseUrl: baseUrl ?? window.location.origin,
|
||||
session,
|
||||
// The page language, which I18nProvider keeps on <html lang>.
|
||||
language: () => languageOf(document.documentElement.lang),
|
||||
fetch,
|
||||
}),
|
||||
[baseUrl, session, fetch],
|
||||
);
|
||||
useEffect(
|
||||
() =>
|
||||
session.subscribe(() => {
|
||||
if (session.get() === null) queryClient.clear();
|
||||
}),
|
||||
[session, queryClient],
|
||||
);
|
||||
const value = useMemo(() => ({ api, session }), [api, session]);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiContext.Provider value={value}>{children}</ApiContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The typed API client (openapi-fetch over the generated `schema.d.ts`). Same origin as the SPA (`/me`, `/sync`, …).
|
||||
* Every call carries the headers of SPEC §14.3.1: `X-Kvitt-Protocol: 1`, `X-Kvitt-App-Version`,
|
||||
* `X-Kvitt-App-Version-Name`, `Accept-Language`, and `Authorization: Bearer <token>` while signed in. A 401 signs the
|
||||
* session out (`unauthorized` or `device_revoked`), which sends the router to /app/login.
|
||||
*/
|
||||
import createClient, { type Client } from 'openapi-fetch';
|
||||
import { BUILD } from '../app/buildInfo';
|
||||
import type { Language } from '../i18n/language';
|
||||
import type { paths } from './schema';
|
||||
import type { SessionStore } from './session';
|
||||
import type { ErrorBody, ErrorCode } from './types';
|
||||
|
||||
/** The protocol version this client speaks (SPEC §14.4). */
|
||||
export const PROTOCOL_VERSION = '1';
|
||||
|
||||
/** The operations' header parameters are set by the middleware below, so callers never pass them. */
|
||||
type WithoutHeaderParams<Operation> = Operation extends { parameters: infer Params }
|
||||
? Omit<Operation, 'parameters'> & { parameters: Omit<Params, 'header'> & { header?: never } }
|
||||
: Operation;
|
||||
export type ApiPaths = {
|
||||
[Path in keyof paths]: { [Method in keyof paths[Path]]: WithoutHeaderParams<paths[Path][Method]> };
|
||||
};
|
||||
export type Api = Client<ApiPaths>;
|
||||
|
||||
export interface ApiOptions {
|
||||
/** The origin the SPA is served from; the API lives at its root (OpenAPI `servers`). */
|
||||
baseUrl: string;
|
||||
session: SessionStore;
|
||||
language: () => Language;
|
||||
fetch?: (request: Request) => Promise<Response>;
|
||||
}
|
||||
|
||||
/** An API failure: an error body of SPEC §14.3.2, or `network` when the server could not be reached. */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: ErrorCode | 'network' | 'unexpected',
|
||||
readonly field: string | null,
|
||||
message: string,
|
||||
readonly retryAfterSeconds: number | null = null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorBody(value: unknown): value is ErrorBody {
|
||||
return typeof value === 'object' && value !== null && typeof (value as { code?: unknown }).code === 'string';
|
||||
}
|
||||
|
||||
async function errorCodeOf(response: Response): Promise<string | null> {
|
||||
try {
|
||||
const body: unknown = await response.clone().json();
|
||||
return isErrorBody(body) ? body.code : null;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError || error instanceof TypeError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function createApi(options: ApiOptions): Api {
|
||||
const client = createClient<ApiPaths>({ baseUrl: options.baseUrl, fetch: options.fetch });
|
||||
client.use({
|
||||
onRequest({ request }) {
|
||||
request.headers.set('X-Kvitt-Protocol', PROTOCOL_VERSION);
|
||||
request.headers.set('X-Kvitt-App-Version', String(BUILD.versionCode));
|
||||
request.headers.set('X-Kvitt-App-Version-Name', BUILD.versionName);
|
||||
request.headers.set('Accept-Language', options.language());
|
||||
const session = options.session.get();
|
||||
if (session !== null) request.headers.set('Authorization', `Bearer ${session.token}`);
|
||||
return request;
|
||||
},
|
||||
async onResponse({ request, response }) {
|
||||
if (response.status === 401 && request.headers.has('Authorization')) {
|
||||
const code = await errorCodeOf(response);
|
||||
options.session.signOut(code === 'device_revoked' ? 'device_revoked' : 'unauthorized');
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
interface Outcome<D> {
|
||||
data?: D;
|
||||
error?: unknown;
|
||||
response: Response;
|
||||
}
|
||||
|
||||
async function settle<D>(pending: Promise<Outcome<D>>): Promise<Outcome<D>> {
|
||||
try {
|
||||
return await pending;
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === 'AbortError') throw cause;
|
||||
throw new ApiError(0, 'network', null, cause instanceof Error ? cause.message : 'network error');
|
||||
}
|
||||
}
|
||||
|
||||
function failure(outcome: Outcome<unknown>): ApiError {
|
||||
const { response, error } = outcome;
|
||||
const retryAfter = response.headers.get('Retry-After');
|
||||
const retryAfterSeconds = retryAfter === null ? null : Number(retryAfter);
|
||||
if (isErrorBody(error)) {
|
||||
return new ApiError(response.status, error.code, error.field, error.message, retryAfterSeconds);
|
||||
}
|
||||
return new ApiError(response.status, 'unexpected', null, `HTTP ${response.status}`, retryAfterSeconds);
|
||||
}
|
||||
|
||||
/** The response body of a successful call; throws ApiError otherwise. */
|
||||
export async function unwrap<D>(pending: Promise<Outcome<D>>): Promise<D> {
|
||||
const outcome = await settle(pending);
|
||||
if (!outcome.response.ok) throw failure(outcome);
|
||||
if (outcome.data === undefined) throw new ApiError(outcome.response.status, 'unexpected', null, 'empty body');
|
||||
return outcome.data;
|
||||
}
|
||||
|
||||
/** For calls answered with 204 No Content; throws ApiError on failure. */
|
||||
export async function unwrapEmpty(pending: Promise<Outcome<unknown>>): Promise<void> {
|
||||
const outcome = await settle(pending);
|
||||
if (!outcome.response.ok) throw failure(outcome);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FakeServer, errorResponse, json } from '../test/fakeServer';
|
||||
import { ANNA, ANNA_ID, GROCERIES, JACOB, JACOB_ID, account, expense, page, settlement } from '../test/fixtures';
|
||||
import { createApi } from './client';
|
||||
import { ledgerBalances, live, pullLedger } from './ledger';
|
||||
import { SessionStore } from './session';
|
||||
|
||||
function apiFor(server: FakeServer) {
|
||||
const session = new SessionStore('kvitt.session.ledger-test');
|
||||
session.signIn({ token: 'kvt_ledger', userId: JACOB_ID, deviceId: 'd', inviteKind: 'device' });
|
||||
return createApi({ baseUrl: 'http://kvitt.test', session, language: () => 'sv', fetch: server.fetch });
|
||||
}
|
||||
|
||||
const X = '01929c5e-0000-7000-8000-0000000000a1';
|
||||
const Y = '01929c5e-0000-7000-8000-0000000000a2';
|
||||
|
||||
describe('pullLedger (SPEC §14.4.2)', () => {
|
||||
it('pulls every page from cursor 0, then incrementally from the cursor it holds', async () => {
|
||||
const pages = [
|
||||
page(0, { users: [JACOB, ANNA], categories: [GROCERIES] }, true),
|
||||
page(16, {
|
||||
accounts: [account(X, 'Vardag', 17)],
|
||||
expenses: [expense('e1', X, 1001, JACOB_ID, { [JACOB_ID]: 501, [ANNA_ID]: 500 }, 18)],
|
||||
}),
|
||||
page(18, { expenses: [expense('e2', X, 100, ANNA_ID, { [JACOB_ID]: 60, [ANNA_ID]: 40 }, 19)] }),
|
||||
];
|
||||
const server = new FakeServer().on('GET', '/sync', () => {
|
||||
const next = pages.shift();
|
||||
return next === undefined ? json(page(19, {})) : json(next);
|
||||
});
|
||||
const api = apiFor(server);
|
||||
|
||||
const first = await pullLedger(api, undefined);
|
||||
expect(server.calls('GET', '/sync').map((r) => r.search)).toEqual(['?since=0&limit=1000', '?since=16&limit=1000']);
|
||||
expect(first.cursor).toBe(18);
|
||||
expect(first.users.size).toBe(2);
|
||||
|
||||
const second = await pullLedger(api, first);
|
||||
expect(server.calls('GET', '/sync').at(-1)?.search).toBe('?since=18&limit=1000');
|
||||
expect(second.cursor).toBe(19);
|
||||
expect(ledgerBalances(second).global).toEqual({ [JACOB_ID]: 440, [ANNA_ID]: -440 });
|
||||
});
|
||||
|
||||
it('keeps the newer server_version, applies tombstones and leaves deleted rows out of balances', async () => {
|
||||
const server = new FakeServer().on('GET', '/sync', (request) =>
|
||||
request.search.startsWith('?since=0')
|
||||
? json(
|
||||
page(0, {
|
||||
users: [JACOB, ANNA],
|
||||
accounts: [account(X, 'Vardag', 17)],
|
||||
expenses: [expense('e1', X, 1000, JACOB_ID, { [JACOB_ID]: 500, [ANNA_ID]: 500 }, 18)],
|
||||
}),
|
||||
)
|
||||
: json(
|
||||
page(18, {
|
||||
expenses: [
|
||||
expense('e1', X, 1000, JACOB_ID, { [JACOB_ID]: 500, [ANNA_ID]: 500 }, 20, '2026-10-17T07:00:00.000Z'),
|
||||
],
|
||||
settlements: [settlement('s1', ANNA_ID, JACOB_ID, { [X]: 200 }, 21)],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const api = apiFor(server);
|
||||
const first = await pullLedger(api, undefined);
|
||||
expect(ledgerBalances(first).perAccount[X]).toEqual({ [JACOB_ID]: 500, [ANNA_ID]: -500 });
|
||||
const second = await pullLedger(api, first);
|
||||
expect(live(second.expenses)).toHaveLength(0);
|
||||
expect(second.expenses.get('e1')?.server_version).toBe(20);
|
||||
expect(ledgerBalances(second).perAccount[X]).toEqual({ [JACOB_ID]: -200, [ANNA_ID]: 200 });
|
||||
});
|
||||
|
||||
it('starts again from 0 when the epoch changes (SPEC §14.4.8)', async () => {
|
||||
let call = 0;
|
||||
const server = new FakeServer().on('GET', '/sync', () => {
|
||||
call += 1;
|
||||
if (call === 1) return json(page(0, { users: [JACOB, ANNA], accounts: [account(X, 'Vardag', 17)] }));
|
||||
return json(
|
||||
page(
|
||||
0,
|
||||
{ users: [JACOB, ANNA], accounts: [account(Y, 'Huset', 3)] },
|
||||
false,
|
||||
'ffffffffffffffffffffffffffffffff',
|
||||
),
|
||||
);
|
||||
});
|
||||
const api = apiFor(server);
|
||||
const restored = await pullLedger(api, await pullLedger(api, undefined));
|
||||
expect([...restored.accounts.keys()]).toEqual([Y]);
|
||||
expect(restored.epoch).toBe('ffffffffffffffffffffffffffffffff');
|
||||
expect(server.calls('GET', '/sync').map((r) => r.search)).toEqual([
|
||||
'?since=0&limit=1000',
|
||||
'?since=17&limit=1000',
|
||||
'?since=0&limit=1000',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats cursor_ahead as a restart from 0', async () => {
|
||||
const server = new FakeServer().on('GET', '/sync', (request) =>
|
||||
request.search.startsWith('?since=0') ? json(page(0, { users: [JACOB] })) : errorResponse('cursor_ahead', 409),
|
||||
);
|
||||
const api = apiFor(server);
|
||||
const held = { ...(await pullLedger(api, undefined)), cursor: 900 };
|
||||
const again = await pullLedger(api, held);
|
||||
expect(again.cursor).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* The household ledger, read through sync pulls (SPEC §7: accounts, categories, templates, expenses, settlements and
|
||||
* audit entries are read only through `GET /sync`). The web is online-only: the ledger lives in the TanStack Query
|
||||
* cache (key `['ledger']`), the first read pulls from cursor 0, every refetch (window focus, every 60 s, after a
|
||||
* write) pulls incrementally from the cursor it holds. A new `epoch` or 409 `cursor_ahead` starts again from 0
|
||||
* (SPEC §14.4.8). Rows are applied by `server_version`, tombstones included (SPEC §14.4.2).
|
||||
*/
|
||||
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { computeBalances, type Balances } from '../lib/balances';
|
||||
import { ApiError, unwrap, type Api } from './client';
|
||||
import { useApi } from './useApi';
|
||||
import type {
|
||||
Account,
|
||||
AuditEntry,
|
||||
Category,
|
||||
Expense,
|
||||
RecurringTemplate,
|
||||
Settlement,
|
||||
SyncPullResponse,
|
||||
User,
|
||||
} from './types';
|
||||
|
||||
export const LEDGER_QUERY_KEY = ['ledger'] as const;
|
||||
/** Rows per pull page: the protocol maximum (SPEC §9.6), so a full first read takes the fewest round trips. */
|
||||
export const PULL_PAGE_LIMIT = 1000;
|
||||
|
||||
export interface Ledger {
|
||||
epoch: string;
|
||||
/** The highest `server_version` applied. */
|
||||
cursor: number;
|
||||
serverTime: string;
|
||||
users: ReadonlyMap<string, User>;
|
||||
categories: ReadonlyMap<string, Category>;
|
||||
accounts: ReadonlyMap<string, Account>;
|
||||
recurringTemplates: ReadonlyMap<string, RecurringTemplate>;
|
||||
expenses: ReadonlyMap<string, Expense>;
|
||||
settlements: ReadonlyMap<string, Settlement>;
|
||||
auditEntries: ReadonlyMap<string, AuditEntry>;
|
||||
}
|
||||
|
||||
export function emptyLedger(epoch = ''): Ledger {
|
||||
return {
|
||||
epoch,
|
||||
cursor: 0,
|
||||
serverTime: '',
|
||||
users: new Map(),
|
||||
categories: new Map(),
|
||||
accounts: new Map(),
|
||||
recurringTemplates: new Map(),
|
||||
expenses: new Map(),
|
||||
settlements: new Map(),
|
||||
auditEntries: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rows to apply, as in a pull page or the response of a REST write. */
|
||||
export interface LedgerRows {
|
||||
users?: readonly User[];
|
||||
categories?: readonly Category[];
|
||||
accounts?: readonly Account[];
|
||||
recurringTemplates?: readonly RecurringTemplate[];
|
||||
expenses?: readonly Expense[];
|
||||
settlements?: readonly Settlement[];
|
||||
auditEntries?: readonly AuditEntry[];
|
||||
}
|
||||
|
||||
function upsert<T extends { id: string; server_version: number }>(
|
||||
current: ReadonlyMap<string, T>,
|
||||
rows: readonly T[] | undefined,
|
||||
): ReadonlyMap<string, T> {
|
||||
if (rows === undefined || rows.length === 0) return current;
|
||||
const next = new Map(current);
|
||||
for (const row of rows) {
|
||||
const existing = next.get(row.id);
|
||||
if (existing === undefined || existing.server_version < row.server_version) next.set(row.id, row);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Applies rows (newer `server_version` wins; audit entries are insert-or-ignore by id). Pure. */
|
||||
export function applyRows(ledger: Ledger, rows: LedgerRows): Ledger {
|
||||
let auditEntries = ledger.auditEntries;
|
||||
if (rows.auditEntries !== undefined && rows.auditEntries.length > 0) {
|
||||
const next = new Map(auditEntries);
|
||||
for (const entry of rows.auditEntries) if (!next.has(entry.id)) next.set(entry.id, entry);
|
||||
auditEntries = next;
|
||||
}
|
||||
return {
|
||||
...ledger,
|
||||
users: upsert(ledger.users, rows.users),
|
||||
categories: upsert(ledger.categories, rows.categories),
|
||||
accounts: upsert(ledger.accounts, rows.accounts),
|
||||
recurringTemplates: upsert(ledger.recurringTemplates, rows.recurringTemplates),
|
||||
expenses: upsert(ledger.expenses, rows.expenses),
|
||||
settlements: upsert(ledger.settlements, rows.settlements),
|
||||
auditEntries,
|
||||
};
|
||||
}
|
||||
|
||||
/** Applies one pull page (SPEC §14.4.2 phone steps 2 to 4, without the pending-row rule: the web has no local edits). */
|
||||
export function applyPage(ledger: Ledger, page: SyncPullResponse): Ledger {
|
||||
const applied = applyRows(ledger, {
|
||||
users: page.users,
|
||||
categories: page.categories,
|
||||
accounts: page.accounts,
|
||||
recurringTemplates: page.recurring_templates,
|
||||
expenses: page.expenses,
|
||||
settlements: page.settlements,
|
||||
auditEntries: page.audit_entries,
|
||||
});
|
||||
return { ...applied, epoch: page.epoch, cursor: page.next_since, serverTime: page.server_time };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls until `has_more` is false, starting from `previous` (incremental) or from 0. A changed epoch or a
|
||||
* `cursor_ahead` answer discards what is held and pulls everything again, once.
|
||||
*/
|
||||
export async function pullLedger(api: Api, previous: Ledger | undefined, signal?: AbortSignal): Promise<Ledger> {
|
||||
let ledger = previous ?? emptyLedger();
|
||||
let restarted = previous === undefined;
|
||||
for (;;) {
|
||||
let page: SyncPullResponse;
|
||||
try {
|
||||
page = await unwrap(
|
||||
api.GET('/sync', { params: { query: { since: ledger.cursor, limit: PULL_PAGE_LIMIT } }, signal }),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'cursor_ahead' && !restarted) {
|
||||
ledger = emptyLedger();
|
||||
restarted = true;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (ledger.epoch !== '' && page.epoch !== ledger.epoch) {
|
||||
if (restarted) throw new ApiError(200, 'unexpected', null, 'epoch changed during a full pull');
|
||||
ledger = emptyLedger();
|
||||
restarted = true;
|
||||
continue;
|
||||
}
|
||||
ledger = applyPage(ledger, page);
|
||||
if (!page.has_more) return ledger;
|
||||
}
|
||||
}
|
||||
|
||||
function ledgerQuery(api: Api, queryClient: QueryClient) {
|
||||
return {
|
||||
queryKey: LEDGER_QUERY_KEY,
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
pullLedger(api, queryClient.getQueryData<Ledger>(LEDGER_QUERY_KEY), signal),
|
||||
structuralSharing: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The ledger, optionally through a selector (memoise `select` with useCallback when it is expensive). */
|
||||
export function useLedger<T = Ledger>(select?: (ledger: Ledger) => T) {
|
||||
const api = useApi();
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery({ ...ledgerQuery(api, queryClient), select });
|
||||
}
|
||||
|
||||
/** Merges the rows a REST write returned (SPEC §7: every write returns its row) and pulls what else changed. */
|
||||
export function useLedgerWriter() {
|
||||
const queryClient = useQueryClient();
|
||||
const apply = useCallback(
|
||||
(rows: LedgerRows) => {
|
||||
queryClient.setQueryData<Ledger>(LEDGER_QUERY_KEY, (current) =>
|
||||
current === undefined ? current : applyRows(current, rows),
|
||||
);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
const refresh = useCallback(() => queryClient.invalidateQueries({ queryKey: LEDGER_QUERY_KEY }), [queryClient]);
|
||||
return { apply, refresh };
|
||||
}
|
||||
|
||||
/** Non-deleted rows of a ledger map. */
|
||||
export function live<T extends { deleted_at: string | null }>(rows: ReadonlyMap<string, T>): T[] {
|
||||
return [...rows.values()].filter((row) => row.deleted_at === null);
|
||||
}
|
||||
|
||||
/** Household user ids, sorted (slot order of design-spec §2.5). */
|
||||
export function householdUserIds(ledger: Ledger): string[] {
|
||||
return [...ledger.users.keys()].sort();
|
||||
}
|
||||
|
||||
/** Balances of SPEC §9.3 over non-deleted rows, every non-deleted account included. */
|
||||
export function ledgerBalances(ledger: Ledger): Balances {
|
||||
return computeBalances(
|
||||
householdUserIds(ledger),
|
||||
live(ledger.accounts).map((a) => a.id),
|
||||
live(ledger.expenses).map((e) => ({
|
||||
accountId: e.account_id,
|
||||
amountOre: e.amount_ore,
|
||||
payerUserId: e.payer_user_id,
|
||||
shares: Object.fromEntries(e.splits.map((s) => [s.user_id, s.share_ore])),
|
||||
})),
|
||||
live(ledger.settlements).map((s) => ({
|
||||
fromUserId: s.from_user_id,
|
||||
toUserId: s.to_user_id,
|
||||
allocations: Object.fromEntries(s.allocations.map((a) => [a.account_id, a.amount_ore])),
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** E-7 `GET /me`: the signed-in user, this device and the household (REST, not part of the sync ledger). */
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { unwrap } from './client';
|
||||
import { useApi } from './useApi';
|
||||
|
||||
export const ME_QUERY_KEY = ['me'] as const;
|
||||
|
||||
export function useMe() {
|
||||
const api = useApi();
|
||||
return useQuery({
|
||||
queryKey: ME_QUERY_KEY,
|
||||
queryFn: ({ signal }) => unwrap(api.GET('/me', { signal })),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* E-11 profile pictures. The endpoint needs the bearer token, so an <img src> cannot load it directly: the picture is
|
||||
* fetched as a Blob (cached per user and `picture_version`, AC-4.6) and shown through an object URL.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { unwrap } from './client';
|
||||
import { useApi } from './useApi';
|
||||
|
||||
export function useUserPicture(userId: string, pictureVersion: number | null, size: 128 | 512 = 128): string | null {
|
||||
const api = useApi();
|
||||
const query = useQuery({
|
||||
queryKey: ['picture', userId, pictureVersion, size],
|
||||
enabled: pictureVersion !== null,
|
||||
staleTime: Infinity,
|
||||
queryFn: ({ signal }) =>
|
||||
unwrap(
|
||||
api.GET('/users/{user_id}/picture', {
|
||||
params: { path: { user_id: userId }, query: { size, v: pictureVersion ?? undefined } },
|
||||
parseAs: 'blob',
|
||||
signal,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const blob = query.data instanceof Blob ? query.data : null;
|
||||
const url = useMemo(() => (blob === null ? null : URL.createObjectURL(blob)), [blob]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (url !== null) URL.revokeObjectURL(url);
|
||||
},
|
||||
[url],
|
||||
);
|
||||
return url;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* E-32 `POST /sync` for the web's expense and settlement writes. The web holds no pending rows: a write is pushed at
|
||||
* once (online-only), its accepted entity is merged into the ledger, and a rejection is returned to the caller to
|
||||
* show (SPEC §14.4.6 reasons). Batches are 1 to 100 mutations (SPEC §9.6).
|
||||
*/
|
||||
import { uuidv7 } from '../lib/ids';
|
||||
import { unwrap, type Api } from './client';
|
||||
import type { Ledger, LedgerRows } from './ledger';
|
||||
import { applyRows } from './ledger';
|
||||
import type { Expense, Mutation, MutationResult, PushedExpense, PushedSettlement, Settlement } from './types';
|
||||
|
||||
export const MAX_MUTATIONS_PER_PUSH = 100;
|
||||
|
||||
export function expenseMutation(entity: PushedExpense): Mutation {
|
||||
return { mutation_id: uuidv7(), entity_type: 'expense', entity };
|
||||
}
|
||||
|
||||
export function settlementMutation(entity: PushedSettlement): Mutation {
|
||||
return { mutation_id: uuidv7(), entity_type: 'settlement', entity };
|
||||
}
|
||||
|
||||
export async function pushMutations(api: Api, mutations: readonly Mutation[]): Promise<MutationResult[]> {
|
||||
if (mutations.length < 1 || mutations.length > MAX_MUTATIONS_PER_PUSH) {
|
||||
throw new RangeError(`a push holds 1 to ${MAX_MUTATIONS_PER_PUSH} mutations`);
|
||||
}
|
||||
const response = await unwrap(api.POST('/sync', { body: { mutations: [...mutations] } }));
|
||||
return response.results;
|
||||
}
|
||||
|
||||
/** The rows of the results to merge into the ledger: accepted entities and the `current` row of rejections. */
|
||||
export function rowsOfResults(results: readonly MutationResult[]): LedgerRows {
|
||||
const expenses: Expense[] = [];
|
||||
const settlements: Settlement[] = [];
|
||||
for (const result of results) {
|
||||
const row = result.status === 'accepted' ? result.entity : result.current;
|
||||
if (row === null) continue;
|
||||
if (result.entity_type === 'expense' && 'expense_date' in row) expenses.push(row);
|
||||
if (result.entity_type === 'settlement' && 'settlement_date' in row) settlements.push(row);
|
||||
}
|
||||
return { expenses, settlements };
|
||||
}
|
||||
|
||||
export function applyResults(ledger: Ledger, results: readonly MutationResult[]): Ledger {
|
||||
return applyRows(ledger, rowsOfResults(results));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { ApiError } from './client';
|
||||
|
||||
/** Refetch interval of every query: the web's stand-in for the app's sync (owner decision for the web, 2026-10-21). */
|
||||
export const REFETCH_INTERVAL_MS = 60_000;
|
||||
|
||||
function shouldRetry(failureCount: number, error: unknown): boolean {
|
||||
// A 4xx other than 429 will not change on its own; network errors, 429 and 5xx follow TanStack's default of 3.
|
||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 429) return false;
|
||||
return failureCount < 3;
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: REFETCH_INTERVAL_MS,
|
||||
refetchIntervalInBackground: false,
|
||||
retry: shouldRetry,
|
||||
},
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
Vendored
+2724
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The web session: the device token and who it belongs to, kept in localStorage (`kvitt.session`). The token is never
|
||||
* logged and never put in a URL (SPEC §8.2). A 401 clears it (client.ts) and the router sends the user to /app/login.
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { readStored, writeStored } from '../lib/storage';
|
||||
import type { InviteKind } from './types';
|
||||
|
||||
export interface Session {
|
||||
token: string;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
inviteKind: InviteKind;
|
||||
}
|
||||
|
||||
/** Why the last session ended: the user signed out, or the server answered 401 (`unauthorized` / `device_revoked`). */
|
||||
export type SignOutReason = 'user' | 'unauthorized' | 'device_revoked';
|
||||
|
||||
const STORAGE_KEY = 'kvitt.session';
|
||||
|
||||
function isSession(value: unknown): value is Session {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.token === 'string' &&
|
||||
typeof v.userId === 'string' &&
|
||||
typeof v.deviceId === 'string' &&
|
||||
(v.inviteKind === 'admin_setup' || v.inviteKind === 'partner' || v.inviteKind === 'device')
|
||||
);
|
||||
}
|
||||
|
||||
function parseStored(text: string | null): Session | null {
|
||||
if (text === null) return null;
|
||||
try {
|
||||
const value: unknown = JSON.parse(text);
|
||||
return isSession(value) ? value : null;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionStore {
|
||||
private session: Session | null;
|
||||
private reason: SignOutReason | null = null;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly storageKey: string = STORAGE_KEY) {
|
||||
this.session = parseStored(readStored(storageKey));
|
||||
}
|
||||
|
||||
get = (): Session | null => this.session;
|
||||
|
||||
/** Why the previous session ended, until the next sign-in. */
|
||||
lastSignOut = (): SignOutReason | null => this.reason;
|
||||
|
||||
signIn(session: Session): void {
|
||||
this.session = session;
|
||||
this.reason = null;
|
||||
writeStored(this.storageKey, JSON.stringify(session));
|
||||
this.emit();
|
||||
}
|
||||
|
||||
signOut(reason: SignOutReason): void {
|
||||
if (this.session === null) return;
|
||||
this.session = null;
|
||||
this.reason = reason;
|
||||
writeStored(this.storageKey, null);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
export function useSessionValue(store: SessionStore): Session | null {
|
||||
return useSyncExternalStore(store.subscribe, store.get, store.get);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Wire objects of SPEC §14.3.3, from the generated `schema.d.ts` (contracts/openapi.yaml). */
|
||||
import type { components } from './schema';
|
||||
|
||||
type Schemas = components['schemas'];
|
||||
|
||||
export type User = Schemas['User'];
|
||||
export type Device = Schemas['Device'];
|
||||
export type Invite = Schemas['Invite'];
|
||||
export type Account = Schemas['Account'];
|
||||
export type Category = Schemas['Category'];
|
||||
export type Expense = Schemas['Expense'];
|
||||
export type Settlement = Schemas['Settlement'];
|
||||
export type RecurringTemplate = Schemas['RecurringTemplate'];
|
||||
export type AuditEntry = Schemas['AuditEntry'];
|
||||
export type ErrorBody = Schemas['Error'];
|
||||
export type ErrorCode = Schemas['ErrorCode'];
|
||||
export type InviteKind = Schemas['InviteKind'];
|
||||
export type DefaultCategoryKey = Schemas['DefaultCategoryKey'];
|
||||
export type SplitMode = Schemas['SplitMode'];
|
||||
export type MeResponse = Schemas['MeResponse'];
|
||||
export type SyncPullResponse = Schemas['SyncPullResponse'];
|
||||
export type Mutation = Schemas['Mutation'];
|
||||
export type PushedExpense = Schemas['PushedExpense'];
|
||||
export type PushedSettlement = Schemas['PushedSettlement'];
|
||||
export type MutationResult = Schemas['MutationResult'];
|
||||
export type SyncPushResponse = Schemas['SyncPushResponse'];
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Api } from './client';
|
||||
import type { SessionStore } from './session';
|
||||
|
||||
export interface ApiContextValue {
|
||||
api: Api;
|
||||
session: SessionStore;
|
||||
}
|
||||
|
||||
export const ApiContext = createContext<ApiContextValue | null>(null);
|
||||
|
||||
function useApiContext(): ApiContextValue {
|
||||
const value = useContext(ApiContext);
|
||||
if (value === null) throw new Error('useApi outside ApiProvider');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function useApi(): Api {
|
||||
return useApiContext().api;
|
||||
}
|
||||
|
||||
export function useSessionStore(): SessionStore {
|
||||
return useApiContext().session;
|
||||
}
|
||||
Reference in New Issue
Block a user