From c451bf935f50145ef0c4d63cba5fa1de8103ba31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:05:08 +0000 Subject: [PATCH 01/11] Mark commune changes, bound the snapshot and page older expenses Findings 18, 19 and 37 from the app review: the database side. Migration 0036 adds commune_private.commune_changes. A trigger on every table that a snapshot, the commune list or an invitation list shows adds at most one row per commune per transaction. A commune's change mark (today's UTC date, then the count and highest id of its rows since the start of yesterday) moves with every commit, including one that commits after a later write. The worker's scheduled prune drops rows older than 2 days. public.my_communes() returns the caller's communes with their marks in one call. The communes and commune_members row checks look the caller's communes up once per query instead of once per row: reading them as a member of one commune among 5,000 took 58 ms and 231 ms, and now takes 1.7 ms and 1.8 ms. create_commune refuses an 11th commune in an hour, a 31st in a day and a 101st in all. The snapshot now carries the open expenses and those created, corrected, voided or settled since the start of last month (UTC), with expenseHistory counting what it left out and changeMark read in the same statement. For a busy household's year it went from 1.8 MB and 135 ms to 270 KB and 46 ms. Older expenses come from public.commune_expense_page a month or more at a time and named ones from public.commune_expense_records (at most 50). public.commune_summary is the snapshot without the detail only a commune's own screens use (150 KB for the same household). infra/neon/tests/lighter_loading.sql and change_mark_order.mjs cover them, the grants test lists the new functions, and the worker integration test checks the prune and that a delivery outcome moves the mark. Apply 0036 before releasing the web app that uses it. The worker needs no redeploy. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/reminder-worker/tests/integration.mjs | 27 + .../neon/migrations/0036_lighter_loading.sql | 386 ++++++++++++ infra/neon/tests/change_mark_order.mjs | 102 +++ infra/neon/tests/database_hardening.sql | 5 +- infra/neon/tests/grants.sql | 19 +- infra/neon/tests/lighter_loading.sql | 581 ++++++++++++++++++ infra/neon/tests/run-local.sh | 4 +- 7 files changed, 1116 insertions(+), 8 deletions(-) create mode 100644 infra/neon/migrations/0036_lighter_loading.sql create mode 100644 infra/neon/tests/change_mark_order.mjs create mode 100644 infra/neon/tests/lighter_loading.sql diff --git a/apps/reminder-worker/tests/integration.mjs b/apps/reminder-worker/tests/integration.mjs index 3c79c58b..c5000b52 100644 --- a/apps/reminder-worker/tests/integration.mjs +++ b/apps/reminder-worker/tests/integration.mjs @@ -91,6 +91,12 @@ async function runWorker() { headers: { "x-neon-trigger-invocation-id": "local-test" }, })); assert.equal(response.status, 200); + return response.json(); +} + +async function changeMark(communeId) { + const { rows } = await db.query("SELECT commune_private.change_mark($1) AS mark", [communeId]); + return rows[0].mark; } async function outbox(reminderId) { @@ -428,8 +434,12 @@ try { `, [commune, afterShare, alice, bob])).rows[0].id; await db.query("INSERT INTO public.reminder_email_outbox (reminder_id) VALUES ($1)", [afterReminder]); const callsBefore = providerCalls.length; + // What became of the email shows in the sender's reminders, so the run + // moves the commune's change mark (0036) and open apps read it again. + const markBefore = await changeMark(commune); await runWorker(); assert.equal((await outbox(afterReminder)).status, "suppressed"); + assert.notEqual(await changeMark(commune), markBefore, "A delivery outcome must move the commune's mark"); assert.equal(providerCalls.length, callsBefore); // The sender gets the same answer as when an email went, so it never // tells them the recipient turned email off. @@ -449,6 +459,23 @@ try { assert.equal((await outbox(third.reminderId)).payload_amount_minor, null); assert.notEqual((await outbox(batchIds[0])).payload, null, "A recent email is kept"); + // The same run drops change marks older than 2 days. A mark looks back to + // the start of yesterday at most, so recent ones stay. + const { rows: [oldChange] } = await db.query(` + INSERT INTO commune_private.commune_changes (commune_id, changed_at) + VALUES ($1, now() - interval '3 days') RETURNING id + `, [commune]); + const { rows: [recentChange] } = await db.query(` + INSERT INTO commune_private.commune_changes (commune_id, changed_at) + VALUES ($1, now() - interval '1 day') RETURNING id + `, [commune]); + const sweep = await runWorker(); + assert.equal(sweep.pruned.changeMarks, 1); + const { rows: kept } = await db.query( + "SELECT id FROM commune_private.commune_changes WHERE id IN ($1, $2)", [oldChange.id, recentChange.id], + ); + assert.deepEqual(kept.map((row) => row.id), [recentChange.id]); + // An address the provider reported as bouncing (or as a spam complaint) is // skipped exactly like an opt-out, whatever the case of the account's // address, so the sender cannot tell which happened. diff --git a/infra/neon/migrations/0036_lighter_loading.sql b/infra/neon/migrations/0036_lighter_loading.sql new file mode 100644 index 00000000..5c90abce --- /dev/null +++ b/infra/neon/migrations/0036_lighter_loading.sql @@ -0,0 +1,386 @@ +-- Lighter loading. The app read every commune's whole snapshot once a minute +-- in every open tab, and a snapshot carried every expense ever recorded with +-- its repayments, reports and corrections: about 1.8 MB for a busy household's +-- year. The commune list read two tables whose row checks ran once per row, +-- so it slowed as the platform grew, and anyone could create communes without +-- limit. +-- 1. Change marks. Every write that a snapshot, the commune list or an +-- invitation list shows adds a row to commune_private.commune_changes, at +-- most one per commune per transaction. A commune's mark summarises its +-- recent rows; the app compares marks (one small request) and reads a +-- snapshot only when its mark has moved. The worker's scheduled run prunes +-- rows older than 2 days. +-- 2. public.my_communes() is the commune list with each commune's mark. The +-- communes and commune_members row checks look the caller's communes up +-- once per query. create_commune refuses an 11th commune in an hour, a 31st +-- in a day and a 101st in all. +-- 3. The snapshot carries the expenses still open and those created or +-- changed since the start of last month (UTC), plus expenseHistory, which +-- says from when and how many older ones were left out. Older ones come a +-- month or more at a time from public.commune_expense_page, and named ones +-- from public.commune_expense_records, in the same shape. +-- The web app released with this needs it applied first. Apply after 0035. +BEGIN; + +-- 1. Change marks -------------------------------------------------------- + +-- Rows are only ever inserted. A shared counter row would make every write in +-- a commune wait for the one before it, and with the commune lock that money +-- commands take first it could deadlock. No foreign key either: one would take +-- a key-share lock on the commune row from every write. +CREATE TABLE commune_private.commune_changes ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + commune_id uuid NOT NULL, + changed_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX commune_changes_recent_idx ON commune_private.commune_changes (commune_id, changed_at) INCLUDE (id); +CREATE INDEX commune_changes_age_idx ON commune_private.commune_changes (changed_at); +ALTER TABLE commune_private.commune_changes ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON commune_private.commune_changes FROM PUBLIC, authenticated; + +-- The trigger's argument names the column that leads to the commune. The +-- communes already noted in this transaction are kept in a transaction-local +-- setting, so a command that writes twenty rows adds one change row. A +-- subtransaction that rolls back takes its change row and its note with it. +CREATE FUNCTION commune_private.note_commune_change() +RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE + v_key uuid := (to_jsonb(CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END) ->> TG_ARGV[0])::uuid; + v_noted text := coalesce(current_setting('commune.changed_communes', true), ''); + v_commune uuid; +BEGIN + v_commune := CASE TG_ARGV[0] + WHEN 'id' THEN v_key + WHEN 'commune_id' THEN v_key + WHEN 'expense_id' THEN (SELECT e.commune_id FROM public.expenses e WHERE e.id = v_key) + WHEN 'share_id' THEN (SELECT e.commune_id FROM public.expense_shares s + JOIN public.expenses e ON e.id = s.expense_id WHERE s.id = v_key) + WHEN 'fund_id' THEN (SELECT f.commune_id FROM public.funds f WHERE f.id = v_key) + WHEN 'commitment_id' THEN (SELECT f.commune_id FROM public.fund_commitments c + JOIN public.funds f ON f.id = c.fund_id WHERE c.id = v_key) + WHEN 'reminder_id' THEN (SELECT r.commune_id FROM public.reminders r WHERE r.id = v_key) + WHEN 'announcement_id' THEN (SELECT a.commune_id FROM public.commune_announcements a WHERE a.id = v_key) + WHEN 'bill_id' THEN (SELECT b.commune_id FROM public.recurring_bills b WHERE b.id = v_key) + END; + -- A row whose parent went first, as in a cascading delete, was noted with + -- its parent. + IF v_commune IS NULL OR strpos(v_noted, v_commune::text) > 0 THEN + RETURN NULL; + END IF; + INSERT INTO commune_private.commune_changes (commune_id) VALUES (v_commune); + PERFORM set_config('commune.changed_communes', v_noted || v_commune::text || ' ', true); + RETURN NULL; +END; +$$; +REVOKE ALL ON FUNCTION commune_private.note_commune_change() FROM PUBLIC; + +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.communes + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_members + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_invites + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expenses + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expense_shares + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('expense_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expense_share_receipts + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('share_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expense_share_payment_reports + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('share_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.expense_revisions + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('expense_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.funds + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.fund_commitments + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('fund_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.fund_contributions + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commitment_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.fund_pledge_requests + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commitment_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.notifications + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.reminders + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +-- A sent reminder shows whether an email may have gone (reminder_email_state), +-- which only the status and the error decide. Trimming old payloads is not a +-- change anyone sees. +CREATE TRIGGER change_mark AFTER INSERT OR DELETE OR UPDATE OF status, last_error ON public.reminder_email_outbox + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('reminder_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_notes + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.note_revisions + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_operations + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.operation_occurrences + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_announcements + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.announcement_acknowledgements + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('announcement_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.recurring_bills + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.recurring_bill_occurrences + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('bill_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.split_templates + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); +CREATE TRIGGER change_mark AFTER INSERT OR UPDATE OR DELETE ON public.commune_events + FOR EACH ROW EXECUTE FUNCTION commune_private.note_commune_change('commune_id'); + +-- A commune's mark: today's date (UTC), then the count and the highest id of +-- its change rows since the start of yesterday (UTC). Ids are handed out when +-- a row is inserted, not when it commits, so a write can commit with a lower +-- id than one already seen; the count still moves. Within a day rows only +-- join the window, so the count moves with every commit, and the window +-- reaches back a day so that a write which began just before midnight and +-- committed after it still counts. The date makes every mark move once a day, +-- which also refreshes what the snapshot works out from the date (reminders +-- that are due, the expense window). Pruning after 2 days never touches it. +CREATE FUNCTION commune_private.change_mark(p_commune_id uuid) +RETURNS text LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD') || ':' || count(*) || ':' || coalesce(max(c.id), 0) + FROM commune_private.commune_changes c + WHERE c.commune_id = p_commune_id + AND c.changed_at >= (date_trunc('day', now() AT TIME ZONE 'UTC') - interval '1 day') AT TIME ZONE 'UTC'; +$$; +REVOKE ALL ON FUNCTION commune_private.change_mark(uuid) FROM PUBLIC; + +-- Only the worker calls this, as the database owner. +CREATE OR REPLACE FUNCTION commune_private.prune_delivery_records() +RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE v_payloads integer; v_invitations integer; v_changes integer; +BEGIN + UPDATE public.reminder_email_outbox SET payload = NULL, payload_amount_minor = NULL + WHERE payload IS NOT NULL AND created_at < now() - interval '30 days'; + GET DIAGNOSTICS v_payloads = ROW_COUNT; + DELETE FROM public.invitation_email_deliveries + WHERE created_at < now() - interval '30 days' AND status IN ('sent', 'failed', 'sending'); + GET DIAGNOSTICS v_invitations = ROW_COUNT; + -- Marks look back to the start of yesterday at most. + DELETE FROM commune_private.commune_changes WHERE changed_at < now() - interval '2 days'; + GET DIAGNOSTICS v_changes = ROW_COUNT; + RETURN jsonb_build_object('reminderPayloads', v_payloads, 'invitationEmails', v_invitations, 'changeMarks', v_changes); +END; +$$; +REVOKE ALL ON FUNCTION commune_private.prune_delivery_records() FROM PUBLIC; + +-- 2. The commune list and creating communes -------------------------------- + +-- The caller's communes, looked up once per query by the row checks below. +-- The row checks run as the caller, so it may run this helper; the Data API +-- offers only public, so it is not an RPC. +CREATE FUNCTION commune_private.my_commune_ids() +RETURNS uuid[] LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ + SELECT coalesce(array_agg(m.commune_id), '{}') FROM public.commune_members m + WHERE m.user_id = commune_private.data_api_user_id() AND m.active; +$$; +REVOKE ALL ON FUNCTION commune_private.my_commune_ids() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION commune_private.my_commune_ids() TO authenticated; + +ALTER POLICY commune_read ON public.communes + USING (id = ANY ((SELECT commune_private.my_commune_ids())::uuid[])); +ALTER POLICY member_read ON public.commune_members + USING (commune_id = ANY ((SELECT commune_private.my_commune_ids())::uuid[])); + +CREATE FUNCTION public.my_communes() +RETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ + SELECT coalesce(jsonb_agg(jsonb_build_object('id', c.id, 'name', c.name, 'kind', c.kind, 'currency', c.currency, + 'address', c.address, 'destination', c.destination, 'stayName', c.stay_name, 'stayAddress', c.stay_address, + 'role', m.role, 'memberCount', (SELECT count(*) FROM public.commune_members cm WHERE cm.commune_id = c.id AND cm.active), + 'changeMark', commune_private.change_mark(c.id)) ORDER BY c.created_at DESC, c.id), '[]'::jsonb) + FROM public.commune_members m JOIN public.communes c ON c.id = m.commune_id + WHERE m.user_id = commune_private.data_api_user_id() AND m.active; +$$; +REVOKE ALL ON FUNCTION public.my_communes() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.my_communes() TO authenticated; + +CREATE INDEX communes_created_by_idx ON public.communes (created_by, created_at); + +-- One person's creations are counted one at a time, so two at once cannot +-- both slip under a limit. +DO $$ +DECLARE v_original text; v_source text; +BEGIN + v_original := pg_get_functiondef('public.create_commune(text,text,text)'::regprocedure); + v_source := replace(v_original, ' INSERT INTO public.communes (name, kind, currency, created_by)', + $patch$ PERFORM pg_advisory_xact_lock(hashtextextended('commune-create:' || v_user, 0)); + IF (SELECT count(*) FROM public.communes WHERE created_by = v_user AND created_at > now() - interval '1 hour') >= 10 THEN + RAISE EXCEPTION 'You have created 10 communes in the last hour. Try again later.' USING ERRCODE = 'P0001'; + END IF; + IF (SELECT count(*) FROM public.communes WHERE created_by = v_user AND created_at > now() - interval '1 day') >= 30 THEN + RAISE EXCEPTION 'You have created 30 communes in the last day. Try again tomorrow.' USING ERRCODE = 'P0001'; + END IF; + IF (SELECT count(*) FROM public.communes WHERE created_by = v_user) >= 100 THEN + RAISE EXCEPTION 'You have created 100 communes, which is the most one account can create.' USING ERRCODE = 'P0001'; + END IF; + INSERT INTO public.communes (name, kind, currency, created_by)$patch$); + IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported definition of public.create_commune'; END IF; + EXECUTE v_source; +END; +$$; + +-- 3. A bounded snapshot and paged expense history --------------------------- + +-- Where a snapshot's expense history starts: the first day of last month, UTC. +CREATE FUNCTION commune_private.expense_window_start() +RETURNS timestamptz LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT (date_trunc('month', now() AT TIME ZONE 'UTC') - interval '1 month') AT TIME ZONE 'UTC'; +$$; +REVOKE ALL ON FUNCTION commune_private.expense_window_start() FROM PUBLIC; + +-- The expenses a snapshot carries: every open one (not voided, a share still +-- owed), and every one created, corrected or voided since the window start or +-- with a share settled since then. A repayment, its reversal and a payment +-- report either leave a share owed or settle it, and a correction or void sets +-- updated_at, so these cover them all. The commune's expenses are read through +-- expenses_commune_idx and each one's shares through their (expense_id, +-- user_id) key. +CREATE FUNCTION commune_private.snapshot_expense_ids(p_commune_id uuid, p_since timestamptz) +RETURNS SETOF uuid LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT e.id FROM public.expenses e + WHERE e.commune_id = p_commune_id + AND (e.created_at >= p_since OR e.updated_at >= p_since + OR EXISTS (SELECT 1 FROM public.expense_shares s WHERE s.expense_id = e.id + AND ((e.voided_at IS NULL AND s.settled_minor < s.amount_minor) OR s.settled_at >= p_since))); +$$; +REVOKE ALL ON FUNCTION commune_private.snapshot_expense_ids(uuid, timestamptz) FROM PUBLIC; + +-- Every path builds an expense the same way, newest first. +CREATE FUNCTION commune_private.expense_records(p_ids uuid[]) +RETURNS jsonb LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT coalesce(jsonb_agg(jsonb_build_object('id',e.id,'title',e.title,'category',e.category,'amountMinor',e.amount_minor,'paidByUserId',e.paid_by_user_id,'recordedBy',e.recorded_by,'dueOn',e.due_on,'createdAt',e.created_at, + 'version',e.version,'updatedAt',e.updated_at,'voidedAt',e.voided_at,'voidedBy',e.voided_by,'voidReason',e.void_reason, + 'recurring',EXISTS(SELECT 1 FROM public.recurring_bill_occurrences o WHERE o.expense_id=e.id), + 'revisions',coalesce((SELECT jsonb_agg(jsonb_build_object('id',v.id,'version',v.version,'action',v.action,'actorId',v.actor_id,'reason',v.reason,'before',v.before,'after',v.after,'createdAt',v.created_at) ORDER BY v.version DESC) + FROM (SELECT * FROM public.expense_revisions rv WHERE rv.expense_id=e.id ORDER BY rv.version DESC LIMIT 20) v),'[]'::jsonb), + 'shares',coalesce((SELECT jsonb_agg(jsonb_build_object('id',s.id,'userId',s.user_id,'amountMinor',s.amount_minor,'settledMinor',s.settled_minor,'settledAt',s.settled_at, + 'receipts',coalesce((SELECT jsonb_agg(jsonb_build_object('id',r.id,'amountMinor',r.amount_minor,'receivedOn',r.received_on,'reference',r.reference,'recordedBy',r.recorded_by,'recordedAt',r.recorded_at,'origin',r.origin, + 'reversedAt',r.reversed_at,'reversedBy',r.reversed_by,'reversalReason',r.reversal_reason) ORDER BY r.recorded_at DESC, r.id) FROM public.expense_share_receipts r WHERE r.share_id=s.id),'[]'::jsonb), + 'paymentReports',coalesce((SELECT jsonb_agg(jsonb_build_object('id',p.id,'reportedBy',p.reported_by,'amountMinor',p.amount_minor,'sentOn',p.sent_on,'reference',p.reference, + 'reportedAt',p.reported_at,'status',p.status,'resolvedAt',p.resolved_at,'resolvedBy',p.resolved_by,'note',p.resolution_note,'receiptId',p.receipt_id) ORDER BY p.reported_at DESC, p.id) + FROM (SELECT * FROM public.expense_share_payment_reports x WHERE x.share_id=s.id ORDER BY x.reported_at DESC, x.id LIMIT 10) p),'[]'::jsonb)) ORDER BY s.user_id) FROM public.expense_shares s WHERE s.expense_id=e.id),'[]'::jsonb)) ORDER BY e.created_at DESC, e.id DESC),'[]'::jsonb) + FROM public.expenses e WHERE e.id = ANY (p_ids); +$$; +REVOKE ALL ON FUNCTION commune_private.expense_records(uuid[]) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION commune_private.snapshot_expenses(p_commune_id uuid) +RETURNS jsonb LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT commune_private.expense_records(ARRAY( + SELECT commune_private.snapshot_expense_ids(p_commune_id, commune_private.expense_window_start()))); +$$; +REVOKE ALL ON FUNCTION commune_private.snapshot_expenses(uuid) FROM PUBLIC; + +-- From when the snapshot's expenses are complete (every expense created on or +-- after that day is in it), and how many older ones it leaves out. +CREATE FUNCTION commune_private.expense_history(p_commune_id uuid) +RETURNS jsonb LANGUAGE plpgsql STABLE SET search_path = public, pg_temp AS $$ +DECLARE v_since timestamptz := commune_private.expense_window_start(); +BEGIN + RETURN (SELECT jsonb_build_object('since', to_char(v_since AT TIME ZONE 'UTC', 'YYYY-MM-DD'), + 'olderCount', count(*), 'olderVoidedCount', count(*) FILTER (WHERE e.voided_at IS NOT NULL)) + FROM public.expenses e + WHERE e.commune_id = p_commune_id AND e.created_at < v_since + AND e.id NOT IN (SELECT commune_private.snapshot_expense_ids(p_commune_id, v_since))); +END; +$$; +REVOKE ALL ON FUNCTION commune_private.expense_history(uuid) FROM PUBLIC; + +-- The snapshot is STABLE, so the mark is read with the same view of the data +-- as everything else in it. +DO $$ +DECLARE v_original text; v_source text; +BEGIN + v_original := pg_get_functiondef('public.commune_snapshot(uuid)'::regprocedure); + v_source := replace(v_original, $anchor$'expenses',commune_private.snapshot_expenses(co.id),$anchor$, + $patch$'expenses',commune_private.snapshot_expenses(co.id), + 'expenseHistory',commune_private.expense_history(co.id),'changeMark',commune_private.change_mark(co.id),$patch$); + IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported snapshot expenses definition'; END IF; + EXECUTE v_source; +END; +$$; + +-- What the overview screens show of a commune that is not open: the snapshot +-- with the same keys, less the detail only its own screens use (repayment, +-- report and correction history, note bodies, task and bill history, notices, +-- templates, sent reminders and the reader's notices there). detail says so, +-- and the app never shows a commune's own screens from it. +CREATE FUNCTION public.commune_summary(p_commune_id uuid) +RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE v_full jsonb := public.commune_snapshot(p_commune_id); +BEGIN + RETURN v_full || jsonb_build_object('detail', 'summary', + 'expenses', (SELECT coalesce(jsonb_agg(x.item || jsonb_build_object('revisions', '[]'::jsonb, + 'shares', (SELECT coalesce(jsonb_agg(y.item || '{"receipts": [], "paymentReports": []}'::jsonb ORDER BY y.ord), '[]'::jsonb) + FROM jsonb_array_elements(x.item -> 'shares') WITH ORDINALITY y(item, ord))) ORDER BY x.ord), '[]'::jsonb) + FROM jsonb_array_elements(v_full -> 'expenses') WITH ORDINALITY x(item, ord)), + 'notes', (SELECT coalesce(jsonb_agg(x.item || '{"body": "", "revisions": []}'::jsonb ORDER BY x.ord), '[]'::jsonb) + FROM jsonb_array_elements(v_full -> 'notes') WITH ORDINALITY x(item, ord)), + 'operations', (SELECT coalesce(jsonb_agg(x.item || '{"history": []}'::jsonb ORDER BY x.ord), '[]'::jsonb) + FROM jsonb_array_elements(v_full -> 'operations') WITH ORDINALITY x(item, ord)), + 'recurringBills', (SELECT coalesce(jsonb_agg(x.item || '{"history": []}'::jsonb ORDER BY x.ord), '[]'::jsonb) + FROM jsonb_array_elements(v_full -> 'recurringBills') WITH ORDINALITY x(item, ord)), + 'announcements', '[]'::jsonb, 'splitTemplates', '[]'::jsonb, 'sentReminders', '[]'::jsonb, + 'notifications', '[]'::jsonb); +END; +$$; +REVOKE ALL ON FUNCTION public.commune_summary(uuid) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.commune_summary(uuid) TO authenticated; + +-- Expenses the snapshot left out, created in the p_months calendar months +-- (UTC) before p_before, or all of them before it when p_months is null. +-- p_before defaults to the snapshot's window start. When those months hold +-- none, the page reaches back to the month of the newest older one, so a +-- quiet spell never ends the history early. olderCount is how many older +-- ones remain; "from" is null when the page reaches back to the beginning. +CREATE FUNCTION public.commune_expense_page(p_commune_id uuid, p_before date, p_months integer) +RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE + v_window uuid[]; + v_before timestamptz; + v_from timestamptz; + v_newest timestamptz; +BEGIN + IF NOT public.is_commune_member(p_commune_id) THEN RAISE EXCEPTION 'Not a commune member' USING ERRCODE = '42501'; END IF; + IF p_months IS NOT NULL AND p_months NOT BETWEEN 1 AND 12 THEN + RAISE EXCEPTION 'Ask for 1 to 12 months of expenses at a time' USING ERRCODE = '22023'; + END IF; + v_window := ARRAY(SELECT commune_private.snapshot_expense_ids(p_commune_id, commune_private.expense_window_start())); + v_before := date_trunc('month', coalesce(p_before::timestamp, commune_private.expense_window_start() AT TIME ZONE 'UTC')) AT TIME ZONE 'UTC'; + SELECT max(e.created_at) INTO v_newest FROM public.expenses e + WHERE e.commune_id = p_commune_id AND e.created_at < v_before AND NOT (e.id = ANY (v_window)); + v_from := CASE WHEN p_months IS NULL OR v_newest IS NULL THEN NULL + ELSE least((date_trunc('month', v_before AT TIME ZONE 'UTC') - make_interval(months => p_months)) AT TIME ZONE 'UTC', + date_trunc('month', v_newest AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') END; + RETURN jsonb_build_object( + 'expenses', commune_private.expense_records(ARRAY(SELECT e.id FROM public.expenses e + WHERE e.commune_id = p_commune_id AND e.created_at < v_before AND e.created_at >= coalesce(v_from, '-infinity') + AND NOT (e.id = ANY (v_window)))), + 'before', to_char(v_before AT TIME ZONE 'UTC', 'YYYY-MM-DD'), + 'from', to_char(v_from AT TIME ZONE 'UTC', 'YYYY-MM-DD')) + || (SELECT jsonb_build_object('olderCount', count(*), 'olderVoidedCount', count(*) FILTER (WHERE e.voided_at IS NOT NULL)) + FROM public.expenses e + WHERE e.commune_id = p_commune_id AND e.created_at < coalesce(v_from, '-infinity') AND NOT (e.id = ANY (v_window))); +END; +$$; +REVOKE ALL ON FUNCTION public.commune_expense_page(uuid, date, integer) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.commune_expense_page(uuid, date, integer) TO authenticated; + +-- Named expenses of one commune, for a link to an expense that is not loaded. +-- An id from elsewhere is left out, as is one that does not exist. +CREATE FUNCTION public.commune_expense_records(p_commune_id uuid, p_ids uuid[]) +RETURNS jsonb LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ +BEGIN + IF NOT public.is_commune_member(p_commune_id) THEN RAISE EXCEPTION 'Not a commune member' USING ERRCODE = '42501'; END IF; + IF p_ids IS NULL OR cardinality(p_ids) NOT BETWEEN 1 AND 50 THEN + RAISE EXCEPTION 'Ask for 1 to 50 expenses at a time' USING ERRCODE = '22023'; + END IF; + RETURN jsonb_build_object('expenses', commune_private.expense_records(ARRAY(SELECT e.id FROM public.expenses e + WHERE e.commune_id = p_commune_id AND e.id = ANY (p_ids)))); +END; +$$; +REVOKE ALL ON FUNCTION public.commune_expense_records(uuid, uuid[]) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.commune_expense_records(uuid, uuid[]) TO authenticated; + +NOTIFY pgrst, 'reload schema'; +COMMIT; diff --git a/infra/neon/tests/change_mark_order.mjs b/infra/neon/tests/change_mark_order.mjs new file mode 100644 index 00000000..065f462a --- /dev/null +++ b/infra/neon/tests/change_mark_order.mjs @@ -0,0 +1,102 @@ +// Change marks when two writes commit out of order (0036). A change row's id +// is handed out when the row is inserted, not when its transaction commits, +// so a write that started first can commit last with the lower id. The app +// reads a commune's snapshot only when its mark moves, so a mark that went by +// the highest id alone would miss that write until the next one. This test +// keeps the first write open while a second one commits, reads the mark, +// then commits the first and checks that the mark moved again. It also shows +// that the second write did not wait for the first. +// +// Run against a disposable local database with every migration applied, on +// top of infra/local/bootstrap.sql or infra/neon/tests/local_auth_stub.sql: +// DATABASE_URL=postgres://commune:commune@127.0.0.1:5432/ node infra/neon/tests/change_mark_order.mjs +// It adds a commune and removes it again. Never point it at a shared or +// hosted database. +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; + +if (!process.env.DATABASE_URL?.includes("127.0.0.1")) { + throw new Error("The change mark order test requires a local 127.0.0.1 DATABASE_URL"); +} +// The pg driver comes from the local API's dependencies. +const { Client } = createRequire(new URL("../../../apps/local-api/package.json", import.meta.url))("pg"); + +const avery = randomUUID(), blake = randomUUID(), casey = randomUUID(); +const connect = async () => { + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + // A wait that never ends fails the test instead of hanging it. + await client.query("SET statement_timeout = '20s'"); + return client; +}; +const owner = await connect(); + +// One transaction as one person, the way the local API runs a request. +async function begin(userId) { + const client = await connect(); + await client.query("BEGIN"); + await client.query("SELECT set_config('request.jwt.sub', $1, true)", [userId]); + await client.query("SELECT set_config('request.jwt.claims', $1, true)", [JSON.stringify({ sub: userId })]); + await client.query("SET LOCAL ROLE authenticated"); + return client; +} +async function as(userId, sql, values) { + const client = await begin(userId); + try { + const result = await client.query(sql, values); + await client.query("COMMIT"); + return result.rows[0]; + } finally { + await client.end(); + } +} +// The mark the app compares, read the way the app reads it. +async function mark(commune) { + const { list } = await as(avery, "SELECT public.my_communes() AS list"); + return list.find((entry) => entry.id === commune).changeMark; +} + +const commune = (await as(avery, "SELECT public.create_commune('Mark order', 'household', 'GBP') AS id")).id; +const sessions = []; +try { + await owner.query("BEGIN"); + // Fixture members skip the invitation, with Avery as the actor in the history. + await owner.query("SELECT set_config('request.jwt.claims', $1, true)", [JSON.stringify({ sub: avery })]); + await owner.query("INSERT INTO public.commune_members (commune_id, user_id, role) VALUES ($1, $2, 'member'), ($1, $3, 'member')", + [commune, blake, casey]); + await owner.query("COMMIT"); + const notice = (await as(avery, "SELECT public.post_announcement($1, 'Boiler service', 'Friday morning', true, $2) ->> 'id' AS id", + [commune, randomUUID()])).id; + const before = await mark(commune); + + // Blake confirms the notice first and takes the lower change id, but has not committed. + const first = await begin(blake); + sessions.push(first); + await first.query("SELECT public.acknowledge_announcement($1)", [notice]); + // Casey confirms it too and commits straight away: nothing makes her wait for Blake. + const second = await begin(casey); + sessions.push(second); + await second.query("SELECT public.acknowledge_announcement($1)", [notice]); + await second.query("COMMIT"); + const between = await mark(commune); + assert.notEqual(between, before, "A committed write did not move the mark"); + + await first.query("COMMIT"); + const after = await mark(commune); + assert.notEqual(after, between, "A write that committed after a later one did not move the mark"); + // The highest id is the same both times; only the count shows Blake's write. + assert.equal(after.split(":")[2], between.split(":")[2], `Expected the lower id to commit last: ${between} then ${after}`); + assert.equal(Number(after.split(":")[1]), Number(between.split(":")[1]) + 1); + + // The snapshot carries the same mark as the list. + const { snapshot } = await as(avery, "SELECT public.commune_snapshot($1) AS snapshot", [commune]); + assert.equal(snapshot.changeMark, after); + console.log("change mark order passed: a write that commits after a later one still moves the mark"); +} finally { + // Ending a session rolls back anything it left open. + await Promise.all(sessions.map((client) => client.end().catch(() => undefined))); + await owner.query("DELETE FROM public.communes WHERE id = $1", [commune]); + await owner.query("DELETE FROM commune_private.commune_changes WHERE commune_id = $1", [commune]); + await owner.end(); +} diff --git a/infra/neon/tests/database_hardening.sql b/infra/neon/tests/database_hardening.sql index 5111416b..93f90f90 100644 --- a/infra/neon/tests/database_hardening.sql +++ b/infra/neon/tests/database_hardening.sql @@ -102,7 +102,8 @@ BEGIN CASE i.email WHEN 'dee@hardening.test' THEN now() - interval '31 days' ELSE now() END FROM public.commune_invites i WHERE i.commune_id = v_home; v_pruned := commune_private.prune_delivery_records(); - IF v_pruned <> '{"reminderPayloads": 1, "invitationEmails": 1}'::jsonb + -- Change marks are pruned in the same run (0036, lighter_loading.sql). + IF v_pruned - 'changeMarks' <> '{"reminderPayloads": 1, "invitationEmails": 1}'::jsonb OR (SELECT payload FROM public.reminder_email_outbox WHERE reminder_id = v_reminder_old) IS NOT NULL OR (SELECT payload_amount_minor FROM public.reminder_email_outbox WHERE reminder_id = v_reminder_old) IS NOT NULL OR (SELECT status FROM public.reminder_email_outbox WHERE reminder_id = v_reminder_old) <> 'sent' @@ -112,7 +113,7 @@ BEGIN OR NOT EXISTS (SELECT 1 FROM public.commune_invites WHERE email = 'dee@hardening.test') THEN RAISE EXCEPTION 'Old email records were not trimmed as expected: %', v_pruned; END IF; - IF commune_private.prune_delivery_records() <> '{"reminderPayloads": 0, "invitationEmails": 0}'::jsonb THEN + IF commune_private.prune_delivery_records() - 'changeMarks' <> '{"reminderPayloads": 0, "invitationEmails": 0}'::jsonb THEN RAISE EXCEPTION 'A second run should find nothing to trim'; END IF; END; diff --git a/infra/neon/tests/grants.sql b/infra/neon/tests/grants.sql index aeeb821d..624eb562 100644 --- a/infra/neon/tests/grants.sql +++ b/infra/neon/tests/grants.sql @@ -40,8 +40,9 @@ BEGIN RAISE EXCEPTION 'Anonymous workflow execution grant'; END IF; - -- Money-lifecycle, occurrence, announcement, note and task commands (0015-0027): members only, - -- never anonymous, and the private helpers are not callable by any client role. + -- Money-lifecycle, occurrence, announcement, note and task commands (0015-0027) and the + -- loading reads (0036): members only, never anonymous, and the private helpers are not + -- callable by any client role. FOREACH table_name IN ARRAY ARRAY[ 'public.send_reminder_command(text,uuid,uuid)', 'public.record_share_receipt(uuid,bigint,date,text,uuid)', @@ -64,7 +65,11 @@ BEGIN 'public.remove_note(uuid)', 'public.create_operation(uuid,text,text,text,date,time,boolean,text,jsonb,text,text,uuid)', 'public.update_operation(uuid,integer,text,text,text,date,time,boolean,text,jsonb,text,text)', - 'public.remove_operation(uuid)' + 'public.remove_operation(uuid)', + 'public.my_communes()', + 'public.commune_summary(uuid)', + 'public.commune_expense_page(uuid,date,integer)', + 'public.commune_expense_records(uuid,uuid[])' ] LOOP IF has_function_privilege('anonymous', table_name, 'EXECUTE') OR has_function_privilege('public', table_name, 'EXECUTE') @@ -104,7 +109,13 @@ BEGIN 'commune_private.operation_content(text,text,text,date,time,boolean,text,jsonb,text,text)', 'commune_private.money_correction_reserve(text,text,jsonb)', 'commune_private.prune_delivery_records()', - 'commune_private.currency_locked_by(uuid)' + 'commune_private.currency_locked_by(uuid)', + 'commune_private.note_commune_change()', + 'commune_private.change_mark(uuid)', + 'commune_private.expense_window_start()', + 'commune_private.snapshot_expense_ids(uuid,timestamptz)', + 'commune_private.expense_records(uuid[])', + 'commune_private.expense_history(uuid)' ] LOOP IF has_function_privilege('authenticated', table_name, 'EXECUTE') OR has_function_privilege('anonymous', table_name, 'EXECUTE') THEN diff --git a/infra/neon/tests/lighter_loading.sql b/infra/neon/tests/lighter_loading.sql new file mode 100644 index 00000000..04bd6cc4 --- /dev/null +++ b/infra/neon/tests/lighter_loading.sql @@ -0,0 +1,581 @@ +-- Lighter loading (0036): change marks, the commune list and its row checks, +-- creation limits, the bounded snapshot, paged history and the overview +-- summary. Run only in an empty disposable local DB built from +-- infra/neon/tests/local_auth_stub.sql and every migration. Everything is +-- rolled back. infra/neon/tests/change_mark_order.mjs covers two real +-- transactions committing in the reverse order of their change rows. +BEGIN; + +CREATE FUNCTION pg_temp.act(p_user text) RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + PERFORM set_config('request.jwt.claims', json_build_object('sub', p_user, 'role', 'authenticated')::text, true); +END; +$$; +-- This test runs in one transaction. Forgetting the communes already noted +-- makes the next write look like the first of a new transaction. +CREATE FUNCTION pg_temp.next_transaction() RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + PERFORM set_config('commune.changed_communes', '', true); +END; +$$; +CREATE FUNCTION pg_temp.changes(p_commune uuid) RETURNS bigint LANGUAGE sql AS $$ + SELECT count(*) FROM commune_private.commune_changes WHERE commune_id = p_commune; +$$; +CREATE FUNCTION pg_temp.refused(p_sql text) RETURNS text LANGUAGE plpgsql AS $$ +BEGIN + EXECUTE p_sql; + RETURN NULL; +EXCEPTION WHEN OTHERS THEN + RETURN SQLSTATE || ' ' || SQLERRM; +END; +$$; + +-- Ann and Ben share a home; Cat has her own; Dee used to be in Ann's. +DO $$ +DECLARE + v_home uuid; v_other uuid; v_expense uuid; v_share uuid; v_reported uuid; v_report_share uuid; + v_fund jsonb; v_pledge jsonb; v_note jsonb; v_task jsonb; v_notice jsonb; v_bill uuid; v_reminder uuid; +BEGIN + PERFORM pg_temp.act('ll-ann'); + v_home := public.create_commune('Lighter home', 'household', 'GBP'); + INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_home, 'll-ben', 'member'), (v_home, 'll-dee', 'member'); + v_expense := public.create_expense(v_home, 'Gas', 2000, + '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date); + SELECT id INTO v_share FROM public.expense_shares WHERE expense_id = v_expense AND user_id = 'll-ben'; + PERFORM public.record_share_receipt(v_share, 400, current_date, NULL, gen_random_uuid()); + PERFORM public.correct_expense(v_expense, 1, 'Gas bill', 2000, + '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date, + 'll-ann', 'Utilities', 'Clearer title', gen_random_uuid()); + v_reported := public.create_expense(v_home, 'Water', 2000, + '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date); + SELECT id INTO v_report_share FROM public.expense_shares WHERE expense_id = v_reported AND user_id = 'll-ben'; + v_fund := public.create_fund_command(v_home, 'Boiler', 50000, gen_random_uuid()); + v_pledge := public.pledge_fund_command((v_fund->>'id')::uuid, 5000, current_date + 30, gen_random_uuid()); + PERFORM public.record_fund_contribution_command((v_pledge->>'id')::uuid, 1000, gen_random_uuid()); + v_note := public.create_note(v_home, 'Wifi', 'Wi-Fi', 'Everyone', 'Router in the hall', 'Restart it first', '[]'::jsonb, gen_random_uuid()); + PERFORM public.update_note((v_note->>'id')::uuid, 1, 'Wifi', 'Wi-Fi', 'Everyone', 'Router in the hall', 'Restart it twice', '[]'::jsonb); + v_task := public.create_operation(v_home, 'task', 'chore', 'Bins', current_date, NULL, true, 'll-ann', NULL, 'weekly', '', gen_random_uuid()); + PERFORM public.record_operation_occurrence((v_task->>'id')::uuid, 'completed', current_date, current_date, gen_random_uuid()); + v_notice := public.post_announcement(v_home, 'Boiler service', 'Friday morning', true, gen_random_uuid()); + v_bill := public.save_recurring_bill(v_home, 'Rent', 2000, 'll-ann', + '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, 'monthly', current_date, + NULL, NULL, gen_random_uuid(), NULL); + PERFORM public.record_recurring_period(v_bill, current_date); + PERFORM public.save_split_template(v_home, 'Halves', 'Equal', '[{"user_id":"ll-ann","value":""},{"user_id":"ll-ben","value":""}]'::jsonb); + PERFORM public.create_commune_invite(v_home, 'eve@lighter.test', 'member'); + INSERT INTO public.reminders (commune_id, kind, source_id, sender_user_id, recipient_user_id, amount_minor) + VALUES (v_home, 'nudge', v_share, 'll-ann', 'll-ben', 600) RETURNING id INTO v_reminder; + INSERT INTO public.reminder_email_outbox (reminder_id, payload) VALUES (v_reminder, '{"to": ["ben@lighter.test"]}'); + + PERFORM pg_temp.act('ll-ben'); + PERFORM public.report_share_payment(v_report_share, 1000, current_date, NULL, gen_random_uuid()); + PERFORM public.acknowledge_announcement((v_notice->>'id')::uuid); + + PERFORM pg_temp.act('ll-cat'); + v_other := public.create_commune('Other home', 'household', 'GBP'); + + PERFORM set_config('test.home', v_home::text, true); + PERFORM set_config('test.other', v_other::text, true); + PERFORM set_config('test.expense', v_expense::text, true); + PERFORM set_config('test.share', v_share::text, true); + PERFORM set_config('test.report_share', v_report_share::text, true); + PERFORM set_config('test.fund', v_fund->>'id', true); + PERFORM set_config('test.pledge', v_pledge->>'id', true); + PERFORM set_config('test.notice', v_notice->>'id', true); + PERFORM set_config('test.bill', v_bill::text, true); + PERFORM set_config('test.reminder', v_reminder::text, true); +END; +$$; + +-- 1. Every kind of write moves the mark of its own commune, and only that. +DO $$ +DECLARE + v_home uuid := current_setting('test.home')::uuid; + v_other uuid := current_setting('test.other')::uuid; + v_write record; v_home_before bigint; v_other_before bigint; v_mark text; v_other_mark text; +BEGIN + PERFORM pg_temp.act('ll-ann'); + FOR v_write IN SELECT * FROM (VALUES + ('communes', 'name', format('id = %L', v_home)), + ('commune_members', 'role', format('commune_id = %L AND user_id = %L', v_home, 'll-ben')), + ('commune_invites', 'role', format('commune_id = %L', v_home)), + ('expenses', 'title', format('commune_id = %L', v_home)), + ('expense_shares', 'amount_minor', format('id = %L', current_setting('test.share'))), + ('expense_share_receipts', 'reference', format('share_id = %L', current_setting('test.share'))), + ('expense_share_payment_reports', 'reference', format('share_id = %L', current_setting('test.report_share'))), + ('expense_revisions', 'reason', format('expense_id = %L', current_setting('test.expense'))), + ('funds', 'name', format('commune_id = %L', v_home)), + ('fund_commitments', 'amount_minor', format('fund_id = %L', current_setting('test.fund'))), + ('fund_contributions', 'amount_minor', format('commitment_id = %L', current_setting('test.pledge'))), + ('fund_pledge_requests', 'amount_minor', format('commitment_id = %L', current_setting('test.pledge'))), + ('notifications', 'detail', format('commune_id = %L', v_home)), + ('reminders', 'amount_minor', format('commune_id = %L', v_home)), + ('reminder_email_outbox', 'status', format('reminder_id = %L', current_setting('test.reminder'))), + ('commune_notes', 'title', format('commune_id = %L', v_home)), + ('note_revisions', 'title', format('commune_id = %L', v_home)), + ('commune_operations', 'title', format('commune_id = %L', v_home)), + ('operation_occurrences', 'reason', format('commune_id = %L', v_home)), + ('commune_announcements', 'title', format('commune_id = %L', v_home)), + ('announcement_acknowledgements', 'user_id', format('announcement_id = %L', current_setting('test.notice'))), + ('recurring_bills', 'title', format('commune_id = %L', v_home)), + ('recurring_bill_occurrences', 'note', format('bill_id = %L', current_setting('test.bill'))), + ('split_templates', 'name', format('commune_id = %L', v_home)), + ('commune_events', 'title', format('commune_id = %L', v_home)) + ) AS w(table_name, column_name, condition) LOOP + PERFORM pg_temp.next_transaction(); + v_home_before := pg_temp.changes(v_home); + v_other_before := pg_temp.changes(v_other); + v_mark := commune_private.change_mark(v_home); + v_other_mark := commune_private.change_mark(v_other); + EXECUTE format('UPDATE public.%I SET %I = %I WHERE %s', v_write.table_name, v_write.column_name, v_write.column_name, v_write.condition); + IF pg_temp.changes(v_home) <> v_home_before + 1 OR commune_private.change_mark(v_home) = v_mark THEN + RAISE EXCEPTION 'A write to % did not move its commune''s mark', v_write.table_name; + END IF; + IF pg_temp.changes(v_other) <> v_other_before OR commune_private.change_mark(v_other) <> v_other_mark THEN + RAISE EXCEPTION 'A write to % moved another commune''s mark', v_write.table_name; + END IF; + END LOOP; + + -- Inserting and deleting count too; trimming an old email payload does not. + PERFORM pg_temp.next_transaction(); + v_home_before := pg_temp.changes(v_home); + INSERT INTO public.commune_events (commune_id, actor_id, kind, entity_id, title, description) + VALUES (v_home, 'll-ann', 'notes', gen_random_uuid(), 'Wifi', 'Updated'); + PERFORM pg_temp.next_transaction(); + DELETE FROM public.commune_events WHERE commune_id = v_home AND title = 'Wifi' AND description = 'Updated'; + PERFORM pg_temp.next_transaction(); + UPDATE public.reminder_email_outbox SET payload = NULL WHERE reminder_id = current_setting('test.reminder')::uuid; + IF pg_temp.changes(v_home) <> v_home_before + 2 THEN + RAISE EXCEPTION 'Expected one change for the insert and one for the delete, and none for trimming a payload, got %', + pg_temp.changes(v_home) - v_home_before; + END IF; +END; +$$; + +-- 2. One change row per commune per transaction, however many rows a command +-- writes; a rolled-back part of a transaction takes its change row with it. +DO $$ +DECLARE + v_home uuid := current_setting('test.home')::uuid; + v_other uuid := current_setting('test.other')::uuid; + v_before bigint; v_other_before bigint; +BEGIN + PERFORM pg_temp.act('ll-ann'); + PERFORM pg_temp.next_transaction(); + v_before := pg_temp.changes(v_home); + v_other_before := pg_temp.changes(v_other); + PERFORM public.create_expense_command(v_home, 'Big shop', 9000, + '[{"user_id":"ll-ann","amount_minor":3000},{"user_id":"ll-ben","amount_minor":3000},{"user_id":"ll-dee","amount_minor":3000}]'::jsonb, + NULL, NULL, 'Groceries', gen_random_uuid()); + PERFORM public.record_share_receipt(current_setting('test.share')::uuid, 100, current_date, NULL, gen_random_uuid()); + PERFORM public.update_commune_details(v_home, 'Lighter house', 'household', 'GBP'); + IF pg_temp.changes(v_home) <> v_before + 1 THEN + RAISE EXCEPTION 'A transaction with many writes should add one change row, added %', pg_temp.changes(v_home) - v_before; + END IF; + -- A second commune in the same transaction gets its own row. + INSERT INTO public.commune_events (commune_id, actor_id, kind, entity_id, title, description) + VALUES (v_other, 'll-cat', 'notes', gen_random_uuid(), 'Keys', 'Created'); + IF pg_temp.changes(v_other) <> v_other_before + 1 OR pg_temp.changes(v_home) <> v_before + 1 THEN + RAISE EXCEPTION 'Each commune written to should have one change row'; + END IF; + + PERFORM pg_temp.next_transaction(); + v_before := pg_temp.changes(v_home); + BEGIN + INSERT INTO public.commune_events (commune_id, actor_id, kind, entity_id, title, description) + VALUES (v_home, 'll-ann', 'notes', gen_random_uuid(), 'Bins', 'Created'); + RAISE EXCEPTION 'roll this part back'; + EXCEPTION WHEN raise_exception THEN NULL; + END; + IF pg_temp.changes(v_home) <> v_before THEN RAISE EXCEPTION 'A rolled-back write left a change row'; END IF; + INSERT INTO public.commune_events (commune_id, actor_id, kind, entity_id, title, description) + VALUES (v_home, 'll-ann', 'notes', gen_random_uuid(), 'Bins', 'Created'); + IF pg_temp.changes(v_home) <> v_before + 1 THEN + RAISE EXCEPTION 'A write after a rolled-back one should still be noted'; + END IF; +END; +$$; + +-- 3. What a mark counts: rows since the start of yesterday (UTC), so a lower +-- id committing late still moves it, and pruning never reaches them. +DO $$ +DECLARE + v_home uuid := current_setting('test.home')::uuid; + v_mark text; v_yesterday timestamptz := (date_trunc('day', now() AT TIME ZONE 'UTC') - interval '1 day') AT TIME ZONE 'UTC'; + v_pruned jsonb; +BEGIN + v_mark := commune_private.change_mark(v_home); + IF split_part(v_mark, ':', 1) <> to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD') THEN + RAISE EXCEPTION 'A mark starts with today''s UTC date, got %', v_mark; + END IF; + -- A transaction that took a lower id commits after the mark was read. + INSERT INTO commune_private.commune_changes (id, commune_id) OVERRIDING SYSTEM VALUE + VALUES ((SELECT min(id) - 1 FROM commune_private.commune_changes), v_home); + IF commune_private.change_mark(v_home) = v_mark THEN + RAISE EXCEPTION 'A change with a lower id that commits late must move the mark'; + END IF; + v_mark := commune_private.change_mark(v_home); + INSERT INTO commune_private.commune_changes (commune_id, changed_at) VALUES (v_home, v_yesterday - interval '1 second'); + IF commune_private.change_mark(v_home) <> v_mark THEN + RAISE EXCEPTION 'A change from before yesterday is outside the mark'; + END IF; + INSERT INTO commune_private.commune_changes (commune_id, changed_at) VALUES (v_home, v_yesterday); + IF commune_private.change_mark(v_home) = v_mark THEN + RAISE EXCEPTION 'A change from the start of yesterday is inside the mark'; + END IF; + v_mark := commune_private.change_mark(v_home); + INSERT INTO commune_private.commune_changes (commune_id, changed_at) VALUES + (v_home, now() - interval '2 days 1 second'), (v_home, now() - interval '3 days'); + v_pruned := commune_private.prune_delivery_records(); + IF (v_pruned->>'changeMarks')::int <> 2 OR commune_private.change_mark(v_home) <> v_mark + OR NOT EXISTS (SELECT 1 FROM commune_private.commune_changes WHERE commune_id = v_home AND changed_at = v_yesterday - interval '1 second') THEN + RAISE EXCEPTION 'Pruning should remove only changes older than 2 days, got %', v_pruned; + END IF; +END; +$$; + +-- 4. The commune list: the caller's own communes, newest first, with marks. +DO $$ +DECLARE + v_home uuid := current_setting('test.home')::uuid; + v_other uuid := current_setting('test.other')::uuid; + v_list jsonb; v_second uuid; +BEGIN + PERFORM pg_temp.act('ll-ann'); + v_second := public.create_commune('Ann''s workspace', 'workspace', 'EUR'); + UPDATE public.communes SET created_at = created_at + interval '1 minute' WHERE id = v_second; + v_list := public.my_communes(); + IF jsonb_array_length(v_list) <> 2 OR (v_list->0->>'id')::uuid <> v_second OR (v_list->1->>'id')::uuid <> v_home THEN + RAISE EXCEPTION 'Ann should see her two communes, newest first: %', v_list; + END IF; + IF v_list->1->>'name' <> 'Lighter house' OR v_list->1->>'kind' <> 'household' OR v_list->1->>'currency' <> 'GBP' + OR v_list->1->>'role' <> 'admin' OR (v_list->1->>'memberCount')::int <> 3 OR v_list->1->>'address' <> '' + OR NOT (v_list->1 ?& ARRAY['destination', 'stayName', 'stayAddress']) + OR v_list->1->>'changeMark' <> commune_private.change_mark(v_home) + OR v_list->1->>'changeMark' <> public.commune_snapshot(v_home)->>'changeMark' THEN + RAISE EXCEPTION 'The list should carry the commune''s details and the same mark as its snapshot: %', v_list->1; + END IF; + PERFORM pg_temp.act('ll-cat'); + v_list := public.my_communes(); + IF jsonb_array_length(v_list) <> 1 OR (v_list->0->>'id')::uuid <> v_other OR v_list->0->>'role' <> 'admin' THEN + RAISE EXCEPTION 'Cat should see only her own commune: %', v_list; + END IF; + -- Someone who left sees nothing of the commune. + UPDATE public.commune_members SET active = false, left_at = now() WHERE commune_id = v_home AND user_id = 'll-dee'; + PERFORM pg_temp.act('ll-dee'); + IF public.my_communes() <> '[]'::jsonb THEN RAISE EXCEPTION 'A former member still lists the commune'; END IF; + PERFORM pg_temp.act('ll-ann'); + IF (public.my_communes()->1->>'memberCount')::int <> 2 THEN RAISE EXCEPTION 'A former member is still counted'; END IF; +END; +$$; + +-- 5. The row checks on communes and commune_members still let a member read +-- their own communes and nobody else's, now looked up once per query. +SELECT pg_temp.act('ll-ann'); +SET LOCAL ROLE authenticated; +DO $$ +BEGIN + IF (SELECT count(*) FROM public.communes) <> 2 + OR EXISTS (SELECT 1 FROM public.communes WHERE id = current_setting('test.other')::uuid) + OR (SELECT count(*) FROM public.commune_members WHERE commune_id = current_setting('test.home')::uuid) <> 3 + OR EXISTS (SELECT 1 FROM public.commune_members WHERE commune_id = current_setting('test.other')::uuid) THEN + RAISE EXCEPTION 'Ann should read her own communes and their members only'; + END IF; +END; +$$; +RESET ROLE; +SELECT pg_temp.act('ll-dee'); +SET LOCAL ROLE authenticated; +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM public.communes) OR EXISTS (SELECT 1 FROM public.commune_members) THEN + RAISE EXCEPTION 'A former member can still read the commune'; + END IF; +END; +$$; +RESET ROLE; +SELECT pg_temp.act('ll-cat'); +SET LOCAL ROLE authenticated; +DO $$ +BEGIN + IF (SELECT count(*) FROM public.communes) <> 1 + OR (SELECT count(*) FROM public.commune_members) <> 1 + OR EXISTS (SELECT 1 FROM public.communes WHERE id = current_setting('test.home')::uuid) THEN + RAISE EXCEPTION 'Cat should read only her own commune'; + END IF; +END; +$$; +RESET ROLE; +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'communes' AND policyname = 'commune_read' + AND qual LIKE '%my_commune_ids%') + OR NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename = 'commune_members' AND policyname = 'member_read' + AND qual LIKE '%my_commune_ids%') THEN + RAISE EXCEPTION 'The row checks should look the caller''s communes up once per query'; + END IF; + IF has_function_privilege('anonymous', 'commune_private.my_commune_ids()', 'EXECUTE') THEN + RAISE EXCEPTION 'Only a signed-in person''s row checks use the helper'; + END IF; +END; +$$; + +-- 6. Creating communes is limited to 10 an hour, 30 a day and 100 in all. +DO $$ +DECLARE v_error text; i integer; +BEGIN + PERFORM pg_temp.act('ll-maker'); + FOR i IN 1..10 LOOP PERFORM public.create_commune('Maker ' || i, 'household', 'GBP'); END LOOP; + v_error := pg_temp.refused($sql$SELECT public.create_commune('One more', 'household', 'GBP')$sql$); + IF v_error IS DISTINCT FROM 'P0001 You have created 10 communes in the last hour. Try again later.' THEN + RAISE EXCEPTION 'An eleventh commune in an hour: %', v_error; + END IF; + -- The other way of creating one is held to the same limit. + v_error := pg_temp.refused($sql$SELECT public.create_commune_with_location('One more', 'household', 'GBP', '', '', '', '')$sql$); + IF v_error NOT LIKE 'P0001 You have created 10 communes in the last hour%' THEN + RAISE EXCEPTION 'Creating with a location skipped the limit: %', v_error; + END IF; + UPDATE public.communes SET created_at = now() - interval '3 hours' WHERE created_by = 'll-maker'; + FOR i IN 11..20 LOOP PERFORM public.create_commune('Maker ' || i, 'household', 'GBP'); END LOOP; + UPDATE public.communes SET created_at = now() - interval '2 hours' WHERE created_by = 'll-maker' AND created_at = now(); + FOR i IN 21..30 LOOP PERFORM public.create_commune('Maker ' || i, 'household', 'GBP'); END LOOP; + UPDATE public.communes SET created_at = now() - interval '90 minutes' WHERE created_by = 'll-maker' AND created_at = now(); + v_error := pg_temp.refused($sql$SELECT public.create_commune('One more', 'household', 'GBP')$sql$); + IF v_error IS DISTINCT FROM 'P0001 You have created 30 communes in the last day. Try again tomorrow.' THEN + RAISE EXCEPTION 'A 31st commune in a day: %', v_error; + END IF; + UPDATE public.communes SET created_at = now() - interval '2 days' WHERE created_by = 'll-maker'; + INSERT INTO public.communes (name, kind, currency, created_by, created_at) + SELECT 'Older ' || g, 'household', 'GBP', 'll-maker', now() - interval '30 days' FROM generate_series(1, 69) g; + PERFORM public.create_commune('The hundredth', 'household', 'GBP'); + v_error := pg_temp.refused($sql$SELECT public.create_commune('One more', 'household', 'GBP')$sql$); + IF v_error IS DISTINCT FROM 'P0001 You have created 100 communes, which is the most one account can create.' THEN + RAISE EXCEPTION 'A 101st commune: %', v_error; + END IF; + -- Someone else is not affected. + PERFORM pg_temp.act('ll-fresh'); + PERFORM public.create_commune('Fresh start', 'household', 'GBP'); +END; +$$; + +-- 7. The snapshot carries open expenses and those created or changed since +-- the start of last month (UTC); expenseHistory says how many it left out. +CREATE FUNCTION pg_temp.expense(p_home uuid, p_title text, p_settle boolean) RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE v_id uuid; +BEGIN + v_id := public.create_expense(p_home, p_title, 2000, + '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date); + IF p_settle THEN + PERFORM public.record_share_receipt((SELECT id FROM public.expense_shares WHERE expense_id = v_id AND user_id = 'll-ben'), + 1000, current_date, NULL, gen_random_uuid()); + END IF; + RETURN v_id; +END; +$$; +-- When each part of an expense happened, as if recorded then. +CREATE FUNCTION pg_temp.age(p_expense uuid, p_created timestamptz, p_changed timestamptz) RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + PERFORM set_config('session_replication_role', 'replica', true); + UPDATE public.expenses SET created_at = p_created, + updated_at = CASE WHEN updated_at IS NULL THEN NULL ELSE p_changed END, + voided_at = CASE WHEN voided_at IS NULL THEN NULL ELSE p_changed END + WHERE id = p_expense; + UPDATE public.expense_shares SET settled_at = CASE WHEN settled_at IS NULL THEN NULL ELSE p_changed END WHERE expense_id = p_expense; + UPDATE public.expense_share_receipts r SET recorded_at = p_changed + FROM public.expense_shares s WHERE s.id = r.share_id AND s.expense_id = p_expense; + UPDATE public.expense_revisions SET created_at = p_changed WHERE expense_id = p_expense; + PERFORM set_config('session_replication_role', 'origin', true); +END; +$$; + +DO $$ +DECLARE + v_home uuid; v_window timestamptz := commune_private.expense_window_start(); + v_old timestamptz := commune_private.expense_window_start() - interval '3 months'; + v_recent timestamptz := now() - interval '1 hour'; + v_open uuid; v_quiet uuid; v_new uuid; v_settled uuid; v_voided uuid; v_quiet_void uuid; v_edited uuid; v_edge_in uuid; v_edge_out uuid; + v_snapshot jsonb; v_ids uuid[]; v_history jsonb; +BEGIN + PERFORM pg_temp.act('ll-ann'); + v_home := public.create_commune('History home', 'household', 'GBP'); + INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_home, 'll-ben', 'member'); + v_open := pg_temp.expense(v_home, 'Old and open', false); + v_quiet := pg_temp.expense(v_home, 'Old and settled', true); + v_new := pg_temp.expense(v_home, 'New and settled', true); + v_settled := pg_temp.expense(v_home, 'Old, settled lately', true); + v_voided := pg_temp.expense(v_home, 'Old, voided lately', false); + PERFORM public.void_expense(v_voided, 1, 'Entered twice', gen_random_uuid()); + v_quiet_void := pg_temp.expense(v_home, 'Old and voided', false); + PERFORM public.void_expense(v_quiet_void, 1, 'Entered twice', gen_random_uuid()); + v_edited := pg_temp.expense(v_home, 'Old, edited lately', true); + PERFORM public.correct_expense(v_edited, 1, 'Old, edited lately', 2000, + '[{"user_id":"ll-ann","amount_minor":1000},{"user_id":"ll-ben","amount_minor":1000}]'::jsonb, current_date, + 'll-ann', 'Utilities', 'Right category', gen_random_uuid()); + v_edge_in := pg_temp.expense(v_home, 'First moment of the window', true); + v_edge_out := pg_temp.expense(v_home, 'Last moment before it', true); + PERFORM pg_temp.age(v_open, v_old, v_old); + PERFORM pg_temp.age(v_quiet, v_old, v_old); + PERFORM pg_temp.age(v_new, v_recent, v_recent); + PERFORM pg_temp.age(v_settled, v_old, v_recent); + PERFORM pg_temp.age(v_voided, v_old, v_recent); + PERFORM pg_temp.age(v_quiet_void, v_old, v_old); + PERFORM pg_temp.age(v_edited, v_old, v_old); + UPDATE public.expenses SET updated_at = v_recent WHERE id = v_edited; + PERFORM pg_temp.age(v_edge_in, v_window, v_window); + PERFORM pg_temp.age(v_edge_out, v_window - interval '1 second', v_window - interval '1 second'); + + v_snapshot := public.commune_snapshot(v_home); + SELECT array_agg((e->>'id')::uuid) INTO v_ids FROM jsonb_array_elements(v_snapshot->'expenses') e; + IF NOT v_ids @> ARRAY[v_open, v_new, v_settled, v_voided, v_edited, v_edge_in] OR v_ids && ARRAY[v_quiet, v_quiet_void, v_edge_out] + OR cardinality(v_ids) <> 6 THEN + RAISE EXCEPTION 'The snapshot carried the wrong expenses: %', v_snapshot->'expenses'; + END IF; + v_history := v_snapshot->'expenseHistory'; + IF v_history <> jsonb_build_object('since', to_char(v_window AT TIME ZONE 'UTC', 'YYYY-MM-DD'), 'olderCount', 3, 'olderVoidedCount', 1) THEN + RAISE EXCEPTION 'expenseHistory should count the three left out, one of them voided: %', v_history; + END IF; + IF to_char(v_window AT TIME ZONE 'UTC', 'DD HH24:MI:SS') <> '01 00:00:00' + OR v_window <> (date_trunc('month', (date_trunc('month', now() AT TIME ZONE 'UTC') - interval '1 day')) AT TIME ZONE 'UTC') THEN + RAISE EXCEPTION 'The window should start at midnight UTC on the first day of last month, got %', v_window; + END IF; + -- Every other part of the snapshot keeps its shape. + IF NOT v_snapshot ?& ARRAY['commune', 'members', 'expenses', 'funds', 'notifications', 'reminderTargets', 'sentReminders', + 'notes', 'operations', 'announcements', 'recurringBills', 'splitTemplates', 'activityEvents', 'expenseHistory', 'changeMark'] THEN + RAISE EXCEPTION 'The snapshot lost a part: %', (SELECT array_agg(k) FROM jsonb_object_keys(v_snapshot) k); + END IF; + -- One builder: an expense looks the same however it is fetched. + IF (SELECT e FROM jsonb_array_elements(v_snapshot->'expenses') e WHERE (e->>'id')::uuid = v_open) + <> public.commune_expense_records(v_home, ARRAY[v_open])->'expenses'->0 THEN + RAISE EXCEPTION 'The snapshot and the record of one expense differ'; + END IF; + PERFORM set_config('test.history_home', v_home::text, true); + PERFORM set_config('test.quiet', v_quiet::text, true); + PERFORM set_config('test.quiet_void', v_quiet_void::text, true); + PERFORM set_config('test.edge_out', v_edge_out::text, true); + PERFORM set_config('test.history_open', v_open::text, true); +END; +$$; + +-- 8. Pages of older expenses, a month or more at a time, and named records. +DO $$ +DECLARE + v_home uuid := current_setting('test.history_home')::uuid; + v_window timestamptz := commune_private.expense_window_start(); + v_page jsonb; v_ids uuid[]; v_error text; v_other_expense uuid; +BEGIN + PERFORM pg_temp.act('ll-ben'); + -- The month before the window holds the one from its last second. + v_page := public.commune_expense_page(v_home, NULL, 1); + SELECT array_agg((e->>'id')::uuid) INTO v_ids FROM jsonb_array_elements(v_page->'expenses') e; + IF v_ids IS DISTINCT FROM ARRAY[current_setting('test.edge_out')::uuid] + OR v_page->>'before' <> to_char(v_window AT TIME ZONE 'UTC', 'YYYY-MM-DD') + OR v_page->>'from' <> to_char((v_window - interval '1 month') AT TIME ZONE 'UTC', 'YYYY-MM-DD') + OR (v_page->>'olderCount')::int <> 2 OR (v_page->>'olderVoidedCount')::int <> 1 THEN + RAISE EXCEPTION 'First page: %', v_page; + END IF; + -- The next month is empty, so the page reaches back to the month that has some. + v_page := public.commune_expense_page(v_home, (v_page->>'from')::date, 1); + SELECT array_agg((e->>'id')::uuid ORDER BY e->>'title') INTO v_ids FROM jsonb_array_elements(v_page->'expenses') e; + IF NOT v_ids @> ARRAY[current_setting('test.quiet')::uuid, current_setting('test.quiet_void')::uuid] OR cardinality(v_ids) <> 2 + OR v_page->>'from' <> to_char((v_window - interval '3 months') AT TIME ZONE 'UTC', 'YYYY-MM-DD') + OR (v_page->>'olderCount')::int <> 0 THEN + RAISE EXCEPTION 'Second page should skip the empty month: %', v_page; + END IF; + -- Nothing older: an empty page with no start. + v_page := public.commune_expense_page(v_home, (v_page->>'from')::date, 1); + IF v_page->'expenses' <> '[]'::jsonb OR v_page->'from' <> 'null'::jsonb OR (v_page->>'olderCount')::int <> 0 THEN + RAISE EXCEPTION 'Past the beginning: %', v_page; + END IF; + -- Everything before the window at once, for a statement or an export. + v_page := public.commune_expense_page(v_home, NULL, NULL); + IF jsonb_array_length(v_page->'expenses') <> 3 OR v_page->'from' <> 'null'::jsonb OR (v_page->>'olderCount')::int <> 0 THEN + RAISE EXCEPTION 'All older expenses: %', v_page; + END IF; + -- An open expense is always in the snapshot, never in a page. + IF EXISTS (SELECT 1 FROM jsonb_array_elements(v_page->'expenses') e WHERE (e->>'id')::uuid = current_setting('test.history_open')::uuid) THEN + RAISE EXCEPTION 'A page repeated an open expense'; + END IF; + v_error := pg_temp.refused(format('SELECT public.commune_expense_page(%L, NULL, 0)', v_home)); + IF v_error NOT LIKE '22023 %' THEN RAISE EXCEPTION 'Zero months: %', v_error; END IF; + v_error := pg_temp.refused(format('SELECT public.commune_expense_page(%L, NULL, 13)', v_home)); + IF v_error NOT LIKE '22023 %' THEN RAISE EXCEPTION 'Thirteen months: %', v_error; END IF; + + -- Named records: only this commune's, at most 50 at a time. + SELECT e.id INTO v_other_expense FROM public.expenses e WHERE e.commune_id = current_setting('test.home')::uuid LIMIT 1; + v_page := public.commune_expense_records(v_home, ARRAY[current_setting('test.quiet')::uuid, v_other_expense, gen_random_uuid()]); + IF jsonb_array_length(v_page->'expenses') <> 1 OR (v_page->'expenses'->0->>'id')::uuid <> current_setting('test.quiet')::uuid THEN + RAISE EXCEPTION 'Records should hold only this commune''s expense: %', v_page; + END IF; + v_error := pg_temp.refused(format('SELECT public.commune_expense_records(%L, %L::uuid[])', v_home, + (SELECT array_agg(gen_random_uuid()) FROM generate_series(1, 51)))); + IF v_error NOT LIKE '22023 %' THEN RAISE EXCEPTION 'Fifty-one ids: %', v_error; END IF; + v_error := pg_temp.refused(format('SELECT public.commune_expense_records(%L, ''{}''::uuid[])', v_home)); + IF v_error NOT LIKE '22023 %' THEN RAISE EXCEPTION 'No ids: %', v_error; END IF; +END; +$$; + +-- 9. The overview summary: the same parts, less the detail of the commune's +-- own screens, and marked as a summary. +DO $$ +DECLARE + v_home uuid := current_setting('test.home')::uuid; + v_full jsonb; v_summary jsonb; +BEGIN + PERFORM pg_temp.act('ll-ann'); + v_full := public.commune_snapshot(v_home); + v_summary := public.commune_summary(v_home); + IF v_summary->>'detail' <> 'summary' OR v_full ? 'detail' + OR (SELECT array_agg(k ORDER BY k) FROM jsonb_object_keys(v_summary - 'detail') k) + <> (SELECT array_agg(k ORDER BY k) FROM jsonb_object_keys(v_full) k) THEN + RAISE EXCEPTION 'A summary should have the snapshot''s parts and say it is one'; + END IF; + IF (SELECT array_agg(e->>'id') FROM jsonb_array_elements(v_summary->'expenses') e) + <> (SELECT array_agg(e->>'id') FROM jsonb_array_elements(v_full->'expenses') e) + OR EXISTS (SELECT 1 FROM jsonb_array_elements(v_summary->'expenses') e, jsonb_array_elements(e->'shares') s + WHERE e->'revisions' <> '[]'::jsonb OR s->'receipts' <> '[]'::jsonb OR s->'paymentReports' <> '[]'::jsonb) + OR NOT EXISTS (SELECT 1 FROM jsonb_array_elements(v_full->'expenses') e, jsonb_array_elements(e->'shares') s + WHERE s->'receipts' <> '[]'::jsonb) THEN + RAISE EXCEPTION 'Summary expenses should keep their shares without repayment, report or correction history'; + END IF; + IF v_summary->'notes'->0->>'body' <> '' OR v_summary->'notes'->0->>'title' <> 'Wifi' + OR v_summary->'operations'->0->'history' <> '[]'::jsonb OR v_summary->'operations'->0->>'title' <> 'Bins' + OR v_summary->'recurringBills'->0->'history' <> '[]'::jsonb + OR v_summary->'announcements' <> '[]'::jsonb OR v_summary->'splitTemplates' <> '[]'::jsonb + OR v_summary->'sentReminders' <> '[]'::jsonb OR v_summary->'notifications' <> '[]'::jsonb + OR v_summary->'commune' <> v_full->'commune' OR v_summary->'members' <> v_full->'members' + OR v_summary->'activityEvents' <> v_full->'activityEvents' OR v_summary->'funds' <> v_full->'funds' + OR v_summary->'expenseHistory' <> v_full->'expenseHistory' OR v_summary->>'changeMark' <> v_full->>'changeMark' THEN + RAISE EXCEPTION 'The summary dropped or kept the wrong parts'; + END IF; +END; +$$; + +-- 10. Someone outside a commune, or who left it, gets none of it. +DO $$ +DECLARE v_home uuid := current_setting('test.history_home')::uuid; v_user text; v_call text; v_error text; +BEGIN + INSERT INTO public.commune_members (commune_id, user_id, role, active, left_at) VALUES (v_home, 'll-dee', 'member', false, now()); + FOREACH v_user IN ARRAY ARRAY['ll-cat', 'll-dee'] LOOP + PERFORM pg_temp.act(v_user); + FOREACH v_call IN ARRAY ARRAY[ + format('SELECT public.commune_snapshot(%L)', v_home), + format('SELECT public.commune_summary(%L)', v_home), + format('SELECT public.commune_expense_page(%L, NULL, 1)', v_home), + format('SELECT public.commune_expense_page(%L, NULL, NULL)', v_home), + format('SELECT public.commune_expense_records(%L, ARRAY[%L]::uuid[])', v_home, current_setting('test.quiet')) + ] LOOP + v_error := pg_temp.refused(v_call); + IF v_error NOT LIKE '42501 %' THEN RAISE EXCEPTION '% was not refused for %: %', v_call, v_user, v_error; END IF; + END LOOP; + END LOOP; + FOREACH v_call IN ARRAY ARRAY['public.my_communes()', 'public.commune_summary(uuid)', + 'public.commune_expense_page(uuid,date,integer)', 'public.commune_expense_records(uuid,uuid[])'] LOOP + IF has_function_privilege('anonymous', v_call, 'EXECUTE') OR has_function_privilege('public', v_call, 'EXECUTE') + OR NOT has_function_privilege('authenticated', v_call, 'EXECUTE') THEN + RAISE EXCEPTION 'Unexpected execution grant on %', v_call; + END IF; + END LOOP; +END; +$$; + +ROLLBACK; diff --git a/infra/neon/tests/run-local.sh b/infra/neon/tests/run-local.sh index 8c21e351..5ebd86d2 100755 --- a/infra/neon/tests/run-local.sh +++ b/infra/neon/tests/run-local.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Runs the database tests in this folder against a local PostgreSQL server: # every *.sql test on its own fresh database, then every *.mjs test -# (task_lock_order.mjs) the same way. CI runs it, and so can you before -# pushing a migration: +# (task_lock_order.mjs, change_mark_order.mjs) the same way. CI runs it, and +# so can you before pushing a migration: # # PGHOST=127.0.0.1 PGPORT=5432 PGUSER=commune PGPASSWORD=commune \ # infra/neon/tests/run-local.sh [test-name ...] From cc59a48070e475ed556401d681f932360463e760 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:54:38 +0000 Subject: [PATCH 02/11] Serve change marks, summaries and older expenses from the local API The commune list now comes from public.my_communes(), so it carries each commune's change mark and is read the same way as on the hosted app. The snapshot route takes ?detail=summary for the overview screens. Two new routes page through expenses older than the snapshot holds and fetch named expenses for links, with the same limits the database enforces. lighter-loading.mjs covers marks, the summary, paging, named records and the limit on creating communes against an isolated API port. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/local-api/README.md | 13 ++- apps/local-api/src/server.ts | 55 +++++++---- apps/local-api/tests/lighter-loading.mjs | 116 +++++++++++++++++++++++ 3 files changed, 162 insertions(+), 22 deletions(-) create mode 100644 apps/local-api/tests/lighter-loading.mjs diff --git a/apps/local-api/README.md b/apps/local-api/README.md index 5e2c696e..b0d5c3ef 100644 --- a/apps/local-api/README.md +++ b/apps/local-api/README.md @@ -17,11 +17,13 @@ browser code sends same-origin `/api` requests. Non-2xx errors are | POST | `/api/auth/login` | `{email,password}` | `{user}` + cookie | | POST | `/api/auth/logout` | `{}` | `{ok:true}` + cleared cookie | | GET | `/api/auth/me` | public | `{user}` or `{user:null}` | -| GET | `/api/communes` | | `{communes:[...]}` | +| GET | `/api/communes` | | `{communes:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark}]}`; a commune's `changeMark` moves whenever anything in it changes | | GET | `/api/search?q=...&commune=...&limit=8` | session required; optional commune UUID; 2–80 chars, 1–25 results per type | `{query,items,total,hasMore}`; only active memberships, safe note excerpts | -| POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` | `{id}` | +| POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}`; one account creates at most 10 communes an hour, 30 a day and 100 in all (409) | `{id}` | | PATCH | `/api/communes/:id` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` admin; the currency is fixed (400) once the commune has an expense, fund, recurring bill or exact split template, voided or archived ones included | `{ok:true}` | -| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404 | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents}` | +| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents,expenseHistory,changeMark}`; `expenses` are the open ones and those created or changed since the start of last month (UTC); `expenseHistory` is `{since,olderCount,olderVoidedCount}` | +| GET | `/api/communes/:id/expenses/history?before=&months=` | member; `before` (a date) defaults to the snapshot's `since`; `months` 1 to 12, or left out for every older expense | `{expenses,before,from,olderCount,olderVoidedCount}`: whole months (UTC) before `before`, reaching back past months with none; `from` is null once nothing older remains | +| GET | `/api/communes/:id/expenses/records?ids=` | member; 1 to 50 comma-separated expense ids; ids from another commune are left out | `{expenses}` in the snapshot's shape | | POST | `/api/communes/:id/expenses` | `{title,amountMinor,paidByUserId?,category?,shares:[{userId,amountMinor}],dueOn?,requestId?}`; recording for another payer requires admin; a repeated `requestId` returns the same expense, and reusing it for different details is 400 | `{id,duplicate}` (201, or 200 for a repeat) | | POST | `/api/shares/:id/confirm` | `{}` payer only; records one receipt for everything outstanding | `{ok:true}` | | POST | `/api/shares/:id/receipts` | `{amountMinor,receivedOn,reference?,requestId}` payer only; amount up to the outstanding share; a repeated `requestId` returns the same entry | `{id,duplicate,settledMinor,outstandingMinor}` (201, or 200 for a repeat) | @@ -99,6 +101,7 @@ API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/money-lifecycle.mjs API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/operations-history.mjs API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/invites-pledges-requests.mjs API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/session-viewer.mjs +API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/lighter-loading.mjs ``` `money-lifecycle.mjs` needs migrations 0015 to 0017 and covers reminder @@ -124,6 +127,10 @@ the signed-in account and requests without the header are still served. `invitation-email-opt-in.mjs` needs no API: `node --test apps/local-api/tests/invitation-email-opt-in.mjs`. +`lighter-loading.mjs` needs 0036 and covers change marks in the list and the +snapshot, the overview summary, pages of older expenses, named expense records +and the limit on creating communes. It refuses port 3002. + Search integration requires migration 0014. It covers all eight result types, active membership, selected/all-commune scopes, removed-member denial, literal wildcards, result caps and private-note masking. Its loopback guard refuses diff --git a/apps/local-api/src/server.ts b/apps/local-api/src/server.ts index afb86001..398a3d6e 100644 --- a/apps/local-api/src/server.ts +++ b/apps/local-api/src/server.ts @@ -210,30 +210,20 @@ function failed(key: string): void { }); } +// The same list the hosted app reads, with each commune's change mark. async function listCommunes(user: User) { return withActor(user.id, async (client) => { - const { rows } = await client.query(` - SELECT c.id, c.name, c.kind, c.currency, c.address, - c.destination, c.stay_name, c.stay_address, m.role, - (SELECT count(*)::integer FROM public.commune_members mm - WHERE mm.commune_id = c.id AND mm.active) AS member_count - FROM public.communes c - JOIN public.commune_members m ON m.commune_id = c.id - WHERE m.user_id = auth.user_id() AND m.active - ORDER BY c.created_at DESC - `); - return { communes: rows.map((row) => ({ - id: row.id, name: row.name, kind: row.kind, currency: row.currency, - address: row.address, destination: row.destination, - stayName: row.stay_name, stayAddress: row.stay_address, - role: row.role, memberCount: row.member_count, - })) }; + const { rows } = await client.query("SELECT public.my_communes() AS communes"); + return { communes: rows[0].communes }; }); } -async function snapshot(user: User, communeId: string) { +// A summary is what the overview screens show of a commune that is not open. +async function snapshot(user: User, communeId: string, summary = false) { return withActor(user.id, async client => { - const result = await client.query("SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_snapshot($1) ELSE NULL END AS snapshot", [communeId]); + const result = await client.query(summary + ? "SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_summary($1) ELSE NULL END AS snapshot" + : "SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_snapshot($1) ELSE NULL END AS snapshot", [communeId]); if (!result.rows[0].snapshot) throw new ApiError(404,"Commune not found"); return result.rows[0].snapshot; }); @@ -404,7 +394,34 @@ route("PATCH", /^\/api\/communes\/([0-9a-f-]{36})$/, async (req, res, match) => }); route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/snapshot$/, async (req, res, match) => { - send(res, await snapshot(await actor(req), uuid(match[1]))); + const detail = new URL(req.url ?? "", "http://127.0.0.1").searchParams.get("detail"); + if (detail !== null && detail !== "summary") throw new ApiError(400, "Invalid detail"); + send(res, await snapshot(await actor(req), uuid(match[1]), detail === "summary")); +}); + +// Expenses older than the snapshot holds: the given number of months before +// `before` (the snapshot's own start when absent), or all of them when months +// is absent, as a statement or an export needs. +route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/expenses\/history$/, async (req, res, match) => { + const params = new URL(req.url ?? "", "http://127.0.0.1").searchParams; + const before = isoDate(params.get("before"), true, "before"); + const months = params.has("months") ? Number(params.get("months")) : null; + if (months !== null && (!Number.isInteger(months) || months < 1 || months > 12)) { + throw new ApiError(400, "Ask for 1 to 12 months of expenses at a time"); + } + const result = await userAction(req, async client => (await client.query( + "SELECT public.commune_expense_page($1, $2::date, $3) AS result", [uuid(match[1]), before, months])).rows[0].result); + send(res, result); +}); + +// Named expenses, for a link to one that is not loaded. +route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/expenses\/records$/, async (req, res, match) => { + const ids = (new URL(req.url ?? "", "http://127.0.0.1").searchParams.get("ids") ?? "").split(",").filter(Boolean); + if (ids.length < 1 || ids.length > 50) throw new ApiError(400, "Ask for 1 to 50 expenses at a time"); + const result = await userAction(req, async client => (await client.query( + "SELECT public.commune_expense_records($1, $2::uuid[]) AS result", + [uuid(match[1]), ids.map((id) => uuid(id, "ids"))])).rows[0].result); + send(res, result); }); route("GET", /^\/api\/reminders\/usage$/, async (req, res) => { diff --git a/apps/local-api/tests/lighter-loading.mjs b/apps/local-api/tests/lighter-loading.mjs new file mode 100644 index 00000000..67681de2 --- /dev/null +++ b/apps/local-api/tests/lighter-loading.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +// Change marks in the commune list, the overview summary, pages of older +// expenses, named expense records and the limit on creating communes (0036). +// Creates accounts and communes, so it refuses the normal application API. +const base = process.env.API_BASE; +if (!base) throw new Error("Set API_BASE to an isolated local test API, not the user's database."); +const url = new URL(base); +if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname) || !url.port || + url.port === "3002" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) { + throw new Error("Lighter loading tests require an isolated loopback API port (not 3002)."); +} +// Letters only, so a commune name that carries it never holds a phone-like run of digits. +const suffix = randomUUID().slice(0, 8).replace(/[0-9]/g, (digit) => "ghijklmnop"[digit]); +function session() { + let cookie = ""; + return async (method, path, input) => { + const response = await fetch(`${base}/api${path}`, { + method, + headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) }, + body: input === undefined ? undefined : JSON.stringify(input), + }); + const set = response.headers.get("set-cookie"); + if (set) cookie = set.split(";")[0]; + return { ...(await response.json()), status: response.status }; + }; +} +async function signup(call, name, slug) { + const result = await call("POST", "/auth/signup", { name, email: `${slug}-${suffix}@example.test`, password: "DisposableLoadingFixture-2026!" }); + assert.equal(result.status, 201, JSON.stringify(result)); + return result.user; +} +const owner = session(), member = session(), outsider = session(), maker = session(); +const avery = await signup(owner, "Avery Loading", "loading-owner"); +const blake = await signup(member, "Blake Loading", "loading-member"); +await signup(outsider, "Casey Loading", "loading-outsider"); +const home = await owner("POST", "/communes", { name: `Loading ${suffix}`, kind: "household", currency: "GBP" }); +assert.equal(home.status, 201, JSON.stringify(home)); +const invite = await owner("POST", `/communes/${home.id}/invites`, { email: blake.email, role: "member" }); +assert.equal((await member("POST", `/invites/${invite.token}/accept`, {})).status, 200); + +// The list carries each commune's mark, which stays put until something changes. +async function listed(call) { + const list = await call("GET", "/communes"); + assert.equal(list.status, 200, JSON.stringify(list)); + return list.communes.find((commune) => commune.id === home.id); +} +const first = await listed(owner); +assert.deepEqual(Object.keys(first).sort(), ["address", "changeMark", "currency", "destination", "id", "kind", + "memberCount", "name", "role", "stayAddress", "stayName"]); +assert.deepEqual([first.name, first.role, first.memberCount], [`Loading ${suffix}`, "admin", 2]); +assert.match(first.changeMark, /^\d{4}-\d{2}-\d{2}:\d+:\d+$/); +assert.equal((await listed(owner)).changeMark, first.changeMark, "Reading moved the mark"); +assert.equal((await listed(member)).changeMark, first.changeMark, "Members see the same mark"); +assert.equal((await listed(outsider)), undefined, "Someone outside sees the commune"); +const snapshot = await owner("GET", `/communes/${home.id}/snapshot`); +assert.equal(snapshot.changeMark, first.changeMark); +const today = new Date(); +const since = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1)).toISOString().slice(0, 10); +assert.deepEqual(snapshot.expenseHistory, { since, olderCount: 0, olderVoidedCount: 0 }); + +// A write by anyone in the commune moves it. +const expense = await member("POST", `/communes/${home.id}/expenses`, { title: "Milk", category: "Groceries", amountMinor: 300, + shares: [{ userId: avery.id, amountMinor: 150 }, { userId: blake.id, amountMinor: 150 }], requestId: randomUUID() }); +assert.equal(expense.status, 201, JSON.stringify(expense)); +const moved = (await listed(owner)).changeMark; +assert.notEqual(moved, first.changeMark, "A new expense did not move the mark"); + +// The overview summary: the same parts without the detail of the commune's own screens. +const summary = await owner("GET", `/communes/${home.id}/snapshot?detail=summary`); +assert.equal(summary.status, 200, JSON.stringify(summary)); +assert.equal(summary.detail, "summary"); +assert.equal(summary.changeMark, moved); +assert.deepEqual(summary.expenses.map((item) => item.id), [expense.id]); +assert.deepEqual(summary.expenses[0].shares.map((share) => share.amountMinor), [150, 150]); +assert.equal((await owner("GET", `/communes/${home.id}/snapshot?detail=everything`)).status, 400); +assert.equal((await outsider("GET", `/communes/${home.id}/snapshot?detail=summary`)).status, 404); + +// Older pages. Everything here is new, so there is nothing before the snapshot's start. +const page = await member("GET", `/communes/${home.id}/expenses/history?months=1`); +assert.equal(page.status, 200, JSON.stringify(page)); +assert.deepEqual([page.expenses, page.before, page.from, page.olderCount, page.olderVoidedCount], [[], since, null, 0, 0]); +const all = await member("GET", `/communes/${home.id}/expenses/history`); +assert.deepEqual([all.status, all.expenses, all.from], [200, [], null]); +const earlier = await member("GET", `/communes/${home.id}/expenses/history?before=2026-01-15&months=2`); +assert.deepEqual([earlier.status, earlier.before], [200, "2026-01-01"]); +for (const query of ["months=0", "months=13", "months=one", "before=2026-02-30", "before=yesterday"]) { + assert.equal((await member("GET", `/communes/${home.id}/expenses/history?${query}`)).status, 400, query); +} +assert.equal((await outsider("GET", `/communes/${home.id}/expenses/history?months=1`)).status, 403); + +// Named records come in the snapshot's shape, and only from this commune. +const fresh = await owner("GET", `/communes/${home.id}/snapshot`); +const named = await owner("GET", `/communes/${home.id}/expenses/records?ids=${expense.id},${randomUUID()}`); +assert.equal(named.status, 200, JSON.stringify(named)); +assert.deepEqual(named.expenses, fresh.expenses); +const tooMany = Array.from({ length: 51 }, () => randomUUID()).join(","); +for (const query of ["", "ids=", `ids=${tooMany}`, "ids=not-an-id"]) { + assert.equal((await owner("GET", `/communes/${home.id}/expenses/records?${query}`)).status, 400, query); +} +assert.equal((await outsider("GET", `/communes/${home.id}/expenses/records?ids=${expense.id}`)).status, 403); + +// Ten new communes in an hour is the most; the next one is refused calmly. +await signup(maker, "Morgan Loading", "loading-maker"); +for (const letter of "abcdefghij") { + const made = await maker("POST", "/communes", { name: `Maker ${letter} ${suffix}`, kind: "household", currency: "GBP" }); + assert.equal(made.status, 201, JSON.stringify(made)); +} +const refused = await maker("POST", "/communes", { name: `Maker more ${suffix}`, kind: "household", currency: "GBP" }); +assert.equal(refused.status, 409, JSON.stringify(refused)); +assert.equal(refused.error, "You have created 10 communes in the last hour. Try again later."); +assert.equal((await maker("GET", "/communes")).communes.length, 10); +// Someone else can still create one. +assert.equal((await outsider("POST", "/communes", { name: `Casey ${suffix}`, kind: "trip", currency: "EUR" })).status, 201); +console.log("Lighter loading: marks, summary, older pages, named records and the creation limit behave as expected."); From 9ac2a24d8a4eb3c7827abc4b231fbbb60b81a799 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:55:52 +0000 Subject: [PATCH 03/11] Read change marks, summaries and older expenses through the hosted client The hosted app reads its commune list with one call to my_communes instead of selecting from communes and commune_members, which the set-based policies in 0036 now answer quickly. Each listed commune carries its change mark. loadSnapshot can ask for the overview summary, and a full read refuses a summary in its place. Two loaders page through older expenses and fetch named ones, mapped to the new database functions with the same limits. The contract tests follow the single list call and cover the summary, the paging and named-record calls, their refusals and malformed answers. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/live/api.ts | 24 +++++- apps/web/src/live/model.ts | 10 +++ apps/web/src/live/neon.ts | 79 ++++++++++++------- apps/web/tests/neon-auth.test.tsx | 48 +++++++---- .../tests/neon-lifecycle-contract.test.tsx | 26 ++++++ 5 files changed, 140 insertions(+), 47 deletions(-) diff --git a/apps/web/src/live/api.ts b/apps/web/src/live/api.ts index 82d6f938..bb58b731 100644 --- a/apps/web/src/live/api.ts +++ b/apps/web/src/live/api.ts @@ -1,4 +1,4 @@ -import type { Commune, Snapshot, User } from "./model"; +import type { Commune, Expense, ExpensePage, Snapshot, User } from "./model"; import { currentViewer } from "./session"; // Pass already-verified identity and commune metadata through one refresh. @@ -11,12 +11,28 @@ export async function loadCommunes(user: User): Promise<{ communes: Commune[] }> return api("/communes"); } -export async function loadSnapshot(commune: Commune): Promise { +/** A summary is what the overview screens show of a commune that is not open. */ +export async function loadSnapshot(commune: Commune, detail?: "summary"): Promise { if (import.meta.env.PROD) { const { snapshot } = await import("./neon"); - return snapshot(commune.id, commune); + return snapshot(commune.id, commune, detail); } - return api(`/communes/${encodeURIComponent(commune.id)}/snapshot`); + return api(`/communes/${encodeURIComponent(commune.id)}/snapshot${detail ? "?detail=summary" : ""}`); +} + +/** + * Expenses older than the snapshot holds, created in the `months` calendar + * months (UTC) before `before`, or all of them when months is left out. + * Without `before`, the page starts where the snapshot's expenses do. + */ +export function loadExpensePage(communeId: string, before: string | null, months?: number): Promise { + const query = new URLSearchParams({ ...(before ? { before } : {}), ...(months ? { months: String(months) } : {}) }).toString(); + return api(`/communes/${encodeURIComponent(communeId)}/expenses/history${query ? `?${query}` : ""}`); +} + +/** Named expenses of one commune, at most 50, for links to ones not loaded. */ +export function loadExpenseRecords(communeId: string, ids: string[]): Promise<{ expenses: Expense[] }> { + return api(`/communes/${encodeURIComponent(communeId)}/expenses/records?ids=${ids.map(encodeURIComponent).join(",")}`); } export class ApiError extends Error { diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 5419adf1..de2ae5c7 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -32,6 +32,8 @@ export type Commune = { destination?: string; stayName?: string; stayAddress?: string; + /** Moves whenever anything in the commune changes; compared with a snapshot's own. */ + changeMark?: string; /** In a snapshot: what the database says keeps the currency as it is (0035). */ currencyLockedBy?: CurrencyLock[]; }; @@ -283,7 +285,15 @@ export type Snapshot = { splitTemplates?: SplitTemplate[]; announcements?: Announcement[]; activityEvents?: Array<{id:string;actorId:string;kind:"funds"|"members"|"recurring"|"templates"|"notes"|"operations"|"expenses"|"announcements";entityId:string;title:string;description:string;createdAt:string}>; + /** The snapshot holds every expense created since `since`, and every open one. */ + expenseHistory?: ExpenseHistory; + changeMark?: string; + /** An overview summary leaves out what only the commune's own screens show. */ + detail?: "summary"; }; +export type ExpenseHistory = { since: string; olderCount: number; olderVoidedCount: number }; +/** Older expenses: those created from `from` up to `before`, and how many are older still. */ +export type ExpensePage = { expenses: Expense[]; before: string; from: string | null; olderCount: number; olderVoidedCount: number }; export type LiveExpenseRow = Omit & { amountMinor: number; paidByUserId: string; diff --git a/apps/web/src/live/neon.ts b/apps/web/src/live/neon.ts index f7b80c57..5cae7633 100644 --- a/apps/web/src/live/neon.ts +++ b/apps/web/src/live/neon.ts @@ -5,7 +5,7 @@ import { parseSearchResponse } from "./search"; import { ACCOUNT_CHANGED, currentViewer } from "./session"; import { readStored, removeStored } from "./storage"; import type { - Commune, CurrencyCode, ReminderUsage, Snapshot, User, + Commune, CurrencyCode, Expense, ExpensePage, ReminderUsage, Snapshot, User, } from "./model"; // This module is used only by production builds. The browser sees endpoint @@ -165,12 +165,12 @@ function idResult(value: unknown): { id: string } { if (typeof value !== "string" || !value) throw new ApiError("Database did not return an ID", 502); return { id: value }; } -function communal(row: Row, role: string, memberCount: number): Commune { +function communal(row: Row): Commune { return { id: str(row.id), name: str(row.name), kind: row.kind as Commune["kind"], - currency: row.currency as CurrencyCode, role: role as Commune["role"], memberCount, + currency: row.currency as CurrencyCode, role: row.role as Commune["role"], memberCount: minor(row.memberCount), address: str(row.address), destination: str(row.destination), - stayName: str(row.stay_name), stayAddress: str(row.stay_address), + stayName: str(row.stayName), stayAddress: str(row.stayAddress), changeMark: str(row.changeMark), }; } @@ -197,44 +197,63 @@ async function requireUser(): Promise { return user; } +// One call returns the caller's communes, their role and member count in +// each, and each commune's change mark, read together by the database. export async function listCommunes(knownViewer?: User): Promise { const viewer = await requireUser(); if (knownViewer && knownViewer.id !== viewer.id) throw new ApiError("Your account changed. Reload Commune.", 401); - const [communeResult, memberResult] = await Promise.all([ - db().from("communes").select("id,name,kind,currency,address,destination,stay_name,stay_address,created_at").order("created_at", { ascending: false }), - db().from("commune_members").select("commune_id,user_id,role").eq("active", true), - ]); - const communeData = unwrap(communeResult); - const memberData = unwrap(memberResult); - if (!Array.isArray(communeData) || !Array.isArray(memberData)) { - throw new ApiError("Could not verify your commune list. Please try again.", 502); - } - const communeRows = rows(communeData); - const memberRows = rows(memberData); - if (memberRows.some((member) => member.user_id === viewer.id && !communeRows.some((row) => row.id === member.commune_id))) { - throw new ApiError("Your commune membership changed while loading. Please try again.", 409); - } - return communeRows.map((row) => { - const group = memberRows.filter((member) => member.commune_id === row.id); - const membership = group.find((member) => member.user_id === viewer.id); - if (!membership || (membership.role !== "admin" && membership.role !== "member")) { + const data = unwrap(await db().rpc("my_communes")); + if (!Array.isArray(data)) throw new ApiError("Could not verify your commune list. Please try again.", 502); + return rows(data).map((row) => { + if (typeof row.id !== "string" || !row.id || (row.role !== "admin" && row.role !== "member") || + typeof row.changeMark !== "string") { throw new ApiError("Could not verify your commune membership. Please try again.", 409); } - return communal(row, str(membership.role), group.length); + return communal(row); }); } -export async function snapshot(communeId: string, knownCommune?: Commune): Promise { +export async function snapshot(communeId: string, knownCommune?: Commune, detail?: "summary"): Promise { await requireUser(); if (knownCommune && knownCommune.id !== communeId) throw new ApiError("Commune mismatch", 400); // The RPC re-checks membership against the verified JWT and reads one // consistent database snapshot. No browser-stored identity is authority. - const data = unwrap(await db().rpc("commune_snapshot", { p_commune_id: communeId })) as Snapshot; - if (!data || data.commune?.id !== communeId || !Array.isArray(data.members) || !Array.isArray(data.expenses) || !Array.isArray(data.funds)) { + const data = unwrap(await db().rpc(detail === "summary" ? "commune_summary" : "commune_snapshot", { p_commune_id: communeId })) as Snapshot; + if (!data || data.commune?.id !== communeId || !Array.isArray(data.members) || !Array.isArray(data.expenses) || !Array.isArray(data.funds) || + data.detail !== detail) { throw new ApiError("Shared records could not be verified. Please try again.", 502); } return data; } + +// Older expenses and named ones come in the snapshot's shape, so they are +// checked the same way. +function expenseList(value: unknown): { expenses: Expense[] } { + const item = asObject(value); + if (!Array.isArray(item.expenses) || item.expenses.some((expense) => typeof asObject(expense).id !== "string" || + !Array.isArray(asObject(expense).shares))) { + throw new ApiError("Shared records could not be verified. Please try again.", 502); + } + return item as { expenses: Expense[] }; +} +async function expensePage(communeId: string, params: URLSearchParams): Promise { + await requireUser(); + const months = params.has("months") ? Number(params.get("months")) : null; + if (months !== null && (!Number.isInteger(months) || months < 1 || months > 12)) { + throw new ApiError("Ask for 1 to 12 months of expenses at a time", 400); + } + const page = unwrap(await db().rpc("commune_expense_page", { p_commune_id: communeId, + p_before: params.get("before") || null, p_months: months })); + const item = asObject(page); + return { ...expenseList(item), before: str(item.before), from: typeof item.from === "string" ? item.from : null, + olderCount: minor(item.olderCount), olderVoidedCount: minor(item.olderVoidedCount) }; +} +async function expenseRecords(communeId: string, params: URLSearchParams): Promise<{ expenses: Expense[] }> { + await requireUser(); + const ids = (params.get("ids") ?? "").split(",").filter(Boolean); + if (ids.length < 1 || ids.length > 50) throw new ApiError("Ask for 1 to 50 expenses at a time", 400); + return expenseList(unwrap(await db().rpc("commune_expense_records", { p_commune_id: communeId, p_ids: ids }))); +} async function reminderUsage(): Promise { await requireUser(); const records = rows(unwrap(await db().rpc("reminder_usage"))); @@ -462,8 +481,12 @@ async function route(path: string, init: RequestInit): Promise { } else if (path === "/preferences" && method === "PUT") { if (typeof body.emailReminders !== "boolean") throw new ApiError("Choose whether to get reminder emails", 400); response = { emailReminders: unwrap(await db().rpc("set_email_reminders", { p_enabled: body.emailReminders })) === true }; - } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/snapshot$/)) { - response = await snapshot(pathId(path, /^\/communes\/([^/]+)\/snapshot$/)!); + } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/snapshot(?:\?detail=summary)?$/)) { + response = await snapshot(pathId(path, /^\/communes\/([^/]+)\/snapshot/)!, undefined, path.endsWith("?detail=summary") ? "summary" : undefined); + } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/expenses\/history(?:\?|$)/)) { + response = await expensePage(pathId(path, /^\/communes\/([^/]+)\/expenses\/history/)!, new URL(path, "https://commune.invalid").searchParams); + } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/expenses\/records\?/)) { + response = await expenseRecords(pathId(path, /^\/communes\/([^/]+)\/expenses\/records/)!, new URL(path, "https://commune.invalid").searchParams); } else if (method === "POST" && pathId(path, /^\/communes\/([^/]+)\/expenses$/)) { const shares = Array.isArray(body.shares) ? body.shares.map((share) => { const item = asObject(share); diff --git a/apps/web/tests/neon-auth.test.tsx b/apps/web/tests/neon-auth.test.tsx index c1e4d157..a569e093 100644 --- a/apps/web/tests/neon-auth.test.tsx +++ b/apps/web/tests/neon-auth.test.tsx @@ -14,40 +14,46 @@ beforeEach(() => { }); describe("hosted read authentication", () => { - function listResults(communes: unknown, members: unknown, error: unknown = null) { + // The list, each role and member count and each change mark come from one + // database function, read together. + const listed = { id: "commune-1", name: "Home", kind: "household", currency: "GBP", address: "", destination: "", + stayName: "", stayAddress: "", role: "admin", memberCount: 2, changeMark: "2026-10-09:3:41" }; + function listResult(data: unknown, error: unknown = null) { mocks.getSession.mockResolvedValue({ data: { user: { id: "viewer", emailVerified: true } } }); - mocks.from.mockImplementation((table: string) => { - const result = Promise.resolve({ data: table === "communes" ? communes : members, error }); - return { select: () => Object.assign(result, { order: () => result, eq: () => result }) }; - }); + mocks.rpc.mockResolvedValue({ data, error }); } it("reports missing database identity as authentication failure, not an empty list", async () => { - listResults(null, null, { code: "28000", message: "Request identity could not be verified" }); + listResult(null, { code: "28000", message: "Request identity could not be verified" }); const { listCommunes } = await import("../src/live/neon"); await expect(listCommunes()).rejects.toMatchObject({ status: 401 }); }); it("does not turn malformed database responses into No communes yet", async () => { - listResults(null, []); + listResult(null); const { listCommunes } = await import("../src/live/neon"); await expect(listCommunes()).rejects.toMatchObject({ status: 502 }); }); - it("rejects inconsistent membership reads instead of displaying an empty account", async () => { - listResults([], [{ commune_id: "commune-1", user_id: "viewer", role: "admin" }]); + it("reads communes, roles, member counts and change marks in one consistent call", async () => { + listResult([listed]); const { listCommunes } = await import("../src/live/neon"); - await expect(listCommunes()).rejects.toMatchObject({ status: 409 }); + expect(await listCommunes()).toEqual([{ id: "commune-1", name: "Home", kind: "household", currency: "GBP", role: "admin", + memberCount: 2, address: "", destination: "", stayName: "", stayAddress: "", changeMark: "2026-10-09:3:41" }]); + expect(mocks.rpc).toHaveBeenCalledExactlyOnceWith("my_communes"); + expect(mocks.from).not.toHaveBeenCalled(); }); - it("does not publish a commune without the viewer's membership", async () => { - listResults([{ id: "commune-1", name: "Home" }], []); + it("does not publish a commune without the viewer's membership or its change mark", async () => { const { listCommunes } = await import("../src/live/neon"); - await expect(listCommunes()).rejects.toMatchObject({ status: 409 }); + for (const entry of [{ id: "commune-1", name: "Home" }, { ...listed, role: "owner" }, { ...listed, changeMark: null }]) { + listResult([entry]); + await expect(listCommunes()).rejects.toMatchObject({ status: 409 }); + } }); it("still accepts a genuinely empty new account", async () => { - listResults([], []); + listResult([]); const { listCommunes } = await import("../src/live/neon"); expect(await listCommunes()).toEqual([]); }); @@ -68,6 +74,18 @@ describe("hosted read authentication", () => { expect(mocks.rpc).toHaveBeenCalledExactlyOnceWith("commune_snapshot",{p_commune_id:commune.id}); expect(mocks.from).not.toHaveBeenCalled(); }); + it("reads an overview summary through its own RPC and never takes one for the full record", async () => { + mocks.getSession.mockResolvedValue({ data: { user: { id: "viewer", emailVerified: true } } }); + const data = { commune, members: [], expenses: [], funds: [], reminderTargets: [], notifications: [], detail: "summary" }; + mocks.rpc.mockResolvedValue({ data, error: null }); + const { snapshot, neonApi } = await import("../src/live/neon"); + expect(await snapshot(commune.id, commune, "summary")).toEqual(data); + expect(mocks.rpc).toHaveBeenLastCalledWith("commune_summary", { p_commune_id: commune.id }); + expect(await neonApi(`/communes/${commune.id}/snapshot?detail=summary`)).toEqual(data); + expect(mocks.rpc).toHaveBeenLastCalledWith("commune_summary", { p_commune_id: commune.id }); + await expect(snapshot(commune.id, commune)).rejects.toMatchObject({ status: 502 }); + expect(mocks.rpc).toHaveBeenLastCalledWith("commune_snapshot", { p_commune_id: commune.id }); + }); it("rejects a mis-scoped snapshot instead of rendering another commune's records",async()=>{ mocks.getSession.mockResolvedValue({data:{user:{id:"viewer",emailVerified:true}}}); mocks.rpc.mockResolvedValue({data:{commune:{...commune,id:"other"},members:[],expenses:[],funds:[]},error:null}); @@ -80,7 +98,7 @@ describe("hosted read authentication", () => { const { listCommunes } = await import("../src/live/neon"); await expect(listCommunes({ id: "old-user", name: "Old", email: "old@example.test", emailVerified: true })) .rejects.toThrow("Your account changed"); - expect(mocks.from).not.toHaveBeenCalled(); + expect(mocks.rpc).not.toHaveBeenCalled(); }); it("checks a verified session before requesting reminder usage", async () => { diff --git a/apps/web/tests/neon-lifecycle-contract.test.tsx b/apps/web/tests/neon-lifecycle-contract.test.tsx index 05e989ac..755ff06e 100644 --- a/apps/web/tests/neon-lifecycle-contract.test.tsx +++ b/apps/web/tests/neon-lifecycle-contract.test.tsx @@ -134,6 +134,32 @@ describe("hosted money lifecycle and task commands", () => { ]); }); + it("pages older expenses and fetches named ones in the snapshot's shape", async () => { + const expense = { id: "expense-old", title: "Gas", amountMinor: 2000, paidByUserId: "viewer", dueOn: null, shares: [] }; + const page = { expenses: [expense], before: "2026-09-01", from: "2026-08-01", olderCount: 4, olderVoidedCount: 1 }; + mocks.rpc.mockResolvedValue({ data: page, error: null }); + expect(await send("/communes/home-1/expenses/history?before=2026-09-01&months=1", undefined, "GET")).toEqual(page); + expect(mocks.rpc).toHaveBeenLastCalledWith("commune_expense_page", { p_commune_id: "home-1", p_before: "2026-09-01", p_months: 1 }); + // Everything older, from where the snapshot's expenses start, for a statement or an export. + mocks.rpc.mockResolvedValue({ data: { ...page, expenses: [], from: null, olderCount: 0, olderVoidedCount: 0 }, error: null }); + expect((await send("/communes/home-1/expenses/history", undefined, "GET")).from).toBeNull(); + expect(mocks.rpc).toHaveBeenLastCalledWith("commune_expense_page", { p_commune_id: "home-1", p_before: null, p_months: null }); + mocks.rpc.mockResolvedValue({ data: { expenses: [expense] }, error: null }); + expect(await send("/communes/home-1/expenses/records?ids=expense-old,expense-gone", undefined, "GET")).toEqual({ expenses: [expense] }); + expect(mocks.rpc).toHaveBeenLastCalledWith("commune_expense_records", { p_commune_id: "home-1", p_ids: ["expense-old", "expense-gone"] }); + // A refusal stays a refusal, and an expense in another shape is not shown. + mocks.rpc.mockResolvedValue({ data: null, error: { code: "42501", message: "Not a commune member" } }); + await expect(send("/communes/home-1/expenses/records?ids=expense-old", undefined, "GET")).rejects.toMatchObject({ status: 403 }); + mocks.rpc.mockResolvedValue({ data: { ...page, expenses: [{ id: "expense-old" }] }, error: null }); + await expect(send("/communes/home-1/expenses/history?months=1", undefined, "GET")).rejects.toMatchObject({ status: 502 }); + const calls = mocks.rpc.mock.calls.length; + await expect(send("/communes/home-1/expenses/history?months=13", undefined, "GET")).rejects.toMatchObject({ status: 400 }); + await expect(send(`/communes/home-1/expenses/records?ids=${Array.from({ length: 51 }, (_, index) => `e${index}`).join(",")}`, undefined, "GET")) + .rejects.toMatchObject({ status: 400 }); + expect(mocks.rpc).toHaveBeenCalledTimes(calls); + expect(mocks.from).not.toHaveBeenCalled(); + }); + it("completes and undoes a task through occurrence functions, not a row overwrite", async () => { mocks.rpc.mockResolvedValue({ data: { id: "occurrence-1", duplicate: false, date: "2026-10-09", assignee: "Blake", status: "open" }, error: null }); const done = await send("/operations/task-1/occurrences", { action: "completed", expectedDate: "2026-10-02", today: "2026-10-02", requestId }); From 2040a9f1a5fe1adff19bb5c57046378a341ea969 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:57:51 +0000 Subject: [PATCH 04/11] Check for changes instead of reloading every commune each minute The minute timer used to ask who was signed in, then reload the commune list, the reminder counts, every admin commune's invitations and the open commune's whole record, and on the overview screens every other commune's too. Now each check reads the commune list alone and compares each commune's change mark with the one it holds. A commune whose mark moved is read again, with its invitations; the reminder counts are read when any mark moved or a reminder slot has opened. Joins, departures and role changes come from the list. The overview screens read other communes as summaries; a commune's own screens wait for its whole record. The check runs only while the tab is visible and someone has used it in the last ten minutes, and starts again with the next use. Who is signed in is asked at start-up and when the tab is shown or focused again; a refused session still signs the tab out. Screens can only ask for a full refresh. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/state.tsx | 203 +++++++++++++-------- apps/web/tests/lighter-loading.test.tsx | 231 ++++++++++++++++++++++++ 2 files changed, 357 insertions(+), 77 deletions(-) create mode 100644 apps/web/tests/lighter-loading.test.tsx diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 8a85e609..dfdcf6c3 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -515,23 +515,28 @@ export function AppProvider({ children }: { children: ReactNode }) { // it was not saved if the tab is then signed out. const refusedAction = useRef(false); const lastRefreshAt = useRef(0); + // Someone used this tab (pointer, keys, touch). The minute check stops + // after ten quiet minutes and starts again with the next use. + const lastActivityAt = useRef(Date.now()); const snapshotReads = useRef(new Map>()); - const snapshotTimes = useRef(new Map()); const snapshotRevisions = useRef(new Map()); const overview = screen === "home" || screen === "home-empty" || screen === "home-inbox" || screen === "communes" || screen === "home-activity"; const viewRef = useRef({ hubId, overview }); viewRef.current = { hubId, overview }; const native = isNativeNow(); - const readSnapshot = useCallback((commune: Commune, sequence: number, force = false): Promise => { + const readSnapshot = useCallback((commune: Commune, sequence: number, force = false, detail?: "summary"): Promise => { const current = dataRef.current; if (!current || sequence !== refreshSequence.current) return Promise.resolve(); const cacheKey = `${current.user.id}:${commune.id}`; const revision = snapshotRevisions.current.get(cacheKey) ?? 0; - const key = `${sequence}:${commune.id}:${revision}`; + const key = `${sequence}:${commune.id}:${revision}:${detail ?? "full"}`; const pending = snapshotReads.current.get(key); if (pending) return pending; - if (!force && current.snapshots[commune.id] && Date.now() - (snapshotTimes.current.get(cacheKey) ?? 0) < 15_000) { + // The list's change mark says whether anything changed since the copy held + // here was read. A summary will do only where one was asked for. + const held = current.snapshots[commune.id]; + if (!force && held && held.changeMark === commune.changeMark && (detail === "summary" || held.detail !== "summary")) { return Promise.resolve(); } setSnapshotErrors((errors) => { @@ -539,10 +544,11 @@ export function AppProvider({ children }: { children: ReactNode }) { delete next[commune.id]; return next; }); - const request = loadSnapshot(commune).then((snapshot) => { + const request = (detail ? loadSnapshot(commune, detail) : loadSnapshot(commune)).then((snapshot) => { if (sequence !== refreshSequence.current || dataRef.current?.user.id !== current.user.id || revision !== (snapshotRevisions.current.get(cacheKey) ?? 0)) return; if (snapshot.commune.id !== commune.id) throw new ApiError("Commune response did not match the request", 502); - snapshotTimes.current.set(cacheKey, Date.now()); + // A summary never replaces the open commune's record; its full read is under way. + if (snapshot.detail === "summary" && viewRef.current.hubId === commune.id && dataRef.current.snapshots[commune.id]) return; publishData({ ...dataRef.current, snapshots: { ...dataRef.current.snapshots, [commune.id]: snapshot } }); }).catch((error: unknown) => { if (sequence !== refreshSequence.current || dataRef.current?.user.id !== current.user.id || revision !== (snapshotRevisions.current.get(cacheKey) ?? 0)) return; @@ -552,7 +558,6 @@ export function AppProvider({ children }: { children: ReactNode }) { if (error instanceof ApiError && [401, 403, 404].includes(error.status)) { const snapshots = { ...dataRef.current.snapshots }; delete snapshots[commune.id]; - snapshotTimes.current.delete(cacheKey); publishData({ ...dataRef.current, snapshots }); } }).finally(() => snapshotReads.current.delete(key)); @@ -569,11 +574,11 @@ export function AppProvider({ children }: { children: ReactNode }) { if (!active) return; await readSnapshot(active, sequence, force); // Overview cards appear immediately from verified membership metadata. - // Fill the remaining cards progressively instead of bursting N full - // commune reads into the selected page's critical path. + // Fill the remaining cards progressively, from summaries, instead of + // bursting N full commune reads into the selected page's critical path. for (const commune of current.communes) { if (sequence !== refreshSequence.current || !viewRef.current.overview) return; - if (commune.id !== active.id) await readSnapshot(commune, sequence, force); + if (commune.id !== active.id) await readSnapshot(commune, sequence, force, "summary"); } }, [readSnapshot]); @@ -587,7 +592,6 @@ export function AppProvider({ children }: { children: ReactNode }) { publishData(null); setViewer(null); setSnapshotErrors({}); - snapshotTimes.current.clear(); snapshotRevisions.current.clear(); setHubId(""); setWindowId(null); @@ -598,9 +602,14 @@ export function AppProvider({ children }: { children: ReactNode }) { loadedRef.current = true; }, [publishData]); + // A full refresh (start-up, a sign-in, Try again, a membership change made + // here) asks who is signed in and reads everything on screen again. A check + // reads the commune list, whose change marks say which communes changed, and + // reads only those again, with their invitations and the reminder counts. + // Coming back to the tab, a check also asks who is signed in. // announce: whether a sign-in this finds may be told to the other tabs. // Only a load this tab started itself may tell them (see the listener). - const load = useCallback(async (announce: boolean) => { + const load = useCallback(async (announce: boolean, check?: { session: boolean }) => { const sequence = ++refreshSequence.current; const shownBefore = currentViewer(); refreshing.current = true; @@ -618,76 +627,95 @@ export function AppProvider({ children }: { children: ReactNode }) { } } try { - const result = await startupRead(() => api<{ user: User | null }>("/auth/me")); - if (sequence !== refreshSequence.current) return; - const shown = currentViewer(); - if (!result.user || (shown && shown !== result.user.id)) { - // Signed out, or signed in as someone else in another tab: never - // switch this screen to another person's data without a sign-in. - forgetSession(shown ? endedNotice(refusedAction.current) : null); + let user = check && !check.session ? dataRef.current?.user ?? null : null; + if (!user) { + const result = await startupRead(() => api<{ user: User | null }>("/auth/me")); + if (sequence !== refreshSequence.current) return; + const shown = currentViewer(); + if (!result.user || (shown && shown !== result.user.id)) { + // Signed out, or signed in as someone else in another tab: never + // switch this screen to another person's data without a sign-in. + forgetSession(shown ? endedNotice(refusedAction.current) : null); + refusedAction.current = false; + setApiError(null); + return; + } refusedAction.current = false; - setApiError(null); - return; + setViewer(result.user.id); + // A tab that showed no one now shows this account: a sign-in, however + // it happened (password, code, or the return from Google), or a new + // tab opening. Tell the other tabs. + if (announce && !shown) announceSession(result.user.id); + setSignOutUnconfirmed(false); + setSessionNotice(null); + user = result.user; } - refusedAction.current = false; - setViewer(result.user.id); - // A tab that showed no one now shows this account: a sign-in, however - // it happened (password, code, or the return from Google), or a new - // tab opening. Tell the other tabs. - if (announce && !shown) announceSession(result.user.id); - setSignOutUnconfirmed(false); - setSessionNotice(null); - const { communes } = await startupRead(() => loadCommunes(result.user!)); + const viewer = user; + const { communes } = await startupRead(() => loadCommunes(viewer)); if (sequence !== refreshSequence.current) return; - const previous = dataRef.current?.user.id === result.user.id ? dataRef.current : null; + const previous = dataRef.current?.user.id === viewer.id ? dataRef.current : null; if (!previous) { setSnapshotErrors({}); - snapshotTimes.current.clear(); snapshotRevisions.current.clear(); } + // A commune changed if its mark moved, your role in it changed or you just joined it. + const seen = new Map((previous?.communes ?? []).map((commune) => [commune.id, commune])); + const changed = communes.filter((commune) => seen.get(commune.id)?.changeMark !== commune.changeMark || + seen.get(commune.id)?.role !== commune.role); + const admins = communes.filter((commune) => commune.role === "admin"); + const invites = Object.fromEntries(Object.entries(previous?.invites ?? {}) + .filter(([id]) => admins.some((commune) => commune.id === id))); // Identity and membership are ready. Keep existing content on refresh; // missing snapshots render a loading state, never a false empty ledger. publishData({ - user: result.user, communes, + user: viewer, communes, snapshots: Object.fromEntries(communes.filter((commune) => previous?.snapshots[commune.id]) .map((commune) => [commune.id, previous!.snapshots[commune.id]])), - invites: previous?.invites ?? {}, reminderUsage: previous?.reminderUsage ?? null, + invites, reminderUsage: previous?.reminderUsage ?? null, }); setAuthStatus("authenticated"); setHubId((current) => communes.some((commune) => commune.id === current) - ? current : communes.find((commune) => commune.id === readPrefs(result.user!.id).activeHubId)?.id ?? communes[0]?.id ?? ""); - if (!previous) setPrefs(readPrefs(result.user.id)); + ? current : communes.find((commune) => commune.id === readPrefs(viewer.id).activeHubId)?.id ?? communes[0]?.id ?? ""); + if (!previous) setPrefs(readPrefs(viewer.id)); loadedRef.current = true; - // Counts and invitations must not delay the commune itself. - void ( - // A pending database rollout must not make the rest of the app unusable. - // The send RPC still enforces limits even if this display is unavailable. - api("/reminders/usage").catch((error: unknown) => { - console.warn("Reminder usage could not be loaded", error instanceof Error ? error.message : "unknown error"); - return null; - }) - ).then((reminderUsage) => { - if (sequence !== refreshSequence.current || !dataRef.current) return; - publishData({ ...dataRef.current, reminderUsage }); - }); - void Promise.all(communes.filter((commune) => commune.role === "admin") - .map(async (commune) => { - const result = await api<{ invites: InviteRecord[] }>(`/communes/${encodeURIComponent(commune.id)}/invites`); - return [commune.id, result.invites.map((invite) => ({ - id: invite.id, email: invite.email ?? null, - role: invite.role === "admin" ? "Admin" as const : "Member" as const, - status: inviteStatus(invite), createdAt: invite.createdAt, expiresAt: invite.expiresAt, - acceptedAt: invite.acceptedAt, revokedAt: invite.revokedAt, - invitedBy: invite.invitedBy ?? null, invitedByName: invite.invitedByName ?? null, - acceptedBy: invite.acceptedBy ?? null, - }))] as const; - })).then((inviteEntries) => { - if (sequence !== refreshSequence.current || !dataRef.current) return; - publishData({ ...dataRef.current, invites: Object.fromEntries(inviteEntries) }); - }).catch((error: unknown) => { - if (sequence === refreshSequence.current) setApiError(error instanceof Error ? error.message : "Could not refresh invitations"); - }); - await loadVisibleSnapshots(sequence, true); + // Counts and invitations must not delay the commune itself. A check + // reads the counts when something changed or a reminder slot has opened. + const usage = previous?.reminderUsage; + if (!check || changed.length || communes.length !== seen.size || !usage || + (usage.nextSlotAt && Date.parse(usage.nextSlotAt) <= Date.now())) { + void ( + // A pending database rollout must not make the rest of the app unusable. + // The send RPC still enforces limits even if this display is unavailable. + api("/reminders/usage").catch((error: unknown) => { + console.warn("Reminder usage could not be loaded", error instanceof Error ? error.message : "unknown error"); + return null; + }) + ).then((reminderUsage) => { + if (sequence !== refreshSequence.current || !dataRef.current) return; + publishData({ ...dataRef.current, reminderUsage }); + }); + } + // An invitation sent, accepted or withdrawn moves its commune's mark. + const inviteReads = admins.filter((commune) => !check || changed.includes(commune) || !invites[commune.id]); + if (inviteReads.length) { + void Promise.all(inviteReads.map(async (commune) => { + const result = await api<{ invites: InviteRecord[] }>(`/communes/${encodeURIComponent(commune.id)}/invites`); + return [commune.id, result.invites.map((invite) => ({ + id: invite.id, email: invite.email ?? null, + role: invite.role === "admin" ? "Admin" as const : "Member" as const, + status: inviteStatus(invite), createdAt: invite.createdAt, expiresAt: invite.expiresAt, + acceptedAt: invite.acceptedAt, revokedAt: invite.revokedAt, + invitedBy: invite.invitedBy ?? null, invitedByName: invite.invitedByName ?? null, + acceptedBy: invite.acceptedBy ?? null, + }))] as const; + })).then((inviteEntries) => { + if (sequence !== refreshSequence.current || !dataRef.current) return; + publishData({ ...dataRef.current, invites: { ...dataRef.current.invites, ...Object.fromEntries(inviteEntries) } }); + }).catch((error: unknown) => { + if (sequence === refreshSequence.current) setApiError(error instanceof Error ? error.message : "Could not refresh invitations"); + }); + } + await loadVisibleSnapshots(sequence, !check); if (sequence !== refreshSequence.current) return; lastRefreshAt.current = Date.now(); } catch (error) { @@ -707,7 +735,7 @@ export function AppProvider({ children }: { children: ReactNode }) { if (sequence === refreshSequence.current) refreshing.current = false; } }, [forgetSession, loadVisibleSnapshots, publishData]); - const refresh = useCallback(() => load(true), [load]); + const refresh = useCallback((check?: { session: boolean }) => load(true, check), [load]); // Another tab signed in or out. A tab showing an account that is no longer // the signed-in one forgets it (its screens then ask for a sign-in); a @@ -729,21 +757,41 @@ export function AppProvider({ children }: { children: ReactNode }) { if (authStatus === "authenticated") void loadVisibleSnapshots(refreshSequence.current); }, [authStatus, hubId, overview, loadVisibleSnapshots]); + // Once a minute, while the tab is visible and someone has used it in the + // last ten minutes, check the commune list for changes. Coming back to the + // tab checks at once and asks who is signed in; using it again after a + // quiet spell checks at once. useEffect(() => { if (authStatus !== "authenticated") return; + const quiet = () => Date.now() - lastActivityAt.current >= 10 * 60_000; + const due = (gap: number) => !refreshing.current && Date.now() - lastRefreshAt.current > gap; const onFocus = () => { - if (document.visibilityState === "visible" && !refreshing.current && Date.now() - lastRefreshAt.current > 15_000) void refresh(); + if (document.visibilityState !== "visible") return; + lastActivityAt.current = Date.now(); + if (due(15_000)) void refresh({ session: true }); + }; + const onActivity = () => { + const returning = quiet(); + lastActivityAt.current = Date.now(); + if (returning && document.visibilityState === "visible" && due(60_000)) void refresh({ session: false }); + }; + const onMinute = () => { + if (document.visibilityState === "visible" && !quiet() && due(15_000)) void refresh({ session: false }); }; - const onVisible = onFocus; + const activity = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"]; window.addEventListener("focus", onFocus); - document.addEventListener("visibilitychange", onVisible); - const timer = window.setInterval(onFocus, 60_000); + document.addEventListener("visibilitychange", onFocus); + for (const name of activity) window.addEventListener(name, onActivity, { capture: true, passive: true }); + const timer = window.setInterval(onMinute, 60_000); return () => { window.removeEventListener("focus", onFocus); - document.removeEventListener("visibilitychange", onVisible); + document.removeEventListener("visibilitychange", onFocus); + for (const name of activity) window.removeEventListener(name, onActivity, { capture: true }); window.clearInterval(timer); }; }, [authStatus, refresh]); + // Screens only ever ask for a full refresh, even when they pass refresh as a handler. + const fullRefresh = useCallback(() => refresh(), [refresh]); const mutate = useCallback(async (action: () => Promise): Promise => { setMutationCount(count => count + 1); @@ -848,7 +896,9 @@ export function AppProvider({ children }: { children: ReactNode }) { }) : []; const hub = places.find((place) => place.id === hubId) ?? places[0] ?? emptyHub(); const activeId = hub.id; - const snapshot = data?.snapshots[activeId]; + const held = data?.snapshots[activeId]; + // The open commune's own screens never show a summary; they wait for its record. + const snapshot = held?.detail === "summary" ? undefined : held; const contentLoading = !!data?.communes.length && !overview && !snapshot; const contentError = snapshotErrors[activeId] ?? null; const allExpenses = snapshot && data ? expenseRows(snapshot, data.user.id) : []; @@ -905,7 +955,6 @@ export function AppProvider({ children }: { children: ReactNode }) { const latest = dataRef.current; if (!stillCurrent() || !latest || !latest.communes.some(item => item.id === activeId)) return; if (snapshot.commune.id !== activeId) throw new ApiError("Commune response did not match the request", 502); - snapshotTimes.current.set(cacheKey, Date.now()); publishData({ ...latest, communes: latest.communes.map(item => item.id === activeId ? { ...item, ...snapshot.commune } : item), snapshots: { ...latest.snapshots, [activeId]: snapshot } }); setSnapshotErrors(errors => { const next = { ...errors }; delete next[activeId]; return next; }); } catch (error) { @@ -915,7 +964,6 @@ export function AppProvider({ children }: { children: ReactNode }) { if (error instanceof ApiError && [401, 403, 404].includes(error.status)) { const snapshots = { ...latest.snapshots }; delete snapshots[activeId]; - snapshotTimes.current.delete(cacheKey); publishData({ ...latest, snapshots }); } // The write already committed. Do not invite a duplicate submission when @@ -968,7 +1016,8 @@ export function AppProvider({ children }: { children: ReactNode }) { }; const latest = dataRef.current; if (!latest || latest.user.id !== current.user.id) return result.id; - snapshotTimes.current.set(`${current.user.id}:${result.id}`, Date.now()); + // Neither this nor its list entry has a change mark, so it is not read + // again until the next check lists the commune with one. publishData({ ...latest, communes: [commune, ...latest.communes], snapshots: { ...latest.snapshots, [result.id]: { commune, members: [{ userId: current.user.id, name: current.user.name, email: current.user.email, role: "admin" }], @@ -1397,7 +1446,7 @@ export function AppProvider({ children }: { children: ReactNode }) { : PAGE_LEDES[screen] ?? ""; return 0, clearApiError: () => setApiError(null), refresh, + authStatus, apiError, mutationPending: mutationCount > 0, clearApiError: () => setApiError(null), refresh: fullRefresh, signInWithPassword, signInWithGoogle, signUpWithPassword, verifyEmailCode, session, screen, hubId: activeId, hub, places, snapshots: data?.snapshots ?? {}, contentLoading, contentError, emptyHub: empty, window: windowId, moreOpen, navMode: prefs.navMode, noteKey, notes, operationKey, operationDate, diff --git a/apps/web/tests/lighter-loading.test.tsx b/apps/web/tests/lighter-loading.test.tsx new file mode 100644 index 00000000..dad853e8 --- /dev/null +++ b/apps/web/tests/lighter-loading.test.tsx @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { Router } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; +import { App } from "../src/App"; +import { AppProvider, useApp, type AppState } from "../src/state"; +import { api, post, loadCommunes, loadSnapshot, ApiError } from "../src/live/api"; +import type { Commune, Expense, Snapshot } from "../src/live/model"; + +vi.mock("../src/live/api", async (importOriginal) => ({ + ...await importOriginal(), + api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(), +})); + +const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true }; +const housemate = { userId: "user-2", name: "Housemate", role: "member" as const, active: true }; +const home: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin", changeMark: "2026-10-08:3:30" }; +const trip: Commune = { id: "trip-1", name: "Lisbon trip", kind: "trip", currency: "EUR", role: "member", changeMark: "2026-10-08:2:12" }; + +// Months are counted in UTC, as the shared records count them. +const today = new Date(); +function monthStart(back: number) { + return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - back, 1)).toISOString().slice(0, 10); +} +/** The snapshot holds every expense created from the start of last month, and every open one. */ +const since = monthStart(1); +function expense(id: string, title: string, back: number, kind: "settled" | "open" | "voided" = "settled"): Expense { + const at = `${monthStart(back)}T12:00:00.000Z`; + return { + id, title, category: "Other", amountMinor: 2000, paidByUserId: user.id, dueOn: null, createdAt: at, version: 1, + voidedAt: kind === "voided" ? at : null, voidReason: kind === "voided" ? "Entered twice" : null, + shares: [ + { id: `${id}-own`, userId: user.id, amountMinor: 1000, settledMinor: 1000 }, + { id: `${id}-share`, userId: housemate.userId, amountMinor: 1000, settledMinor: kind === "open" ? 0 : 1000 }, + ], + }; +} +function record(commune: Commune, expenses: Expense[] = [], history = { olderCount: 0, olderVoidedCount: 0 }): Snapshot { + const { changeMark, ...identity } = commune; + return { + commune: identity, members: [{ userId: user.id, name: user.name, role: commune.role, active: true }, housemate], + expenses, funds: [], notes: [], operations: [], notifications: [], reminderTargets: [], sentReminders: [], + recurringBills: [], splitTemplates: [], activityEvents: [], expenseHistory: { since, ...history }, changeMark, + }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} + +let listed: Commune[]; +let records: Record; +let app: AppState; +function Capture() { app = useApp(); return null; } +function mount(path: string) { + const location = memoryLocation({ path, record: true }); + render(); + return location; +} +const calls = (path: string) => vi.mocked(api).mock.calls.filter(([called]) => called === path).length; +const reads = (id: string, detail?: "summary") => + vi.mocked(loadSnapshot).mock.calls.filter(([commune, asked]) => commune.id === id && asked === detail).length; +// Lets every request the app has started finish: the mocks answer at once. +const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); + +beforeEach(() => { + vi.resetAllMocks(); + sessionStorage.clear(); + listed = [home]; + records = { [home.id]: record(home) }; + vi.mocked(api).mockImplementation(async (path) => { + if (path === "/auth/me") return { user }; + if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt: null }; + if (path.endsWith("/invites")) return { invites: [] }; + throw new Error(`Unexpected API request ${path}`); + }); + vi.mocked(post).mockResolvedValue({ ok: true }); + vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: listed.map((commune) => ({ ...commune })) })); + vi.mocked(loadSnapshot).mockImplementation(async (commune, detail) => + detail ? { ...records[commune.id], detail } : records[commune.id]); +}); +afterEach(cleanup); + +describe("checking for changes", () => { + let visibility: DocumentVisibilityState; + beforeEach(() => { + const now = Date.now(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(now); + visibility = "visible"; + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility }); + vi.spyOn(window, "setInterval"); + }); + afterEach(() => { + vi.useRealTimers(); + delete (document as unknown as Record).visibilityState; + }); + // Minutes pass: the clock moves on and the app's minute timer runs each time. + async function minutesPass(count = 1) { + for (let minute = 0; minute < count; minute += 1) { + vi.advanceTimersByTime(60_000); + const timer = vi.mocked(window.setInterval).mock.calls.filter(([, delay]) => delay === 60_000).at(-1)?.[0]; + act(() => (timer as () => void)()); + await settle(); + } + } + async function started(path = "/hub") { + mount(path); + await waitFor(() => expect(calls("/reminders/usage")).toBe(1)); + await settle(); + } + + it("reads only the commune list while nothing has changed", async () => { + await started(); + expect([calls("/auth/me"), calls(`/communes/${home.id}/invites`), reads(home.id)]).toEqual([1, 1, 1]); + await minutesPass(3); + expect(loadCommunes).toHaveBeenCalledTimes(4); + expect([calls("/auth/me"), calls("/reminders/usage"), calls(`/communes/${home.id}/invites`), reads(home.id)]) + .toEqual([1, 1, 1, 1]); + }); + + it("reads the open commune again when its mark moves, with its invitations and the reminder counts", async () => { + await started(); + listed = [{ ...home, changeMark: "2026-10-09:4:31" }]; + records[home.id] = record(listed[0], [expense("expense-new", "Window cleaner", 0)]); + await minutesPass(); + expect(await screen.findByText("Window cleaner")).toBeTruthy(); + expect([reads(home.id), calls("/reminders/usage"), calls(`/communes/${home.id}/invites`), calls("/auth/me")]) + .toEqual([2, 2, 2, 1]); + await minutesPass(); + expect([reads(home.id), calls("/reminders/usage"), calls(`/communes/${home.id}/invites`)]).toEqual([2, 2, 2]); + }); + + it("reads other communes as summaries on overview screens, and in full once one is opened", async () => { + listed = [home, trip]; + records[trip.id] = record(trip); + await started("/places"); + await waitFor(() => expect(reads(trip.id, "summary")).toBe(1)); + expect([reads(home.id), reads(home.id, "summary"), reads(trip.id)]).toEqual([1, 0, 0]); + // A trip member does not manage its invitations. + expect(calls(`/communes/${trip.id}/invites`)).toBe(0); + + listed = [home, { ...trip, changeMark: "2026-10-09:3:44" }]; + records[trip.id] = record(listed[1]); + await minutesPass(); + expect([reads(home.id), reads(trip.id, "summary"), reads(trip.id)]).toEqual([1, 2, 0]); + + // Its own screens never show the summary: they wait for the whole record. + const full = deferred(); + vi.mocked(loadSnapshot).mockImplementationOnce(() => full.promise); + act(() => app.go("hub", { hub: trip.id })); + expect(await screen.findByRole("status", { name: "Loading shared records" })).toBeTruthy(); + expect(reads(trip.id)).toBe(1); + await act(async () => full.resolve(records[trip.id])); + await waitFor(() => expect(screen.queryByRole("status", { name: "Loading shared records" })).toBeNull()); + expect(app.snapshots[trip.id].detail).toBeUndefined(); + }); + + it("follows a commune joined or left from the list alone", async () => { + await started(); + listed = [home, trip]; + records[trip.id] = record(trip); + await minutesPass(); + expect(app.places.map((place) => place.id)).toEqual([home.id, trip.id]); + // Not on screen, so not read until it is opened. + expect([reads(trip.id), reads(trip.id, "summary"), calls("/reminders/usage")]).toEqual([0, 0, 2]); + + listed = [trip]; + await minutesPass(); + await waitFor(() => expect(app.hubId).toBe(trip.id)); + expect(reads(trip.id)).toBe(1); + expect(screen.queryByText(home.name)).toBeNull(); + }); + + it("stops checking in a tab nobody has used for ten minutes, and checks again once it is used", async () => { + await started(); + await minutesPass(60); + // Nine checks while the start counts as recent use, then none. + expect(loadCommunes).toHaveBeenCalledTimes(10); + expect([calls("/auth/me"), reads(home.id)]).toEqual([1, 1]); + fireEvent.pointerMove(document.body); + await settle(); + expect(loadCommunes).toHaveBeenCalledTimes(11); + await minutesPass(); + expect(loadCommunes).toHaveBeenCalledTimes(12); + expect(calls("/auth/me")).toBe(1); + }); + + it("does not check while the tab is hidden, and asks who is signed in when it is shown again", async () => { + await started(); + visibility = "hidden"; + act(() => { document.dispatchEvent(new Event("visibilitychange")); }); + await minutesPass(5); + expect(loadCommunes).toHaveBeenCalledTimes(1); + visibility = "visible"; + act(() => { document.dispatchEvent(new Event("visibilitychange")); }); + await settle(); + expect([calls("/auth/me"), vi.mocked(loadCommunes).mock.calls.length, reads(home.id)]).toEqual([2, 2, 1]); + // Focus straight after does not ask again. + fireEvent.focus(window); + await settle(); + expect(calls("/auth/me")).toBe(2); + }); + + it("signs the tab out when a check finds the session has ended", async () => { + await started(); + vi.mocked(loadCommunes).mockRejectedValue(new ApiError("Sign in required", 401)); + await minutesPass(); + await screen.findByRole("heading", { name: "Welcome back" }); + expect(screen.getByRole("status").textContent).toBe("You were signed out. Sign in to carry on."); + expect(screen.queryByText(home.name)).toBeNull(); + }); + + it("ignores a check that answers after a newer refresh", async () => { + await started(); + const late = deferred<{ communes: Commune[] }>(); + vi.mocked(loadCommunes).mockImplementationOnce(() => late.promise); + await minutesPass(); + listed = [{ ...home, changeMark: "2026-10-09:4:31" }]; + records[home.id] = record(listed[0], [expense("expense-new", "Window cleaner", 0)]); + await act(async () => app.refresh()); + expect(await screen.findByText("Window cleaner")).toBeTruthy(); + // The older answer would have taken the commune away. + await act(async () => late.resolve({ communes: [] })); + await settle(); + expect(screen.getByRole("heading", { name: home.name })).toBeTruthy(); + expect(screen.getByText("Window cleaner")).toBeTruthy(); + }); +}); + From e39801bc6bada6bcaf38a667c0f26946f65663bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:58:31 +0000 Subject: [PATCH 05/11] Load older expenses when someone asks for them The snapshot now holds open expenses and those created or changed since the start of last month. Older ones are fetched into a store per commune that survives the snapshot being read again; the snapshot's copy of an expense wins, and the store is dropped when a new month moves the window. The Expenses screen shows open expenses, then settled ones from this month, or from last month while none has been settled this month. Show earlier adds the latest earlier month that has one, fetching as it passes what is loaded, and says plainly which months are not shown yet. Voided expenses page on their own. A link to an expense older than what is loaded fetches that one expense behind the existing loading state; a refusal shows the existing not-found line and a failed connection offers Try again. The statement and both export files load every older expense first, with a short loading line. Counts and empty states include the expenses left on the server. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/live/model.ts | 4 +- apps/web/src/screens/Money.tsx | 45 ++++- apps/web/src/screens/Settings.tsx | 30 ++- apps/web/src/state.tsx | 202 ++++++++++++++++++- apps/web/src/windows/Windows.tsx | 25 ++- apps/web/tests/lighter-loading.test.tsx | 245 +++++++++++++++++++++++- apps/web/tests/money-lifecycle.test.tsx | 7 +- 7 files changed, 527 insertions(+), 31 deletions(-) diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index de2ae5c7..7ca598ca 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -401,6 +401,8 @@ export function hubFromSnapshot( // Voided expenses stay in the ledger as history but never count towards a // balance, a total or an outstanding share anywhere on the hub. const snapshot: Snapshot = { ...source, expenses: source.expenses.filter((expense) => !expense.voidedAt) }; + // Older settled expenses stay on the server until asked for; they still count as logged. + const olderLogged = (source.expenseHistory?.olderCount ?? 0) - (source.expenseHistory?.olderVoidedCount ?? 0); const { commune } = snapshot; const currency = commune.currency; const names = new Map(snapshot.members.map((member) => [member.userId, member.name])); @@ -555,7 +557,7 @@ export function hubFromSnapshot( }))].sort((a,b)=>(Date.parse(b.createdAt ?? "")||0)-(Date.parse(a.createdAt ?? "")||0)), notes, jumps: [ - { go: "expenses", kicker: "Expenses", title: `${snapshot.expenses.length} logged`, hint: `${openRows.length} open` }, + { go: "expenses", kicker: "Expenses", title: `${snapshot.expenses.length + olderLogged} logged`, hint: `${openRows.length} open` }, { go: "settle", kicker: "Settle", title: moves.length ? oweLabel : "You’re even", hint: moves.length ? money(moneyFocus, currency) : "No outstanding shares involving you" }, { go: "funds", kicker: "Funds", title: `${snapshot.funds.length} funds`, hint: `${money(fundTotal, currency)} recorded in` }, { go: "notes", kicker: "Notes", title: `${notes.length} notes`, hint: "Shared information" }, diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx index e46e2b33..d0572260 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -18,14 +18,44 @@ function formatMoney(minor: number, currency: string): string { }).format(minor / 100); } +// Months start at midnight UTC, as the shared records count them. +function monthName(month: string): string { + const date = new Date(`${month}T00:00:00Z`); + return new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC", + ...(date.getUTCFullYear() === new Date().getUTCFullYear() ? {} : { year: "numeric" }) }).format(date); +} + export function ExpensesScreen() { - const { go, expenses, voidedExpenses, session, hub } = useApp(); + const { go, expenses, voidedExpenses, session, hub, expenseHistory, loadEarlierExpenses } = useApp(); const [searchQuery, setSearchQuery] = useState(""); const [filter, setFilter] = useState<"All" | "Open" | "Settled" | "Voided">("All"); + // Open expenses always show. Settled and voided ones show from the start of + // the latest month (UTC) with a settled one loaded, or of this month, and + // Show earlier reaches back a month at a time. + const [shownFrom, setShownFrom] = useState(() => { + const newest = Math.max(...expenses.filter((row) => !row.open).map((row) => Date.parse(row.createdAt ?? "")).filter(Number.isFinite)); + return `${new Date(Number.isFinite(newest) ? newest : Date.now()).toISOString().slice(0, 7)}-01`; + }); + const [earlier, setEarlier] = useState<"" | "loading" | "error">(""); + const shown = (row: (typeof expenses)[number]) => + row.open || !row.createdAt || Date.parse(row.createdAt) >= Date.parse(`${shownFrom}T00:00:00Z`); const needle = searchQuery.trim().toLocaleLowerCase(); // Voided expenses are history only: never in All, Open, Settled or a total. - const showVoided = filter === "Voided" && voidedExpenses.length > 0; - const rows = (showVoided ? voidedExpenses : expenses) + const showVoided = filter === "Voided"; + const pool = showVoided ? voidedExpenses : expenses; + const more = filter !== "Open" && (pool.some((row) => !shown(row)) || + (showVoided ? expenseHistory.olderVoidedCount : expenseHistory.olderCount - expenseHistory.olderVoidedCount) > 0); + async function showEarlier() { + setEarlier("loading"); + try { + const month = await loadEarlierExpenses(shownFrom, showVoided); + if (month) setShownFrom(month); + setEarlier(""); + } catch { + setEarlier("error"); + } + } + const rows = [...pool.filter((row) => row.open), ...pool.filter((row) => !row.open && shown(row))] .filter((row) => { if (filter === "All") return true; if (filter === "Open") return row.open === true; @@ -44,7 +74,7 @@ export function ExpensesScreen() { <>
- {(voidedExpenses.length ? ["All", "Open", "Settled", "Voided"] as const : ["All", "Open", "Settled"] as const).map((chip) => ( + {(voidedExpenses.length || expenseHistory.olderVoidedCount ? ["All", "Open", "Settled", "Voided"] as const : ["All", "Open", "Settled"] as const).map((chip) => ( +
: null} ); } diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 13810823..ef8f8843 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -13,6 +13,7 @@ import { SignInSettings } from "../ui/SignInSettings"; import { communeExportJson, downloadText, expensesCsv } from "../data/export"; import { todayIso } from "../data/operations"; import { currencyHint, currencyLockedBy } from "../data/currency"; +import type { Snapshot } from "../live/model"; export function SettingsScreen() { const { @@ -226,7 +227,7 @@ export function SettingsScreen() { export function CommuneSettingsScreen() { const { - hub, session, go, setHubPhoto, updateCommune, snapshots, + hub, session, go, setHubPhoto, updateCommune, snapshots, loadFullHistory, } = useApp(); const [name, setName] = useState(hub.name); const [type, setType] = useState(hub.type); @@ -240,6 +241,7 @@ export function CommuneSettingsScreen() { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [saved, setSaved] = useState(false); + const [exporting, setExporting] = useState<"" | "loading" | "error">(""); // The database's rule, as its snapshot reports it. const currencyLocks = currencyLockedBy(snapshots[hub.id]); const canEdit = hub.role === "admin"; @@ -270,6 +272,18 @@ export function CommuneSettingsScreen() { } } + // A file holds every expense, so the older ones are loaded first. + async function download(save: (ledger: Snapshot) => void) { + setExporting("loading"); + try { + const ledger = await loadFullHistory(); + if (ledger) save(ledger); + setExporting(""); + } catch { + setExporting("error"); + } + } + if (!hub.id) { return

No commune selected.

; } @@ -326,21 +340,19 @@ export function CommuneSettingsScreen() {

Records

- -
+ {exporting === "loading" ?

Loading earlier expenses for the file…

: null} + {exporting === "error" ?

Earlier expenses couldn’t be loaded, so no file was made. Try again.

: null}

Files are made on this device from what you can already see. Nothing is sent anywhere.

diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index dfdcf6c3..3e7a7a27 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -34,7 +34,7 @@ import { import { isStoredImage, readHubPhotos } from "./data/images"; import { newRequestId } from "./data/requestId"; import { isNativeNow, keepSearch, pathOnly } from "./nav"; -import { api, post, loadCommunes, loadSnapshot, ApiError } from "./live/api"; +import { api, post, loadCommunes, loadExpensePage, loadExpenseRecords, loadSnapshot, ApiError } from "./live/api"; import { announceSession, currentViewer, onOtherTabSession, setViewer } from "./live/session"; import { readStored, removeStored, writeStored } from "./live/storage"; import { @@ -43,6 +43,7 @@ import { money, type Commune, type CurrencyCode, + type Expense, type Fund, type LiveExpenseRow, type LiveHub, @@ -201,6 +202,30 @@ type LiveData = { snapshots: Record; invites: Record; reminderUsage: ReminderUsage | null; + older: Record; +}; +/** + * Expenses older than a commune's snapshot holds, fetched when someone asks + * for earlier months, a statement, an export or a link to one. They are kept + * when the snapshot is read again, and the snapshot's copy of an expense wins. + */ +type OlderExpenses = { + /** The snapshot's window these continue. When it moves on, they are dropped. */ + since: string; + /** Every expense created from this date (UTC) on is loaded; null once all are. */ + from: string | null; + olderCount: number; + olderVoidedCount: number; + expenses: Record; +}; +/** How far back the open commune's expenses are loaded. */ +export type ExpenseHistoryState = { + /** Every expense created from this date (UTC) on is loaded. */ + loadedFrom: string | null; + complete: boolean; + /** Expenses older than what is loaded, and how many of them are voided. */ + olderCount: number; + olderVoidedCount: number; }; type AuthStatus = "loading" | "guest" | "authenticated" | "error"; @@ -251,6 +276,21 @@ export type AppState = { createdExpenses: LiveExpenseRow[]; /** Kept for history. Never part of a balance, total or open count. */ voidedExpenses: LiveExpenseRow[]; + /** + * The open expense window's expense is older than what is loaded: it is + * being fetched, or fetching it failed and can be tried again. + */ + expenseRead: "loading" | "failed" | null; + retryExpenseRead: () => void; + expenseHistory: ExpenseHistoryState; + /** + * The month (UTC) to show settled expenses from, or voided ones, going back + * from `before`: the latest earlier month that has one, loading older + * expenses as needed. Null when there are none before it. + */ + loadEarlierExpenses: (before: string, voided?: boolean) => Promise; + /** Loads every older expense, for a statement or an export, and returns the whole record. */ + loadFullHistory: () => Promise; funds: Fund[]; recurringBills: RecurringBill[]; splitTemplates: SplitTemplate[]; @@ -478,6 +518,33 @@ function operationPayload(item: NewOperation | OperationItem) { }; } +// The older expenses kept for this snapshot's window, if any. +function olderFor(snapshot: Snapshot, older: OlderExpenses | undefined): OlderExpenses | undefined { + return older && older.since === snapshot.expenseHistory?.since ? older : undefined; +} + +function historyState(snapshot: Snapshot | undefined, older: OlderExpenses | undefined): ExpenseHistoryState { + const history = snapshot?.expenseHistory; + // A snapshot that does not say holds every expense. + if (!snapshot || !history) return { loadedFrom: null, complete: true, olderCount: 0, olderVoidedCount: 0 }; + const kept = olderFor(snapshot, older); + return kept + ? { loadedFrom: kept.from, complete: kept.from === null || kept.olderCount === 0, olderCount: kept.olderCount, olderVoidedCount: kept.olderVoidedCount } + : { loadedFrom: history.since, complete: history.olderCount === 0, olderCount: history.olderCount, olderVoidedCount: history.olderVoidedCount }; +} + +// The snapshot's expenses and the older ones fetched for it, newest first as +// the database orders them. The snapshot's copy of an expense is the latest. +function withOlderExpenses(snapshot: Snapshot, older: OlderExpenses | undefined): Snapshot { + const kept = olderFor(snapshot, older); + if (!kept) return snapshot; + const held = new Set(snapshot.expenses.map((expense) => expense.id)); + const extra = Object.values(kept.expenses).filter((expense) => !held.has(expense.id)); + if (!extra.length) return snapshot; + return { ...snapshot, expenses: [...snapshot.expenses, ...extra].sort((a, b) => + (Date.parse(b.createdAt ?? "") || 0) - (Date.parse(a.createdAt ?? "") || 0) || b.id.localeCompare(a.id)) }; +} + export function AppProvider({ children }: { children: ReactNode }) { const [location, setLocation] = useLocation(); const screen: ScreenId = PATH_SCREEN[pathOnly(location)] ?? "home"; @@ -520,6 +587,11 @@ export function AppProvider({ children }: { children: ReactNode }) { const lastActivityAt = useRef(Date.now()); const snapshotReads = useRef(new Map>()); const snapshotRevisions = useRef(new Map()); + const olderReads = useRef(new Map>()); + // Linked expenses being fetched, and the ones that were refused or failed, + // by account, commune and expense. + const recordReads = useRef(new Set()); + const [recordResults, setRecordResults] = useState>>({}); const overview = screen === "home" || screen === "home-empty" || screen === "home-inbox" || screen === "communes" || screen === "home-activity"; const viewRef = useRef({ hubId, overview }); viewRef.current = { hubId, overview }; @@ -662,6 +734,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const seen = new Map((previous?.communes ?? []).map((commune) => [commune.id, commune])); const changed = communes.filter((commune) => seen.get(commune.id)?.changeMark !== commune.changeMark || seen.get(commune.id)?.role !== commune.role); + const listed = (id: string) => communes.some((commune) => commune.id === id); const admins = communes.filter((commune) => commune.role === "admin"); const invites = Object.fromEntries(Object.entries(previous?.invites ?? {}) .filter(([id]) => admins.some((commune) => commune.id === id))); @@ -672,6 +745,7 @@ export function AppProvider({ children }: { children: ReactNode }) { snapshots: Object.fromEntries(communes.filter((commune) => previous?.snapshots[commune.id]) .map((commune) => [commune.id, previous!.snapshots[commune.id]])), invites, reminderUsage: previous?.reminderUsage ?? null, + older: Object.fromEntries(Object.entries(previous?.older ?? {}).filter(([id]) => listed(id))), }); setAuthStatus("authenticated"); setHubId((current) => communes.some((commune) => commune.id === current) @@ -875,8 +949,8 @@ export function AppProvider({ children }: { children: ReactNode }) { email: data.user.email, emailVerified: data.user.emailVerified === true, hasHubs: data.communes.length > 0, - emptyLedger: data.communes.every((commune) => - (data.snapshots[commune.id]?.expenses.length ?? 0) === 0), + emptyLedger: data.communes.every((commune) => (data.snapshots[commune.id]?.expenses.length ?? 0) + + (data.snapshots[commune.id]?.expenseHistory?.olderCount ?? 0) === 0), seed: "new", hubPhotos: prefs.hubPhotos, avatar: prefs.avatar, @@ -899,17 +973,26 @@ export function AppProvider({ children }: { children: ReactNode }) { const held = data?.snapshots[activeId]; // The open commune's own screens never show a summary; they wait for its record. const snapshot = held?.detail === "summary" ? undefined : held; + const older = data?.older[activeId]; + const expenseHistory = historyState(snapshot, older); const contentLoading = !!data?.communes.length && !overview && !snapshot; const contentError = snapshotErrors[activeId] ?? null; - const allExpenses = snapshot && data ? expenseRows(snapshot, data.user.id) : []; + const allExpenses = snapshot && data ? expenseRows(withOlderExpenses(snapshot, older), data.user.id) : []; const expenses = allExpenses.filter((row) => !row.voidedAt); const voidedExpenses = allExpenses.filter((row) => row.voidedAt); const selectedExpense = selectedExpenseId ? allExpenses.find((row) => row.id === selectedExpenseId) ?? null : expenses[0] ?? null; const lastExpense = selectedExpense ? { name: selectedExpense.name, amount: selectedExpense.amount, paidBy: selectedExpense.paidBy } : null; - // A commune whose only expenses are voided still has a ledger to show. - const empty = !!session?.hasHubs && allExpenses.length === 0; + // A commune whose only expenses are voided, or older than the snapshot + // holds, still has a ledger to show. + const empty = !!session?.hasHubs && allExpenses.length === 0 && !snapshot?.expenseHistory?.olderCount; + const selectedLoaded = !selectedExpenseId || allExpenses.some((row) => row.id === selectedExpenseId); + const recordKey = `${data?.user.id ?? ""}:${activeId}:${selectedExpenseId}`; + const recordResult = recordResults[recordKey]; + // Until it is fetched, a linked expense older than what is loaded is on its way. + const expenseRead = (windowId === "expense" || windowId === "expense-paid") && !selectedLoaded && + snapshot?.expenseHistory && recordResult !== "missing" ? recordResult ?? "loading" : null; const notes: Record = Object.fromEntries( (snapshot?.notes ?? []).map((note) => [note.id, note]), ); @@ -971,6 +1054,112 @@ export function AppProvider({ children }: { children: ReactNode }) { } }, [activeId, publishData]); + // Older expenses of one commune, a page at a time: whole months (UTC) going + // back from where the loaded ones start, or all of them when months is left + // out. One read per commune at a time; a second caller waits for the first. + const readOlder = useCallback((communeId: string, months?: number): Promise => { + const current = dataRef.current; + const held = current?.snapshots[communeId]; + const since = held?.expenseHistory?.since; + if (!current || !held || held.detail === "summary" || !since) return Promise.resolve(); + const key = `${current.user.id}:${communeId}`; + const pending = olderReads.current.get(key); + if (pending) return pending; + const state = historyState(held, current.older[communeId]); + if (state.complete) return Promise.resolve(); + const request = loadExpensePage(communeId, state.loadedFrom, months).then((page) => { + const latest = dataRef.current; + const now = latest?.snapshots[communeId]; + // A new month moves the snapshot's window on; pages for the old one no longer join up. + if (!latest || latest.user.id !== current.user.id || !now || now.expenseHistory?.since !== since) return; + const kept = olderFor(now, latest.older[communeId]); + publishData({ ...latest, older: { ...latest.older, [communeId]: { since, from: page.from, olderCount: page.olderCount, + olderVoidedCount: page.olderVoidedCount, + expenses: { ...kept?.expenses, ...Object.fromEntries(page.expenses.map((expense) => [expense.id, expense])) } } } }); + }).finally(() => olderReads.current.delete(key)); + olderReads.current.set(key, request); + return request; + }, [publishData]); + + const loadEarlierExpenses = useCallback(async (before: string, voided = false): Promise => { + const communeId = activeId; + const limit = Date.parse(`${before}T00:00:00Z`); + // Each page reaches back at least a month, so this ends; the bound is a backstop. + for (let page = 0; page < 600; page += 1) { + const current = dataRef.current; + const held = current?.snapshots[communeId]; + if (!current || !held || held.detail === "summary") return null; + const older = current.older[communeId]; + const state = historyState(held, older); + // Open expenses always show, so only settled ones (or voided ones) count. + const newest = Math.max(...withOlderExpenses(held, older).expenses + .filter((expense) => voided ? !!expense.voidedAt + : !expense.voidedAt && expense.shares.every((share) => share.settledMinor >= share.amountMinor)) + .map((expense) => Date.parse(expense.createdAt ?? "")).filter((time) => time < limit)); + // Everything between that month and `before` must be loaded too. + const loadedFrom = state.loadedFrom ? Date.parse(`${state.loadedFrom}T00:00:00Z`) : -Infinity; + if (Number.isFinite(newest) && (state.complete || newest >= loadedFrom)) { + const month = new Date(newest); + return new Date(Date.UTC(month.getUTCFullYear(), month.getUTCMonth(), 1)).toISOString().slice(0, 10); + } + if (state.complete) return null; + await readOlder(communeId, 1); + } + return null; + }, [activeId, readOlder]); + + const loadFullHistory = useCallback(async (): Promise => { + const communeId = activeId; + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = dataRef.current; + const held = current?.snapshots[communeId]; + if (!current || !held || held.detail === "summary") return null; + const older = current.older[communeId]; + if (historyState(held, older).complete) { + // Every expense is in it now, so it says nothing about older ones. + const { expenseHistory: _history, changeMark: _mark, ...ledger } = withOlderExpenses(held, older); + return ledger; + } + await readOlder(communeId); + } + throw new Error("Earlier expenses couldn’t be loaded. Try again."); + }, [activeId, readOlder]); + + // A link to an expense older than what is loaded (from search, a notice, + // activity or a recurring bill's history) fetches that one expense. If it + // is refused or gone, the window says it isn't in this commune. + useEffect(() => { + const current = dataRef.current; + const key = recordKey; + if (!current || expenseRead !== "loading" || recordReads.current.has(key)) return; + recordReads.current.add(key); + const finish = (result?: "missing" | "failed") => { + recordReads.current.delete(key); + if (result) setRecordResults((results) => ({ ...results, [key]: result })); + }; + loadExpenseRecords(activeId, [selectedExpenseId]).then(({ expenses: found }) => { + const latest = dataRef.current; + const now = latest?.snapshots[activeId]; + if (!latest || latest.user.id !== current.user.id || !now?.expenseHistory) return finish(); + if (!found.length) return finish("missing"); + // The snapshot holds every expense changed since its window began, and + // its copy wins, so an expense fetched here is never out of date. + const kept = olderFor(now, latest.older[activeId]) ?? { since: now.expenseHistory.since, from: now.expenseHistory.since, + olderCount: now.expenseHistory.olderCount, olderVoidedCount: now.expenseHistory.olderVoidedCount, expenses: {} }; + publishData({ ...latest, older: { ...latest.older, [activeId]: { ...kept, + expenses: { ...kept.expenses, ...Object.fromEntries(found.map((expense) => [expense.id, expense])) } } } }); + finish(); + }, (error: unknown) => { + // A refusal stands; a failed connection can be tried again. + finish(error instanceof ApiError && error.status >= 400 && error.status < 500 ? "missing" : "failed"); + }); + }, [activeId, expenseRead, publishData, recordKey, selectedExpenseId]); + const retryExpenseRead = useCallback(() => setRecordResults((results) => { + const next = { ...results }; + delete next[recordKey]; + return next; + }), [recordKey]); + const workflow = useCallback(async (path: string, body: unknown, method = "POST"): Promise => mutate(async () => { const result = await api(path,{method,body:JSON.stringify(body)}); await refreshRecords(); @@ -1454,6 +1643,7 @@ export function AppProvider({ children }: { children: ReactNode }) { dark: prefs.dark, noticesOn: prefs.noticesOn, currency: prefs.currency, searchQuery, searchScope, searchTarget, noteSearchQuery, invites: data?.invites[activeId] ?? [], payMethods: prefs.payMethods, native, you, lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses, + expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory, funds, recurringBills, splitTemplates, expenseTemplateId, reminderTargets, reminderUsage, sentReminders, notifications, signOut, signOutUnconfirmed, retrySignOut: confirmSignOut, sessionNotice, go, changePassword, signOutOtherDevices, diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx index b82c9fc2..4ef0fd22 100644 --- a/apps/web/src/windows/Windows.tsx +++ b/apps/web/src/windows/Windows.tsx @@ -56,6 +56,8 @@ export function Windows() { mutationPending, contentLoading, contentError, + expenseRead, + retryExpenseRead, refresh, } = useApp(); const frameRef = useRef(null); @@ -98,7 +100,8 @@ export function Windows() { }, [isOpen]); useEffect(() => { if (window) frameRef.current?.focus(); }, [window]); if (!window || inlineNote) return null; - const waitingForRecord = contentLoading && ["expense", "expense-paid", "note", "note-edit", "operation", "operation-edit"].includes(window); + const waitingForRecord = (contentLoading && ["expense", "expense-paid", "note", "note-edit", "operation", "operation-edit"].includes(window)) || !!expenseRead; + const recordError = contentLoading ? contentError : expenseRead === "failed" ? "That expense couldn’t be loaded. Try again." : null; const title = window === "expense" || window === "expense-paid" ? selectedExpense?.name ?? "Expense" @@ -119,8 +122,8 @@ export function Windows() {
- {waitingForRecord ? contentError ?

{contentError}

-
: + {waitingForRecord ? recordError ?

{recordError}

+
:
: <> {window === "notices" && } {window === "reminder-preview" && } @@ -764,7 +767,16 @@ function Profile() { } function Breakdown() { - const { go, closeWindow, hub, expenses, voidedExpenses, session, snapshots } = useApp(); + const { go, closeWindow, hub, expenses, voidedExpenses, session, snapshots, expenseHistory, loadFullHistory } = useApp(); + // A statement covers everything shared with that person, so it waits for every older expense. + const [historyFailed, setHistoryFailed] = useState(false); + useEffect(() => { + if (expenseHistory.complete || historyFailed) return; + let open = true; + loadFullHistory().catch(() => { if (open) setHistoryFailed(true); }); + return () => { open = false; }; + }, [expenseHistory.complete, historyFailed, loadFullHistory]); + const history = expenseHistory.complete ? "ready" : historyFailed ? "error" : "loading"; const others = hub.members.filter((member) => member.userId !== session?.userId); const ledger = [...expenses, ...voidedExpenses]; // People who have left keep their statements, listed under their own @@ -828,7 +840,10 @@ function Breakdown() { );})} {!moves.length ?

No outstanding shares for you in this commune.

: null}
- {other && statement ? ( + {other && history === "loading" ?

Loading earlier expenses for the statement…

: null} + {other && history === "error" ?

Earlier expenses couldn’t be loaded, so the statement isn’t shown.

+
: null} + {other && statement && history === "ready" ? (
diff --git a/apps/web/tests/lighter-loading.test.tsx b/apps/web/tests/lighter-loading.test.tsx index dad853e8..0d8f71bd 100644 --- a/apps/web/tests/lighter-loading.test.tsx +++ b/apps/web/tests/lighter-loading.test.tsx @@ -1,15 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; import { App } from "../src/App"; import { AppProvider, useApp, type AppState } from "../src/state"; -import { api, post, loadCommunes, loadSnapshot, ApiError } from "../src/live/api"; -import type { Commune, Expense, Snapshot } from "../src/live/model"; +import { api, post, loadCommunes, loadExpensePage, loadExpenseRecords, loadSnapshot, ApiError } from "../src/live/api"; +import { downloadText } from "../src/data/export"; +import type { Commune, Expense, ExpensePage, Snapshot } from "../src/live/model"; vi.mock("../src/live/api", async (importOriginal) => ({ ...await importOriginal(), - api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(), + api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(), loadExpensePage: vi.fn(), loadExpenseRecords: vi.fn(), +})); +vi.mock("../src/data/export", async (importOriginal) => ({ + ...await importOriginal(), + downloadText: vi.fn(), })); const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true }; @@ -22,6 +27,11 @@ const today = new Date(); function monthStart(back: number) { return new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - back, 1)).toISOString().slice(0, 10); } +function monthLabel(back: number) { + const start = new Date(`${monthStart(back)}T00:00:00Z`); + return new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC", + ...(start.getUTCFullYear() === today.getUTCFullYear() ? {} : { year: "numeric" }) }).format(start); +} /** The snapshot holds every expense created from the start of last month, and every open one. */ const since = monthStart(1); function expense(id: string, title: string, back: number, kind: "settled" | "open" | "voided" = "settled"): Expense { @@ -229,3 +239,230 @@ describe("checking for changes", () => { }); }); +describe("older expenses", () => { + const groceries = expense("expense-groceries", "Groceries", 0); + const internet = expense("expense-internet", "Internet", 1); + const boiler = expense("expense-boiler", "Boiler repair", 5, "open"); + const garden = expense("expense-garden", "Garden tools", 3); + const duplicate = expense("expense-duplicate", "Duplicate rent", 4, "voided"); + const sofa = expense("expense-sofa", "Sofa", 7); + const older = [garden, duplicate, sofa]; + // Pages as commune_expense_page returns them: whole months back from + // `before`, reaching back to the newest older expense past empty months. + function page(_communeId: string, before: string | null, months?: number): Promise { + const end = before ?? since; + const earlier = older.filter((item) => item.createdAt! < end); + let from: string | null = null; + if (months && earlier.length) { + const [year, month] = end.split("-").map(Number); + const back = new Date(Date.UTC(year, month - 1 - months, 1)).toISOString().slice(0, 10); + const newest = `${earlier[0].createdAt!.slice(0, 7)}-01`; + from = back < newest ? back : newest; + } + const rest = earlier.filter((item) => from !== null && item.createdAt! < from); + return Promise.resolve({ expenses: earlier.filter((item) => !rest.includes(item)), before: end, from, + olderCount: rest.length, olderVoidedCount: rest.filter((item) => item.voidedAt).length }); + } + const shownNames = () => within(screen.getByRole("region", { name: "Shared expenses" })).queryAllByRole("button") + .map((button) => button.querySelector("strong")?.textContent); + beforeEach(() => { + records[home.id] = record(home, [groceries, internet, boiler], { olderCount: 3, olderVoidedCount: 1 }); + vi.mocked(loadExpensePage).mockImplementation(page); + }); + + it("shows open expenses and this month's settled ones, and earlier months on request", async () => { + mount("/expenses"); + await screen.findByRole("button", { name: /Groceries/ }); + expect(shownNames()).toEqual(["Boiler repair", "Groceries"]); + expect(screen.getByText(`Settled expenses before ${monthLabel(0)} aren’t shown yet.`)).toBeTruthy(); + + // Last month is in the snapshot already. + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByText(`Settled expenses before ${monthLabel(1)} aren’t shown yet.`); + expect(shownNames()).toEqual(["Boiler repair", "Groceries", "Internet"]); + expect(loadExpensePage).not.toHaveBeenCalled(); + + // Two months back holds nothing, so the next month shown is three months back. + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByText(`Settled expenses before ${monthLabel(3)} aren’t shown yet.`); + expect(shownNames()).toEqual(["Boiler repair", "Groceries", "Internet", "Garden tools"]); + expect(loadExpensePage).toHaveBeenCalledExactlyOnceWith(home.id, since, 1); + + // Voided ones page the same way, and filters apply to what is shown. + fireEvent.click(screen.getByRole("button", { name: "Voided" })); + expect(screen.getByText("0 voided expenses · kept for history, not counted in any balance")).toBeTruthy(); + expect(screen.getByText(`Voided expenses before ${monthLabel(3)} aren’t shown yet.`)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByRole("button", { name: /Duplicate rent/ }); + expect(loadExpensePage).toHaveBeenLastCalledWith(home.id, monthStart(3), 1); + expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "All" })); + expect(shownNames()).toEqual(["Boiler repair", "Groceries", "Internet", "Garden tools"]); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByRole("button", { name: /Sofa/ }); + expect(loadExpensePage).toHaveBeenCalledTimes(3); + // Everything is loaded now. + expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull(); + fireEvent.change(screen.getByLabelText("Filter expenses in this commune"), { target: { value: "sofa" } }); + expect(shownNames()).toEqual(["Sofa"]); + }); + + it("starts from last month while nothing has been settled this month", async () => { + records[home.id] = record(home, [internet, boiler], { olderCount: 3, olderVoidedCount: 1 }); + mount("/expenses"); + await screen.findByRole("button", { name: /Internet/ }); + expect(shownNames()).toEqual(["Boiler repair", "Internet"]); + expect(screen.getByText(`Settled expenses before ${monthLabel(1)} aren’t shown yet.`)).toBeTruthy(); + }); + + it("pages voided expenses on their own, passing over settled ones", async () => { + mount("/expenses"); + await screen.findByRole("button", { name: /Groceries/ }); + fireEvent.click(screen.getByRole("button", { name: "Voided" })); + expect(screen.getByText(`Voided expenses before ${monthLabel(0)} aren’t shown yet.`)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByRole("button", { name: /Duplicate rent/ }); + expect(shownNames()).toEqual(["Duplicate rent"]); + expect(vi.mocked(loadExpensePage).mock.calls).toEqual([[home.id, since, 1], [home.id, monthStart(3), 1]]); + expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull(); + }); + + it("says when earlier expenses couldn’t be loaded, and tries again", async () => { + vi.mocked(loadExpensePage).mockRejectedValueOnce(new TypeError("Failed to fetch")); + mount("/expenses"); + await screen.findByRole("button", { name: /Groceries/ }); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByRole("button", { name: /Internet/ }); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + expect((await screen.findByRole("alert")).textContent).toBe("Earlier expenses couldn’t be loaded. Try again."); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByRole("button", { name: /Garden tools/ }); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("keeps older expenses when the snapshot is read again, and the snapshot’s copy wins", async () => { + mount("/expenses"); + await screen.findByRole("button", { name: /Groceries/ }); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByRole("button", { name: /Internet/ }); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByRole("button", { name: /Garden tools/ }); + // Someone corrects the older expense, so the snapshot carries it now. + const corrected = { ...garden, title: "Garden tools and seeds", version: 2, updatedAt: new Date().toISOString() }; + listed = [{ ...home, changeMark: "2026-10-09:4:31" }]; + records[home.id] = record(listed[0], [groceries, internet, corrected, boiler], { olderCount: 2, olderVoidedCount: 1 }); + await act(async () => app.refresh()); + await screen.findByRole("button", { name: /Garden tools and seeds/ }); + expect(shownNames()).toEqual(["Boiler repair", "Groceries", "Internet", "Garden tools and seeds"]); + expect(loadExpensePage).toHaveBeenCalledTimes(1); + }); + + it("does not call a commune empty when its expenses are older than the snapshot", async () => { + records[home.id] = record(home, [], { olderCount: 3, olderVoidedCount: 1 }); + mount("/expenses"); + await screen.findByText(`Settled expenses before ${monthLabel(0)} aren’t shown yet.`); + expect(screen.queryByText("No expenses yet.")).toBeNull(); + expect(app.emptyHub).toBe(false); + }); + + it("fetches a linked expense that is older than what is loaded", async () => { + const pending = deferred<{ expenses: Expense[] }>(); + vi.mocked(loadExpenseRecords).mockReturnValueOnce(pending.promise); + mount("/hub"); + await screen.findByRole("heading", { name: home.name }); + act(() => app.go("expense", { expense: sofa.id })); + const loading = await screen.findByRole("dialog", { name: "Expense" }); + expect(within(loading).getByRole("status", { name: "Loading selected record" })).toBeTruthy(); + expect(within(loading).queryByText("That expense isn’t in this hub.")).toBeNull(); + expect(loadExpenseRecords).toHaveBeenCalledExactlyOnceWith(home.id, [sofa.id]); + await act(async () => pending.resolve({ expenses: [sofa] })); + const shown = await screen.findByRole("dialog", { name: "Sofa" }); + expect(within(shown).getByText(/All shares are settled/)).toBeTruthy(); + // Kept with the other older expenses, so it opens again without a request. + act(() => app.closeWindow()); + act(() => app.go("expense", { expense: sofa.id })); + await screen.findByRole("dialog", { name: "Sofa" }); + expect(loadExpenseRecords).toHaveBeenCalledTimes(1); + }); + + it("says a linked expense isn’t in the commune when it is refused or gone, and offers to try again when the connection fails", async () => { + mount("/hub"); + await screen.findByRole("heading", { name: home.name }); + vi.mocked(loadExpenseRecords).mockRejectedValueOnce(new ApiError("Not a commune member", 403)); + act(() => app.go("expense", { expense: "expense-elsewhere" })); + expect(await screen.findByText("That expense isn’t in this hub.")).toBeTruthy(); + act(() => app.closeWindow()); + vi.mocked(loadExpenseRecords).mockResolvedValueOnce({ expenses: [] }); + act(() => app.go("expense", { expense: "expense-gone" })); + expect(await screen.findByText("That expense isn’t in this hub.")).toBeTruthy(); + act(() => app.closeWindow()); + + vi.mocked(loadExpenseRecords).mockRejectedValueOnce(new TypeError("Failed to fetch")); + act(() => app.go("expense", { expense: sofa.id })); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("That expense couldn’t be loaded. Try again."); + vi.mocked(loadExpenseRecords).mockResolvedValueOnce({ expenses: [sofa] }); + fireEvent.click(within(alert).getByRole("button", { name: "Try again" })); + await screen.findByRole("dialog", { name: "Sofa" }); + expect(loadExpenseRecords).toHaveBeenCalledTimes(4); + }); + + it("loads every older expense before showing a statement", async () => { + const pending = deferred(); + vi.mocked(loadExpensePage).mockReturnValueOnce(pending.promise); + mount("/settle"); + fireEvent.click(await screen.findByRole("button", { name: "View breakdown" })); + const dialog = await screen.findByRole("dialog", { name: "Breakdown" }); + expect(within(dialog).getByText("Loading earlier expenses for the statement…")).toBeTruthy(); + expect(within(dialog).queryByRole("region", { name: "Statement" })).toBeNull(); + expect(loadExpensePage).toHaveBeenCalledExactlyOnceWith(home.id, since, undefined); + await act(async () => pending.resolve({ expenses: older, before: since, from: null, olderCount: 0, olderVoidedCount: 0 })); + const statement = await within(dialog).findByRole("region", { name: "Statement" }); + for (const title of ["Sofa", "Duplicate rent", "Garden tools", "Internet"]) expect(statement.textContent).toContain(title); + expect(within(dialog).queryByText("Loading earlier expenses for the statement…")).toBeNull(); + }); + + it("says when a statement can’t be shown because earlier expenses didn’t load", async () => { + vi.mocked(loadExpensePage).mockRejectedValueOnce(new TypeError("Failed to fetch")); + mount("/settle"); + fireEvent.click(await screen.findByRole("button", { name: "View breakdown" })); + const dialog = await screen.findByRole("dialog", { name: "Breakdown" }); + expect((await within(dialog).findByRole("alert")).textContent).toContain("Earlier expenses couldn’t be loaded, so the statement isn’t shown."); + expect(within(dialog).queryByRole("region", { name: "Statement" })).toBeNull(); + fireEvent.click(within(dialog).getByRole("button", { name: "Try again" })); + expect((await within(dialog).findByRole("region", { name: "Statement" })).textContent).toContain("Sofa"); + }); + + it("loads every older expense before making an export file", async () => { + const pending = deferred(); + vi.mocked(loadExpensePage).mockReturnValueOnce(pending.promise); + mount("/commune-settings"); + fireEvent.click(await screen.findByRole("button", { name: /Download expenses/ })); + expect(await screen.findByText("Loading earlier expenses for the file…")).toBeTruthy(); + expect((screen.getByRole("button", { name: /Download all records/ }) as HTMLButtonElement).disabled).toBe(true); + expect(downloadText).not.toHaveBeenCalled(); + await act(async () => pending.resolve({ expenses: older, before: since, from: null, olderCount: 0, olderVoidedCount: 0 })); + await waitFor(() => expect(downloadText).toHaveBeenCalledTimes(1)); + const csv = vi.mocked(downloadText).mock.calls[0][1]; + for (const title of ["Sofa", "Duplicate rent", "Garden tools", "Groceries", "Boiler repair"]) expect(csv).toContain(title); + expect(screen.queryByText("Loading earlier expenses for the file…")).toBeNull(); + + // The next file needs nothing more, and says nothing about a window. + fireEvent.click(screen.getByRole("button", { name: /Download all records/ })); + await waitFor(() => expect(downloadText).toHaveBeenCalledTimes(2)); + const file = JSON.parse(vi.mocked(downloadText).mock.calls[1][1]) as Snapshot; + expect(file.expenses.map((item) => item.title)).toEqual(["Groceries", "Internet", "Garden tools", "Duplicate rent", "Boiler repair", "Sofa"]); + expect(file.expenseHistory).toBeUndefined(); + expect(file.changeMark).toBeUndefined(); + expect(loadExpensePage).toHaveBeenCalledTimes(1); + }); + + it("makes no file when earlier expenses couldn’t be loaded", async () => { + vi.mocked(loadExpensePage).mockRejectedValueOnce(new TypeError("Failed to fetch")); + mount("/commune-settings"); + fireEvent.click(await screen.findByRole("button", { name: /Download expenses/ })); + expect((await screen.findByRole("alert")).textContent).toBe("Earlier expenses couldn’t be loaded, so no file was made. Try again."); + expect(downloadText).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/tests/money-lifecycle.test.tsx b/apps/web/tests/money-lifecycle.test.tsx index c1c6f4a8..8878dc31 100644 --- a/apps/web/tests/money-lifecycle.test.tsx +++ b/apps/web/tests/money-lifecycle.test.tsx @@ -687,7 +687,9 @@ describe("safe expense correction and void", () => { act(() => app.go("expenses")); expect(await screen.findByText("1 expense · £24.00 total recorded in this view")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Voided" })); - expect(screen.getByText("1 voided expense · kept for history, not counted in any balance")).toBeTruthy(); + // It was recorded in January; earlier months show on request. + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + expect(await screen.findByText("1 voided expense · kept for history, not counted in any balance")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /House supplies/ })); const windowFrame = await screen.findByRole("dialog", { name: "House supplies" }); expect(within(windowFrame).getByText("Voided by Test Person on 8 Jan 2026: Entered twice")).toBeTruthy(); @@ -720,7 +722,8 @@ describe("review fixes: voided ledger, lost replies, locks and Escape", () => { expect(await screen.findByRole("button", { name: "Voided" })).toBeTruthy(); expect(screen.queryByText("No expenses yet.")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Voided" })); - expect(screen.getByRole("button", { name: /House supplies/ })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + expect(await screen.findByRole("button", { name: /House supplies/ })).toBeTruthy(); }); it("tells an admin who did not pay to ask the payer to reverse, not to reverse themselves", async () => { From 155883dca3891677657e40b9372c96a3377cd5c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:37:36 +0000 Subject: [PATCH 06/11] Keep the five newest expenses in every snapshot With only the window, a commune that had gone quiet (a finished trip, a household that last spent three months ago) lost its hub's recent expenses and its activity, and the hub could list an old open expense as recent while newer settled ones were missing. snapshot_expense_ids now also takes the five newest expenses that are not voided, by created_at and then id, so the snapshot, expenseHistory, the pages and the summary still agree on which ones are left out. The SQL test adds a quiet commune: its five newest are kept however old, a voided one takes no place, a tie for fifth goes by id, a new expense pushes the fifth out, and the counts and pages leave out the same ones. The web tests check that a quiet commune's expenses start at its newest settled month and page back past the snapshot's start, and that its hub and activity list its latest. The busy household's snapshot is unchanged: its five newest are already in the window. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/local-api/README.md | 2 +- apps/web/src/live/model.ts | 5 +- apps/web/tests/lighter-loading.test.tsx | 89 ++++++++++++-- .../neon/migrations/0036_lighter_loading.sql | 30 +++-- infra/neon/tests/lighter_loading.sql | 115 +++++++++++++++++- 5 files changed, 210 insertions(+), 31 deletions(-) diff --git a/apps/local-api/README.md b/apps/local-api/README.md index b0d5c3ef..8730d8a2 100644 --- a/apps/local-api/README.md +++ b/apps/local-api/README.md @@ -21,7 +21,7 @@ browser code sends same-origin `/api` requests. Non-2xx errors are | GET | `/api/search?q=...&commune=...&limit=8` | session required; optional commune UUID; 2–80 chars, 1–25 results per type | `{query,items,total,hasMore}`; only active memberships, safe note excerpts | | POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}`; one account creates at most 10 communes an hour, 30 a day and 100 in all (409) | `{id}` | | PATCH | `/api/communes/:id` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` admin; the currency is fixed (400) once the commune has an expense, fund, recurring bill or exact split template, voided or archived ones included | `{ok:true}` | -| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents,expenseHistory,changeMark}`; `expenses` are the open ones and those created or changed since the start of last month (UTC); `expenseHistory` is `{since,olderCount,olderVoidedCount}` | +| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents,expenseHistory,changeMark}`; `expenses` are the open ones, those created or changed since the start of last month (UTC) and the five newest that are not voided; `expenseHistory` is `{since,olderCount,olderVoidedCount}` | | GET | `/api/communes/:id/expenses/history?before=&months=` | member; `before` (a date) defaults to the snapshot's `since`; `months` 1 to 12, or left out for every older expense | `{expenses,before,from,olderCount,olderVoidedCount}`: whole months (UTC) before `before`, reaching back past months with none; `from` is null once nothing older remains | | GET | `/api/communes/:id/expenses/records?ids=` | member; 1 to 50 comma-separated expense ids; ids from another commune are left out | `{expenses}` in the snapshot's shape | | POST | `/api/communes/:id/expenses` | `{title,amountMinor,paidByUserId?,category?,shares:[{userId,amountMinor}],dueOn?,requestId?}`; recording for another payer requires admin; a repeated `requestId` returns the same expense, and reusing it for different details is 400 | `{id,duplicate}` (201, or 200 for a repeat) | diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 7ca598ca..72f3f2d0 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -285,7 +285,10 @@ export type Snapshot = { splitTemplates?: SplitTemplate[]; announcements?: Announcement[]; activityEvents?: Array<{id:string;actorId:string;kind:"funds"|"members"|"recurring"|"templates"|"notes"|"operations"|"expenses"|"announcements";entityId:string;title:string;description:string;createdAt:string}>; - /** The snapshot holds every expense created since `since`, and every open one. */ + /** + * The snapshot holds every expense created since `since`, every open one + * and the five newest that are not voided. + */ expenseHistory?: ExpenseHistory; changeMark?: string; /** An overview summary leaves out what only the commune's own screens show. */ diff --git a/apps/web/tests/lighter-loading.test.tsx b/apps/web/tests/lighter-loading.test.tsx index 0d8f71bd..6967cba7 100644 --- a/apps/web/tests/lighter-loading.test.tsx +++ b/apps/web/tests/lighter-loading.test.tsx @@ -32,10 +32,13 @@ function monthLabel(back: number) { return new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC", ...(start.getUTCFullYear() === today.getUTCFullYear() ? {} : { year: "numeric" }) }).format(start); } -/** The snapshot holds every expense created from the start of last month, and every open one. */ +/** + * The snapshot holds every expense created from the start of last month, + * every open one and the five newest that are not voided. + */ const since = monthStart(1); -function expense(id: string, title: string, back: number, kind: "settled" | "open" | "voided" = "settled"): Expense { - const at = `${monthStart(back)}T12:00:00.000Z`; +function expense(id: string, title: string, back: number, kind: "settled" | "open" | "voided" = "settled", day = 1): Expense { + const at = `${monthStart(back).slice(0, 8)}${String(day).padStart(2, "0")}T12:00:00.000Z`; return { id, title, category: "Other", amountMinor: 2000, paidByUserId: user.id, dueOn: null, createdAt: at, version: 1, voidedAt: kind === "voided" ? at : null, voidReason: kind === "voided" ? "Entered twice" : null, @@ -246,12 +249,16 @@ describe("older expenses", () => { const garden = expense("expense-garden", "Garden tools", 3); const duplicate = expense("expense-duplicate", "Duplicate rent", 4, "voided"); const sofa = expense("expense-sofa", "Sofa", 7); + // The database would also send Garden tools and Sofa, as two of the five + // newest; these tests leave them out to page from the window's own start. + // The tests for a commune that has gone quiet, below, use what it sends. const older = [garden, duplicate, sofa]; - // Pages as commune_expense_page returns them: whole months back from - // `before`, reaching back to the newest older expense past empty months. - function page(_communeId: string, before: string | null, months?: number): Promise { + // Pages as commune_expense_page returns them from what the snapshot left + // out (newest first): whole months back from `before`, reaching back to + // the newest older expense past empty months. + const pagesOf = (leftOut: Expense[]) => (_communeId: string, before: string | null, months?: number): Promise => { const end = before ?? since; - const earlier = older.filter((item) => item.createdAt! < end); + const earlier = leftOut.filter((item) => item.createdAt! < end); let from: string | null = null; if (months && earlier.length) { const [year, month] = end.split("-").map(Number); @@ -262,12 +269,12 @@ describe("older expenses", () => { const rest = earlier.filter((item) => from !== null && item.createdAt! < from); return Promise.resolve({ expenses: earlier.filter((item) => !rest.includes(item)), before: end, from, olderCount: rest.length, olderVoidedCount: rest.filter((item) => item.voidedAt).length }); - } + }; const shownNames = () => within(screen.getByRole("region", { name: "Shared expenses" })).queryAllByRole("button") .map((button) => button.querySelector("strong")?.textContent); beforeEach(() => { records[home.id] = record(home, [groceries, internet, boiler], { olderCount: 3, olderVoidedCount: 1 }); - vi.mocked(loadExpensePage).mockImplementation(page); + vi.mocked(loadExpensePage).mockImplementation(pagesOf(older)); }); it("shows open expenses and this month's settled ones, and earlier months on request", async () => { @@ -358,14 +365,72 @@ describe("older expenses", () => { expect(loadExpensePage).toHaveBeenCalledTimes(1); }); - it("does not call a commune empty when its expenses are older than the snapshot", async () => { - records[home.id] = record(home, [], { olderCount: 3, olderVoidedCount: 1 }); + it("does not call a commune empty when its only expenses are voided ones older than the snapshot", async () => { + records[home.id] = record(home, [], { olderCount: 1, olderVoidedCount: 1 }); mount("/expenses"); - await screen.findByText(`Settled expenses before ${monthLabel(0)} aren’t shown yet.`); + fireEvent.click(await screen.findByRole("button", { name: "Voided" })); + expect(screen.getByText(`Voided expenses before ${monthLabel(0)} aren’t shown yet.`)).toBeTruthy(); expect(screen.queryByText("No expenses yet.")).toBeNull(); expect(app.emptyHub).toBe(false); }); + describe("a commune that has gone quiet", () => { + // Nothing since the window's start: the snapshot holds the five newest + // that are not voided (Hotel is still open too), and the rest are left out. + const farewell = expense("expense-farewell", "Farewell dinner", 3, "settled", 20); + const taxi = expense("expense-taxi", "Airport taxi", 3, "settled", 12); + const museum = expense("expense-museum", "Museum tickets", 4, "settled", 18); + const hotel = expense("expense-hotel", "Hotel", 4, "open", 10); + const ferry = expense("expense-ferry", "Ferry", 4, "settled", 6); + const repeated = expense("expense-repeated", "Taxi entered twice", 3, "voided", 25); + const tram = expense("expense-tram", "Tram passes", 4, "settled", 2); + const lunch = expense("expense-lunch", "Harbour lunch", 6, "settled", 15); + beforeEach(() => { + records[home.id] = record(home, [farewell, taxi, museum, hotel, ferry], { olderCount: 3, olderVoidedCount: 1 }); + vi.mocked(loadExpensePage).mockImplementation(pagesOf([repeated, tram, lunch])); + }); + + it("starts at its newest settled month and pages back past the snapshot’s start", async () => { + mount("/expenses"); + await screen.findByRole("button", { name: /Farewell dinner/ }); + expect(shownNames()).toEqual(["Hotel", "Farewell dinner", "Airport taxi"]); + expect(screen.getByText(`Settled expenses before ${monthLabel(3)} aren’t shown yet.`)).toBeTruthy(); + expect(loadExpensePage).not.toHaveBeenCalled(); + + // Four months back is partly in the snapshot; the rest of it is fetched first. + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByText(`Settled expenses before ${monthLabel(4)} aren’t shown yet.`); + expect(shownNames()).toEqual(["Hotel", "Farewell dinner", "Airport taxi", "Museum tickets", "Ferry", "Tram passes"]); + expect(vi.mocked(loadExpensePage).mock.calls).toEqual([[home.id, since, 1], [home.id, monthStart(3), 1]]); + + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByRole("button", { name: /Harbour lunch/ }); + expect(loadExpensePage).toHaveBeenLastCalledWith(home.id, monthStart(4), 1); + expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull(); + }); + + it("offers the earlier months its snapshot holds when nothing older is left", async () => { + records[home.id] = record(home, [farewell, museum]); + mount("/expenses"); + await screen.findByRole("button", { name: /Farewell dinner/ }); + expect(shownNames()).toEqual(["Farewell dinner"]); + expect(screen.getByText(`Settled expenses before ${monthLabel(3)} aren’t shown yet.`)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Show earlier" })); + await screen.findByRole("button", { name: /Museum tickets/ }); + expect(shownNames()).toEqual(["Farewell dinner", "Museum tickets"]); + expect(screen.queryByRole("button", { name: "Show earlier" })).toBeNull(); + expect(loadExpensePage).not.toHaveBeenCalled(); + }); + + it("lists its latest expenses on the hub and in its activity", async () => { + mount("/hub"); + const recent = await screen.findByRole("region", { name: "Recent expenses" }); + expect(within(recent).getAllByRole("button").filter((button) => button.textContent !== "All expenses") + .map((button) => button.querySelector("strong")?.textContent)).toEqual(["Farewell dinner", "Airport taxi", "Museum tickets"]); + expect(app.hub.activity.map((row) => row.title)).toEqual(["Farewell dinner", "Airport taxi", "Museum tickets", "Hotel", "Ferry"]); + }); + }); + it("fetches a linked expense that is older than what is loaded", async () => { const pending = deferred<{ expenses: Expense[] }>(); vi.mocked(loadExpenseRecords).mockReturnValueOnce(pending.promise); diff --git a/infra/neon/migrations/0036_lighter_loading.sql b/infra/neon/migrations/0036_lighter_loading.sql index 5c90abce..cf6706c6 100644 --- a/infra/neon/migrations/0036_lighter_loading.sql +++ b/infra/neon/migrations/0036_lighter_loading.sql @@ -14,11 +14,12 @@ -- communes and commune_members row checks look the caller's communes up -- once per query. create_commune refuses an 11th commune in an hour, a 31st -- in a day and a 101st in all. --- 3. The snapshot carries the expenses still open and those created or --- changed since the start of last month (UTC), plus expenseHistory, which --- says from when and how many older ones were left out. Older ones come a --- month or more at a time from public.commune_expense_page, and named ones --- from public.commune_expense_records, in the same shape. +-- 3. The snapshot carries the expenses still open, those created or changed +-- since the start of last month (UTC) and the five newest that are not +-- voided, plus expenseHistory, which says from when and how many older +-- ones were left out. Older ones come a month or more at a time from +-- public.commune_expense_page, and named ones from +-- public.commune_expense_records, in the same shape. -- The web app released with this needs it applied first. Apply after 0035. BEGIN; @@ -230,19 +231,24 @@ $$; REVOKE ALL ON FUNCTION commune_private.expense_window_start() FROM PUBLIC; -- The expenses a snapshot carries: every open one (not voided, a share still --- owed), and every one created, corrected or voided since the window start or --- with a share settled since then. A repayment, its reversal and a payment --- report either leave a share owed or settle it, and a correction or void sets --- updated_at, so these cover them all. The commune's expenses are read through --- expenses_commune_idx and each one's shares through their (expense_id, --- user_id) key. +-- owed), every one created, corrected or voided since the window start or +-- with a share settled since then, and the five newest that are not voided, +-- so a commune that has gone quiet still shows its latest. A repayment, its +-- reversal and a payment report either leave a share owed or settle it, and +-- a correction or void sets updated_at, so these cover them all. Ties on +-- created_at go by id, so every caller leaves out the same ones. The +-- commune's expenses are read through expenses_commune_idx and each one's +-- shares through their (expense_id, user_id) key. CREATE FUNCTION commune_private.snapshot_expense_ids(p_commune_id uuid, p_since timestamptz) RETURNS SETOF uuid LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ SELECT e.id FROM public.expenses e WHERE e.commune_id = p_commune_id AND (e.created_at >= p_since OR e.updated_at >= p_since OR EXISTS (SELECT 1 FROM public.expense_shares s WHERE s.expense_id = e.id - AND ((e.voided_at IS NULL AND s.settled_minor < s.amount_minor) OR s.settled_at >= p_since))); + AND ((e.voided_at IS NULL AND s.settled_minor < s.amount_minor) OR s.settled_at >= p_since))) + UNION + (SELECT e.id FROM public.expenses e WHERE e.commune_id = p_commune_id AND e.voided_at IS NULL + ORDER BY e.created_at DESC, e.id DESC LIMIT 5); $$; REVOKE ALL ON FUNCTION commune_private.snapshot_expense_ids(uuid, timestamptz) FROM PUBLIC; diff --git a/infra/neon/tests/lighter_loading.sql b/infra/neon/tests/lighter_loading.sql index 04bd6cc4..e2d475dd 100644 --- a/infra/neon/tests/lighter_loading.sql +++ b/infra/neon/tests/lighter_loading.sql @@ -394,7 +394,7 @@ DECLARE v_old timestamptz := commune_private.expense_window_start() - interval '3 months'; v_recent timestamptz := now() - interval '1 hour'; v_open uuid; v_quiet uuid; v_new uuid; v_settled uuid; v_voided uuid; v_quiet_void uuid; v_edited uuid; v_edge_in uuid; v_edge_out uuid; - v_snapshot jsonb; v_ids uuid[]; v_history jsonb; + v_today uuid[]; v_snapshot jsonb; v_ids uuid[]; v_history jsonb; BEGIN PERFORM pg_temp.act('ll-ann'); v_home := public.create_commune('History home', 'household', 'GBP'); @@ -413,6 +413,10 @@ BEGIN 'll-ann', 'Utilities', 'Right category', gen_random_uuid()); v_edge_in := pg_temp.expense(v_home, 'First moment of the window', true); v_edge_out := pg_temp.expense(v_home, 'Last moment before it', true); + -- Three from today keep the five newest inside the window, so the window + -- alone decides here. Section 9 has a commune that has gone quiet. + v_today := ARRAY[pg_temp.expense(v_home, 'Today one', true), pg_temp.expense(v_home, 'Today two', true), + pg_temp.expense(v_home, 'Today three', true)]; PERFORM pg_temp.age(v_open, v_old, v_old); PERFORM pg_temp.age(v_quiet, v_old, v_old); PERFORM pg_temp.age(v_new, v_recent, v_recent); @@ -426,8 +430,8 @@ BEGIN v_snapshot := public.commune_snapshot(v_home); SELECT array_agg((e->>'id')::uuid) INTO v_ids FROM jsonb_array_elements(v_snapshot->'expenses') e; - IF NOT v_ids @> ARRAY[v_open, v_new, v_settled, v_voided, v_edited, v_edge_in] OR v_ids && ARRAY[v_quiet, v_quiet_void, v_edge_out] - OR cardinality(v_ids) <> 6 THEN + IF NOT v_ids @> (ARRAY[v_open, v_new, v_settled, v_voided, v_edited, v_edge_in] || v_today) + OR v_ids && ARRAY[v_quiet, v_quiet_void, v_edge_out] OR cardinality(v_ids) <> 9 THEN RAISE EXCEPTION 'The snapshot carried the wrong expenses: %', v_snapshot->'expenses'; END IF; v_history := v_snapshot->'expenseHistory'; @@ -514,7 +518,108 @@ BEGIN END; $$; --- 9. The overview summary: the same parts, less the detail of the commune's +-- 9. A commune that has gone quiet keeps its five newest expenses that are +-- not voided, however old, and its counts and pages leave out the same ones. +DO $$ +DECLARE + v_home uuid; v_window timestamptz := commune_private.expense_window_start(); + v_m1 timestamptz; v_m2 timestamptz; v_m3 timestamptz; + v_void uuid; v_first uuid; v_second uuid; v_third uuid; v_fourth uuid; v_a uuid; v_b uuid; v_tie_in uuid; v_tie_out uuid; + v_lunch uuid; v_deposit uuid; v_back uuid; v_snapshot jsonb; v_ids uuid[]; v_page jsonb; v_paged uuid[]; +BEGIN + PERFORM pg_temp.act('ll-ann'); + v_home := public.create_commune('Quiet home', 'household', 'GBP'); + INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_home, 'll-ben', 'member'); + v_void := pg_temp.expense(v_home, 'Voided after the rest', false); + PERFORM public.void_expense(v_void, 1, 'Entered twice', gen_random_uuid()); + v_first := pg_temp.expense(v_home, 'Farewell dinner', true); + v_second := pg_temp.expense(v_home, 'Airport taxi', true); + v_third := pg_temp.expense(v_home, 'Museum', true); + v_fourth := pg_temp.expense(v_home, 'Ferry', true); + v_a := pg_temp.expense(v_home, 'Tram', true); + v_b := pg_temp.expense(v_home, 'Bus', true); + v_lunch := pg_temp.expense(v_home, 'Lunch', true); + v_deposit := pg_temp.expense(v_home, 'Deposit', false); + -- Two share the fifth place; the higher id takes it. + v_tie_in := greatest(v_a, v_b); + v_tie_out := least(v_a, v_b); + -- The starts of the three months before the window, in UTC. + v_m1 := ((v_window AT TIME ZONE 'UTC') - interval '1 month') AT TIME ZONE 'UTC'; + v_m2 := ((v_window AT TIME ZONE 'UTC') - interval '2 months') AT TIME ZONE 'UTC'; + v_m3 := ((v_window AT TIME ZONE 'UTC') - interval '3 months') AT TIME ZONE 'UTC'; + PERFORM pg_temp.age(v_void, v_m1 + interval '20 days', v_m1 + interval '20 days'); + PERFORM pg_temp.age(v_first, v_m1 + interval '15 days', v_m1 + interval '15 days'); + PERFORM pg_temp.age(v_second, v_m1 + interval '10 days', v_m1 + interval '10 days'); + PERFORM pg_temp.age(v_third, v_m2 + interval '15 days', v_m2 + interval '15 days'); + PERFORM pg_temp.age(v_fourth, v_m2 + interval '10 days', v_m2 + interval '10 days'); + -- Aged in this order, the row that loses the tie is stored first, so a rule + -- that left ties to storage order would keep it. + PERFORM pg_temp.age(v_tie_out, v_m2 + interval '5 days', v_m2 + interval '5 days'); + PERFORM pg_temp.age(v_tie_in, v_m2 + interval '5 days', v_m2 + interval '5 days'); + PERFORM pg_temp.age(v_lunch, v_m3 + interval '5 days', v_m3 + interval '5 days'); + PERFORM pg_temp.age(v_deposit, v_m3 - interval '3 months', v_m3 - interval '3 months'); + + -- The five newest that are not voided, newest first, and the old open one. + v_snapshot := public.commune_snapshot(v_home); + SELECT array_agg((x.item->>'id')::uuid ORDER BY x.ord) INTO v_ids + FROM jsonb_array_elements(v_snapshot->'expenses') WITH ORDINALITY x(item, ord); + IF v_ids IS DISTINCT FROM ARRAY[v_first, v_second, v_third, v_fourth, v_tie_in, v_deposit] THEN + RAISE EXCEPTION 'A quiet commune''s snapshot should keep its five newest that are not voided: %', v_snapshot->'expenses'; + END IF; + IF v_snapshot->'expenseHistory' <> jsonb_build_object('since', to_char(v_window AT TIME ZONE 'UTC', 'YYYY-MM-DD'), + 'olderCount', 3, 'olderVoidedCount', 1) THEN + RAISE EXCEPTION 'expenseHistory should count the voided one, the one that lost the tie and the oldest: %', v_snapshot->'expenseHistory'; + END IF; + IF (SELECT array_agg(e->>'id') FROM jsonb_array_elements(public.commune_summary(v_home)->'expenses') e) + IS DISTINCT FROM (SELECT array_agg(e->>'id') FROM jsonb_array_elements(v_snapshot->'expenses') e) THEN + RAISE EXCEPTION 'The summary should carry the snapshot''s expenses'; + END IF; + + -- Pages go back a month at a time from the window and never repeat one the + -- snapshot holds. + PERFORM pg_temp.act('ll-ben'); + v_page := public.commune_expense_page(v_home, NULL, 1); + IF v_page->'expenses'->0->>'id' IS DISTINCT FROM v_void::text OR jsonb_array_length(v_page->'expenses') <> 1 + OR v_page->>'from' <> to_char(v_m1 AT TIME ZONE 'UTC', 'YYYY-MM-DD') + OR (v_page->>'olderCount')::int <> 2 OR (v_page->>'olderVoidedCount')::int <> 0 THEN + RAISE EXCEPTION 'A quiet commune''s first page: %', v_page; + END IF; + v_page := public.commune_expense_page(v_home, (v_page->>'from')::date, 1); + IF v_page->'expenses'->0->>'id' IS DISTINCT FROM v_tie_out::text OR jsonb_array_length(v_page->'expenses') <> 1 + OR v_page->>'from' <> to_char(v_m2 AT TIME ZONE 'UTC', 'YYYY-MM-DD') OR (v_page->>'olderCount')::int <> 1 THEN + RAISE EXCEPTION 'The month of the tie should hold only the one that lost it: %', v_page; + END IF; + v_page := public.commune_expense_page(v_home, (v_page->>'from')::date, 1); + IF v_page->'expenses'->0->>'id' IS DISTINCT FROM v_lunch::text OR jsonb_array_length(v_page->'expenses') <> 1 + OR v_page->>'from' <> to_char(v_m3 AT TIME ZONE 'UTC', 'YYYY-MM-DD') OR (v_page->>'olderCount')::int <> 0 THEN + RAISE EXCEPTION 'A quiet commune''s last page: %', v_page; + END IF; + -- Everything older at once holds exactly what the snapshot left out. + v_page := public.commune_expense_page(v_home, NULL, NULL); + SELECT array_agg((e->>'id')::uuid) INTO v_paged FROM jsonb_array_elements(v_page->'expenses') e; + IF cardinality(v_paged) <> 3 OR NOT v_paged @> ARRAY[v_void, v_tie_out, v_lunch] OR v_paged && v_ids THEN + RAISE EXCEPTION 'All of a quiet commune''s older expenses: %', v_page; + END IF; + + -- A new expense takes one of the five places, and the one it pushes out + -- joins the older ones. + PERFORM pg_temp.act('ll-ann'); + v_back := pg_temp.expense(v_home, 'Back again', true); + v_snapshot := public.commune_snapshot(v_home); + SELECT array_agg((x.item->>'id')::uuid ORDER BY x.ord) INTO v_ids + FROM jsonb_array_elements(v_snapshot->'expenses') WITH ORDINALITY x(item, ord); + IF v_ids IS DISTINCT FROM ARRAY[v_back, v_first, v_second, v_third, v_fourth, v_deposit] + OR (v_snapshot->'expenseHistory'->>'olderCount')::int <> 4 THEN + RAISE EXCEPTION 'A new expense should push the fifth newest out: %', v_snapshot; + END IF; + SELECT array_agg((e->>'id')::uuid) INTO v_paged FROM jsonb_array_elements(public.commune_expense_page(v_home, NULL, NULL)->'expenses') e; + IF cardinality(v_paged) <> 4 OR NOT v_paged @> ARRAY[v_void, v_tie_in, v_tie_out, v_lunch] THEN + RAISE EXCEPTION 'The one pushed out should be among the older expenses: %', v_paged; + END IF; +END; +$$; + +-- 10. The overview summary: the same parts, less the detail of the commune's -- own screens, and marked as a summary. DO $$ DECLARE @@ -550,7 +655,7 @@ BEGIN END; $$; --- 10. Someone outside a commune, or who left it, gets none of it. +-- 11. Someone outside a commune, or who left it, gets none of it. DO $$ DECLARE v_home uuid := current_setting('test.history_home')::uuid; v_user text; v_call text; v_error text; BEGIN From b33e907794180be184686debbf3e2264aab749b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:38:40 +0000 Subject: [PATCH 07/11] Record the lighter loading migration in the release notes The local list and the Neon release order now run to 0036. The release note says what 0036 changes for an older client and the worker, and that it caps commune creation at 10 an hour, 30 a day and 100 in all. The handoff summarises the speed fixes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- docs/HANDOFF.md | 11 ++++++++++- infra/local/README.md | 3 ++- infra/neon/README.md | 20 +++++++++++++++++--- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 2382a137..2f138c2b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -48,6 +48,15 @@ lifecycle pack. Work is paused for a handoff, not a production release. commune's currency once it has an expense, fund, recurring bill or exact split template (migration 0035); statements open for former members; and money dialogs no longer say nothing was saved after an unsure attempt. +- Also from 9 October, for review: speed fixes from the same review. An open + tab checks a small commune list each minute, only while someone is using + it, and reads a commune again only when something in it changed (migration + 0036). A snapshot carries open and recent expenses and the five newest; + older ones load on Show earlier, and statements and exports load them all + first. The commune list no longer slows as the platform grows, and an + account can create at most 10 communes an hour, 30 a day and 100 in all. + Deploy the client straight after 0036: see + [infra/neon/README.md](../infra/neon/README.md). - The website is `web/` (the editorial homepage); the earlier v2 site is in `web-backup/`. Launch names `website` (3004) and `website-backup` (3003). - No push, deployment, hosted migration or provider change has been made. Do @@ -161,7 +170,7 @@ no further copy-cleanup pass is needed for the handoff. Read [the product-depth roadmap](PRODUCT-DEPTH-ROADMAP-2026-10-02.md) and the Remaining section of [money lifecycle](MONEY-LIFECYCLE-2026-10-02.md). -1. **Hosted parity.** Migrations 0013 to 0035, schema-cache refresh, the +1. **Hosted parity.** Migrations 0013 to 0036, schema-cache refresh, the matching web client and sender, auth, invitations and real email delivery are all unverified on Neon. Apply migrations before deploying the client. Measure cold/warm/idle loading with separate accounts. Do not declare diff --git a/infra/local/README.md b/infra/local/README.md index 46b4e0bc..1589f003 100644 --- a/infra/local/README.md +++ b/infra/local/README.md @@ -40,7 +40,8 @@ files in order using a database owner connection: 34. `infra/neon/migrations/0033_invitation_email_safety.sql` 35. `infra/neon/migrations/0034_database_hardening.sql` 36. `infra/neon/migrations/0035_currency_rule.sql` -37. `infra/local/after-core.sql` +37. `infra/neon/migrations/0036_lighter_loading.sql` +38. `infra/local/after-core.sql` `after-core.sql` is idempotent. Re-run it after applying a newer migration to an existing local database so the local-only reminder helpers stay current. diff --git a/infra/neon/README.md b/infra/neon/README.md index b65b5c92..d2be9948 100644 --- a/infra/neon/README.md +++ b/infra/neon/README.md @@ -14,8 +14,8 @@ this is not a replacement of Neon Auth or a switch back to Supabase. - [Current design language and preserved layouts](../../docs/DESIGN-LANGUAGE.md) - [Local build-out from 5 October: migrations 0020 onwards](../../docs/LOCAL-BUILDOUT-2026-10-05.md) -Migrations **0015 to 0035 are local only**. They have not been applied to any -Neon branch. Release order when they are: apply 0013 to 0035 in numeric order +Migrations **0015 to 0036 are local only**. They have not been applied to any +Neon branch. Release order when they are: apply 0013 to 0036 in numeric order to the preview branch, refresh that branch's Data API schema cache, then deploy the matching web client. The new client calls `send_reminder_command`, `record_share_receipt`, `reverse_share_receipt`, `correct_expense`, @@ -70,6 +70,20 @@ Six of the 9 October migrations change that rule: 1.3, pg_trgm 1.6). None of them reads Commune's data; `crypt`, `gen_random_bytes`, `set_limit` and the rest take parameters such an API cannot pass. +- **0036** adds `my_communes`, `commune_summary`, `commune_expense_page` and + `commune_expense_records`, which the new client calls, and a change mark + per commune, so the client reads a commune again only when something in it + changed. The snapshot now carries open expenses, those created or changed + since the start of last month (UTC) and the five newest that are not + voided. A client from before 0036, including a tab left open, keeps + working but sees only those, so its statements and exports leave older + expenses out: deploy the new client straight after 0036 and the schema + cache refresh. 0036 also limits each account to creating 10 communes an + hour, 30 a day and 100 in all (communes it has since left still count), + and makes the `communes` and `commune_members` read policies set-based + (the same rows stay visible). It replaces `prune_delivery_records()` in + place, so the worker needs no redeploy; each run also deletes change rows + older than two days. 0026 replaces `save_recurring_bill` and adds `set_recurring_bill_paused` and `set_recurring_bill_archived`; production has no recurring bills yet, so only @@ -238,7 +252,7 @@ rate limits) are recorded in [`docs/AUTH-SETTINGS.md`](../../docs/AUTH-SETTINGS. Broad grants would let direct writes bypass ledger invariants. Keep preview branches separate from production. 3. Apply `migrations/0001_commune_core.sql` through - `migrations/0035_currency_rule.sql` in numeric order to the + `migrations/0036_lighter_loading.sql` in numeric order to the development branch via an **unpooled** connection. Run `tests/grants.sql`, review RLS, and run two-account signed-JWT tests before applying the same reviewed migrations to production. After a migration adds or changes a From 81747e993e8073a2a24530c4e6a2e010c417a391 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 12:53:20 +0000 Subject: [PATCH 08/11] Serve Inter and the wordmark font from the app itself The app loaded Inter from Google Fonts, so Google saw every visit, and it shipped the whole 63 KB DM Sans variable font to draw the eight letters of the wordmark. - Inter: every file fonts.gstatic.com served for the old stylesheet link, in the same order with the same weight range and unicode ranges, so text renders exactly as before. Latin (48 KB) draws the interface. Latin Extended, Cyrillic, Cyrillic Extended, Greek, Greek Extended and Vietnamese (10 to 85 KB each) download only for a page that shows one of their characters, such as a member's name. font-display: swap as before. - Wordmark: a 4.4 KB subset of the same DM Sans file holding only "commune." with both variation axes, under its own family name. - The OFL licence sits beside each font. - index.html drops the Google preconnects and stylesheet; the hosted policy drops fonts.googleapis.com and fonts.gstatic.com, and the build never inlines a font as a data: URL, which font-src 'self' would block. - The privacy page no longer names Google as the font provider. Screenshots of the sign-in page, the expenses screen and every cover placement are pixel-identical before and after. @fontsource-variable/dm-sans stays in package.json and the lockfile: pnpm cannot rewrite the lockfile without resolving the whole workspace, which fails here on a mobile dependency fetched from GitHub. Nothing imports it any more. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/index.html | 6 -- apps/web/src/legal/LegalPages.tsx | 2 +- apps/web/src/main.tsx | 2 +- apps/web/src/styles/app.css | 2 +- apps/web/src/styles/fonts.css | 89 +++++++++++++++++ apps/web/src/styles/fonts/DMSans-OFL.txt | 93 ++++++++++++++++++ apps/web/src/styles/fonts/Inter-OFL.txt | 93 ++++++++++++++++++ .../src/styles/fonts/dm-sans-wordmark.woff2 | Bin 0 -> 4404 bytes .../src/styles/fonts/inter-cyrillic-ext.woff2 | Bin 0 -> 25960 bytes .../web/src/styles/fonts/inter-cyrillic.woff2 | Bin 0 -> 18748 bytes .../src/styles/fonts/inter-greek-ext.woff2 | Bin 0 -> 11232 bytes apps/web/src/styles/fonts/inter-greek.woff2 | Bin 0 -> 18996 bytes .../src/styles/fonts/inter-latin-ext.woff2 | Bin 0 -> 85068 bytes apps/web/src/styles/fonts/inter-latin.woff2 | Bin 0 -> 48256 bytes .../src/styles/fonts/inter-vietnamese.woff2 | Bin 0 -> 10252 bytes apps/web/tests/fonts.test.ts | 45 +++++++++ apps/web/tests/security-headers.test.ts | 6 ++ apps/web/vercel.json | 2 +- apps/web/vite.config.ts | 5 + 19 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/styles/fonts.css create mode 100644 apps/web/src/styles/fonts/DMSans-OFL.txt create mode 100644 apps/web/src/styles/fonts/Inter-OFL.txt create mode 100644 apps/web/src/styles/fonts/dm-sans-wordmark.woff2 create mode 100644 apps/web/src/styles/fonts/inter-cyrillic-ext.woff2 create mode 100644 apps/web/src/styles/fonts/inter-cyrillic.woff2 create mode 100644 apps/web/src/styles/fonts/inter-greek-ext.woff2 create mode 100644 apps/web/src/styles/fonts/inter-greek.woff2 create mode 100644 apps/web/src/styles/fonts/inter-latin-ext.woff2 create mode 100644 apps/web/src/styles/fonts/inter-latin.woff2 create mode 100644 apps/web/src/styles/fonts/inter-vietnamese.woff2 create mode 100644 apps/web/tests/fonts.test.ts diff --git a/apps/web/index.html b/apps/web/index.html index 751d0d1e..9d441d98 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -11,12 +11,6 @@ name="description" content="Hub OS for shared spaces. Track splits, settle balances, and keep house money clear." /> - - -
diff --git a/apps/web/src/legal/LegalPages.tsx b/apps/web/src/legal/LegalPages.tsx index f07eb9fc..996d47ba 100644 --- a/apps/web/src/legal/LegalPages.tsx +++ b/apps/web/src/legal/LegalPages.tsx @@ -58,7 +58,7 @@ export function PrivacyPage() {

Photos you add, the payment details you keep for Pay me, and display settings such as dark mode are kept in your browser. They are not sent to Commune’s database or shown to other members.

-

Neon stores Commune’s data and runs sign-in. Vercel hosts the app. Resend delivers invitation and reminder emails. Google provides the app’s Inter font and, if you choose it, Google sign-in. Unsplash supplies the default cover photos. When your browser loads the font or a cover photo, Google or Unsplash receives your IP address, as with any website.

+

Neon stores Commune’s data and runs sign-in. Vercel hosts the app. Resend delivers invitation and reminder emails. If you choose Google sign-in, Google handles it. Unsplash supplies the default cover photos. When your browser loads a cover photo, Unsplash receives your IP address, as with any website.

Commune records what people pay each other outside the app. It does not take payments, hold money or store card details.

diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1cffa68b..0411cf35 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,7 +8,7 @@ import "./styles/auth.css"; import "./styles/legal.css"; import "./styles/overrides.css"; import "./styles/design-system.css"; -import "@fontsource-variable/dm-sans/standard.css"; +import "./styles/fonts.css"; const root = document.getElementById("root"); if (!root) throw new Error("Missing #root"); diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 85e49e09..8a70a1d0 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -199,7 +199,7 @@ } .a-wordmark { - font-family: "DM Sans Variable", "DM Sans", sans-serif; + font-family: "DM Sans Wordmark", "DM Sans", sans-serif; font-size: 34px; font-weight: 600; letter-spacing: -2px; diff --git a/apps/web/src/styles/fonts.css b/apps/web/src/styles/fonts.css new file mode 100644 index 00000000..5bef5737 --- /dev/null +++ b/apps/web/src/styles/fonts.css @@ -0,0 +1,89 @@ +/* + * The app serves its own fonts, so no other company sees who opens it. + * + * Inter: the files fonts.gstatic.com serves for + * fonts.googleapis.com/css2?family=Inter:wght@400..800 (Inter 4.001, v20), + * every subset in the same order with the same descriptors, so text renders + * as it did. A browser downloads a file only when the page shows a character + * in its unicode-range: the interface needs only Latin, and the others are + * fetched just for names such as Łukasz, Дмитрий, Νίκος or Nguyễn. + * + * DM Sans: only the glyphs of the "commune." wordmark (.a-wordmark), with both + * variation axes kept so it renders identically at every size it is drawn. + * Made from @fontsource-variable/dm-sans 5.3.0 dm-sans-latin-standard-normal.woff2: + * pyftsubset dm-sans-latin-standard-normal.woff2 --unicodes=U+002E,U+0063,U+0065,U+006D,U+006E,U+006F,U+0075 --layout-features='*' --flavor=woff2 + * Make it again if the wordmark's letters change. + * + * Both are under the SIL Open Font License; the licences sit beside the files. + */ + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("./fonts/inter-cyrillic-ext.woff2") format("woff2"); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("./fonts/inter-cyrillic.woff2") format("woff2"); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("./fonts/inter-greek-ext.woff2") format("woff2"); + unicode-range: U+1F00-1FFF; +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("./fonts/inter-greek.woff2") format("woff2"); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("./fonts/inter-vietnamese.woff2") format("woff2"); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("./fonts/inter-latin-ext.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("./fonts/inter-latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "DM Sans Wordmark"; + font-style: normal; + font-weight: 100 1000; + font-display: swap; + src: url("./fonts/dm-sans-wordmark.woff2") format("woff2"); +} diff --git a/apps/web/src/styles/fonts/DMSans-OFL.txt b/apps/web/src/styles/fonts/DMSans-OFL.txt new file mode 100644 index 00000000..441171d6 --- /dev/null +++ b/apps/web/src/styles/fonts/DMSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/src/styles/fonts/Inter-OFL.txt b/apps/web/src/styles/fonts/Inter-OFL.txt new file mode 100644 index 00000000..21f6aff9 --- /dev/null +++ b/apps/web/src/styles/fonts/Inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/web/src/styles/fonts/dm-sans-wordmark.woff2 b/apps/web/src/styles/fonts/dm-sans-wordmark.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..eaffe845b17237efca41844c544d36b7d6a929b7 GIT binary patch literal 4404 zcmV-45zFp(Pew8T0RR9101-3*6#xJL03$d601&|d1OWyB00000000000000000000 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/styles/fonts/inter-cyrillic-ext.woff2 b/apps/web/src/styles/fonts/inter-cyrillic-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..de83a9c7418a1121c8a59bbc7002c69c43cbe76a GIT binary patch literal 25960 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 bX8aVHA=YmlRlob!7u&+mJ0~xUw*UYD-|trf literal 0 HcmV?d00001 diff --git a/apps/web/src/styles/fonts/inter-cyrillic.woff2 b/apps/web/src/styles/fonts/inter-cyrillic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d750914763177b95070fba3dc83c3d4466919c01 GIT binary patch literal 18748 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/styles/fonts/inter-greek.woff2 b/apps/web/src/styles/fonts/inter-greek.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..024f0770326a416e2e8da7ea541959bc049c29d1 GIT binary patch literal 18996 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/styles/fonts/inter-latin-ext.woff2 b/apps/web/src/styles/fonts/inter-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..479d010d2902fa3a5b48e84a973db1978b2e9949 GIT binary patch literal 85068 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/styles/fonts/inter-latin.woff2 b/apps/web/src/styles/fonts/inter-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..d15208de03cd1ad7c5199f0a0ce915fe841e4722 GIT binary patch literal 48256 zcmY(qQ;;}L&?GpvJ@Xsewr$(CZQHhO+qP}nHuwMC-frAwKU8*AMD|NXKXg{R$%!%o z00R7nZhrvO{{rAX{(l_wf9L)u|NjS8sP4bOMq%s~dp>bh0mUc_KAHc5E|@-_lCpqu zfGim)KnN?HH6jF5Ko@^N0Sa_D8~}Y_HZLG65HWB(1_(ZAC@pva2n@KE(@cV3_rJN@ zn;~mU`I0RJ|H+Cb#jiShU^cg6`%XnS7bCbgg;C{t9iDca{lng-mFvI%FegiD6m8pN z`_JD$G)3d@FJGZEOKh?vwFPSdW*2cc0wYxVV2D{tb@kF9Yg_0c?AmFTV}z(AIX2o% zbRV=fRtM}F;{$;rLcW}5?IlE|+b1%2-cvuD&UoawY`)@_X=c6RC(2%7q(kw8_;ocFmVabMzd#!`S#lF4}TNR1HVRdk&j$3c@!VLD?fRP8wTo&;#< z^>TZjk^$};QGy^-M1Xk%!mBnDHKrXwS7bP`v?pB3BdhmGchcQ{0boVeg;WVyqG2f; zY|1^n9u#lk&o#A9`t9rP_ixW`DD#QLQcmKQ$YKdgI~OjhE_{n8(pf-)GRUueoVcnv z$G7Tj(K8P3Wn(zmbquoXvEiRH@(mi_3jsYl+P+XFu2Di$qk6jMI=#UO{J$P(Ny9pC?Wz3Reo4FL?m`Cxd8oPci-6PtOleSE|_=_gHmg_&wdmz@LO8Qxp6rie?9yykM=cb*R2=4msGM zyaX^JNU>pmu{KJT{s0D%X!JHlP>g`xuz7+3V4$%DFs0}pJkb+T*ciuGS}7PzI=uoP z8HKf(;a6Ffm?R~?mZZ#*a`_Tkg&|`)J}<{DZxhpr@z7D}Pf^LC6CWbgmTgy;U2QaS zN^P%7OkpBE0Wm@tzqtIz1KZW#F)@FZf*rU&;GXHPz1oYmXG}OUpekb0h&Q61WKx;lXIUZ3L(l7M?(DVvqz!wbpx|_Q z_+M`KSy`*&O5bOl(`6eJr!q%BTs`pld=W6a4RKW-0H^v)>Vhl=&i;x8+cOC72p!zh z+iOb?6w1Cn`4J|a3VZ)rv=O#--vXby8Ken84ax!F>TdS5|`#IG001Vf{0? zk)InKeD2+tZ%&ldUj_k_aEd6TFs1MBP^r*j_?_*tDN?3|lGo4eE?nN($6noD$FSU^ zX{uj#a1Wc40-i;@b`z^0!Q7>Lv0{b0d2tt_v9*~pIR?iDbPPRAqF>zL8~ zTj`5Jyn-Z!z2a~Uy(x`UMQ~96-`}29rrX=+Rk2!zwaHDk-zdR7 z88wy=gejX2Q4iV7(o%}`?B=Sw{lQjKQ!t>_^-My~7ohti)$qngzx$^>sB)R4!s)a6 zZztTV%_ir^-&*fVjthK(thnK>c{ZGrTf0@b*E84SUM4h;(oymAl zgx>`4AnjyeQmcO>z`YSh=)=83Nv9o9v;Q8_0_DwbF5j30SAn4^MQRJ~a$t)oDD?wh zTW8xG0qcOZxP|(#%K#cIjbuJ;Y1g7Q_aMbDFmf^GEI~3q5(?7TSDcf@ROJJ00 zHJz8%1Jb#HL#C{-^5i^p*Vs#2mr3%1!QL&0*;y*#2EL+jV6gzP({8B69SA)B4&FAI zfbg&{p;&xk=b0Nx)-y&{MbMn%pVvRI^MK-x?$;?^t)PsfM(;6PbdE{-SRshkb%nrS z`PeQ!D+?JG*G*gfl|4WOf4sl(*%$Q42Dv}3XQBQ#gj0PiaxE6+Bgp4zs`459=P86} ze86Z?G<_fx08(M#e$m4~tSPi)HtlupWcrxMdhX78!O(ib(|TlYgEEjIT8WUAiC~M3 z!b|)RmxsbjWKjVScIPn4B9tKTIrA#U265APjv+zu%)N&S+wGE}$XdW{Dp$A(&&MW7)+d@!BM5pJZGT z`J7|{nx(%k39o0?nP?;bihzskYV!%IKEm6FhS85$cGgS-)6Mip`Rpux!o9185wC~+ zxcM2;7%&{TQUgNeXANpyEV&B}kWt~zj?c^mm)v10Pfomt3BIv92+}}8`9Q;b7_q@6 z1uqMtaO@JI$WbCEl*mE`EOaA1?5HXb%32vi2?r!O$tCmCsX`6vC^Hq0R&i*>Y`TyW zE+G+T6s2hgM??$%bm^p|Me;;%^PVsz?!Fuk(+$eB;Z)0Nf|RL>JTwv&5rP!usYbJn zu|d`(8ue9ycfa7;{LvRT6@GN%#N^bJw(dydvf!xJt} zRPt1rH0pZaCo6a7RoS_TkPVoD_XOKT)%v~d>tRL-j>biSMWW*t#dkl&F<8{9YTr9U zLYP_lmq&nnU(ZXK?x=ZBgRX9nhRqWCk3Are?z@E5$dn?$1BLr5l;FRIbw)q?LW)M; zm3ZmUYdVcDUlW6QX&rHMu8~9g@oe6vLLOadrx{6jmWY*|Yv?)E>1-t@LrpSACCk0) zw!a#vin{a{-j9t$-QGUh?enz~DJgxcDl&A%pCV2tv{z^Hi-)zHi`T7%HK8%nWS2V@6^F`g=EWY|*W`q)TbBYbqt$IjXcRrZWI7BV$Rmi(;jywUr> zQANO4BMD1kQ-Y3Fm4O$wioxXv&E0rS915#9j~Rjl6w&!{XqH0Z(^!P8 z5Nr4VK{gF6>cV`R3UN^5JjSMk>ik=nnu6qP?fIahU|g%_U7PbsI~y!DmJ(5%l@d6s zv#DlPP{?-M+trwZ^7_cb?n(X3i?O}${IAJvdNkYIX_JWa3a=D4R6>Q)a2qo<8!>U4 zLD9B`ksaIDy4PTh2)4+H%L6ugw)$JIrD*;l6IXSFta7%Sxyv)Jw$4g^J73wM4QvsR zCrpLHtyGcFl0yeCmNuKc9=P+i-w{dPS(FGZsvX0#VjnS!O;$>|$L3lJC+(YIBaZ1H zQtsql1ETsIH8r?ulNV%-wQIb5ZpeTC^%oOLcgT8YuezbCGK-`UJ4^ji*()!f5U>_U zn(64p`NTsvM&U@J**&P%+IKXYWhxjOu9T%v(FIkSpq)``TAw2HuJ_!HAT~D+U$M>( zNZqb^-_5f+$@`$}aEmt@nWcN_MY$(?Tt>gbbG8RfHB_ zJG=^2B(wBivHgl;cSy}9G zny_v6g)cY<0ri@ZruGd&f}C}aWD-Pe=EC9YSfc>#sPVAX(#;14R9f4snq0SuSL;t$ z;ua5UYn}OO8rkcmh{Vz;7e7tj0ezaH_eTbr)UcP+QrOL0ZtoggZwxAw3!K}D6j+NR zQX=|SrosJb)0&aGq8OTa4PzRVhLjBKY@$usD9ysfM!4L%Sz993`Jit^702e2mk5jU z3akwQ{^i8W6STs#9YVRJP4E)`+Sm6#1k2#$PL>8@uIk#W-R8A5Z%jrL9&M?X-w!_y zKInGdHkZ{QYxAc%rts^NrKJeieGBZA`4siAdx`n)7IUsP=mAlgBp1I(nFi8GMtACn zDfTNnbed)Av}P+Un#WCrg7fFJ&IiDZ_S|$qj+#6Z;)L9# zfMhFywxd!h90NV-MyxUWmS5sDC;%CF&WsR~0h3R4k{e#B$k>&7v%7dCCf^2x>vRTDj#RiL6|_c3r0j6Q=w*zmcPNW0hU; zKHPmI63sT8Jms$S1Lz}fIH~QEJvt91hYmvA7450dg5Pl*o!jtEc@CUVrhhhe_4hGf zNdm|m5;=x5c3b}_>dU;Zf#1~NYJyGRN-u?}IU| z0#Xc+xJGn^xxqd}x#|PMw|4-u?HkJG!i`AT;cq7CE#8ZWPvlH;Pvt%jmd#;3#Cek_ zGuU}7VS1l=WG^$Gh=BVcp_-(WDW#Wmn7=cQsb%Co&_I1^c;5 zg|rnwhr=r85iq7*s%;)PH2zN++fhREv3>VmUA(vg2i*#o>k555--j|}d69O!uE9km zn>NR?X(|fv;=V|H7(Up5;PlhJjmFl1-5k&}7h0g5IYL3zMUcct$1aCajZj<9pAe-^ zO>M0p5AOv#ZiR7bkvQ(b;HNXx<5)+kz|`HaLY|6d#4^f>fXivR|E+%6NPchhq}B+M ztARuTCuAG=*5tboz2XpQ01ISlsv4=>k3%Vev`_MLfIDRU05Af}r<~0f@+Ti< zAp7;e?OSUCiDIL&X?-h1`dMZ6N=t{7I>8EN$qyIcgi&bY{XEp8AqMUZ8qdYa3DpjxSoIK{q2@i7>MCZ(!R100F=&DG{r5cyJTP zs8#ZS9$F0XhGX%#5{*pEgCIp(yF^l39O55+#qSL=$SQe{yMP}<8ZV*u52TQ{zTxjv z9<*SKZsv3S=oa@N!d*Mp)1ELyG?5{qOJJpiUm5C?URc&Yrfx zU11^;N^f*l;y62G>!c{B?*FcweD*9Kb+9*%=%U=w4N|-_a{=sU2Cwl`NxIsSzv6bo z?_RT0)zNP6&JJQUzg245a<1f$jgbd1!3q_hsjbkbL!0T7gYL$(;$;y#D+cS1!t-L=ClH~jGnT~jOurKzl zC*$n#w)?K+hNB{8VD6w35(Q;w3G}c#Q3HlzR2HoVXgIhUYl<`*rxN8&Gw(EK+$X9n<0WC${ae8Zq zS(j72{eF4n3;9(a(}G2e)}VQu%3dmXWrl54lI8t(YHzqT6e^ zn!5uKFg;d{9#WwCwW*qjSPh<8iIqA#$4qBfR@n}mUAxqVfXFb<{u$ATe>B|N^OZ}i(nxgYk`)kE5{!zhKq%o zm{8_;nyOvLIP&^C5|Lo;(3Th>%r6D=u@bG^z;;w#krph@W=|%|s-|=0T$h!%y0y`e zf7ocLy4Got3yw+nK+#8lPw)DIK=h0t;er7Ug!<^K2GTN6$tU1vghEOhzar4YEjX;WAvyhIdGoScwym$cK;+cT^3wgg0rWSE{eY1+6Ke8RRqd$PQvjpBq%MPld4fdGwS8j@gvL=uR_o!+R@c=+NmifqGa=>86>8N|p`W@K zp-F$sO;G1C2g712183y1?<}ys2U&{LKtATqj!uzmJFe%(Qii-Bk05-!J)a*Q27QT$ zI%Sf%*L{VF^yD3OB7iy>LYuA zyWPN1`+HTo-yCBcKiAsfo6`;_cYm`PI|`{!1`-?)6vqWSRtoTr%|-?=3q~Mm^WahBx#TOO@zRps-c)nK|?fI4cax4 zE?L1$j$m)}3;%UPXeF-QWal5z|83t3B(6xaJs}Oi_NMT(9}M`zen4xnmf4-jJK28~ z39Nv(ZS`CI+{4yrO7UEYLKZnPxV>aRCXaUp@uF;-zmZ(y{M}Kj${xAwEuQ*%$?eesc_w3jpQBQVGOZ?mC1`3E^+R_}=!iMx!re;u?O*>9=0%@^Jdk8^Kr$wl+o0^g*2zg=QzBUZLw7 z(P(L3t;5W*!B(R^tP}e~yzbli+L`6XxlgiD=e;J$vs>Y^(*Kde3*GiaYow(3vv2rf9#Oq((-z<#oD56=V z`WgXGEtNa&#eRkF_ob$(O_q3T$QH9Sx~jap&D8Q#E3zD>D{VV@;2H1rHmWpNW}F1k zJWIox*;lfNtG5z%#kw6LZCB=T*;P?fKXYYx<+#h_0!FpZwru6F(_oP_(5wfPYZwQ; z@Urj9O{ZzUQUSi28`gV9SUTLj$-7fq1FdVO%X3<0>vUQ*9mA7Rv#d+|d#iJqRwYAc zeFIgl>Md<1uU1gb1WdI*l)8!uYGn%8By9QXJlHKnm*F|YXp_%mI&#G^8eH6m+Z_vA)PtARZs(s%7~n~q-nazMLj zyTSuh%S}&uErENEB2qhPbX^Ys+$0its=Q+=;F$F zTgO$e)4HM>t9bLjT%cPdc8xcNxeP5UVe;b~nsDX2=m|c;?wh+8@hs{4K-j(D3oGN0 zF?o;O6RYBp@mJ-&jzqB)h)W&Vyj5T7Hd+Oov5PP=BUovrO_o;#3Q^%P;cW%@*OMSx z3*PKU5h|F$?c`>|w5+z?i(=leby-~1Ivo-`Q@@0>p1wW=JPuhL@qr z>AF1ylfky}8GUZQ?f(VwgzL2Fvaw_s+py(U6L7=pb34*kU^rl;(Hpq)Z;TzSP$Yn) z)whG+47Q$x4)JRiHQ6ZJSRk#GQeurRfpykFT?@&1y@>*&ERAl!tj<((0GHcxUhazH zz$|2gJ2&iNEjb?8Wf~ zZJ>EWm%*QQ_Xh!NRReSf5AIJqmx^yBU>P(9_2fXTD{+Xr*Z`{B+gA-5-u@_P`fuT_ z0KauCG5r-wmdf-AD;-B9cQG+u^vIm>#Uw@0sN=y^RZ#O<B^iL=W2wjRcLF-^A-;|^rb{y zqL4{n4JYqZOVfc{@I5$E@Q+Kn*TOPoD_l4<^f?=00@xAR|`M3Q!8PcWS6hKokKx#Jbn3xeK z!}j3yciU@jSD?YX&t*=lMp7axdH%Jvqak~0ds|m(@!@^mDR7xFXVX1c9%=(VF*-#m@Sd%t{X|Si}y^@gk6@PkZvOf_v^pnbHtvklw=uZ5+Hk zXu=xOu2@q+#VFSI4gM4LhsFK+gZV%t1m#nThZVXg5A*NRX6WI@zBxHkuLH1QDZb1R zCuc;jLAOD$LD`777J(ylOK5|*boJ5|$w$2tDs2v{T94q}yP4uGV20Ntt1E>x8|DhL z!(8e@-HVyZA;i$$>)q&L=r7?JJl?<}v;!kMOQgvH4#7p%tzCf>z3r483oP4#$3AS| z_F7zhc@IFhjkkiAW$u{F7Ik#oLG0izb3jb!0Y5XHL|eOw+1)2pbH5FR*4b{t4~x~l z!`*M;!MXOoW=T9r$L4AH8#iL(g6`JNaEKS1y^!5M0ol5lk|2^}es(_i&V__)p4!B0 zJqe`wi2wSNgROu7rVcg6A0_u5O6M1CI!v+;Tz4vRcNf40kq;EaPdZ_MmJ-ZGE1IAV zNY#vNmBuedm>xGQc&0%QFe{P??UI3!nmNSS;+N15BQ~sIbTHn4HsJ~I}IVkD?K;7ERdEdDCq9$sjflWL<6y)l3nTR@czdNCJ_G2a%Wuk-%7bb!?i8+juRW z9DS+gNq3^-S--;OI(C|BLwJ5_hPvklyY;URKo5}Tp+roqkCeQxGx$W%v zfObE+c#}oH2QZ8?gJ;iKmFF%X;7GBdCZZyoApUD@bNokQ1wRu*<|#`%hK(fabH9Zft34Vq;}?w|{iZV>uO z>23nHM!!Vacg^HQVrFs4IWwh?m_S?=P?=FTcAhK%S&n7N#9n1Kufe{D%DqZ&5!%US z*gmqB&!v;z`=VMe??K&9tsLb66mXi?qiggM;TxL$;Qy9GrVKqp1;6j?jSZT(*BPd2 z&iOV&=5E%;e;8jJ++00jaOyW8ano#rydo%)t-47%0xDaq6M=OaTWSDE=>2`~F}?uP zzIppigJw*t*k&00o=yNiodahbaDA|*LGsih-n7iRNk)ZFA^)(H_y<fc0Uv?U;nUwAHnPqBN8FLI0(y`l`2Cda+-&R^HqVY7Ea zhLd(txXQtFwr$~YgA_cc_pImfWbsiU^WD$j{w=Bj)<;Nmhj5^v0e;|*MWc+jy;@*R!+9yS=*w;&$4|x%$~4h;Gi{yK@_*#;VMb!k!reN=g-olGk*V zq%k>ZC}9mH@=Ob@f>&lQOQHVYWLx>P%$M+C+inq1rmImrqxQH6HOi{KTO|UfXXR;U(-AW{z zB~ZYiE=g&LrOD|DCQ53Grph`CK~FmXbpu5dh>VJy7N4A$nvfF3U8E!*efPt#kXeX3 zbd>uRh~9H-Qs=&6)um)rR}glK;xSsBUtS!orVxX5q!XSAt{Av)J?PvQq;y{+)p}BKt;LQpWd$0r8Zyl- zuFfwJU7IK_cmd`qBwO-cx%)f{A*qTGk=m~&VoaozjiFQK(n-8B~#pzviqSp)v7R8$#+9{Rwr z7+HBq#nq{Au<3e?+E7%PU3i2>P?>asIZp9+yu9OGBBCObvZScj3(DO&q9U$ZAeYe? zR|br|#2SNl&m}8V)+M4q=X(;#WFdO9I^X0(!1na@yyY|WSuX7Z+T%N9^Igr!R2R+l zi0(WzW3ICM5bJ zi9$Gn#j^@(@Yux8JPdhRxJBjuhp?!Xcs4Ilw&!fpc1ckX5FViud_!A6EsulHIWymv zSKS6G5a!3Up@fVD+rH}8tYd{s$s$F&CR6+0-(1tvGPm{hcETW9!)36SJmdue*lnW2aN|y= z>JMIEGx5x*815ZUCElK&J~BaR7N8?Ua8x)w2lk6XIGDM(sBA!C@euv~c|){xwe>Xh zHMa7yIhLj@tu3xDuP>m0$KWGsIJq)0C26s`wgkN|$O}Mc>iGLdk%>C8?I9YX@g|l< zhD%fRv{mU$V^|7n!w$C0qmf*e)~!PAGpP=8c#h>)++2iM^Ta znU3JO&mHJ8)Czlr`b0_4E*l!900m3(I4UWBoWFxnFGC4x1(p)3v(68OU<0Fy?z=WWO&pP@hl)Dm3|vjccA<4a%zbiIY?Qc9(9Xi7Z( zwXM;r$8yW8TQ}rui#CVI$zpdmiCHWVgC_f&tg6C*@M0q=TSl;A&r+3nc;z^}N$}XV z{j=3khu#yEnpZJ+dzRB|U8?^Lb?F5*8OVrW!IIOO@_c?gj>uc-Jcse@gd2$Lu+Yrb zEfs0kXw!rYla{Uv9Yc;ImbSMh-Uxrf2ydTPD4OzFDn!R8*B*Kmnu<))AdV4vmk|5L zMN>gaRp3W4M6=KS3La1(Cjv3l3&19D7>VFPb*=YrZQ{W;f;misTFE$S>X;f#qpHRD z3D@C@iYID-HXISHCQ*0d2@<>r1K_}^2ZS_9=&+N86Fo5s+8F^oG5OOI7d>&X$(bnz z(%CCD`>r-p+XgQM9n($j^#ZH*S~p`ksB$&kIVUtsZYMKk2M*x2!1*W(}M6x48(F-gSaz z(X{)FhRTiSC)R^Ri~%_wJbIq#AZ^HrFe+yd8B#J!tH|Rlw?HXQ@1unOBw&N>lS)}6 zja&?!XwFo7qYL-NW%g+D2fs6rb7rkP-}L3Pf9p5gg8L4DTa4`#vnsSp2^>(IE1)jJ zcSp)6nmPZ*x@|d83#NPUX>*aOJw8T%r38txuHfwjQ2@Aj4S11wX_mQvd5#a_D0hE- z?at|`04oFF@cjyz`9!7ZSG*c2r;Yr5OK&)OV;CLaAc3baf2fDy{ry{Ff70lNith@o zO2H!(-O9s$$kQlE3A6M0M1jvS3{G83-V9>czHRu9_hi|;{h$)%614TVn-)(% zvV&9DkmA$K>8JyS8=C$b$71YA%vQYI@s6hOTINI)x}e7i1CEha%48aaN+J1yU`t*m z8$77D^}zg|vNGkmWUup)RQMQtwpZjhx-GYW_lHrldCJ*5dUHU~40M#*Vaj{%dyHYj z$W33^TftOrmxvutJ?h~^54h?ugNlC;q>u;}QtMcNod4f!%UE|$J3NmWZpI2?+q&5h zA<1X|d9ssCwRkyWBAgw4JM?4?aYiYwR&>8FWm8DDJp{^sxj5;!_5oh#kBKo)(_$j{ zfp&!x5Ef|*%+}JKC6k6KH+6s$AADD>#;@+d@!XL)wdwPwShGIAz__Of;Hb&(VT;=9 za4zqVs2sQ7WVw@b!87*P@*!#ZLdY%R6uA+WrDWv0oob48NyRB`SGVN{EH?*#4kb|aXi zAP_@nWRH34DNIS$284$ZG#NCe%%F{=QS+i$Y6`?qRKb17NV3v-0(NGvc?qXcn2lo> zIj{K~TbG)t>^o_)OuNhmr($pY)no=%jK4H|$#Cqt%-`zhDz&fWYD!cI+6duDBnk0> z<0KJHiI91|4AW!+PxFGzCu~#nE%iC5%JdmxsB!#UM3^O!Sjt1@0xyFz583M6!ytvr3|Pj4mBnwB^4D^L_`7!BmjimKXRST)+rA&O6t|`>1k;d z6%}FOU}6aX{)SXTCMKq)ZB(+%%F4>{a0rP+0RMkbwf}AYA3*(|5ovs<|HA+d{xAC< zg7ICf;hh(exGtfBX&~!~LV*WOLvgJ5az-j%{pQrI1}jP7fI=5^WMX#xC!+hy_uD7Z zSyfTi#gby80CIKewcaG7;ACTaou2l6U1G_hKJO zj>2=w{0*Op$fK!Df%*jP8{V8744`%IB`fYBt#UNr?MwJTAUp;b4K=xDdM|V zI?4zMuSH*~)+8ARa}l3{B!rI$^iMD#Owb)0=Yw6varoXy>?fqLG=>wBRI~R*s!FMJ zz9?1te63Qm!$8Neau_j!|2V&hke?_J9GbFtZhvoX;9wMD2+@FBT|d7cGU(go1y?6} zE4O#vwCah=SALY}tGQf-QqXZxI0FEQdiT9xfk0?nL6Z31NF*kko|avCA4H&CjAY2^ zjk#Q@O0mPRgRw-RmP2pXuE!Ulj*wZ`CA@N9QCWZd=v6~NA%Ng+ZNvwiZUkZf2-Pj* z9TJgHI2>Uh5EL4XIz8}zAehe|00as}O!gn-JoYvURq=Hl>7=9h1CEb{a&rm^XkVD6 zHd;C&2i2TN#SRRtswmjKdT-GwI)Gdqz^+?arro_;;qY-^E~7R3r*4#WPA<&8x9J`v z;%vp*b#{B)F$rrg6LCl<^I~18o!g`ug-R)o%7X-(x{SeVQpQr@-%F%i`-`1`82mSB zH++#tA3y<8-6UQ$?3~3MfbQ-RZ$jYtfMeKP+QkT0kP{tU7%7=Ni~MYB`}_zDRtO6X zFGom7mMawg%_}&ffVvi4GdJMiw5mMz`%RyYMu$Vxvzho|;=cldVZcG{Xj-h0mlPnG zpknIXa_39IU#k;p03kdBAj^XdFoPm);-{Ap4`Sh~x8Uo4L9Eh5*n11Nwh(7r5t(>F zE4uS4x;7KT9Pa!yg-8S|1+A$nJ7cUQs*woi7Z9LFu3ZOX4l$A zH#IOZ5_7VDy01JOv=^NxfW+s+Km-XfsNI1?;i+*HhCVzzR~{fk7(x;R5yoIH-aBbF zglSr0i@r2$t8EFfuf3yw1;K+5#{&5XG|PgRSM$%=L|Wso>=SNH-l@92RBAmrvX(Uw zEhZe9DC;_7UD1i_l}{=bEFWCQIY7iN61>du9inqjN%IP(!j1LjiVgCqA5(p@-ybkJ zwTOLw8e};mKM9ihgn+4>`EDnq!L~oWUkV7vRT*<@iSVtPn-e{M!MgaQK=wsm@V(c% zpiT1de&0Oh{~3N365^9mmBM@cU6uJ+8;$xkx>M1+bmz3Yf~I}Twcb6RSe`y|(uB|R z-G9FD`Y5w|+cEV@yYL*sH)|tpw!iY)=}fIEN}#IXTRVRIO~gZgJ!7-{>ATK*rQw@! zmlB5X^O`s>J!&_MBN$3bMYg#%)6ftT{@Y~I3Ewr&u4m776<#rb&EZa3;` zq2-{Vz5ZwB5~sHZ{$ioBq-?h9sMvAY?EHIyHVb z$jJJBhW9)i!dr89X-TTl-|I!>7vxEoqOD)^Y(>zC(dx>w_2|>T4K%Cb<4R$=TS)0* zqGR_;u1@Rm>AOdKCfb>ql?eANv;D5@&2ZEAGBk>!&avZd30Rw2ow-AvB&oF*`^W5X zS-H*@3-^9AaYoK7(q;MeRow{356H@w0$TazN))ijR4sj&{s)vA(+?=hf_l91yFzO1 zC0JWtC;yajcy8jZ{#}|HIyx>&s9O59;6p2;jCJ4{Gf_#AsmTEH~1Yybs zg+#R*C$Yk0;x&ysESaNHFs6yN(n%+%OOyLiy2=&L<(%1GO*=wBtt>~CXCFKSo-jEO z930So-|R{DKku`SK_?;g<*!O0zE|**wkxo*RFb19oR$mefxksEg{iJfwBOsh{};gL zus^_``URIT6yza{qoV9>^^s_|qLiA}ZfE;4JPzqyL*|R0Sp7wGBLcKKuOf=O_!>l| zkvcI+Qus20MFAkK`B124hTpWb6_^ijSE8U_tzcT}=9*QL2#C@9!DxoQCR=fQQaG{7 zUBI)Eo6w_{a4NcBKfX9#B5#`>$dkM_(8vw?pef0x7A;U(BqTq9qrOe6gY^puGrA=e zT2zoX!;ob0y7!GG31MSAXeSZqeloO+lY(naoA|sc4Dv39PNc2?htS9X?q5i=w0s|d z2NL1*xsDk~HpR3%;A&ohIwZx}J%`DvVYVFc8Gnse<{8AEs*ok9M0GLy_(mFqL<_rg zR%(eH8r@lz+f5y|x^U^$N!%+`8brJa5X7fB%Dvk@L;tVxSHfBIi&f(mPEM!K@%ZWy zvzFXOLv)JfXp8)t@0+yEt#lmk(>vW$8qW=YV`>Rz-{P`{>0%@Av~{cR#_jZ?;iqG9 zG)L)XyP3>i+TRb^uKT2(+{>F1ERW2P6D^;e@tN}}YHl~YG$Zqc<57O(T` zktAQuxs+os?Tr_sFX2_$qc7!$8Kqz8@0p_?!OwrsNyezA;L|ecdj0?Qa{=aX^F8(6 zZGh2$fX^kg+G{1QlwV48G<-z#wz@?7#vy{3dcW_@$?4H@$fbJ`C#20x$AvD3E`~yg zBHMq>&oJD)$u+n+GBRxW`f$D-U)#!lzc23m?!&US`o72JzAsK==YDV6{`{?Db|U=( zn2`TIZvJlB-|MpAaejAe+FW(~j$?`5u(|BL*B-9qt~nasj(5u5vNxPw`#QBgMYNX;^4n;M*8}aBW@ZD{~P$UBnFU|zR$}kxx{1=H9Nr*+GvCu zbETfGu|Yq?^TBBV+FyW=KqV-RAmciTQp6&Z2j}@!FvAm|DX*cs0R$A ztNA{^k%P&G@;!G;X$3)ZzxGj5nLz6Q`a_wJ*hlPGq!7_Eq2R+6v{rV9Seym`6qpCq z+7jCP__yY^`&n|gy;7WOgh7fWzR2A5oHN4YZHlw*C0YdsDY`^>&Nw5n2YmbcGB&15 z3-jJuN0!%@ajl*zn`+6V8wZ{;iC5BMmB4%OGr%pBFe7VtZS1rfvC=yqV#63&I+`gK zqYH3KYM<1u!A>*gNIE-u;-FAF_<1au^iYv9SDkp49tuL;pOA%=!Z309W9a|mUr2si zW_oHKtKk_=1q9ruwC;31FzP3ycO2%06Q{Q>L1ckH2gv(-ab(eYJ4Q=aOoxp(lS9Ot z`M1ywzc#-5ehI7Q_f5MAOLOO9T{v%r%bwk%yD;+e>hgm`|G6@Us`(%|QjQh2bLfeC zB}L#_hBu)VB^-`@+(8O;y1*8%Vfi)$wilx$Q(bW^#M>5pID3A5%KIJutZ$tFrBh^1 z-mf*9-XPUySz(iJJjN)_fpUE&-G6y0fBsDfPHKt;R zTls#^vQ@3@p@*BU;CwG%bs1lm9SaGA2Kkxe?%YKn;~U0+ z)_JpH(!d40zoWzb99^$0I2Vd8!srF=6-0oDcssETX^@W&a%OE5f(*O`4QEE%dpz4H zdt$BI&+$EzV|C=3JvO%e!D?eK;0g6}hc?RGuMSLq2RADGScx<#DhoZ-VN=XD}GUAU}~m6#oA^~fe1YU}=K5eB+THdLhcCv)$ zbyrexw@%vFL8dtAfu%rSsBxOnS>juutcJ5a^QFHG$IYEO%ZEXo>!5iCN3}S*7=Vsr zfW)1EF2t0TNqnK;Z`V_0+xDDtLX#{iS16y8F_#%t2qfzmwpf5HQbtJYa0@19yl+lbLe*Pzl|KmJG$M@H*syIzL-*XRTN~vw z{zLfOcC}c%W@4RJDG!#1VwFDN(z;i_^EbB>8Ce=&Ers(A+W4P!ldpMe5UcsQbMe}J z_M#^KLFPzI9b)@&n@$YEdjAM>noeVTi?1NwSY*sgU?8S(jX)?AWvGRHl7Pl+Pkwc6 zeLsq8umjhSD0|#mt(4cwa@gUOPz1j8Lk%}gnQY7VAm9G)8eP#D?_%7y{_eHVEe|wt ztoipkj=t(tUZwO)c@{ZPWITOw7lo!&)2p!$c0W&h_*Ud=v3{^1%^=-i zj}{?RJpj`8>oaci#QNP2+V1P3Gd8O9p8CA!TY1^((Knu+GD5NkKZU$H~!1u#oG zxx$g+(RncW7R!#Lj_me2SHq1r)IS*c+nJ;#X2BufMmgxXNk$%=ODGS%>dT$upSTm; zGH>Mf0`|K}G1)wCSrs%fnzGhO5R~ykOe>#nd*_V|k-hc}_7iP`Tq6FXtGJtK7;9`5 z@|B8i{RS7YaE`oq>r@lBriBFEOO^1rO&FMC5SzIzgsR)tj6&6&QkxPf(+k5_ZjEWx zO2A#!+rQ!#4Z3~kpbeZxu{P3(Gx7~3A%wWD5Qktf3eB54|1BVS>*x(Mg<~m{af{*|3m65Gn1>MFK2?yx6gQ5T_)*Lyjt zZ|s=Z*Gcm95uo7sAk;JB5?%qBG}I{L$Jk_n;M`=c)MDA<7;^{rp9&^W}l zo=`V|xY>4Gn=Mm7U@YR#1D`BDrNRf{hNkt6Up7B9u}ij8YN|DBG||@b!DP)S1KdX% zZYRa)l>;*6QDS1u>JxwDAsiC&4vYrmal(Oh`{1j^3^6s`6f&f;M6jz_k6)!G*xcNP z7>(SF+QRdn zi3ok%s!O}4m(qS8Ce>-TkUSA7?x+xTz{jT{A6Yh!+(~UqFcu~ji>o@!Qsx1iPfS>m4CMzRz|he35Gs562|g|(0Wem30U4ZyI?urs}t z4EpvXXAqNd_Y`<%8#SE=s28{-+Qu%!!+zK(;c9^C^1H9kFjzcx1zBb)DU=Po{xKTC zYDh#c!hApS+tKe1)*CAVdY9;uU#r5CsKU%;(E=41J}X_fOr z_J3Z$@V>mq^X1Y|NG1wWt-JsIJtl~JUSm3AqTkmUAX9Ho#R*C;oAi+c6Hpm(4d%PR z5G0x9(?rZr&?)hxLtIL61>X_JJldZ(g14+2=Oel2-_H}5y&fy>Dvfasg5VmX?Ae0) zY`KX8yfwH?3TrRI@~z(i9+T$Em!8QYb~8ORB?I@X8TEM%=$|dOjH;2wZmZ=GPNppe zz$@LdryLIxpX&d#%f=asi1a&W(g=F;UfP!?RU6#qzvf-|_Lo?q0ii8pXJN>L_cJ)i zIA1o2{4W4GK*qm>S7v|O(rUBceYNgg_uA{a-+sy6%Yn!16t=dt`XN@YJNESxsVt1q z(StLN`JtT1`MSElKm@KaeKXuNvmM>ooHgS%R-kC?`^@EYk9xjy2Q-GoF7{79x-i!D zYbV&0>LntSb#F9{oABn@$A~lATiuO>C@R9D&U6!5{Hz@tVtq1x&*{w$2b2QUy10&P zZz}WCnMIczeQy_U3jW_fCV$V z1i6WX0sBB^dh?ISak6)xM_C;|>>cOFqg5=0Ry+ovw8`#{^`+MB?P*Xg8t2+$F@0)J zrjoXN4QIP*vo~$A9GgKGb|5!>$?nv%4ZwdR`g2@Tl-5VJ%>JT#hl8H+L|Yhe+iqX} zAdt$)ZIA!EVYt`ZpZpI*3P0KS|Hj7eNTt=6wNzF=uuLQOORpYEVbq%L3*$?gUI{Y1}6vm!ihyRBi8*I^TzfqlM(cG6^F0aYIpJ3-Gf2=231jsQR zz5n(CsOQ-O@%>jmwD-4vy4_y~j5#seUe42Ww2!ufX<%$vv+t55elkOZQfEfO*FrE$ zYdC@fx!Flm_fy8>`-G#-nm8h{u!bl~>BQ4YBZx9`oL19B0lr_TIrk%jQ6D}IM!6rI zlU_h{6iKEnWV7ZZQcfmSvn^74yRvZk+-%YE+geSe#x;{dlA9A}*GVN)9mN-rK=V$& z;LxX<+@&*9a`5V*5k-IbJ?O+i((AW9l<+Bgu>lwxW`VIm!bxiTuf5vezyHVWivOmj z|NMVsEwcjn(JarqkM>2q|ET9-+tE4270iKB$)rWvf;EmPO`(^&Q_F8^3zyH&78&2I ztS*|9NEWm;$9u!NkM1{L*CM8OwgOTyn_jLL(hF>y+qBtYadz~|W7;gON>`sFU%QO{fvN85p}yg8gCy zvHreweoMSlj)QF8{h=}2N$wy%W!*h&4vY=Qnckq;sMJUUS!0RzuJ^3ima%BLIH$*I zE7RaC@htOcw8gKpF)CeBu|(y^jHmU)`T37>R1T%dnXz#td&Ryb<=)MJr~Ay|%xnTd z9Xp6E+`24t#qni2R|fC{PJ$tBh@NvhIvDlwgRPX2 zxD?ndc#jQI9z6xy>*&Ik{wy$QC+l6BX}Oq+g%G<;SwoT?4g>dNiVl z!$Xuc;G? zUj)YPv%uILWYpu!s~bM=zCC*(P1rjYt5S`|#`LCLn7zHb;q$U;z#WbUoRV)K(*6xR z>Kr&rjT~QcczFO^(ufVGA!nM`;T>A-_n6UbAoRPwwRO%mw`jft`L49UJGMIPSx9ZT z|H`!%51DFSSg$cWT(flCHrnfsvd+~?<7Qw|&A*4icBdRfonyl02c4lln6Oy?=>Lr^ z6dfV*!Q2bIZDLy2I$7ZFFH`Q{&h_Jdbh{VghVW;XD*-p&5taSG`idto28bqm7Rm30C);R=F*`?t_)oLD3yL^wj~<4{m8B!&LF^ zOc61!2))~Zyeh%8(Cn1ka%_MoIelVo*0$pOd;V%J=e1eN)F@Ys(E@rlV$8?s1seD*A7qQ4Tb2+7&rG4 zk5jd-3?`gY#!XDDB4gF)Sd4#M62LEC2)$#O#9rdqX9=M*aw4j?f_LXjO7R$hx8=wv zFg8qYgLt9%#kh>x8@ha>iOIaBH|lyjQ){!av$=B2vDyqtvMw2WM6Jf1td+2RGK5g4 z!3sGm(WdQ1L-Eq#vEpU%&!#qdgPVfFmRaM6eUJI!u+ZpD!PyOR%*pIb%!!7aY*>sP zKFE)FV#wEu5GBuJG`WL>jqYY$-qOj5e50E>T}Cku&9{SWn+C>)(GkndSV0M%+L@%2 z;7+R5*dw~+WJzrX=9oMeJ6o3xa%{TZH54rw9xgC`wzG{l98z1xyt$W{*tV1zoQWQ+ z6p6(R@kH~>!+W?%NJRcBJ>s*TSx|fk2iy~ddz$x{EeOBI@k^T!bU4!z=C}LCWfvxq zMZXN;Jo>ynZsb?Mld>>S)!T|A)gZ&&F{;dHsZ|DF@2#nM56@L2u1 znZu>prd(PvCQ<|1Uz!$zRX2n)qaZa>-$c!D>UE&txJ-a;dhGD9`|+}MmKg+KY?ubd z2J<1u;-Ym2fR(eM5PJ9gje$Rj>-h1Cicq|&b(fXW)`FR;Y(?v%fV>WGLYJ8eeDJXyZh7*=+drfD#Z2Uq9;nu=EP^Vo#TM%#|ty37epmR z=L9)J`Rbxki4&yJY+_-LBJJoxYDG_9kG!TjO}e|OymT-cpMr6+=>U%UT;(n++U* zQ;Iy<>H9gICA}p{wc)rN1_PH@3r{KmVY!w3t=<{Dt(Goktq-g7zwT?#`*LZQnx)>( zW~tU}i7x;3(X|0KUab!M{^zY_3iSN6>i^@F%^fMaI9yMX5I$3rpPgDGL60TJ?-;6) z0M=cwLYog(XhY{#?z}w^7!-Ol`t;1GXMp(GXPN_RKx`~mZQcsADTf(!J5^vN?<52` zSK+Fl(k<6N4nRhp504}^5?Kx{ElabQ0%`j*)&T$0aemF5q z>Ihez(q#N~1z#JH%}-CF~>OF5|POtd8u9`tXtJvpPB@y+{I@IlXVE3RvI2 zb=hxcdV1c?6tKU2YbU#@-kuD79o6LIEY(zBmtn|y>gw5(RFfQVy*cZlfcRTxU7v@nduR-;jB)}adJ`Cl_Nnk$*|@1f%4lgp1Xy5Ed^VwQYOUgg zb-b=zA?wy~|D85fXK|~1DJ3_HiXJuS3ST_awi!i(!^Qgj(dO6A5&Y^57t=Fl(Yb*f zfu8~y#b~J)2>A6}W~(5Y)}rG8z2EpN+26i(*l)aVXH@YPBC%OZS$%R&BXPWr-E$}3 zu3GT)&E1`rKYeFz>6%zoY@*^-lIu>su*EA)Uj&5m;?3Lb1awrZ(`M%Wv=&dk&~<0> zi@aEIl|yB#GaYRg1KU(A%q@ba!e#9voM^&>&BN>66f+qT2LpWGA@VVDfh_W@=0)Js0S8@o_-IbP=eJ1gOO2i z7R=wYl!$KiaI1#k#|{d4Kx8%j%n6F-S64$@5~9$qh;2L-2MbTk2u6pd`{I&~8MDg@ z6=SCain7_n)Crj~bx0KQTP@x(nYWsARs1A+)9Nbdt^_vPO@to?F3t=_dGX21ylTbx z-Am`hspC7sw~=vQ$HChAviUeMqR z5A{2L!l4M=V~+COyS%6eH$0^7``xN=1T160tk;4kFWeNNaWW|H?q}ZT0#~1>hzGAye zV7n`9?cOY0aAelqVXNm`>%min`gUyBVDxj1MyquV+!m?}?g+aF1l|z5=pIa^sss6_ zi)4$%rNlU`&d*y=%NI+Fjm<)l!*{Y)aQ>>YV8uaIUzn@Bd>LD!F7(SvNj$t%E4X|W zcwlWeFaN`(%YY5RlVDZz{`UsvlmkNpCav%K2Ks>6Bekr; z0-a~LM+rYASwk-#xF$>Y?hUHp1jQ03=-MkJQNGtI=(MsHl5jX+!=-yR0Gnyh?fhwi zA5S#iZamkjZ|4BD;Z?hqV}lW1QRtEf3s+gun|MBd8blga=G5tHtkg|uNGznm;OVa3 zs$N*>$6a?sB&0tp6WmE*95!Qg5||9F3>5jmVo59gmB{RDlrO9~jfv`7L$ z8BW8Jf4W3qd7OhwRLor(B!N@_Cn1F|TtbL+AOyh^q2_x#(4(MNm|&BJ1|>psZ+Xhk zxAqgSe&j#=0~{$e4O=~kD_q|+>A2#jni-9&!%TXtS? zI<+$AM zRVr4wOQpQ9QSk}?F;ez#S;fCs&DJ=Qoq`AtSX^Et9;Yz&c$QoUA&i@f(suW9Q}Pz%m8`zX4lBjtqQa`9 zLse4KSRGkb`$)m9=Y(60%FyPpGoA@qmw>IvrjlXTIZMJQkZPS~$ zH%4bzJuF~{3Xf^yMYjoJqIb9PV+49We|O06+w^70t^lT=A2R^zb*4Y*EdN%A2GCa! zmLIrX%dQHcNg1(8;{Be?mDH8-ChzQ)p*h4TP7*q_)5V@B6EwP!0`qJo5hy9&t2bv= zX(Eoer;n!<79LAWK3!gFR2)iO z&y-N0WmIZFc^Z|;NSSgPHCO422Z(!Zd%$+t_Y*E`r>!g(C-hbq6fplW)X`zPyJY!( zc_dw0-&Abg@q^6f;&6g!fM9zjEn{`uS4wYGYNdwzs*Yo;&r`YMfAjPAk{yVQudxz! zy4mtmB$+W%8a3E25*M&;efLk17@8Xv9-J*EVwO4~VJIoJ{_?@z?ui5Lt$J4nQFKz0 zD3u)<=tsZ@IIZtNxCoQ{r6D^)GLvu!W0&v6WIK+(Fs^!pV5WP@vqjU9ZksVDF4%Q{ zV{Qy*AYARRkN*btL8G1{yI0;TX}eYcRwR@aFin5{^{`b?OaunUjR@+M9ja>7PgkTk zSRez5;%ItkTjq)x9oW>JAK$TEn6d;jZj>k0!;BF2URtqWB0+YT5mU+TK4*LU5zc&T%- zO-mv)R8|w3Nc1nc%~e%|dayCU)APW=Rr4*KU1}gnH+R8@u)KW;0Y3gegL^aD)j6tN z4*2R)lvnCJ>O#!wYubO*-R=~AjYcItDVHbaBL0=lSfr_AJmoYV6k_EQQ2@8xYH;$H7X7iMeSK# zuZ7ieJ3uL4P!mE*Li%lEVz(bz)s9RI+kuNy2h(YVFkD@mq-Rps?#=#kD`(&EMhSE(%Mm$O`Z5v{JW90hvy9fnF zF$^h)(#elaa&ip6I+kUXrUcEkG0Ye%-HdvT+!!kv5dB=bATog=V2BOBl66ZvL9bRI z?GBtrnpnLSU%s?18vxgr`r4_?&fd=6e%GdhJ!Le|OVEX0$Omaa;?5Kc7zqqPWcpks z1JdT*je3pTeW_X1oJ(0sX_kMZ9(aRvI-r09!l*12i9fq_wg7oh=c=awx@38d)aiD1 znQcQ;OzF~I+7r;@#Fh$sQ|?)i`(#JAw|8MWydL4@)V?eB7r0C(4f*e2R&+AV~^ z`qi*Ct2Cu7{_iTY&X3`4*=y;(oss|dxjFFSQJY(V&{0mftYdara%sZfN(i_ix5qPM zRMP>*R(=?apCUAezAntIECtMStpXx@(?1d&043euFF6NmbB!iey)zklZ?bBtZWLHp z_TckSSSeQG*)fBUR1jnfQiWa^*yo{8JU&oE{PUkkV43)(3R~5E+ES;`MNqcDRuTum z9($@Hc-Yx_D41dQa;a}r+)_S?&dsFf1D}`Q*SptW)$1%S{%Ha7mi-CzCSkA%t?5;r z_`AftI2>QfMJ?TY93k0L$l=@r>>k{24Pk3J46x0dqYFK?9cz=GaZgQgi$o;qj^Tby?c!J%)1C8fdJ64i&$fd{!Lu&-)Lj{Yptn&Pb2pG)n4HA zI-c>T|3QNNy1VnY_Y-;5*I7Wnt_v?Tv~p!b3G!h4**|Dz1Xo%&0}pG0Dwz7ucq2@! zyA$nkhjwu!I&wP*4SBQp-C5-`XkRgnCK7qH$EqkS3fZ7p;sbkw9c|yLr2|_R zX=620Ef%zA`891Sg6hhp`mEGs?AbXkG_rH2X#VSJ#jZT!*^N-jZoT0M{~ie#@q1Oj zP>mLD07K!4DF@PIdRqv>HDDAF?Lf6Yoc6Si>TDeS;Rt1*rsB`SPzRyWb{4-o|9K15 zH(0~P9gnyRvLx-VX_k%Wn1FGb6+8-r0>vCcDCOm0AVe$&EtXehpk3N&6a7?Y#`K5o z;y`hb{`9x{FB20DIO+1N&b^Jp$dex&h-x(YVh5`ALDf}`QO7Ti?F})AuWRiJ(Jp+d zy;l>B9bG&1m6!EC$9qQrFR7#M}$^4xwK&nUOQ^T%rzbXa!X|xdy?Q68sZA!h@ zzpQLp66%J|F1Xat`bo~R!9irt_ZMVgy&{tTYQpl6hh&TM+~G3k394a*>M^ZtcmS1J z<7-S$Scp8?v>hJ;!owkPa*!y)IxZWU*gvd*>>gaf{wt!OO=EAPu@*W(UQ2tHFV=M> zZJFu&!(cHJJ3?WMif$2C_Z#m(B-FeDikfIq1kSE*Feks&kZ&@uYFNq__YZs0i`h_z zy4aE(#_N71(n!6Z9?oe`+xQ@*Zn|&z&{l)&V zR}rj0USGm@4R9#5q-knK;z%E5=9E6lni8I2mc|@~`SZaKtz9S5*5M8}J}5u?5FJ#H zL*3&jTCtv6J4HZfS01i)Q?|e#9|v8lFI1ydn0>|q@eu*&kX_#iofCdrI>j8vD&6*| zMoNtm!nl(#CGAYs=7AZ`Jm5Q;^a2;@(JhCp;frLWJ7Md2sCrc3V)u6%w64xtmF8_M&-Tyl!^VsNLTCG}7kKmGn>iokpdWyb!mD!LM_?33X@cIml%y4|-dJTa zfLS(HSd`^fE|KQWGS_!qa9|2gDz; zazL?zN*q$^urf!KJK8G_#`VhklF#dDD~iWo++G}h%WFFCuXjCTF{V+M9!+$5pX?uL z0dKIeg%CXO&?ApM@zgWVz0m4qk?2)k)){u#kO}K8;Bx)acISWX+CPfA`q)%3+&KSE zW8#SMbyS$|`2F~+RNvnI8z|JZ-(@{#-I#}EMXFLw$Bjs*n3Dj*m2xYf08 zRnlF9*hd)F30pze%9fUN-&l)KCY>7G($a}zajwmF1U{_c+&Q}IKQ0_@OLOx$= zSSLTp90W&r1kpX<8fYL&a;5vB2l(K{@QM>LQB;0$SM6oCW5?nha;!4fa!YJ6A=-25#x@(}^bG#Zh%^wU*S z@G9Dx7!uLYI2npSDIb(_QBPmK^>8%lm3u3G38c&x){am)XdPkR=mu-SmzFi}M(8sT z7xFG6zrgF6k4ywP>YK+jmWpB%jkioqPJ1bhB9gUJWD9H zun0626fGIF$Pwtq5q=5G-n6PiNY~~}78S1yZV$v{Mnl3F84{zG>-)9Z@=}q~D&mi% z?fdgbisXZAJV0CF5foJf1&8z4Tw7ObFxmxB{9#!(cwUa^)z%n@ewmhVbr+yHUqlcv zXY`X=ABdYLyP1M@<%=k4{wcm)6 zWOF<-Axwro08>LLLkDbv0g&!|fsKmj(E9V;E{^+Mq?{SUjd~O2TeXTzgIgZ$K#b6$ z_#ZK0u|@V=kFGhw544=9im<}+wVZsufY;tvq7995Lx60e9@%;(-5pI9)GdD2Ad-F< zbqbbPHo>8{=r-3|jv_(|5hl7-S9PDGpFUBqbv6U^ZGxVU3K}q)(F30E)t1pRSOFNp zTZn?KzynQd2jH1~BJ5e(lqgzGSVa`zlLRo-Hh{-MB|BRBejm;85*{{O{1Zk&sSxf} zD~w|93-_}j$&kt&<;5j?rui8{vP#(tZm&(crs+zd{a!Wvc|#NnXr1PO-Znw+MFsQifM3fO_*=Oh zwfG-u^}+pU zY13z`W&fj{<7s2(Q@I04_vsJT_c;u(KZJ!f23O2KD$a?X2Q3}~btt#@PZor)jK~K& z-LtEso8uVi;p)Nl-|_G4=l9kv=d11C3fiAJ-bK{3+?houWZiXqP78J%Te@ujN1a;` zirHhp3Ego6=!+XjzKes~MpB4yYor!V;vPY5FsQTqJmUI^pGC&4D$l>YV&V1fi!i^W$8WX8(HL9&e}t37i0$66WJ%(KXQ|G zw(FeNrLFrY|68%jf?^@CkXYnf)L67x^jb_?oL2mzc%=BC_>VHR475a9=2>bicUm5_ zoVGko{i4KLv8@uV2CNQSomDO>pQ>QiSZkJbg0QI|hrBdr`U9aP{Oi$>H?lCu-&YT;*Ni}&!W5TAvoUwP?V;f}?t<28ZGj5;r zuDBx?<-|_z%3au{-RV)!sBI>1rg?_unEf{UUG}fIQS)$=YeFldn^_6W)pAl;V`{RPI#g^xevHPIS(4Ryk{&>z(z^UCtxU z$DA)(Z&{yPzgUI)4;!dq309$lQ{X`gD4+tAV22Q7pcl}Wf8!nhdpt6}Jo)zIuJ7|@ zUf^^6$CV!jXcz)H@ParkI85RF76rwTHY zkCJqeBx=N5G{qbbqadb7b?l2hk&TsfBu{!sHR)`^Q#=)OW_g~=lqH{JK~B#*bI)qu z>g}r+2?YNNVf~K=ED!JvNDL^3;-F∾Na#*TBTU(!jF?9zmR-!C*+RdvGrd0&{`| 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 /font-family:\s*"([^"]+)"/.exec(face)?.[1]; + +describe("self-hosted fonts", () => { + it("serves Inter and the wordmark from files kept with the app", () => { + expect(faces.map(family)).toEqual([...Array(7).fill("Inter"), "DM Sans Wordmark"]); + for (const face of faces) { + const file = /src:\s*url\("(\.\/fonts\/[\w-]+\.woff2)"\)\s*format\("woff2"\);/.exec(face)?.[1]; + expect(file).toBeTruthy(); + expect(existsSync(new URL(file!, styles))).toBe(true); + } + }); + + it("keeps every Inter subset the old Google Fonts link served, so names in other scripts stay in Inter", () => { + const inter = faces.filter((face) => family(face) === "Inter"); + expect(inter.map((face) => /inter-([\w-]+)\.woff2/.exec(face)?.[1])) + .toEqual(["cyrillic-ext", "cyrillic", "greek-ext", "greek", "vietnamese", "latin-ext", "latin"]); + // A browser downloads a subset only for a page that shows one of its characters. + for (const face of inter) expect(face).toMatch(/unicode-range: U\+[0-9A-F]{4}/); + }); + + it("shows text straight away and swaps the font in when it arrives", () => { + for (const face of faces) expect(face).toContain("font-display: swap;"); + }); + + it("keeps each font's licence beside it", () => { + for (const licence of ["fonts/Inter-OFL.txt", "fonts/DMSans-OFL.txt"]) { + expect(readFileSync(new URL(licence, styles), "utf8")).toContain("SIL OPEN FONT LICENSE Version 1.1"); + } + }); + + it("draws the wordmark with the subset made for it", () => { + const app = readFileSync(new URL("app.css", styles), "utf8"); + expect(/\.a-wordmark\s*{[^}]*font-family:\s*"DM Sans Wordmark"/.exec(app)).not.toBeNull(); + const main = readFileSync(new URL("../main.tsx", styles), "utf8"); + expect(main).toContain('import "./styles/fonts.css";'); + expect(main).not.toContain("@fontsource"); + }); +}); diff --git a/apps/web/tests/security-headers.test.ts b/apps/web/tests/security-headers.test.ts index ad7963c1..02e31957 100644 --- a/apps/web/tests/security-headers.test.ts +++ b/apps/web/tests/security-headers.test.ts @@ -26,6 +26,12 @@ describe("hosted security headers", () => { expect([...policy.values()].flat()).not.toContain("'unsafe-eval'"); }); + it("takes fonts and styles only from the app's own files", () => { + expect(policy.get("style-src")).toEqual(["'self'"]); + expect(policy.get("font-src")).toEqual(["'self'"]); + expect(readFileSync(new URL("../index.html", import.meta.url), "utf8")).not.toMatch(/fonts\.(googleapis|gstatic)\.com/); + }); + it("sends nosniff, a referrer policy and a permissions policy", () => { expect(headers.get("X-Content-Type-Options")).toBe("nosniff"); expect(headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); diff --git a/apps/web/vercel.json b/apps/web/vercel.json index a5c9fdfc..f8ca1aec 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -6,7 +6,7 @@ "headers": [ { "key": "Content-Security-Policy", - "value": "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://images.unsplash.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" + "value": "default-src 'self'; script-src 'self'; style-src 'self'; font-src 'self'; img-src 'self' data: https://images.unsplash.com; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'" }, { "key": "X-Frame-Options", "value": "DENY" }, { "key": "X-Content-Type-Options", "value": "nosniff" }, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ba039407..774ccf22 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -61,6 +61,11 @@ export default defineConfig(({ mode }) => ({ listenIpv6Too(), exactConnectPolicy(loadEnv(mode, fileURLToPath(new URL(".", import.meta.url)), "VITE_")), ], + build: { + // The hosted policy allows fonts only from the app's own origin + // (font-src 'self'), so a small font must never become a data: URL. + assetsInlineLimit: (file: string) => (file.endsWith(".woff2") ? false : undefined), + }, server: { host: "127.0.0.1", port: PORT, From 6c77bafe55a91666eb94f4f538669b09f31e9ab1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 13:23:42 +0000 Subject: [PATCH 09/11] Size cover photos for where they are drawn and keep a calm tone if they fail Home asked Unsplash for every default cover twice, 1200 and 1600 pixels wide, even for the small cards; This Hub and the settings preview loaded the 1600-pixel photo; and a cover that failed showed the browser's broken-image icon on This Hub. - data/covers.ts picks each cover: a photo chosen in this browser as it is, otherwise the commune type's Unsplash default offered at 480, 800, 1200 and 1600 pixels, with a sizes value for each placement (Home's first card, the smaller cards, This Hub, the settings preview), so the browser fetches the width it needs. - ui/CoverImage.tsx draws it as an image that removes itself if it cannot load. Its container carries the commune type's tone (new --cover-household, --cover-workspace and --cover-trip tokens), which shows while the photo loads and stays if it fails. - Home's cards draw the photo as an image rather than a CSS background, so one request serves a commune before and after its records arrive. While loading, the first card no longer shows the household photo for every type. - This Hub's loading placeholder is unchanged: plain sage, or a photo chosen in this browser. - Unused cover styles that still named the photos are removed. Requests on a 1280-pixel desktop: Home 6 (three at 1200, three at 1600) to 3 (1200, 480, 480); This Hub 1600 to 1200; settings preview 1600 to 480. On a phone at 3x: Home 6 to 3, each 1200. With the photos loaded the screens look as before. Member photos already fall back to initials. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/data/covers.ts | 65 ++++++++++++ apps/web/src/data/household.ts | 6 -- apps/web/src/screens/Home.tsx | 12 +-- apps/web/src/screens/Hub.css | 3 +- apps/web/src/screens/Hub.tsx | 5 +- apps/web/src/screens/Settings.tsx | 5 +- apps/web/src/styles/app.css | 73 ++----------- apps/web/src/styles/shared.css | 18 ++++ apps/web/src/ui/CoverImage.tsx | 30 ++++++ apps/web/tests/covers.test.tsx | 164 ++++++++++++++++++++++++++++++ apps/web/tests/loading.test.tsx | 3 +- 11 files changed, 302 insertions(+), 82 deletions(-) create mode 100644 apps/web/src/data/covers.ts create mode 100644 apps/web/src/ui/CoverImage.tsx create mode 100644 apps/web/tests/covers.test.tsx diff --git a/apps/web/src/data/covers.ts b/apps/web/src/data/covers.ts new file mode 100644 index 00000000..50dcaed2 --- /dev/null +++ b/apps/web/src/data/covers.ts @@ -0,0 +1,65 @@ +import { HUBS, type Hub } from "./household"; + +/** + * Commune covers. A photo someone chose in this browser is drawn as it is. + * The default covers are Unsplash photos, which Unsplash resizes on request, + * so each placement offers a few widths and says how wide it is drawn; the + * browser then fetches the one that suits the screen instead of the full + * photo. Every cover sits on a calm tone for the commune's type (the + * --cover-* tokens in shared.css), which shows while the photo loads and + * stays if it cannot load. + */ +export type CoverPlacement = "featured" | "card" | "hero" | "preview"; +export type CoverTone = "household" | "workspace" | "trip"; +export type CoverHub = Pick; +export type CoverSource = { src: string; srcSet?: string; sizes?: string }; + +const WIDTHS = [480, 800, 1200, 1600]; + +// How wide each placement is drawn, in CSS pixels. The sidebar (248px) goes +// away at 560px. featured: the first card on Home. card: the smaller cards on +// Home and Places (three, then two, then one column). hero: the photo at the +// top of This Hub (at most 1208px). preview: the commune settings preview +// (240px beside the form, full width once the form drops below it at 1000px). +const PLACEMENTS: Record = { + featured: { sizes: "(max-width: 560px) 100vw, calc(100vw - 248px)", fallback: 1200 }, + card: { sizes: "(max-width: 560px) 100vw, (max-width: 850px) 50vw, 33vw", fallback: 800 }, + hero: { sizes: "(max-width: 560px) 100vw, (max-width: 1500px) calc(100vw - 292px), 1208px", fallback: 1200 }, + preview: { sizes: "(max-width: 560px) 100vw, (max-width: 1000px) 560px, 240px", fallback: 480 }, +}; + +export function coverTone(hub: Pick): CoverTone { + return hub.type === "Workspace" ? "workspace" : hub.type === "Trip" ? "trip" : "household"; +} + +const DEFAULT_COVERS: Record = { + household: HUBS.park.photo, + workspace: HUBS.studio.photo, + trip: HUBS.lisbon.photo, +}; + +/** The image to draw for a commune's cover in one placement. */ +export function coverSource(hub: CoverHub, placement: CoverPlacement): CoverSource { + // A commune whose records are still loading has no photo yet; it gets the + // same default it will have once they arrive, so nothing is fetched twice. + const photo = hub.photo || DEFAULT_COVERS[coverTone(hub)]; + const resize = unsplashResizer(photo); + if (!resize) return { src: photo }; + const { sizes, fallback } = PLACEMENTS[placement]; + return { src: resize(fallback), srcSet: WIDTHS.map((width) => `${resize(width)} ${width}w`).join(", "), sizes }; +} + +function unsplashResizer(photo: string): ((width: number) => string) | null { + let url: URL; + try { + url = new URL(photo); + } catch { + return null; + } + if (url.protocol !== "https:" || url.hostname !== "images.unsplash.com") return null; + return (width) => { + const sized = new URL(url); + sized.searchParams.set("w", String(width)); + return sized.href; + }; +} diff --git a/apps/web/src/data/household.ts b/apps/web/src/data/household.ts index 92f7dbfa..bbe785af 100644 --- a/apps/web/src/data/household.ts +++ b/apps/web/src/data/household.ts @@ -757,12 +757,6 @@ export function initialsOf(name: string): string { return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); } -export function heroClass(hub: Hub): "park" | "studio" | "lisbon" { - if (hub.id === "studio" || hub.type === "Workspace") return "studio"; - if (hub.id === "lisbon" || hub.type === "Trip") return "lisbon"; - return "park"; -} - export function buildCreatedHub(opts: { name: string; type: Hub["type"]; diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index fd9da2d8..4ff51ffe 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -2,16 +2,14 @@ import type { ReactNode } from "react"; import { useApp } from "../state"; import { Avatar, Pill } from "../ui/widgets"; import { CalendarDate } from "../ui/CalendarDate"; -import { heroClass } from "../data/household"; import type { LiveHub } from "../live/model"; import { cx } from "../ui/cx"; +import { CoverImage, coverToneClass } from "../ui/CoverImage"; -function PhotoFace({ photo, focus, className, children }: { photo: string; focus?: boolean; className?: string; children: ReactNode }) { +function PhotoFace({ hub, focus, children }: { hub: LiveHub; focus?: boolean; children: ReactNode }) { return ( -
+
+ {children}
); @@ -25,7 +23,7 @@ function PlaceCard({ hub, focus = false }: { hub: LiveHub; focus?: boolean }) { const memberCount = ready ? hub.members.length : hub.memberCount; return (