From ee273d7700af150541f7943d8710a1f02248e90a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:11:18 +0000 Subject: [PATCH 01/10] Count unread notices per commune and name each activity record's type Migration 0039 adds unreadNotices to the commune list and to the snapshot's commune, so the bell can count every commune from the list the app already reads each minute, and a public.my_unread_notices() read for Notices to show unread notices from other communes, in the snapshot's shape with the sender's name and a pledge notice's open fund. Each activity event in the snapshot now carries recordType (expense, repayment, fund, pledge, contribution, recurring, template, task, event, note, member or announcement), and the overview summary keeps a share's pending payment report so Home can list it for the bill payer. The shared functions are patched in place with checked anchors. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- .../migrations/0039_notices_and_activity.sql | 119 +++++++++ infra/neon/tests/grants.sql | 13 +- infra/neon/tests/lighter_loading.sql | 10 +- infra/neon/tests/notices_and_activity.sql | 233 ++++++++++++++++++ 4 files changed, 367 insertions(+), 8 deletions(-) create mode 100644 infra/neon/migrations/0039_notices_and_activity.sql create mode 100644 infra/neon/tests/notices_and_activity.sql diff --git a/infra/neon/migrations/0039_notices_and_activity.sql b/infra/neon/migrations/0039_notices_and_activity.sql new file mode 100644 index 00000000..9d67c932 --- /dev/null +++ b/infra/neon/migrations/0039_notices_and_activity.sql @@ -0,0 +1,119 @@ +-- Notices, activity and "Needs you" across communes. +-- 1. The bell counted only the open commune's unread notices, so a notice +-- from another commune showed nothing, even on Home. The commune list +-- (public.my_communes) and the snapshot's commune now carry +-- unreadNotices, the reader's unread count in that commune, so the +-- minute check that already reads the list keeps the bell right for every +-- commune. public.my_unread_notices() returns the unread notices of every +-- commune the caller belongs to, in the snapshot's shape with the +-- sender's name and a pledge notice's fund, for Notices to list under +-- each commune's name. The app reads it only when Notices opens. +-- 2. Activity said "Created" without saying what was created. Each activity +-- event in the snapshot now names its record type (recordType): expense, +-- repayment, fund, pledge, contribution, recurring, template, task, +-- event, note, member or announcement. An event records only its area, +-- so the type comes from what each command records with it. +-- 3. "Needs you" lists first the payments someone says they have sent, +-- which only the bill payer can confirm, on Home as on This Hub. Home +-- shows the communes that are not open from the overview summary, which +-- left out every payment report; it now keeps the pending ones. +-- The shared functions are patched in place, so changes other migrations +-- make to them stay. The web app released with this reads the new fields +-- and shows what it showed before while they are missing. Apply after 0038. +BEGIN; + +-- 1. Unread notices across communes ----------------------------------------- + +-- The count reads only unread rows. +CREATE INDEX notifications_unread_idx ON public.notifications (recipient_user_id, commune_id) WHERE read_at IS NULL; + +CREATE FUNCTION commune_private.unread_notice_count(p_commune_id uuid, p_user_id text) +RETURNS integer LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT count(*)::integer FROM public.notifications n + WHERE n.recipient_user_id = p_user_id AND n.commune_id = p_commune_id AND n.read_at IS NULL; +$$; +REVOKE ALL ON FUNCTION commune_private.unread_notice_count(uuid, text) FROM PUBLIC; + +-- 2. The record type of an activity event ------------------------------------ + +-- A repayment's event carries its receipt, a pledge's or contribution's the +-- row it changed (a pledge names its fund, a contribution its pledge), and a +-- task or event keeps its kind after it is removed. Events written before +-- their details were kept fall back to the words each command uses. +CREATE FUNCTION commune_private.activity_record_type(p_event_id uuid) +RETURNS text LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT CASE e.kind + WHEN 'expenses' THEN CASE WHEN e.details ? 'receiptId' OR e.description LIKE 'Repayment %' + OR e.description = 'Share confirmed received' THEN 'repayment' ELSE 'expense' END + WHEN 'funds' THEN CASE WHEN e.details -> 'after' ? 'commitment_id' OR e.description LIKE 'Contribution %' THEN 'contribution' + WHEN e.details -> 'after' ? 'fund_id' OR e.description LIKE 'Pledge %' THEN 'pledge' ELSE 'fund' END + WHEN 'operations' THEN coalesce((SELECT o.kind FROM public.commune_operations o WHERE o.id = e.entity_id), 'task') + WHEN 'recurring' THEN 'recurring' + WHEN 'templates' THEN 'template' + WHEN 'notes' THEN 'note' + WHEN 'members' THEN 'member' + WHEN 'announcements' THEN 'announcement' + END + FROM public.commune_events e WHERE e.id = p_event_id; +$$; +REVOKE ALL ON FUNCTION commune_private.activity_record_type(uuid) FROM PUBLIC; + +-- Each patch must apply. +DO $$ +DECLARE v_original text; v_source text; +BEGIN + v_original := pg_get_functiondef('public.my_communes()'::regprocedure); + v_source := replace(v_original, $old$'changeMark', commune_private.change_mark(c.id))$old$, + $new$'changeMark', commune_private.change_mark(c.id), + 'unreadNotices', commune_private.unread_notice_count(c.id, m.user_id))$new$); + IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported commune list definition'; END IF; + EXECUTE v_source; + + v_original := pg_get_functiondef('public.commune_snapshot(uuid)'::regprocedure); + v_source := replace(v_original, $old$'currencyLockedBy',to_jsonb(commune_private.currency_locked_by(co.id))),$old$, + $new$'currencyLockedBy',to_jsonb(commune_private.currency_locked_by(co.id)), + 'unreadNotices',commune_private.unread_notice_count(co.id,v_actor)),$new$); + IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported snapshot commune definition'; END IF; + v_original := v_source; + v_source := replace(v_original, $old$'description',a.description,'createdAt',a.created_at)$old$, + $new$'description',a.description,'createdAt',a.created_at,'recordType',commune_private.activity_record_type(a.id))$new$); + IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported snapshot activity definition'; END IF; + EXECUTE v_source; + + -- 3. A summary keeps a share's pending payment report, the one thing about + -- it that the bill payer has to act on. Repayments and answered reports + -- stay on the commune's own screens. + v_original := pg_get_functiondef('public.commune_summary(uuid)'::regprocedure); + v_source := replace(v_original, $old$y.item || '{"receipts": [], "paymentReports": []}'::jsonb$old$, + $new$y.item || jsonb_build_object('receipts', '[]'::jsonb, 'paymentReports', + coalesce((SELECT jsonb_agg(r.report ORDER BY r.ord) FROM jsonb_array_elements(y.item -> 'paymentReports') WITH ORDINALITY r(report, ord) + WHERE r.report ->> 'status' = 'pending'), '[]'::jsonb))$new$); + IF v_source = v_original THEN RAISE EXCEPTION 'Unsupported summary definition'; END IF; + EXECUTE v_source; +END; +$$; + +-- Unread notices in every commune the caller belongs to, newest first, at +-- most 100 a commune (as the snapshot carries). Read only for communes that +-- have one. A pledge notice names the fund holding the pledge while the fund +-- is open, as the reader may not have that commune's records loaded. +CREATE FUNCTION public.my_unread_notices() +RETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, pg_temp AS $$ + SELECT coalesce(jsonb_agg(n.notice || jsonb_build_object( + 'senderName', names.value ->> (n.notice ->> 'senderUserId'), + 'fundId', (SELECT f.id FROM public.fund_commitments c JOIN public.funds f ON f.id = c.fund_id + WHERE n.notice ->> 'kind' IN ('pledge-changed', 'pledge-cancelled') AND c.id = (n.notice ->> 'sourceId')::uuid + AND f.archived_at IS NULL)) + ORDER BY (n.notice ->> 'createdAt')::timestamptz DESC, n.notice ->> 'id'), '[]'::jsonb) + FROM public.commune_members m + CROSS JOIN LATERAL (SELECT commune_private.member_names(m.commune_id) AS value) names + CROSS JOIN LATERAL jsonb_array_elements(commune_private.snapshot_notifications(m.commune_id, m.user_id)) n(notice) + WHERE m.user_id = commune_private.data_api_user_id() AND m.active + AND commune_private.unread_notice_count(m.commune_id, m.user_id) > 0 + AND n.notice ->> 'readAt' IS NULL; +$$; +REVOKE ALL ON FUNCTION public.my_unread_notices() FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.my_unread_notices() TO authenticated; + +NOTIFY pgrst, 'reload schema'; +COMMIT; diff --git a/infra/neon/tests/grants.sql b/infra/neon/tests/grants.sql index 624eb562..427f3085 100644 --- a/infra/neon/tests/grants.sql +++ b/infra/neon/tests/grants.sql @@ -40,9 +40,9 @@ BEGIN RAISE EXCEPTION 'Anonymous workflow execution grant'; END IF; - -- 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. + -- Money-lifecycle, occurrence, announcement, note and task commands (0015-0027), the + -- loading reads (0036) and the notices across communes (0039): 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)', @@ -69,7 +69,8 @@ BEGIN 'public.my_communes()', 'public.commune_summary(uuid)', 'public.commune_expense_page(uuid,date,integer)', - 'public.commune_expense_records(uuid,uuid[])' + 'public.commune_expense_records(uuid,uuid[])', + 'public.my_unread_notices()' ] LOOP IF has_function_privilege('anonymous', table_name, 'EXECUTE') OR has_function_privilege('public', table_name, 'EXECUTE') @@ -115,7 +116,9 @@ BEGIN 'commune_private.expense_window_start()', 'commune_private.snapshot_expense_ids(uuid,timestamptz)', 'commune_private.expense_records(uuid[])', - 'commune_private.expense_history(uuid)' + 'commune_private.expense_history(uuid)', + 'commune_private.unread_notice_count(uuid,text)', + 'commune_private.activity_record_type(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 index e2d475dd..0fddcaac 100644 --- a/infra/neon/tests/lighter_loading.sql +++ b/infra/neon/tests/lighter_loading.sql @@ -637,10 +637,14 @@ BEGIN 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) + WHERE e->'revisions' <> '[]'::jsonb OR s->'receipts' <> '[]'::jsonb + OR EXISTS (SELECT 1 FROM jsonb_array_elements(s->'paymentReports') r WHERE r->>'status' <> 'pending')) 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'; + WHERE s->'receipts' <> '[]'::jsonb) + -- A pending report is for the bill payer to answer, so Home lists it (0039). + OR NOT EXISTS (SELECT 1 FROM jsonb_array_elements(v_summary->'expenses') e, jsonb_array_elements(e->'shares') s + WHERE s->>'id' = current_setting('test.report_share') AND s->'paymentReports'->0->>'status' = 'pending') THEN + RAISE EXCEPTION 'Summary expenses should keep their shares and pending reports without repayment, answered 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' diff --git a/infra/neon/tests/notices_and_activity.sql b/infra/neon/tests/notices_and_activity.sql new file mode 100644 index 00000000..a9001fd9 --- /dev/null +++ b/infra/neon/tests/notices_and_activity.sql @@ -0,0 +1,233 @@ +-- Notices, activity and "Needs you" across communes (0039). Run only in an +-- empty disposable local DB built from infra/neon/tests/local_auth_stub.sql +-- and every migration. Everything is rolled back. +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; +$$; +-- The reader's count in one commune, as the commune list gives it. +CREATE FUNCTION pg_temp.listed_unread(p_commune uuid) RETURNS integer LANGUAGE sql AS $$ + SELECT (c->>'unreadNotices')::integer FROM jsonb_array_elements(public.my_communes()) c WHERE (c->>'id')::uuid = p_commune; +$$; + +INSERT INTO neon_auth."user" (id, name, email) VALUES + ('na-ann', 'Ann Archer', 'ann@notices.test'), ('na-ben', 'Ben Baker', 'ben@notices.test'), + ('na-cat', 'Cat Cole', 'cat@notices.test'), ('na-out', 'Outsider', 'out@notices.test'); + +-- Ann is in two communes and has a notice in each: Ben says he paid her in +-- the house, and corrected a bill of his on the trip. +DO $$ +DECLARE v_home uuid; v_trip uuid; v_expense uuid; v_share uuid; v_trip_bill uuid; v_fund uuid; v_pledge uuid; +BEGIN + PERFORM pg_temp.act('na-ann'); + v_home := public.create_commune('Notice house', 'household', 'GBP'); + INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_home, 'na-ben', 'member'), (v_home, 'na-cat', 'member'); + v_expense := public.create_expense(v_home, 'Gas', 3000, + '[{"user_id":"na-ann","amount_minor":1000},{"user_id":"na-ben","amount_minor":1000},{"user_id":"na-cat","amount_minor":1000}]'::jsonb, current_date); + SELECT id INTO v_share FROM public.expense_shares WHERE expense_id = v_expense AND user_id = 'na-ben'; + v_fund := public.create_fund(v_home, 'Boiler', 50000); + + PERFORM pg_temp.act('na-ben'); + PERFORM public.report_share_payment(v_share, 1000, current_date, NULL, gen_random_uuid()); + v_pledge := public.pledge_fund(v_fund, 10000, current_date + 7); + v_trip := public.create_commune('Notice trip', 'trip', 'EUR'); + INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_trip, 'na-ann', 'member'); + v_trip_bill := public.create_expense(v_trip, 'Ferry', 4000, + '[{"user_id":"na-ann","amount_minor":2000},{"user_id":"na-ben","amount_minor":2000}]'::jsonb, current_date); + PERFORM public.correct_expense(v_trip_bill, 1, 'Ferry tickets', 4000, + '[{"user_id":"na-ann","amount_minor":2500},{"user_id":"na-ben","amount_minor":1500}]'::jsonb, current_date, + 'na-ben', 'Travel', 'Ann had the cabin', gen_random_uuid()); + + -- An admin moving Ben's pledge leaves Ben a notice in the house. + PERFORM pg_temp.act('na-ann'); + PERFORM public.update_fund_pledge(v_pledge, 10000, current_date + 14, false, NULL, NULL); + + PERFORM set_config('test.home', v_home::text, true); + PERFORM set_config('test.trip', v_trip::text, true); + PERFORM set_config('test.share', v_share::text, true); + PERFORM set_config('test.fund', v_fund::text, true); +END; +$$; + +-- 1. The commune list and the snapshot count each commune's unread notices. +DO $$ +DECLARE + v_home uuid := current_setting('test.home')::uuid; v_trip uuid := current_setting('test.trip')::uuid; + v_notice uuid; +BEGIN + PERFORM pg_temp.act('na-ann'); + IF pg_temp.listed_unread(v_home) IS DISTINCT FROM 1 OR pg_temp.listed_unread(v_trip) IS DISTINCT FROM 1 THEN + RAISE EXCEPTION 'The list should count one unread notice in each commune: %', public.my_communes(); + END IF; + IF (public.commune_snapshot(v_home)->'commune'->>'unreadNotices')::integer IS DISTINCT FROM 1 + OR public.commune_summary(v_trip)->'commune' <> public.commune_snapshot(v_trip)->'commune' + OR (public.commune_summary(v_trip)->'commune'->>'unreadNotices')::integer IS DISTINCT FROM 1 THEN + RAISE EXCEPTION 'The snapshot and the summary should carry the same count'; + END IF; + + -- Reading the house notice clears only the house. + SELECT (n->>'id')::uuid INTO v_notice FROM jsonb_array_elements(public.commune_snapshot(v_home)->'notifications') n; + PERFORM public.mark_notification_read(v_notice); + IF pg_temp.listed_unread(v_home) IS DISTINCT FROM 0 OR pg_temp.listed_unread(v_trip) IS DISTINCT FROM 1 + OR (public.commune_snapshot(v_home)->'commune'->>'unreadNotices')::integer IS DISTINCT FROM 0 THEN + RAISE EXCEPTION 'Reading a notice should clear only its own commune'; + END IF; + + -- Someone else's notices never count. + PERFORM pg_temp.act('na-cat'); + IF pg_temp.listed_unread(v_home) IS DISTINCT FROM 0 THEN RAISE EXCEPTION 'Cat has no notices'; END IF; +END; +$$; + +-- 2. Unread notices from every commune, in the snapshot's shape with the +-- sender's name, for the caller only. +DO $$ +DECLARE + v_home uuid := current_setting('test.home')::uuid; v_trip uuid := current_setting('test.trip')::uuid; + v_notices jsonb; v_left uuid; v_expense uuid; v_report jsonb; +BEGIN + PERFORM pg_temp.act('na-ann'); + v_notices := public.my_unread_notices(); + IF jsonb_array_length(v_notices) <> 1 OR v_notices->0->>'communeId' IS DISTINCT FROM v_trip::text + OR v_notices->0->>'kind' IS DISTINCT FROM 'expense-corrected' OR v_notices->0->>'senderName' IS DISTINCT FROM 'Ben Baker' + OR v_notices->0->>'title' IS DISTINCT FROM 'Ferry tickets' OR v_notices->0->>'readAt' IS NOT NULL + OR v_notices->0->'fundId' IS DISTINCT FROM 'null'::jsonb THEN + RAISE EXCEPTION 'Ann should get her unread trip notice only: %', v_notices; + END IF; + IF (v_notices->0) - 'senderName' - 'fundId' <> (SELECT n FROM jsonb_array_elements(public.commune_snapshot(v_trip)->'notifications') n) THEN + RAISE EXCEPTION 'A notice should come in the snapshot''s shape: %', v_notices; + END IF; + + -- A pledge notice names the fund holding the pledge while the fund is open. + PERFORM pg_temp.act('na-ben'); + v_notices := public.my_unread_notices(); + IF jsonb_array_length(v_notices) <> 1 OR v_notices->0->>'kind' IS DISTINCT FROM 'pledge-changed' + OR v_notices->0->>'fundId' IS DISTINCT FROM current_setting('test.fund') OR v_notices->0->>'senderName' IS DISTINCT FROM 'Ann Archer' THEN + RAISE EXCEPTION 'Ben should get his pledge notice with its fund: %', v_notices; + END IF; + PERFORM pg_temp.act('na-ann'); + PERFORM public.update_fund_pledge((SELECT id FROM public.fund_commitments WHERE fund_id = current_setting('test.fund')::uuid), + 10000, current_date + 14, true, NULL, NULL); + PERFORM public.set_fund_archived(current_setting('test.fund')::uuid, true); + PERFORM pg_temp.act('na-ben'); + v_notices := public.my_unread_notices(); + IF (SELECT array_agg(n->>'kind' ORDER BY n->>'kind') FROM jsonb_array_elements(v_notices) n) IS DISTINCT FROM ARRAY['pledge-cancelled', 'pledge-changed'] + OR EXISTS (SELECT 1 FROM jsonb_array_elements(v_notices) n WHERE n->'fundId' IS DISTINCT FROM 'null'::jsonb) THEN + RAISE EXCEPTION 'An archived fund should not be offered: %', v_notices; + END IF; + + -- Someone outside the communes gets nothing, and so does someone who left: + -- Ann confirms the payment Cat reported in a third commune, then Cat leaves. + PERFORM pg_temp.act('na-out'); + IF public.my_unread_notices() <> '[]'::jsonb THEN RAISE EXCEPTION 'An outsider should get no notices'; END IF; + PERFORM pg_temp.act('na-ann'); + v_left := public.create_commune('Left house', 'household', 'GBP'); + INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_left, 'na-cat', 'member'); + v_expense := public.create_expense(v_left, 'Milk', 400, + '[{"user_id":"na-ann","amount_minor":200},{"user_id":"na-cat","amount_minor":200}]'::jsonb, current_date); + PERFORM pg_temp.act('na-cat'); + v_report := public.report_share_payment((SELECT id FROM public.expense_shares WHERE expense_id = v_expense AND user_id = 'na-cat'), + 200, current_date, NULL, gen_random_uuid()); + PERFORM pg_temp.act('na-ann'); + PERFORM public.resolve_share_payment_report((v_report->>'id')::uuid, 'confirm', NULL, gen_random_uuid()); + PERFORM pg_temp.act('na-cat'); + IF (SELECT array_agg(n->>'kind') FROM jsonb_array_elements(public.my_unread_notices()) n) IS DISTINCT FROM ARRAY['payment-confirmed'] THEN + RAISE EXCEPTION 'Cat should have the confirmation: %', public.my_unread_notices(); + END IF; + PERFORM public.manage_commune_member(v_left, 'na-cat', 'leave'); + IF public.my_unread_notices() <> '[]'::jsonb + OR EXISTS (SELECT 1 FROM jsonb_array_elements(public.my_communes()) c WHERE (c->>'id')::uuid = v_left) THEN + RAISE EXCEPTION 'Notices from a commune Cat left should stay out of reach'; + END IF; + + -- Only members can call it, never anonymous visitors. + IF has_function_privilege('anonymous', 'public.my_unread_notices()', 'EXECUTE') + OR NOT has_function_privilege('authenticated', 'public.my_unread_notices()', 'EXECUTE') THEN + RAISE EXCEPTION 'Unexpected grants on my_unread_notices'; + END IF; +END; +$$; + +-- 3. Every activity event names the kind of record it is about. +DO $$ +DECLARE + v_place uuid; v_expense uuid; v_share uuid; v_fund jsonb; v_pledge jsonb; v_task jsonb; v_event jsonb; + v_types jsonb; v_expected jsonb; +BEGIN + PERFORM pg_temp.act('na-ann'); + v_place := public.create_commune('Activity house', 'household', 'GBP'); + INSERT INTO public.commune_members (commune_id, user_id, role) VALUES (v_place, 'na-ben', 'member'); + v_expense := public.create_expense(v_place, 'Broadband', 3000, + '[{"user_id":"na-ann","amount_minor":1500},{"user_id":"na-ben","amount_minor":1500}]'::jsonb, current_date); + SELECT id INTO v_share FROM public.expense_shares WHERE expense_id = v_expense AND user_id = 'na-ben'; + PERFORM public.record_share_receipt(v_share, 500, current_date, NULL, gen_random_uuid()); + PERFORM public.correct_expense(v_expense, 1, 'Broadband bill', 3000, + '[{"user_id":"na-ann","amount_minor":1500},{"user_id":"na-ben","amount_minor":1500}]'::jsonb, current_date, + 'na-ann', 'Utilities', 'Clearer title', gen_random_uuid()); + v_fund := public.create_fund_command(v_place, 'Sofa', 40000, 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()); + PERFORM public.create_note(v_place, 'Router', 'Wi-Fi', 'Everyone', 'In the hall', 'Restart it first', '[]'::jsonb, gen_random_uuid()); + v_task := public.create_operation(v_place, 'task', 'chore', 'Bins', current_date, NULL, true, 'na-ann', NULL, 'weekly', '', gen_random_uuid()); + PERFORM public.remove_operation((v_task->>'id')::uuid); + v_event := public.create_operation(v_place, 'event', 'visit', 'Inspection', current_date + 2, '10:00', false, NULL, NULL, 'none', '', gen_random_uuid()); + PERFORM public.post_announcement(v_place, 'Boiler service', 'Friday morning', false, gen_random_uuid()); + PERFORM public.save_recurring_bill(v_place, 'Rent', 2000, 'na-ann', + '[{"user_id":"na-ann","amount_minor":1000},{"user_id":"na-ben","amount_minor":1000}]'::jsonb, 'monthly', current_date, + NULL, NULL, gen_random_uuid(), NULL); + PERFORM public.save_split_template(v_place, 'Halves', 'Equal', '[{"user_id":"na-ann","value":""},{"user_id":"na-ben","value":""}]'::jsonb); + + SELECT jsonb_object_agg(a->>'title' || ': ' || (a->>'description'), a->>'recordType') + INTO v_types FROM jsonb_array_elements(public.commune_snapshot(v_place)->'activityEvents') a; + v_expected := jsonb_build_object( + 'Ben Baker: Joined the commune', 'member', + 'Broadband: Repayment of £5.00 recorded', 'repayment', + 'Broadband bill: Expense corrected', 'expense', + 'Sofa: Created', 'fund', + 'Sofa: Pledge made', 'pledge', + 'Sofa: Contribution recorded', 'contribution', + 'Router: Created', 'note', + 'Bins: Created', 'task', + 'Bins: Removed', 'task', + 'Inspection: Created', 'event', + 'Boiler service: Posted', 'announcement', + 'Rent: Created', 'recurring', + 'Halves: Created', 'template'); + IF NOT v_types @> v_expected THEN + RAISE EXCEPTION 'Unexpected record types: %', v_types; + END IF; + IF EXISTS (SELECT 1 FROM jsonb_each_text(v_types) t WHERE t.value IS NULL) THEN + RAISE EXCEPTION 'Every event should have a record type: %', v_types; + END IF; +END; +$$; + +-- 4. The overview summary keeps a share's pending payment report, and no +-- repayments or answered reports. +DO $$ +DECLARE v_home uuid := current_setting('test.home')::uuid; v_reports jsonb; +BEGIN + PERFORM pg_temp.act('na-ann'); + SELECT s->'paymentReports' INTO v_reports + FROM jsonb_array_elements(public.commune_summary(v_home)->'expenses') e, jsonb_array_elements(e->'shares') s + WHERE s->>'id' = current_setting('test.share'); + IF jsonb_array_length(v_reports) IS DISTINCT FROM 1 OR v_reports->0->>'status' IS DISTINCT FROM 'pending' + OR (v_reports->0->>'amountMinor')::bigint IS DISTINCT FROM 1000 THEN + RAISE EXCEPTION 'The summary should keep the pending report: %', v_reports; + END IF; + PERFORM public.resolve_share_payment_report((v_reports->0->>'id')::uuid, 'decline', 'Not arrived yet', gen_random_uuid()); + IF EXISTS (SELECT 1 FROM jsonb_array_elements(public.commune_summary(v_home)->'expenses') e, jsonb_array_elements(e->'shares') s + WHERE s->'paymentReports' <> '[]'::jsonb OR s->'receipts' <> '[]'::jsonb) THEN + RAISE EXCEPTION 'An answered report should stay out of the summary'; + END IF; + IF NOT EXISTS (SELECT 1 FROM jsonb_array_elements(public.commune_snapshot(v_home)->'expenses') e, jsonb_array_elements(e->'shares') s + WHERE s->'paymentReports'->0->>'status' = 'declined') THEN + RAISE EXCEPTION 'The full snapshot should still carry the answered report'; + END IF; +END; +$$; + +ROLLBACK; From b2746284905ad05a7191a0c49c907a1e1ab34341 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:26:26 +0000 Subject: [PATCH 02/10] Count every commune's unread notices in the bell and list them by commune The bell counted only the open commune's notices, so a notice from another commune showed nothing, even on Home. The commune list the app already reads each minute now carries each commune's unread count (0039), and the bell adds them up on every screen. Notices lists the open commune first, then each other commune with unread notices under its name, read from the new GET /api/notices/unread (my_unread_notices on Neon) only when Notices opens. Opening a notice from another commune marks it read there, refreshes that commune and switches to it before opening the record. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/local-api/README.md | 15 +- apps/local-api/src/server.ts | 8 + apps/local-api/tests/lighter-loading.mjs | 2 +- apps/local-api/tests/notices-and-activity.mjs | 115 ++++++++++++ apps/web/src/live/model.ts | 8 + apps/web/src/live/neon.ts | 6 + apps/web/src/shell/Shell.tsx | 5 +- apps/web/src/state.tsx | 48 +++-- apps/web/src/styles/app.css | 14 ++ apps/web/src/windows/Windows.tsx | 123 +++++++++---- apps/web/tests/neon-auth.test.tsx | 29 +++ .../tests/notices-across-communes.test.tsx | 172 ++++++++++++++++++ apps/web/tests/quick-search.test.tsx | 2 +- 13 files changed, 486 insertions(+), 61 deletions(-) create mode 100644 apps/local-api/tests/notices-and-activity.mjs create mode 100644 apps/web/tests/notices-across-communes.test.tsx diff --git a/apps/local-api/README.md b/apps/local-api/README.md index 8730d8a2..f0df233f 100644 --- a/apps/local-api/README.md +++ b/apps/local-api/README.md @@ -17,11 +17,11 @@ 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:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark}]}`; a commune's `changeMark` moves whenever anything in it changes | +| GET | `/api/communes` | | `{communes:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark,unreadNotices}]}`; a commune's `changeMark` moves whenever anything in it changes; `unreadNotices` is the reader's unread notices there (0039) | | 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, 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/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show, keeping a share's pending payment reports (0039) | `{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) | @@ -45,6 +45,7 @@ browser code sends same-origin `/api` requests. Non-2xx errors are | POST | `/api/communes/:id/members/:userId` | `{action:"admin"\|"member"\|"remove"\|"leave"}`; admin changes roles/removes, member can leave; an admin who leaves, is removed or becomes a member loses their open invitations | `{ok:true}` | | POST | `/api/reminders` | `{kind:"nudge"\|"chase",sourceId,requestId?}`; a repeated `requestId` returns the reminder it already created | `{id,duplicate,delivery:"in-app",emailStatus:"none",email:"not-configured"}` (201, or 200 for a repeat) | | POST | `/api/notifications/:id/read` | `{}` | `{ok:true}` | +| GET | `/api/notices/unread` | | `{notices}`: the unread notices of every commune the reader belongs to, newest first, each as in `snapshot.notifications` with `senderName` and, for a pledge notice whose fund is open, `fundId` (0039) | | GET | `/api/communes/:id/invites` | admin | `{invites:[...]}` with `invitedBy` and `invitedByName`; an invitation works only while its sender is an admin | | POST | `/api/communes/:id/invites` | `{email,role}` admin | `{token,url}` to copy; no email sent | | GET | `/api/invites/:token` | matching signed-in email | `{invite}` or `{invite:null}` | @@ -71,7 +72,9 @@ carry `version`, `voidedAt/voidedBy/voidReason` and their last 20 `revisions` (`{version,action,actorId,reason,before,after,createdAt}`). Expense shares have `{id,userId,amountMinor,settledMinor,settledAt,receipts}`; `settledMinor` is the sum of receipts that have not been reversed. Operations carry `history` -(recent occurrences) and `undoOccurrenceId`. `sentReminders` lists the +(recent occurrences) and `undoOccurrenceId`. Each of `activityEvents` names its +`recordType` (expense, repayment, fund, pledge, contribution, recurring, template, +task, event, note, member or announcement; 0039). `sentReminders` lists the signed-in sender's own recent reminders with their email state. Funds contain commitments with `contributedMinor`, cancellation and contribution-correction history. Notifications and reminder targets include the IDs @@ -102,6 +105,7 @@ 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 +API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/notices-and-activity.mjs ``` `money-lifecycle.mjs` needs migrations 0015 to 0017 and covers reminder @@ -131,6 +135,11 @@ the signed-in account and requests without the header are still served. snapshot, the overview summary, pages of older expenses, named expense records and the limit on creating communes. It refuses port 3002. +`notices-and-activity.mjs` needs 0039 and covers each commune's unread count in +the list and the snapshot, the unread notices of every commune, the record type +of each activity event and the pending payment reports an overview summary +keeps. 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 f8749c16..b895a3d8 100644 --- a/apps/local-api/src/server.ts +++ b/apps/local-api/src/server.ts @@ -435,6 +435,14 @@ route("GET", /^\/api\/reminders\/usage$/, async (req, res) => { }); }); +// Unread notices from every commune the caller belongs to (0039), read when +// Notices opens. The commune list carries each commune's count. +route("GET", /^\/api\/notices\/unread$/, async (req, res) => { + const notices = await userAction(req, async (client) => (await client.query( + "SELECT public.my_unread_notices() AS notices")).rows[0].notices); + send(res, { notices }); +}); + route("POST", /^\/api\/communes\/([0-9a-f-]{36})\/expenses$/, async (req, res, match) => { const input = await body(req); const communeId = uuid(match[1]); diff --git a/apps/local-api/tests/lighter-loading.mjs b/apps/local-api/tests/lighter-loading.mjs index 67681de2..8567c244 100644 --- a/apps/local-api/tests/lighter-loading.mjs +++ b/apps/local-api/tests/lighter-loading.mjs @@ -48,7 +48,7 @@ async function listed(call) { } const first = await listed(owner); assert.deepEqual(Object.keys(first).sort(), ["address", "changeMark", "currency", "destination", "id", "kind", - "memberCount", "name", "role", "stayAddress", "stayName"]); + "memberCount", "name", "role", "stayAddress", "stayName", "unreadNotices"]); 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"); diff --git a/apps/local-api/tests/notices-and-activity.mjs b/apps/local-api/tests/notices-and-activity.mjs new file mode 100644 index 00000000..79eb958d --- /dev/null +++ b/apps/local-api/tests/notices-and-activity.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +// Unread notices across communes, activity record types and the pending +// payment reports an overview summary keeps (0039). 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("Notice and activity 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.replace(/\/$/, "")}/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]; + const data = await response.json(); + return { ...data, reportStatus: data.status, status: response.status }; + }; +} +const ok = (result, status = 200) => { assert.equal(result.status, status, JSON.stringify(result)); return result; }; +async function signup(call, name, slug) { + return ok(await call("POST", "/auth/signup", { name, email: `${slug}-${suffix}@example.test`, + password: "DisposableNoticeFixture-2026!" }), 201).user; +} +const a = session(), b = session(), outsider = session(), guest = session(); +const avery = await signup(a, "Avery Notices", "notices-avery"); +const blake = await signup(b, "Blake Notices", "notices-blake"); +await signup(outsider, "Casey Notices", "notices-casey"); +async function commune(call, name, currency, member, memberCall) { + const id = ok(await call("POST", "/communes", { name, kind: "household", currency }), 201).id; + const invite = ok(await call("POST", `/communes/${id}/invites`, { email: member.email, role: "member" }), 201); + ok(await memberCall("POST", `/invites/${invite.token}/accept`, {})); + return id; +} +// Avery's home, with Blake, and Blake's trip, with Avery. +const home = await commune(a, `Home ${suffix}`, "GBP", blake, b); +const trip = await commune(b, `Trip ${suffix}`, "EUR", avery, a); +const today = new Date().toISOString().slice(0, 10); +const snapshot = async (call, id) => ok(await call("GET", `/communes/${id}/snapshot`)); +const counts = async (call) => Object.fromEntries((ok(await call("GET", "/communes")).communes) + .filter((item) => [home, trip].includes(item.id)).map((item) => [item.id, item.unreadNotices])); +assert.deepEqual(await counts(a), { [home]: 0, [trip]: 0 }); +assert.deepEqual(ok(await a("GET", "/notices/unread")).notices, []); + +// Home: Blake says he has sent his share of the gas, which Avery has to confirm. +const gas = ok(await a("POST", `/communes/${home}/expenses`, { title: "Gas", amountMinor: 3000, + shares: [{ userId: avery.id, amountMinor: 1500 }, { userId: blake.id, amountMinor: 1500 }], requestId: randomUUID() }), 201).id; +const gasShare = (await snapshot(b, home)).expenses.find((item) => item.id === gas).shares.find((item) => item.userId === blake.id); +const report = ok(await b("POST", `/shares/${gasShare.id}/payment-reports`, + { amountMinor: 1500, sentOn: today, reference: "Bank transfer", requestId: randomUUID() }), 201); +// Trip: Blake nudges Avery about her share of the ferry. +const ferry = ok(await b("POST", `/communes/${trip}/expenses`, { title: "Ferry", amountMinor: 4000, dueOn: today, + shares: [{ userId: avery.id, amountMinor: 2000 }, { userId: blake.id, amountMinor: 2000 }], requestId: randomUUID() }), 201).id; +const ferryShare = (await snapshot(b, trip)).expenses.find((item) => item.id === ferry).shares.find((item) => item.userId === avery.id); +ok(await b("POST", "/reminders", { kind: "nudge", sourceId: ferryShare.id, requestId: randomUUID() }), 201); +// Home again: Avery moves Blake's pledge to a later date, which tells Blake. +const fund = ok(await a("POST", `/communes/${home}/funds`, { name: "Boiler", targetMinor: 50000, requestId: randomUUID() }), 201).id; +const inDays = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10); +const pledge = ok(await b("POST", `/funds/${fund}/pledges`, { amountMinor: 10000, dueOn: inDays(30), requestId: randomUUID() }), 201).id; +ok(await a("PATCH", `/commitments/${pledge}`, { amountMinor: 10000, dueOn: inDays(60), expectedAmountMinor: 10000, expectedDueOn: inDays(30) })); + +// ------------------------------------------------------------ counts +// Each commune in the list carries the reader's own unread count, as does the snapshot. +assert.deepEqual(await counts(a), { [home]: 1, [trip]: 1 }); +assert.deepEqual(await counts(b), { [home]: 1, [trip]: 0 }); +assert.equal((await snapshot(a, trip)).commune.unreadNotices, 1); +assert.equal(ok(await a("GET", `/communes/${home}/snapshot?detail=summary`)).commune.unreadNotices, 1); + +// ------------------------------------------------------------ every commune's unread notices +const averys = ok(await a("GET", "/notices/unread")).notices; +assert.deepEqual(averys.map((item) => [item.communeId, item.kind, item.senderName, item.fundId]), + [[trip, "nudge", "Blake Notices", null], [home, "payment-reported", "Blake Notices", null]]); +// Each is the snapshot's notice with the sender's name and a pledge notice's fund. +const { senderName, fundId, ...nudge } = averys[0]; +assert.deepEqual(nudge, (await snapshot(a, trip)).notifications[0]); +const blakes = ok(await b("GET", "/notices/unread")).notices; +assert.deepEqual(blakes.map((item) => [item.communeId, item.kind, item.senderName, item.fundId]), + [[home, "pledge-changed", "Avery Notices", fund]]); +assert.deepEqual(ok(await outsider("GET", "/notices/unread")).notices, []); +assert.equal((await guest("GET", "/notices/unread")).status, 401); + +// Reading one changes only its own commune. +ok(await a("POST", `/notifications/${averys[0].id}/read`, {})); +assert.deepEqual(await counts(a), { [home]: 1, [trip]: 0 }); +assert.deepEqual(ok(await a("GET", "/notices/unread")).notices.map((item) => item.id), [averys[1].id]); + +// ------------------------------------------------------------ record types +// Each activity event names the kind of record it is about. +const events = (await snapshot(a, home)).activityEvents; +assert.ok(events.every((event) => typeof event.recordType === "string"), JSON.stringify(events)); +const types = Object.fromEntries(events.map((event) => [`${event.title}: ${event.description}`, event.recordType])); +assert.deepEqual([types["Boiler: Created"], types["Boiler: Pledge made"], types["Boiler: Pledge updated"], + types["Blake Notices: Joined the commune"]], ["fund", "pledge", "pledge", "member"], JSON.stringify(types)); + +// ------------------------------------------------------------ the summary keeps a pending report +const reportsIn = (body) => body.expenses.find((item) => item.id === gas).shares + .find((item) => item.userId === blake.id).paymentReports.map((item) => [item.id, item.status]); +assert.deepEqual(reportsIn(ok(await a("GET", `/communes/${home}/snapshot?detail=summary`))), [[report.id, "pending"]]); +ok(await a("POST", `/payment-reports/${report.id}/resolve`, { decision: "confirm", requestId: randomUUID() })); +assert.deepEqual(reportsIn(ok(await a("GET", `/communes/${home}/snapshot?detail=summary`))), []); +assert.deepEqual(reportsIn(await snapshot(a, home)), [[report.id, "confirmed"]]); +// Confirming it records a repayment, named as one. +const repaid = (await snapshot(a, home)).activityEvents.filter((event) => event.title === "Gas"); +assert.deepEqual(repaid.map((event) => event.recordType), ["repayment"], JSON.stringify(repaid)); +console.log("Notices and activity: counts, notices across communes, record types and pending reports behave as expected."); diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 5e7439e6..c544e32c 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -34,6 +34,8 @@ export type Commune = { stayAddress?: string; /** Moves whenever anything in the commune changes; compared with a snapshot's own. */ changeMark?: string; + /** The reader's unread notices in this commune (0039), for the bell across communes. */ + unreadNotices?: number; /** In a snapshot: what the database says keeps the currency as it is (0035). */ currencyLockedBy?: CurrencyLock[]; }; @@ -248,6 +250,12 @@ export type Notification = { createdAt: string; readAt: string | null; }; +/** + * An unread notice from any of the reader's communes (0039), with what the + * reader may not have loaded: the sender's name, and for a pledge notice the + * fund that holds the pledge while it is open. + */ +export type UnreadNotice = Notification & { senderName?: string | null; fundId?: string | null }; /** A note's earlier content, as a summary; the content is read on demand. */ export type NoteRevisionSummary = { id: string; version: number; title: string; diff --git a/apps/web/src/live/neon.ts b/apps/web/src/live/neon.ts index 92cd60d1..6b3fd313 100644 --- a/apps/web/src/live/neon.ts +++ b/apps/web/src/live/neon.ts @@ -171,6 +171,7 @@ function communal(row: Row): Commune { 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.stayName), stayAddress: str(row.stayAddress), changeMark: str(row.changeMark), + ...(row.unreadNotices == null ? {} : { unreadNotices: minor(row.unreadNotices) }), }; } @@ -471,6 +472,11 @@ async function route(path: string, init: RequestInit): Promise { }); } else if (path === "/reminders/usage" && method === "GET") { response = await reminderUsage(); + } else if (path === "/notices/unread" && method === "GET") { + // Unread notices from every commune the reader belongs to, for Notices. + const notices = unwrap(await db().rpc("my_unread_notices")); + if (!Array.isArray(notices)) throw new ApiError("Notices could not be verified. Please try again.", 502); + response = { notices }; } else if (path === "/preferences" && method === "GET") { // No row yet means the default: reminder emails on. const viewer = await requireUser(); diff --git a/apps/web/src/shell/Shell.tsx b/apps/web/src/shell/Shell.tsx index 24b05cf6..3e86f562 100644 --- a/apps/web/src/shell/Shell.tsx +++ b/apps/web/src/shell/Shell.tsx @@ -245,14 +245,15 @@ export function Header({ screen }: { screen: ScreenId }) { native, you, noticesOn, - notifications, + noticeCounts, hubId, session, window: currentWindow, } = useApp(); const [searchOpen, setSearchOpen] = useState(false); const searchingAll = ["home", "home-empty", "communes", "home-activity"].includes(screen); - const unread = noticesOn ? notifications.filter((notice) => !notice.readAt).length : 0; + // Every commune's unread notices, wherever the reader is. + const unread = noticesOn ? Object.values(noticeCounts).reduce((total, count) => total + count, 0) : 0; useEffect(() => { function shortcut(event: KeyboardEvent) { if (event.isComposing || event.repeat) return; diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 13583d4c..334c9f84 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -79,6 +79,7 @@ import { type SplitTemplate, type Announcement, type AnnouncementResult, + type UnreadNotice, } from "./live/model"; export type Seed = "new"; @@ -310,6 +311,13 @@ export type AppState = { acknowledgeAnnouncement: (id: string) => Promise; archiveAnnouncement: (id: string) => Promise; notifications: Notification[]; + /** + * Unread notices in each of the reader's communes, by commune id. Each + * comes with the commune list, so the bell counts every commune. + */ + noticeCounts: Record; + /** Unread notices from every commune the reader belongs to, for Notices. */ + loadUnreadNotices: () => Promise; /** Clears this tab, then signs out. Never throws: see signOutUnconfirmed. */ signOut: () => Promise; /** The last sign-out cleared this tab but the server did not confirm it. */ @@ -416,7 +424,8 @@ export type AppState = { manageMember: (userId: string, action: "admin" | "member" | "leave" | "remove") => Promise; /** requestId stays the same across retries so one decision sends once. */ sendReminder: (kind: "nudge" | "chase", sourceId: string, requestId: string) => Promise; - markNotificationRead: (id: string) => Promise; + /** communeId: the notice's commune, when it is not the open one. */ + markNotificationRead: (id: string, communeId?: string) => Promise; pageTitle: string; pageLede: string; }; @@ -1140,6 +1149,10 @@ export function AppProvider({ children }: { children: ReactNode }) { const sentReminders = snapshot?.sentReminders ?? []; const announcements = snapshot?.announcements ?? []; const notifications = snapshot?.notifications ?? []; + // The open commune's count is the snapshot's own until the database sends + // counts (0039); every refresh of a commune brings its count up to date. + const noticeCounts: Record = Object.fromEntries((data?.communes ?? []).map((commune) => [commune.id, + commune.unreadNotices ?? (commune.id === activeId ? notifications.filter((notice) => !notice.readAt).length : 0)])); const you = { id: data?.user.id ?? "", name: data?.user.name ?? "", @@ -1150,11 +1163,12 @@ export function AppProvider({ children }: { children: ReactNode }) { // A normal mutation does not need another sign-in/list/invitations waterfall. // Refresh just its commune, and keep confirmed content visible throughout. - const refreshRecords = useCallback(async () => { + // Usually the open one; reading a notice from another commune refreshes that. + const refreshCommune = useCallback(async (communeId: string) => { const current = dataRef.current; - const commune = current?.communes.find(item => item.id === activeId); + const commune = current?.communes.find(item => item.id === communeId); if (!current || !commune) return; - const cacheKey = `${current.user.id}:${activeId}`; + const cacheKey = `${current.user.id}:${communeId}`; const revision = (snapshotRevisions.current.get(cacheKey) ?? 0) + 1; snapshotRevisions.current.set(cacheKey, revision); const sequence = refreshSequence.current; @@ -1163,23 +1177,24 @@ export function AppProvider({ children }: { children: ReactNode }) { try { const snapshot = await loadSnapshot(commune); 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); - 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; }); + if (!stillCurrent() || !latest || !latest.communes.some(item => item.id === communeId)) return; + if (snapshot.commune.id !== communeId) throw new ApiError("Commune response did not match the request", 502); + publishData({ ...latest, communes: latest.communes.map(item => item.id === communeId ? { ...item, ...snapshot.commune } : item), snapshots: { ...latest.snapshots, [communeId]: snapshot } }); + setSnapshotErrors(errors => { const next = { ...errors }; delete next[communeId]; return next; }); } catch (error) { const latest = dataRef.current; if (!stillCurrent() || !latest) return; - setSnapshotErrors(errors => ({ ...errors, [activeId]: error instanceof Error ? error.message : "Could not refresh saved activity" })); + setSnapshotErrors(errors => ({ ...errors, [communeId]: error instanceof Error ? error.message : "Could not refresh saved activity" })); if (error instanceof ApiError && [401, 403, 404].includes(error.status)) { const snapshots = { ...latest.snapshots }; - delete snapshots[activeId]; + delete snapshots[communeId]; publishData({ ...latest, snapshots }); } // The write already committed. Do not invite a duplicate submission when // only the following read failed; expose Retry without losing the data. } - }, [activeId, publishData]); + }, [publishData]); + const refreshRecords = useCallback(() => refreshCommune(activeId), [activeId, refreshCommune]); // 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 @@ -1584,10 +1599,13 @@ export function AppProvider({ children }: { children: ReactNode }) { })]); return result; }), [mutate, refreshRecords, publishData]); - const markNotificationRead = useCallback((id: string) => mutate(async () => { + // A notice from another commune changes that commune's notices and count. + const markNotificationRead = useCallback((id: string, communeId?: string) => mutate(async () => { await post(`/notifications/${encodeURIComponent(id)}/read`, {}); - await refreshRecords(); - }), [mutate, refreshRecords]); + await refreshCommune(communeId ?? activeId); + }), [activeId, mutate, refreshCommune]); + const loadUnreadNotices = useCallback(async () => + (await api<{ notices: UnreadNotice[] }>("/notices/unread")).notices, []); const addNote = useCallback((card: NoteCard & { id: string }, requestId: string) => mutate(async () => { if (!activeId) throw new Error("Choose a commune first"); @@ -1776,7 +1794,7 @@ export function AppProvider({ children }: { children: ReactNode }) { 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, + noticeCounts, loadUnreadNotices, signOut, signOutUnconfirmed, retrySignOut: confirmSignOut, sessionNotice, go, changePassword, signOutOtherDevices, closeWindow: () => { if (!mutationCount) leaveRecord(); }, watchTypedText, leaveAsked, answerLeave, setMoreOpen, setNavMode: (navMode) => saveDevice({ navMode }), diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index e5405dcc..3145d396 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -2501,6 +2501,20 @@ button.a-kpi { min-width: 0; } +/* Notices: one group per commune, the open one first, headed like search results. */ +.c-notice-group + .c-notice-group { + margin-top: 18px; +} + +.c-notice-group h3 { + margin: 0 0 4px; + color: var(--ink-soft); + font-size: 11px; + font-weight: 550; + letter-spacing: 0.6px; + text-transform: uppercase; +} + .a-field { display: grid; gap: 6px; diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx index f5761397..40dd6381 100644 --- a/apps/web/src/windows/Windows.tsx +++ b/apps/web/src/windows/Windows.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type CSSProperties } from "react"; +import { useEffect, useId, useRef, useState, type CSSProperties } from "react"; import { NOTE_BODY_HINT, type NoteCard } from "../data/household"; import { LIMITS } from "../data/limits"; import { useApp } from "../state"; @@ -28,6 +28,7 @@ import { netBalances } from "../../../../packages/core/src/balances"; import "./ExpenseEditor.css"; import { SplitEditor } from "../ui/SplitEditor"; import { moneyInputError, parseMoneyInput } from "../data/moneyInput"; +import type { Notification, UnreadNotice } from "../live/model"; const NOTE_TYPES = [ ["wifi", "Wi-Fi"], @@ -238,51 +239,95 @@ function ledgerNoticeLine(kind: string, amount: string, actor: string): string { return `Corrected by ${actor}${amount ? ` · your share is now ${amount}` : ""}`; } +/** One notice: what changed, by whom, for the reader, and when. */ +function NoticeRow({ notice, currency, actor, onOpen }: { + notice: Notification; currency: string; actor: string; onOpen: () => void; +}) { + const copy = NOTICE_COPY[notice.kind] ?? { pill: "Notice", title: "Update" }; + const ledger = notice.kind !== "nudge" && notice.kind !== "chase"; + const amount = notice.amountMinor != null ? formatMoney(notice.amountMinor, currency) : ""; + const date = new Date(notice.createdAt).toLocaleDateString("en-GB"); + return ( + + ); +} + +/** + * The bell: this commune's notices first, then the unread ones from the + * reader's other communes, each under its commune's name. Opening a notice + * from another commune switches to that commune. + */ function Notices() { - const { go, hub, funds, notifications, markNotificationRead } = useApp(); + const { go, hub, places, funds, notifications, noticeCounts, markNotificationRead, loadUnreadNotices } = useApp(); const [error, setError] = useState(""); - if (!notifications.length) { - return ( -
-

Bell

-

No notices for you in this commune.

-
- ); - } + const others = places.filter((place) => place.id !== hub.id && (noticeCounts[place.id] ?? 0) > 0); + // Read again whenever another commune's count changes while this is open. + const othersKey = others.map((place) => `${place.id}:${noticeCounts[place.id]}`).join(","); + const [elsewhere, setElsewhere] = useState(null); + const [elsewhereError, setElsewhereError] = useState(""); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + if (!othersKey) return; + let live = true; + setElsewhereError(""); + loadUnreadNotices() + .then((notices) => { if (live) setElsewhere(notices); }) + .catch((cause: unknown) => { + if (live) setElsewhereError(cause instanceof Error ? cause.message : "Could not load notices from your other communes."); + }); + return () => { live = false; }; + }, [othersKey, attempt, loadUnreadNotices]); + const headingId = useId(); + const read = (notice: Notification, communeId: string | undefined, open: () => void) => () => void markNotificationRead(notice.id, communeId) + .then(open) + .catch((cause) => setError(cause instanceof Error ? cause.message : "Could not mark the notice read.")); return (

Bell

{error ?

{error}

: null} - {notifications.map((notice) => { - const copy = NOTICE_COPY[notice.kind] ?? { pill: "Notice", title: "Update" }; - const actor = hub.members.find((member) => member.userId === notice.senderUserId)?.name ?? "a former member"; - const ledger = notice.kind !== "nudge" && notice.kind !== "chase"; - const amount = notice.amountMinor != null ? formatMoney(notice.amountMinor, hub.currency) : ""; - const date = new Date(notice.createdAt).toLocaleDateString("en-GB"); - // A pledge notice opens the fund that holds the pledge. - const pledge = notice.kind === "pledge-changed" || notice.kind === "pledge-cancelled"; - const fund = pledge ? funds.find((item) => !item.archivedAt && item.commitments.some((entry) => entry.id === notice.sourceId)) : undefined; +
+

{hub.name}

+ {notifications.length ? notifications.map((notice) => { + const actor = hub.members.find((member) => member.userId === notice.senderUserId)?.name ?? "a former member"; + const ledger = notice.kind !== "nudge" && notice.kind !== "chase"; + // A pledge notice opens the fund that holds the pledge. + const pledge = notice.kind === "pledge-changed" || notice.kind === "pledge-cancelled"; + const fund = pledge ? funds.find((item) => !item.archivedAt && item.commitments.some((entry) => entry.id === notice.sourceId)) : undefined; + return + ledger && notice.expenseId ? go("expense", { expense: notice.expenseId }) + : pledge ? go("funds", fund ? { record: { kind: "fund", id: fund.id } } : undefined) + : go(notice.kind === "chase" ? "funds" : "settle"))} />; + }) :

No notices for you in this commune.

} +
+ {others.map((place, index) => { + const notices = elsewhere?.filter((notice) => notice.communeId === place.id) ?? []; return ( - +
+

{place.name}

+ {notices.length ? notices.map((notice) => { + const ledger = notice.kind !== "nudge" && notice.kind !== "chase"; + const pledge = notice.kind === "pledge-changed" || notice.kind === "pledge-cancelled"; + return + ledger && notice.expenseId ? go("expense", { hub: place.id, expense: notice.expenseId }) + : pledge && notice.fundId ? go("funds", { hub: place.id, record: { kind: "fund", id: notice.fundId } }) + : go(pledge || notice.kind === "chase" ? "funds" : "settle", { hub: place.id }))} />; + }) : elsewhereError ? ( +

{elsewhereError}

+
+ ) : !elsewhere ?

Loading notices…

+ :

No unread notices here now.

} +
); })}
diff --git a/apps/web/tests/neon-auth.test.tsx b/apps/web/tests/neon-auth.test.tsx index a569e093..06b29d36 100644 --- a/apps/web/tests/neon-auth.test.tsx +++ b/apps/web/tests/neon-auth.test.tsx @@ -44,6 +44,35 @@ describe("hosted read authentication", () => { expect(mocks.from).not.toHaveBeenCalled(); }); + it("carries each commune's unread notice count for the bell, when the database sends one (0039)", async () => { + listResult([{ ...listed, unreadNotices: 3 }, { ...listed, id: "commune-2" }]); + const { listCommunes } = await import("../src/live/neon"); + const [counted, older] = await listCommunes(); + expect(counted.unreadNotices).toBe(3); + // Before 0039 the field is missing, and the app counts the open commune's own notices. + expect("unreadNotices" in older).toBe(false); + listResult([{ ...listed, unreadNotices: "many" }]); + await expect(listCommunes()).rejects.toMatchObject({ status: 502 }); + }); + + it("checks a verified session before reading notices across communes", async () => { + const { neonApi } = await import("../src/live/neon"); + await expect(neonApi("/notices/unread")).rejects.toThrow("Sign in required"); + expect(mocks.rpc).not.toHaveBeenCalled(); + }); + + it("reads unread notices across communes through one guarded RPC and refuses a malformed answer", async () => { + mocks.getSession.mockResolvedValue({ data: { user: { id: "viewer", emailVerified: true } } }); + const notices = [{ id: "notice-1", kind: "payment-reported", communeId: "commune-2", senderName: "Ben", fundId: null }]; + mocks.rpc.mockResolvedValue({ data: notices, error: null }); + const { neonApi } = await import("../src/live/neon"); + expect(await neonApi("/notices/unread")).toEqual({ notices }); + expect(mocks.rpc).toHaveBeenCalledExactlyOnceWith("my_unread_notices"); + mocks.rpc.mockResolvedValue({ data: null, error: null }); + await expect(neonApi("/notices/unread")).rejects.toMatchObject({ status: 502 }); + expect(mocks.from).not.toHaveBeenCalled(); + }); + it("does not publish a commune without the viewer's membership or its change mark", async () => { const { listCommunes } = await import("../src/live/neon"); for (const entry of [{ id: "commune-1", name: "Home" }, { ...listed, role: "owner" }, { ...listed, changeMark: null }]) { diff --git a/apps/web/tests/notices-across-communes.test.tsx b/apps/web/tests/notices-across-communes.test.tsx new file mode 100644 index 00000000..8a0c9f9f --- /dev/null +++ b/apps/web/tests/notices-across-communes.test.tsx @@ -0,0 +1,172 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +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 } from "../src/live/api"; +import type { Commune, Notification, Snapshot, UnreadNotice } 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(), +})); + +// The bell counts unread notices in every commune (0039), and Notices lists +// the other communes' unread ones under their names. +const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true }; +const home: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" }; +const trip: Commune = { id: "trip-1", name: "Lisbon trip", kind: "trip", currency: "EUR", role: "member" }; +const members = [ + { userId: user.id, name: user.name, role: "admin" as const, active: true }, + { userId: "user-2", name: "Housemate Two", role: "member" as const, active: true }, +]; +const homeNotice: Notification = { + id: "notice-home", kind: "payment-reported", sourceId: "expense-home", amountMinor: 1200, communeId: home.id, + senderUserId: "user-2", expenseId: "expense-home", title: "Gas", detail: null, createdAt: "2026-10-06T09:00:00Z", readAt: null, +}; +const tripNotice: UnreadNotice = { + id: "notice-trip", kind: "expense-corrected", sourceId: "expense-trip", amountMinor: 2500, communeId: trip.id, + senderUserId: "user-3", expenseId: "expense-trip", title: "Ferry tickets", detail: "Ann had the cabin", + createdAt: "2026-10-07T09:00:00Z", readAt: null, senderName: "Ben Baker", fundId: null, +}; +const ferryExpense = { + id: "expense-trip", title: "Ferry tickets", amountMinor: 4000, paidByUserId: "user-2", dueOn: null, + shares: [ + { id: "share-trip-1", userId: user.id, amountMinor: 2500, settledMinor: 0 }, + { id: "share-trip-2", userId: "user-2", amountMinor: 1500, settledMinor: 1500 }, + ], +}; +function snapshotOf(commune: Commune, extra: Partial = {}): Snapshot { + return { + commune, members, expenses: [], funds: [], notes: [], operations: [], notifications: [], reminderTargets: [], + recurringBills: [], splitTemplates: [], activityEvents: [], announcements: [], sentReminders: [], ...extra, + }; +} +let snapshots: Record; +let listed: Commune[]; +let unread: UnreadNotice[]; +let app: AppState; +function Capture() { app = useApp(); return null; } +function mount(path: string) { + const location = memoryLocation({ path, record: true }); + render(); + return location; +} +const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal"); +const originalScroll = Element.prototype.scrollIntoView; +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } }); +}); +afterAll(() => { + Element.prototype.scrollIntoView = originalScroll; + if (modalDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "showModal", modalDescriptor); + else delete (HTMLDialogElement.prototype as unknown as Record).showModal; +}); +beforeEach(() => { + vi.resetAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + snapshots = { + [home.id]: snapshotOf({ ...home, unreadNotices: 1 }, { notifications: [homeNotice] }), + [trip.id]: snapshotOf({ ...trip, unreadNotices: 2 }, { expenses: [ferryExpense] }), + }; + listed = [{ ...home, unreadNotices: 1 }, { ...trip, unreadNotices: 2 }]; + unread = [tripNotice, { ...tripNotice, id: "notice-trip-2", kind: "payment-confirmed", title: "Dinner", expenseId: "expense-dinner", + createdAt: "2026-10-05T09:00:00Z" }, { ...homeNotice, senderName: "Housemate Two", fundId: null }]; + vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: listed })); + vi.mocked(loadSnapshot).mockImplementation(async (commune) => snapshots[commune.id]); + vi.mocked(api).mockImplementation(async (path) => { + if (path === "/auth/me") return { user }; + if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null }; + if (path.endsWith("/invites")) return { invites: [] }; + if (path === "/notices/unread") return { notices: unread }; + return { ok: true }; + }); +}); +afterEach(cleanup); + +describe("notices across communes", () => { + it.each(["/", "/hub", "/expenses"])("counts every commune's unread notices in the bell on %s", async (path) => { + mount(path); + const bell = await screen.findByRole("button", { name: "Notifications, 3 unread" }); + expect(bell.classList.contains("has-unread")).toBe(true); + }); + + it("falls back to the open commune's own notices while the database sends no counts", async () => { + listed = [home, trip]; + snapshots[home.id] = snapshotOf(home, { notifications: [homeNotice] }); + mount("/hub"); + expect(await screen.findByRole("button", { name: "Notifications, 1 unread" })).toBeTruthy(); + }); + + it("lists this commune's notices first, then other communes' unread ones under their names", async () => { + mount("/hub"); + fireEvent.click(await screen.findByRole("button", { name: "Notifications, 3 unread" })); + const notices = await screen.findByRole("dialog", { name: "Notices" }); + await within(notices).findByText("Ferry tickets"); + const headings = within(notices).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent); + expect(headings).toEqual(["Our home", "Lisbon trip"]); + const here = within(notices).getByRole("region", { name: "Our home" }); + expect(within(here).getByText("Gas")).toBeTruthy(); + expect(within(here).getByText(/Housemate Two says they sent £12\.00/)).toBeTruthy(); + const there = within(notices).getByRole("region", { name: "Lisbon trip" }); + // Their own commune's notices are not repeated, and amounts use that commune's currency. + expect(within(there).queryByText("Gas")).toBeNull(); + expect(within(there).getByText(/Corrected by Ben Baker · your share is now €25\.00/)).toBeTruthy(); + expect(within(there).getByText("Dinner")).toBeTruthy(); + expect(vi.mocked(api).mock.calls.filter(([path]) => path === "/notices/unread")).toHaveLength(1); + }); + + it("reads a notice from another commune there, then switches to that commune and opens it", async () => { + vi.mocked(post).mockImplementation(async () => { + snapshots[trip.id] = snapshotOf({ ...trip, unreadNotices: 1 }, { expenses: [ferryExpense] }); + return { ok: true }; + }); + mount("/hub"); + fireEvent.click(await screen.findByRole("button", { name: "Notifications, 3 unread" })); + const notices = await screen.findByRole("dialog", { name: "Notices" }); + const ferry = await within(notices).findByText("Ferry tickets"); + await act(async () => { fireEvent.click(ferry.closest("button")!); }); + expect(vi.mocked(post)).toHaveBeenCalledWith("/notifications/notice-trip/read", {}); + await waitFor(() => expect(app.hub.id).toBe(trip.id)); + expect(app.window).toBe("expense"); + expect(app.selectedExpense?.id).toBe("expense-trip"); + // The trip was read again after the notice was, so its count is the new one. + expect(vi.mocked(loadSnapshot).mock.calls.some(([commune, detail]) => commune.id === trip.id && detail === undefined)).toBe(true); + expect(await screen.findByRole("button", { name: "Notifications, 2 unread" })).toBeTruthy(); + }); + + it("does not ask for other communes' notices when none are unread", async () => { + listed = [{ ...home, unreadNotices: 1 }, { ...trip, unreadNotices: 0 }]; + mount("/hub"); + fireEvent.click(await screen.findByRole("button", { name: "Notifications, 1 unread" })); + const notices = await screen.findByRole("dialog", { name: "Notices" }); + expect(within(notices).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(["Our home"]); + expect(vi.mocked(api).mock.calls.some(([path]) => path === "/notices/unread")).toBe(false); + }); + + it("keeps this commune's notices when the others cannot be read, and offers to try again", async () => { + let fail = true; + vi.mocked(api).mockImplementation(async (path) => { + if (path === "/auth/me") return { user }; + if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null }; + if (path.endsWith("/invites")) return { invites: [] }; + if (path === "/notices/unread") { + if (fail) throw new Error("Notices could not be verified. Please try again."); + return { notices: unread }; + } + return { ok: true }; + }); + mount("/hub"); + fireEvent.click(await screen.findByRole("button", { name: "Notifications, 3 unread" })); + const notices = await screen.findByRole("dialog", { name: "Notices" }); + const there = within(notices).getByRole("region", { name: "Lisbon trip" }); + expect((await within(there).findByRole("alert")).textContent).toContain("Notices could not be verified"); + expect(within(notices).getByText("Gas")).toBeTruthy(); + fail = false; + fireEvent.click(within(there).getByRole("button", { name: "Try again" })); + expect(await within(there).findByText("Ferry tickets")).toBeTruthy(); + }); +}); diff --git a/apps/web/tests/quick-search.test.tsx b/apps/web/tests/quick-search.test.tsx index d1a2a2c1..89847680 100644 --- a/apps/web/tests/quick-search.test.tsx +++ b/apps/web/tests/quick-search.test.tsx @@ -29,7 +29,7 @@ beforeEach(() => { places: [{ id: "home-1", name: "Our home" }, { id: "home-2", name: "Maple House" }], session: { hasHubs: true }, go: mocks.go, setSearchQuery: mocks.setQuery, setSearchScope: mocks.setScope, pageTitle: "Expenses", pageLede: "Shared expenses", setMoreOpen: vi.fn(), native: false, - you: { name: "Alex", initials: "AL" }, noticesOn: true, notifications: [], window: null }; + you: { name: "Alex", initials: "AL" }, noticesOn: true, notifications: [], noticeCounts: {}, window: null }; mocks.api.mockResolvedValue(response([item("expense", "expense-2", "Garden supplies")])); }); afterEach(cleanup); From f1143998abf5709ac093663eb344698bd17fe78d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:57:02 +0000 Subject: [PATCH 03/10] Name each activity record's type, show its time and group the log by day Activity rows read "Inspection: Created" without saying whether it was a task, event, note or expense, and with no time of day. Each row now carries the record type the snapshot names (0039), falling back to the event's area, and shows it as a coloured icon chip and a word (money in slate, plans in sage, shared things in cream). Activity and All activity group their rows under Today, Yesterday or the date and show the time each was recorded; Home's recent activity shows the type with the day and time. Notices' commune headings share the new day heading style. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/data/household.ts | 7 ++ apps/web/src/live/model.ts | 15 ++- apps/web/src/screens/Home.tsx | 7 +- apps/web/src/screens/House.tsx | 16 +-- apps/web/src/screens/Search.tsx | 40 +++---- apps/web/src/styles/app.css | 14 --- apps/web/src/styles/design-system.css | 49 +++++++- apps/web/src/ui/ActivityLog.tsx | 122 ++++++++++++++++++++ apps/web/tests/activity-log.test.tsx | 159 ++++++++++++++++++++++++++ 9 files changed, 373 insertions(+), 56 deletions(-) create mode 100644 apps/web/src/ui/ActivityLog.tsx create mode 100644 apps/web/tests/activity-log.test.tsx diff --git a/apps/web/src/data/household.ts b/apps/web/src/data/household.ts index 9c5fa7b8..994490eb 100644 --- a/apps/web/src/data/household.ts +++ b/apps/web/src/data/household.ts @@ -60,6 +60,11 @@ export type SettlementMove = { expenseId?: string; }; +/** The kinds of record an activity row can be about (0039 names one for each event). */ +export type ActivityType = + | "expense" | "repayment" | "fund" | "pledge" | "contribution" | "recurring" | "template" + | "task" | "event" | "note" | "member" | "announcement"; + export type Hub = { id: HubId; currency?: "GBP" | "USD" | "EUR"; @@ -104,6 +109,8 @@ export type Hub = { time: string; createdAt?: string; expenseId?: string; + /** The kind of record the row is about. */ + type?: ActivityType; }>; notes: Array<{ go: "note"; note: string; title: string; when: string }>; jumps: Array<{ go: ScreenId | WindowId; kicker: string; title: string; hint: string }>; diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index c544e32c..72b6b912 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -1,6 +1,7 @@ import { HUBS, initialsOf, + type ActivityType, type AvatarTone, type ExpenseRow, type Hub, @@ -292,7 +293,8 @@ export type Snapshot = { recurringBills?: RecurringBill[]; 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}>; + /** recordType: the kind of record the event is about (0039). */ + activityEvents?: Array<{id:string;actorId:string;kind:ActivityEventKind;entityId:string;title:string;description:string;createdAt:string;recordType?:ActivityType}>; /** * The snapshot holds every expense created since `since`, every open one * and the five newest that are not voided. @@ -317,12 +319,19 @@ export type LiveExpenseRow = Omit & { +export type LiveHub = Omit & { currency: CurrencyCode; role: MemberRole; activityState?: "loading" | "error" | "ready"; memberCount?: number; members: Array; + activity: Array; +}; +export type ActivityEventKind = "funds" | "members" | "recurring" | "templates" | "notes" | "operations" | "expenses" | "announcements"; +/** The record type an event's area suggests, while the database does not name one (before 0039). */ +const EVENT_TYPES: Record = { + funds: "fund", members: "member", recurring: "recurring", templates: "template", notes: "note", + operations: "task", expenses: "expense", announcements: "announcement", }; const TONES: AvatarTone[] = ["jordan", "alex", "priya", "sam", "ness"]; @@ -560,12 +569,14 @@ export function hubFromSnapshot( time: expense.createdAt ? dateLabel(timestampDay(expense.createdAt)) : "Recently", createdAt: expense.createdAt, expenseId: expense.id, + type: "expense" as const, })), ...(snapshot.activityEvents ?? []).map(event => ({ // Announcements live on This Hub. go:event.kind === "announcements" ? "hub" as const : event.kind,cls:members.find(member=>member.userId===event.actorId)?.cls ?? "jordan" as const, ini:initialsOf(names.get(event.actorId) ?? "Member"), title:event.title, when:`${event.description} · ${names.get(event.actorId) ?? "Member"}`, time:dateLabel(timestampDay(event.createdAt)),createdAt:event.createdAt, expenseId:event.kind==="expenses" ? event.entityId : undefined, + type:event.recordType ?? EVENT_TYPES[event.kind], }))].sort((a,b)=>(Date.parse(b.createdAt ?? "")||0)-(Date.parse(a.createdAt ?? "")||0)), notes, jumps: [ diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index dc143bb3..848851d8 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -5,6 +5,7 @@ import { CalendarDate } from "../ui/CalendarDate"; import type { LiveHub } from "../live/model"; import { cx } from "../ui/cx"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; +import { ActivityTypeChip, activityStamp, activityWord } from "../ui/ActivityLog"; function PhotoFace({ hub, focus, children }: { hub: LiveHub; focus?: boolean; children: ReactNode }) { return ( @@ -86,9 +87,9 @@ export function HomeScreen() { {activity.length ? activity.map(({ hub, item }, index) => ( )) : !incomplete ?

Activity will appear after something is shared.

: null} {incomplete ?

{partialMessage}

: null} diff --git a/apps/web/src/screens/House.tsx b/apps/web/src/screens/House.tsx index 3501b292..2b3a6fde 100644 --- a/apps/web/src/screens/House.tsx +++ b/apps/web/src/screens/House.tsx @@ -1,5 +1,5 @@ import { useApp } from "../state"; -import { Avatar } from "../ui/widgets"; +import { ActivityLog } from "../ui/ActivityLog"; export { NotesScreen } from "./Notes"; export { MembersScreen } from "./Members"; @@ -8,15 +8,11 @@ export function ActivityScreen() { const { hub, go } = useApp(); if (!hub.activity.length) return

No activity yet.

The log starts when something is shared.

; return ( -
- {hub.activity.map((row, index) => ( - - ))} -
+ ({ + key: `${row.title}-${row.createdAt ?? row.time}-${index}`, + item: row, + onOpen: () => go(row.expenseId ? "expense" : row.go, { expense: row.expenseId }), + }))} /> ); } diff --git a/apps/web/src/screens/Search.tsx b/apps/web/src/screens/Search.tsx index 16358500..92978e93 100644 --- a/apps/web/src/screens/Search.tsx +++ b/apps/web/src/screens/Search.tsx @@ -1,6 +1,6 @@ import { useApp } from "../state"; -import { Button, EmptyState, Panel } from "../ui/components"; -import { Avatar } from "../ui/widgets"; +import { Button, EmptyState } from "../ui/components"; +import { ActivityLog } from "../ui/ActivityLog"; import { SEARCH_MAX_QUERY } from "../live/search"; import { SearchResults } from "../ui/SearchResults"; import { useRecordSearch } from "../ui/useRecordSearch"; @@ -20,30 +20,18 @@ export function AllActivityScreen() { <>

All your communes

{rows.length ? ( - - {rows.map(({ hub, item }, index) => ( - - ))} - + ({ + key: `${hub.id}-${index}`, + item, + commune: hub.name, + onOpen: () => + go(item.expenseId ? "expense" : item.go, { + hub: hub.id, + expense: item.expenseId, + }), + }))} + /> ) : ( !incomplete && ( span:not(.avatar) { +.c-result-row > span:not(.avatar, .c-type-chip) { flex: 1; min-width: 0; } @@ -208,6 +208,53 @@ .c-result-row:hover { background: var(--paper); } +/* The kind of record behind an activity row, as a coloured icon chip: + money in slate, plans in sage, what everyone shares in cream. */ +.c-type-chip { + width: 36px; + height: 36px; + flex: none; + border-radius: 10px; + display: grid; + place-items: center; +} +.c-type-chip.is-money { + background: #edf0f3; + color: #637689; +} +.c-type-chip.is-plan { + background: #edf3eb; + color: #2d6a4f; +} +.c-type-chip.is-shared { + background: #f5f1ea; + color: #60706b; +} +.a-body.is-dark .c-type-chip.is-money { + background: #2a3440; + color: #b5c6bb; +} +.a-body.is-dark .c-type-chip.is-plan { + background: #253b34; + color: #a8cbb5; +} +.a-body.is-dark .c-type-chip.is-shared { + background: #2e3646; + color: #b5bdca; +} +/* Activity, a day at a time; Notices, a commune at a time. Both headed like search results. */ +.c-log-day + .c-log-day, +.c-notice-group + .c-notice-group { + margin-top: 22px; +} +:is(.c-log-day, .c-notice-group) > h3 { + margin: 0 0 8px; + color: var(--ink-soft); + font-size: 11px; + font-weight: 550; + letter-spacing: 0.6px; + text-transform: uppercase; +} .c-scope-label { font-size: 12px; color: var(--ink-soft); diff --git a/apps/web/src/ui/ActivityLog.tsx b/apps/web/src/ui/ActivityLog.tsx new file mode 100644 index 00000000..c0e6fb18 --- /dev/null +++ b/apps/web/src/ui/ActivityLog.tsx @@ -0,0 +1,122 @@ +import { useId } from "react"; +import { + ArrowRightLeft, CalendarDays, Clock3, FileText, Files, HandCoins, Handshake, Megaphone, ReceiptText, Repeat2, + ShoppingBag, SquareCheck, UsersRound, type LucideIcon, +} from "lucide-react"; +import type { ActivityType } from "../data/household"; +import { addDays, dateFromIso, timestampDay, todayIso } from "../data/operations"; +import type { LiveHub } from "../live/model"; +import { Panel } from "./components"; + +type ActivityItem = LiveHub["activity"][number]; + +/** + * Each kind of record: its word, the icon its own screen uses, and the chip + * colour that sets money apart from plans and from what everyone shares. + */ +const TYPES: Record = { + expense: { word: "Expense", icon: ReceiptText, tone: "money" }, + repayment: { word: "Repayment", icon: ArrowRightLeft, tone: "money" }, + fund: { word: "Fund", icon: ShoppingBag, tone: "money" }, + pledge: { word: "Pledge", icon: Handshake, tone: "money" }, + contribution: { word: "Contribution", icon: HandCoins, tone: "money" }, + recurring: { word: "Recurring bill", icon: Repeat2, tone: "money" }, + template: { word: "Saved split", icon: Files, tone: "money" }, + task: { word: "Task", icon: SquareCheck, tone: "plan" }, + event: { word: "Event", icon: CalendarDays, tone: "plan" }, + note: { word: "Note", icon: FileText, tone: "shared" }, + member: { word: "Member", icon: UsersRound, tone: "shared" }, + announcement: { word: "Announcement", icon: Megaphone, tone: "shared" }, +}; +// A kind this version of the app does not know yet still gets a calm row. +const UNKNOWN = { word: "", icon: Clock3, tone: "shared" as const }; + +/** The word for a kind of record, such as "Event". */ +export function activityWord(type: ActivityType | undefined): string { + return (type && TYPES[type]?.word) || UNKNOWN.word; +} + +/** The kind of record as a coloured icon chip; the row names it in words. */ +export function ActivityTypeChip({ type }: { type: ActivityType | undefined }) { + const { icon: Glyph, tone } = (type && TYPES[type]) || UNKNOWN; + return ; +} + +/** The time of day something was recorded, where the member is ("14:05"). */ +export function activityClock(createdAt: string | undefined): string { + const date = createdAt ? new Date(createdAt) : null; + if (!date || Number.isNaN(date.getTime())) return ""; + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +function relativeDay(day: string, today: string): string { + return day === today ? "Today" : day === addDays(today, -1) ? "Yesterday" : ""; +} + +// A calendar day (YYYY-MM-DD) in words, with the year only when it is not this one. +function dayWords(day: string, today: string, style: "long" | "short"): string { + const options: Intl.DateTimeFormatOptions = style === "long" + ? { weekday: "long", day: "numeric", month: "long" } : { day: "numeric", month: "short" }; + if (day.slice(0, 4) !== today.slice(0, 4)) options.year = "numeric"; + return new Intl.DateTimeFormat("en-GB", options).format(dateFromIso(day)); +} + +/** A day heading: Today, Yesterday, or the weekday and date. */ +export function activityDayHeading(day: string, today = todayIso()): string { + if (!day) return "Earlier"; + return relativeDay(day, today) || dayWords(day, today, "long"); +} + +/** When, in one short line for a row without a day heading: "Today, 14:05" or "5 Oct, 09:30". */ +export function activityStamp(item: Pick, today = todayIso()): string { + const day = timestampDay(item.createdAt); + if (!day) return item.time; + return `${relativeDay(day, today) || dayWords(day, today, "short")}, ${activityClock(item.createdAt)}`; +} + +/** Rows already in order, grouped under the local day each was recorded on. */ +export function groupByDay(rows: T[], createdAt: (row: T) => string | undefined): Array<{ day: string; rows: T[] }> { + const groups: Array<{ day: string; rows: T[] }> = []; + for (const row of rows) { + const day = timestampDay(createdAt(row)); + const last = groups[groups.length - 1]; + if (last && last.day === day) last.rows.push(row); + else groups.push({ day, rows: [row] }); + } + return groups; +} + +export type ActivityLogRow = { key: string; item: ActivityItem; commune?: string; onOpen: () => void }; + +/** + * The activity log: rows newest first under a heading for each day, each + * naming the kind of record in an icon and a word, and the time it was recorded. + */ +export function ActivityLog({ rows }: { rows: ActivityLogRow[] }) { + const headingId = useId(); + const today = todayIso(); + return ( +
+ {groupByDay(rows, (row) => row.item.createdAt).map((group, index) => ( +
+

{activityDayHeading(group.day, today)}

+ + {group.rows.map(({ key, item, commune, onOpen }) => { + const time = activityClock(item.createdAt); + return ( + + ); + })} + +
+ ))} +
+ ); +} diff --git a/apps/web/tests/activity-log.test.tsx b/apps/web/tests/activity-log.test.tsx new file mode 100644 index 00000000..9183aa84 --- /dev/null +++ b/apps/web/tests/activity-log.test.tsx @@ -0,0 +1,159 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import { Router } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; +import { App } from "../src/App"; +import { AppProvider } from "../src/state"; +import { api, loadCommunes, loadSnapshot } from "../src/live/api"; +import { hubFromSnapshot, type Commune, type Snapshot } from "../src/live/model"; +import { activityDayHeading, activityStamp, groupByDay } from "../src/ui/ActivityLog"; + +vi.mock("../src/live/api", async (importOriginal) => ({ + ...(await importOriginal()), + api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(), +})); + +// Activity names the kind of record each row is about, in an icon and a word, +// shows the time of day, and groups the rows under the day they were recorded. +// It is Friday 9 October 2026, 4pm in London. +const zone = process.env.TZ; +const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true }; +const home: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" }; +const trip: Commune = { id: "trip-1", name: "Lisbon trip", kind: "trip", currency: "EUR", role: "member" }; +const members = [ + { userId: user.id, name: user.name, role: "admin" as const, active: true }, + { userId: "user-2", name: "Housemate Two", role: "member" as const, active: true }, +]; +type Event = NonNullable[number]; +const event = (id: string, kind: Event["kind"], title: string, description: string, createdAt: string, recordType?: Event["recordType"]): Event => + ({ id, actorId: "user-2", kind, entityId: `${id}-record`, title, description, createdAt, ...(recordType ? { recordType } : {}) }); +function snapshotOf(commune: Commune, extra: Partial = {}): Snapshot { + return { + commune, members, expenses: [], funds: [], notes: [], operations: [], notifications: [], reminderTargets: [], + recurringBills: [], splitTemplates: [], activityEvents: [], announcements: [], sentReminders: [], ...extra, + }; +} +const gas = { + id: "expense-gas", title: "Gas", amountMinor: 3000, paidByUserId: "user-2", dueOn: null, createdAt: "2026-10-05T18:45:00Z", + shares: [{ id: "share-gas", userId: user.id, amountMinor: 1500, settledMinor: 1500 }], +}; +const homeSnapshot = () => snapshotOf(home, { + expenses: [gas], + activityEvents: [ + event("a1", "operations", "Inspection", "Created", "2026-10-09T13:05:00Z", "event"), + event("a2", "notes", "Router", "Created", "2026-10-08T08:30:00Z", "note"), + // Written before the database named record types: the area says what it is. + event("a3", "funds", "Boiler", "Created", "2025-12-30T10:00:00Z"), + ], +}); +const tripSnapshot = () => snapshotOf(trip, { + activityEvents: [event("b1", "expenses", "Ferry", "Repayment of €20.00 recorded", "2026-10-09T09:10:00Z", "repayment")], +}); + +function mount(path: string) { + const location = memoryLocation({ path, record: true }); + render(); + return location; +} +const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal"); +beforeAll(() => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } }); +}); +afterAll(() => { + if (modalDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "showModal", modalDescriptor); + else delete (HTMLDialogElement.prototype as unknown as Record).showModal; +}); +beforeEach(() => { + process.env.TZ = "Europe/London"; + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-09T15:00:00Z")); + vi.resetAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + const snapshots: Record = { [home.id]: homeSnapshot(), [trip.id]: tripSnapshot() }; + vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: [home, trip] })); + vi.mocked(loadSnapshot).mockImplementation(async (commune) => snapshots[commune.id]); + vi.mocked(api).mockImplementation(async (path) => { + if (path === "/auth/me") return { user }; + if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null }; + if (path.endsWith("/invites")) return { invites: [] }; + return { ok: true }; + }); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); + if (zone === undefined) delete process.env.TZ; + else process.env.TZ = zone; +}); + +const rowText = (row: HTMLElement) => [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent, + row.querySelector("time")?.textContent]; + +describe("activity rows", () => { + it("name the record type the database gives, or the one its area suggests", () => { + const hub = hubFromSnapshot(homeSnapshot(), user); + expect(hub.activity.map((item) => [item.title, item.type])).toEqual([ + ["Inspection", "event"], ["Router", "note"], ["Gas", "expense"], ["Boiler", "fund"], + ]); + }); + + it("group by the member's own day and say when in words", () => { + expect(activityDayHeading("2026-10-09", "2026-10-09")).toBe("Today"); + expect(activityDayHeading("2026-10-08", "2026-10-09")).toBe("Yesterday"); + expect(activityDayHeading("2026-10-05", "2026-10-09")).toBe("Monday 5 October"); + expect(activityDayHeading("2025-12-30", "2026-10-09")).toBe("Tuesday, 30 December 2025"); + expect(activityDayHeading("", "2026-10-09")).toBe("Earlier"); + // In London, 23:30 UTC on the 8th is already half past midnight on the 9th. + const rows = ["2026-10-09T09:00:00Z", "2026-10-08T23:30:00Z", "2026-10-08T22:30:00Z", undefined]; + expect(groupByDay(rows, (row) => row).map((group) => [group.day, group.rows.length])) + .toEqual([["2026-10-09", 2], ["2026-10-08", 1], ["", 1]]); + expect(activityStamp({ createdAt: "2026-10-09T13:05:00Z", time: "9 Oct" }, "2026-10-09")).toBe("Today, 14:05"); + expect(activityStamp({ createdAt: "2026-10-05T18:45:00Z", time: "5 Oct" }, "2026-10-09")).toBe("5 Oct, 19:45"); + expect(activityStamp({ createdAt: "2025-12-30T10:00:00Z", time: "30 Dec" }, "2026-10-09")).toBe("30 Dec 2025, 10:00"); + expect(activityStamp({ createdAt: undefined, time: "Recently" }, "2026-10-09")).toBe("Recently"); + }); +}); + +describe("the activity log", () => { + it("shows this commune's records under day headings, each with its type and time", async () => { + mount("/activity"); + const today = await screen.findByRole("region", { name: "Today" }); + expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)) + .toEqual(["Today", "Yesterday", "Monday 5 October", "Tuesday, 30 December 2025"]); + expect(within(today).getAllByRole("button").map(rowText)).toEqual([["Inspection", "Event · Created · Housemate Two", "14:05"]]); + expect(within(screen.getByRole("region", { name: "Yesterday" })).getAllByRole("button").map(rowText)) + .toEqual([["Router", "Note · Created · Housemate Two", "09:30"]]); + expect(within(screen.getByRole("region", { name: "Monday 5 October" })).getAllByRole("button").map(rowText)) + .toEqual([["Gas", "Expense · Housemate Two paid", "19:45"]]); + expect(within(screen.getByRole("region", { name: "Tuesday, 30 December 2025" })).getAllByRole("button").map(rowText)) + .toEqual([["Boiler", "Fund · Created · Housemate Two", "10:00"]]); + // The kind is in words; its icon is decoration. + expect(today.querySelector(".c-type-chip.is-plan")?.getAttribute("aria-hidden")).toBe("true"); + }); + + it("shows every commune's records together, each naming its commune", async () => { + mount("/activity/all"); + const today = await screen.findByRole("region", { name: "Today" }); + await within(today).findByText("Ferry"); + expect(within(today).getAllByRole("button").map(rowText)).toEqual([ + ["Inspection", "Our home · Event · Created · Housemate Two", "14:05"], + ["Ferry", "Lisbon trip · Repayment · Repayment of €20.00 recorded · Housemate Two", "10:10"], + ]); + }); + + it("gives Home's recent records their type, day and time", async () => { + mount("/"); + const recent = (await screen.findByRole("heading", { name: "Recent activity" })).closest(".a-card") as HTMLElement; + await within(recent).findByText("Ferry"); + const rows = within(recent).getAllByRole("button").filter((button) => button.classList.contains("a-item")); + expect(rows.map((row) => [row.querySelector("strong")?.textContent, ...[...row.querySelectorAll(".when")].map((node) => node.textContent)])) + .toEqual([ + ["Inspection", "Our home · Event · Created · Housemate Two", "Today, 14:05"], + ["Ferry", "Lisbon trip · Repayment · Repayment of €20.00 recorded · Housemate Two", "Today, 10:10"], + ["Router", "Our home · Note · Created · Housemate Two", "Yesterday, 09:30"], + ["Gas", "Our home · Expense · Housemate Two paid", "5 Oct, 19:45"], + ["Boiler", "Our home · Fund · Created · Housemate Two", "30 Dec 2025, 10:00"], + ]); + }); +}); From da6feee7b1fdafbf24bcbf166d1a5d35bf4d2326 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:44:18 +0000 Subject: [PATCH 04/10] Build Needs you from one rule on Home and This Hub, actions first Home listed every open expense you paid for alongside your unpaid shares, late jobs and pledges, sorted only by date, and This Hub built its list another way. One rule (needsYou) now builds both: payments someone says they have sent, which only the bill payer can confirm, then your unpaid shares, your tasks that are late or due today, then your pledges due, the earliest date first within each. What others owe you, and payments you said you sent, sit in a quieter Waiting on others group with nothing to do. The counts on This Hub include only what needs you. Home reads other communes' pending payment reports from their summaries (0039). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/live/model.ts | 122 +++++++++++++--- apps/web/src/screens/Home.tsx | 45 ++++-- apps/web/src/screens/Hub.css | 3 + apps/web/src/screens/Hub.tsx | 33 +++-- apps/web/src/state.tsx | 1 + apps/web/src/styles/design-system.css | 8 +- apps/web/tests/loading.test.tsx | 2 +- apps/web/tests/needs-you.test.tsx | 191 ++++++++++++++++++++++++++ 8 files changed, 358 insertions(+), 47 deletions(-) create mode 100644 apps/web/tests/needs-you.test.tsx diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 72b6b912..9d29420f 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -319,14 +319,112 @@ export type LiveExpenseRow = Omit & { +export type LiveHub = Omit & { currency: CurrencyCode; role: MemberRole; activityState?: "loading" | "error" | "ready"; memberCount?: number; members: Array; activity: Array; + /** Needs you: what the viewer has to do here, in order (see needsYou). */ + now: Need[]; + /** What the viewer is waiting on others for, with nothing to do. */ + waiting: Need[]; }; + +/** + * One row of "Needs you". confirm: someone says they have sent a payment + * only the viewer, the bill payer, can confirm. pay: the viewer's unpaid + * share. task: the viewer's task, late or due today. pledge: the viewer's + * pledge that is due. owed and reported wait on others: what people owe the + * viewer, and a payment the viewer said they sent. + */ +export type Need = { + kind: "confirm" | "pay" | "task" | "pledge" | "owed" | "reported"; + go: "expense" | "operation" | "funds"; + title: string; + /** Who and what, without the amount. */ + when: string; + amount?: string; + dueOn?: string; + /** Past its date. */ + late: boolean; + expenseId?: string; + operationId?: string; + fundId?: string; +}; +const NEED_ORDER: Need["kind"][] = ["confirm", "pay", "task", "pledge", "owed", "reported"]; +/** Actions in their order, then the earliest date first; anything undated after the dated ones. */ +export function compareNeeds(a: Need, b: Need): number { + return NEED_ORDER.indexOf(a.kind) - NEED_ORDER.indexOf(b.kind) || (a.dueOn ?? "9999").localeCompare(b.dueOn ?? "9999"); +} +function namesList(names: string[]): string { + return names.length < 3 ? names.join(" and ") : `${names.length} people`; +} + +/** + * "Needs you", one rule for Home and This Hub. Actions first, in this order: + * payments someone says they have sent, which only the bill payer can + * confirm; the viewer's unpaid shares; their tasks that are late or due + * today; then their pledges that are due. "Waiting on others" holds what + * people owe the viewer, and payments the viewer said they sent, while + * there is nothing for the viewer to do. A summary snapshot keeps pending + * payment reports (0039), so Home sees them for every commune. + */ +export function needsYou(snapshot: Snapshot, viewerId: string, today = todayIso()): { actions: Need[]; waiting: Need[] } { + const currency = snapshot.commune.currency; + const names = new Map(snapshot.members.map((member) => [member.userId, member.name])); + const nameOf = (userId: string) => names.get(userId) ?? "a former member"; + const actions: Need[] = []; + const waiting: Need[] = []; + for (const expense of snapshot.expenses) { + if (expense.voidedAt) continue; + const dueOn = expense.dueOn ?? undefined; + const late = !!dueOn && dueOn < today; + const base = { go: "expense" as const, title: expense.title, expenseId: expense.id, dueOn, late }; + const owing: Array<{ userId: string; minor: number }> = []; + for (const share of expense.shares) { + const outstanding = share.amountMinor - share.settledMinor; + if (share.userId === expense.paidByUserId || outstanding <= 0) continue; + const report = share.paymentReports?.find((item) => item.status === "pending"); + if (expense.paidByUserId === viewerId) { + if (report) { + actions.push({ ...base, kind: "confirm", when: `${nameOf(share.userId)} says they sent it`, amount: money(report.amountMinor, currency) }); + } else { + owing.push({ userId: share.userId, minor: outstanding }); + } + } else if (share.userId === viewerId) { + const payer = nameOf(expense.paidByUserId); + if (report) { + waiting.push({ ...base, kind: "reported", when: `You said you sent it · waiting for ${payer}`, amount: money(report.amountMinor, currency) }); + } else { + actions.push({ ...base, kind: "pay", when: `You owe ${payer}${late ? " · overdue" : ""}`, amount: money(outstanding, currency) }); + } + } + } + if (owing.length) { + const who = namesList(owing.map((item) => nameOf(item.userId))); + waiting.push({ ...base, kind: "owed", when: `${who.charAt(0).toUpperCase()}${who.slice(1)} ${owing.length === 1 ? "owes" : "owe"} you`, + amount: money(owing.reduce((total, item) => total + item.minor, 0), currency) }); + } + } + for (const item of snapshot.operations ?? []) { + if (item.kind !== "task" || item.status !== "open" || item.assigneeId !== viewerId || item.date > today) continue; + const late = item.date < today; + actions.push({ kind: "task", go: "operation", title: item.title, when: late ? "Overdue · your turn" : "Your turn today", + operationId: item.id, dueOn: item.date, late }); + } + for (const fund of snapshot.funds) { + if (fund.archivedAt) continue; + for (const pledge of fund.commitments) { + if (pledge.cancelledAt || pledge.userId !== viewerId || pledge.amountMinor <= pledge.contributedMinor || pledge.dueOn > today) continue; + const late = pledge.dueOn < today; + actions.push({ kind: "pledge", go: "funds", title: fund.name, when: `Remaining on your pledge${late ? " · overdue" : ""}`, + amount: money(pledge.amountMinor - pledge.contributedMinor, currency), fundId: fund.id, dueOn: pledge.dueOn, late }); + } + } + return { actions: actions.sort(compareNeeds), waiting: waiting.sort(compareNeeds) }; +} export type ActivityEventKind = "funds" | "members" | "recurring" | "templates" | "notes" | "operations" | "expenses" | "announcements"; /** The record type an event's area suggests, while the database does not name one (before 0039). */ const EVENT_TYPES: Record = { @@ -490,9 +588,10 @@ export function hubFromSnapshot( }); const fundTotal = snapshot.funds.reduce((sum, fund) => sum + fund.commitments.reduce((subtotal, commitment) => subtotal + commitment.contributedMinor, 0), 0); + const today = todayIso(); // This month where the member is: an expense entered late on the last // evening of a month belongs to that month. - const month = todayIso().slice(0, 7); + const month = today.slice(0, 7); const monthExpenses = snapshot.expenses.filter((expense) => timestampDay(expense.createdAt).startsWith(month)); const monthTotal = monthExpenses .reduce((sum, expense) => sum + expense.amountMinor, 0); @@ -509,21 +608,7 @@ export function hubFromSnapshot( title: note.title, when: notePreview(note), })); - const today = todayIso(); - const personalNeeds: Hub["now"] = [ - ...openRows.filter(row => row.paidByUserId === viewer.id || row.shares.some(share => share.userId === viewer.id && share.settledMinor < share.amountMinor)).map(row => ({ - go: "expense" as const, pill: row.status, tag: row.due, title: row.name, - when: `${row.amount} · ${row.paidBy} paid`, expenseId: row.id, dueOn: row.dueOn ?? undefined, - })), - ...(snapshot.operations ?? []).filter(item => item.kind === "task" && item.status === "open" && item.assigneeId === viewer.id && item.date <= today).map(item => ({ - go: "operation" as const, pill: item.date < today ? "warn" : "open", tag: item.date < today ? "Overdue" : "Today", - title: item.title, when: item.date < today ? "Overdue · your turn" : "Your turn today", operationId: item.id, dueOn: item.date, - })), - ...snapshot.funds.filter(fund => !fund.archivedAt).flatMap(fund => fund.commitments.filter(pledge => !pledge.cancelledAt && pledge.userId === viewer.id && pledge.amountMinor > pledge.contributedMinor && pledge.dueOn <= today).map(pledge => ({ - go: "funds" as const, pill: pledge.dueOn < today ? "warn" : "open", tag: dateLabel(pledge.dueOn), title: fund.name, - when: `${money(pledge.amountMinor - pledge.contributedMinor, currency)} remaining on your pledge`, dueOn: pledge.dueOn, - }))), - ]; + const needs = needsYou(source, viewer.id); return { id: commune.id, currency, @@ -557,7 +642,8 @@ export function hubFromSnapshot( // A date that has passed says so rather than reading as still to come. { go: "expenses", tone: "ink", kicker: "Next due", num: nextDue?.dueOn ? nextDue.dueOn < today ? `Overdue since ${dateLabel(nextDue.dueOn)}` : dateLabel(nextDue.dueOn) : "-", hint: nextDue?.name ?? "No dated open bills" }, ], - now: personalNeeds.sort((a, b) => (a.dueOn ?? "9999").localeCompare(b.dueOn ?? "9999")), + now: needs.actions, + waiting: needs.waiting, moves, members, activity: [...snapshot.expenses.map((expense) => ({ diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 848851d8..fc77324e 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { useApp } from "../state"; import { Avatar, Pill } from "../ui/widgets"; import { CalendarDate } from "../ui/CalendarDate"; -import type { LiveHub } from "../live/model"; +import { compareNeeds, type LiveHub, type Need } from "../live/model"; import { cx } from "../ui/cx"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; import { ActivityTypeChip, activityStamp, activityWord } from "../ui/ActivityLog"; @@ -46,13 +46,29 @@ function PlaceCard({ hub, focus = false, level = 3 }: { hub: LiveHub; focus?: bo ); } +/** One row of Needs you or Waiting on others, opening the record it is about in its commune. */ +function NeedRow({ hub, item, quiet = false }: { hub: LiveHub; item: Need; quiet?: boolean }) { + const { go } = useApp(); + return ( + + ); +} + export function HomeScreen() { const { go, places, hubId } = useApp(); if (!places.length) return ; const first = places.find(place => place.id === hubId) ?? places[0]; const others = places.filter(place => place.id !== first.id); - const needs = places.flatMap((hub) => hub.now.map((item) => ({ hub, item }))) - .sort((a, b) => (a.item.dueOn ?? "9999").localeCompare(b.item.dueOn ?? "9999")).slice(0, 6); + // The same rule as This Hub, across every commune: actions in their order, then what others owe. + const needs = places.flatMap((hub) => hub.now.map((item) => ({ hub, item }))).sort((a, b) => compareNeeds(a.item, b.item)); + const waiting = places.flatMap((hub) => hub.waiting.map((item) => ({ hub, item }))).sort((a, b) => compareNeeds(a.item, b.item)); const activity = places.flatMap((hub) => hub.activity.map((item) => ({ hub, item }))) .sort((a, b) => (Date.parse(b.item.createdAt ?? "") || 0) - (Date.parse(a.item.createdAt ?? "") || 0)).slice(0, 6); const incomplete = places.some((hub) => hub.activityState !== "ready"); @@ -65,19 +81,22 @@ export function HomeScreen() {

Needs you

-

Open items across your communes.

+

What you need to do, across your communes.

{needs.length ? (
- {needs.map(({ hub, item }, index) => ( - - ))} + {needs.slice(0, 6).map(({ hub, item }, index) => )}
- ) : !incomplete ?

Nothing waiting right now.

: null} + ) : !incomplete ?

Nothing for you to do right now.

: null} + {needs.length > 6 ?

And {needs.length - 6} more.

: null} + {waiting.length ? ( +
+

Waiting on others

+
+ {waiting.slice(0, 4).map(({ hub, item }, index) => )} +
+ {waiting.length > 4 ?

And {waiting.length - 4} more.

: null} +
+ ) : null} {incomplete ?

{partialMessage}

: null}
diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css index 43c7fbb9..b4d5d227 100644 --- a/apps/web/src/screens/Hub.css +++ b/apps/web/src/screens/Hub.css @@ -120,6 +120,9 @@ .c-hub-attention-copy { display: flex; align-items: center; gap: 12px; } .c-hub-icon-chip { width: 37px; height: 37px; flex: none; border-radius: 10px; background: var(--hub-quiet); color: var(--hub-heading-icon); display: grid; place-items: center; } .c-hub-empty .c-hub-button { margin-top: 18px; } +/* Needs you: what others owe, with nothing to do, comes after the actions and quieter. */ +.c-hub-waiting { margin-top: 18px; } +.c-hub .c-hub-waiting h3 { margin-bottom: 2px; color: var(--hub-heading-icon); font-size: 11px; font-weight: 550; letter-spacing: .6px; text-transform: uppercase; } .c-hub-note-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 16px 0; border-right: 0; border-bottom: 0; border-left: 0; background: none; color: var(--hub-ink); text-align: left; } .c-hub-note-row > span { min-width: 0; } diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index 37e2ee7f..3a40fadd 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -30,7 +30,7 @@ function HubSection({ title, icon, context, count, action, children }: { ; } -function HubLocation({ hub }: { hub: Hub }) { +function HubLocation({ hub }: { hub: Pick }) { if (hub.type === "Trip") { if (!hub.destination && !hub.stayName && !hub.stayAddress) return null; return
@@ -159,7 +159,7 @@ function Announcements() { } export function HubScreen() { - const { hub, go, notes, expenses, operations, you, announcements } = useApp(); + const { hub, go, notes, expenses, you, announcements } = useApp(); const entries = Object.entries(notes); const pinned = entries.filter(([, note]) => isNotePinned(note)); const priority = ["Emergency", "Keys / door", "Wi-Fi", "Contacts", "Bins", "House rules", "Other"]; @@ -167,13 +167,15 @@ export function HubScreen() { const rank = (note: typeof a[1]) => { const index = priority.indexOf(noteCategory(note)); return index < 0 ? priority.length : index; }; return rank(a[1]) - rank(b[1]); }); - const outstanding = hub.moves.filter(move => !move.even); - const yours = operations.filter(item => item.status === "open" && item.kind === "task" && item.assigneeId === you.id && item.date <= todayIso()); - const yourPledges = hub.now.filter(item => item.go === "funds"); + // Needs you follows the same rule as Home: payments to confirm and shares + // to pay, then tasks, then pledges. What others owe waits separately. + const money = hub.now.filter(item => item.kind === "confirm" || item.kind === "pay"); + const yours = hub.now.filter(item => item.kind === "task"); + const yourPledges = hub.now.filter(item => item.kind === "pledge"); // Important notices this member has not yet confirmed. const toConfirm = announcements.filter(item => item.needsAcknowledgement && item.authorId !== you.id && !item.acknowledgements.some(entry => entry.userId === you.id)); - const attentionCount = outstanding.length + yours.length + yourPledges.length + toConfirm.length; + const attentionCount = hub.now.length + toConfirm.length; const canInvite = hub.role === "admin"; return
@@ -199,19 +201,26 @@ export function HubScreen() {
- {outstanding.slice(0, 3).map((move, index) =>
- {move.why}{move.names} - {move.amt} + {money.slice(0, 3).map((item, index) =>
+ {item.title}{item.when} + {item.amount}
)} {toConfirm.slice(0, 2).map(item =>
{item.title}Announcement · please confirm you have seen it
)} - {yours.slice(0, 2).map(item => )} - {yourPledges.slice(0, 2).map((item, index) => )} + {yours.slice(0, 2).map(item => )} + {yourPledges.slice(0, 2).map((item, index) => )} {!attentionCount &&

{hub.members.length<2 && canInvite ? "Bring your people in to share costs, jobs and the useful details." : "Nothing needs your attention right now."}

} - {outstanding.length > 3 && } + {money.length > 3 && } {yours.length > 2 && } {yourPledges.length > 2 && } + {hub.waiting.length > 0 &&
+

Waiting on others

+ {hub.waiting.slice(0, 3).map((item, index) => )} + {hub.waiting.length > 3 && } +
}
diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 334c9f84..2603160f 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -478,6 +478,7 @@ function emptyHub(): LiveHub { credit: false, kpis: [], now: [], + waiting: [], moves: [], members: [], activity: [], diff --git a/apps/web/src/styles/design-system.css b/apps/web/src/styles/design-system.css index 686ccbb6..3293d14d 100644 --- a/apps/web/src/styles/design-system.css +++ b/apps/web/src/styles/design-system.css @@ -242,12 +242,14 @@ background: #2e3646; color: #b5bdca; } -/* Activity, a day at a time; Notices, a commune at a time. Both headed like search results. */ +/* Activity, a day at a time; Notices, a commune at a time; Home's Waiting on + others after what needs you. Each headed like search results. */ .c-log-day + .c-log-day, -.c-notice-group + .c-notice-group { +.c-notice-group + .c-notice-group, +.c-needs-waiting { margin-top: 22px; } -:is(.c-log-day, .c-notice-group) > h3 { +:is(.c-log-day, .c-notice-group, .c-needs-waiting) > h3 { margin: 0 0 8px; color: var(--ink-soft); font-size: 11px; diff --git a/apps/web/tests/loading.test.tsx b/apps/web/tests/loading.test.tsx index b7689736..2f095c89 100644 --- a/apps/web/tests/loading.test.tsx +++ b/apps/web/tests/loading.test.tsx @@ -614,7 +614,7 @@ describe("commune loading", () => { mount("/"); await screen.findByRole("heading", { name: "Our home" }); expect(screen.getByRole("heading", { name: "Second home" })).toBeTruthy(); - expect(screen.queryByText("Nothing waiting right now.")).toBeNull(); + expect(screen.queryByText("Nothing for you to do right now.")).toBeNull(); await act(async () => pending.reject(new Error("Not a commune member"))); expect(screen.getByRole("heading", { name: "Our home" })).toBeTruthy(); expect(screen.queryByRole("heading", { name: "Could not load Our home" })).toBeNull(); diff --git a/apps/web/tests/needs-you.test.tsx b/apps/web/tests/needs-you.test.tsx new file mode 100644 index 00000000..110ef607 --- /dev/null +++ b/apps/web/tests/needs-you.test.tsx @@ -0,0 +1,191 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import { Router } from "wouter"; +import { memoryLocation } from "wouter/memory-location"; +import { App } from "../src/App"; +import { AppProvider } from "../src/state"; +import { api, loadCommunes, loadSnapshot } from "../src/live/api"; +import { needsYou, type Commune, type PaymentReport, type 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(), +})); + +// "Needs you" is one rule on Home and This Hub: payments someone says they +// have sent (only the bill payer can confirm them), your unpaid shares, your +// late jobs, then your pledges due. What others owe you waits separately. +// It is Friday 9 October 2026. +const today = "2026-10-09"; +const user = { id: "me", name: "Maya Chen", email: "maya@example.test", emailVerified: true }; +const home: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" }; +const trip: Commune = { id: "trip-1", name: "Lisbon trip", kind: "trip", currency: "EUR", role: "member" }; +const members = [ + { userId: "me", name: "Maya Chen", role: "admin" as const, active: true }, + { userId: "ben", name: "Ben Baker", role: "member" as const, active: true }, + { userId: "cat", name: "Cat Cole", role: "member" as const, active: true }, + { userId: "lee", name: "Lee Lane", role: "member" as const, active: false }, +]; +const pending = (reportedBy: string, amountMinor: number): PaymentReport => ({ + id: `report-${reportedBy}-${amountMinor}`, reportedBy, amountMinor, sentOn: "2026-10-08", reference: null, reportedAt: "2026-10-08T10:00:00Z", + status: "pending", resolvedAt: null, resolvedBy: null, note: null, receiptId: null, +}); +type Expense = Snapshot["expenses"][number]; +const expense = (id: string, title: string, paidByUserId: string, dueOn: string | null, shares: Expense["shares"], extra: Partial = {}): Expense => + ({ id, title, amountMinor: shares.reduce((total, share) => total + share.amountMinor, 0), paidByUserId, dueOn, shares, ...extra }); +const share = (userId: string, amountMinor: number, settledMinor = 0, paymentReports: PaymentReport[] = []) => + ({ id: `share-${userId}-${amountMinor}`, userId, amountMinor, settledMinor, paymentReports }); +type Operation = NonNullable[number]; +const task = (id: string, title: string, date: string, assigneeId: string, kind: Operation["kind"] = "task") => ({ + id, kind, category: kind === "task" ? "chore" : "visit", title, date, time: "", allDay: true, assignee: "", assigneeId, + rotation: [], rotationIds: [], repeat: "none", status: "open", notes: "", version: 1, +}) as unknown as Operation; +function snapshotOf(commune: Commune, extra: Partial = {}): Snapshot { + return { + commune, members, expenses: [], funds: [], notes: [], operations: [], notifications: [], reminderTargets: [], + recurringBills: [], splitTemplates: [], activityEvents: [], announcements: [], sentReminders: [], ...extra, + }; +} +const homeSnapshot = () => snapshotOf(home, { + expenses: [ + expense("electricity", "Electricity", "me", "2026-10-20", [share("me", 2000, 2000), share("ben", 2000, 0, [pending("ben", 2000)]), share("cat", 2000)]), + expense("internet", "Internet", "ben", "2026-10-01", [share("ben", 1500, 1500), share("me", 1500)]), + expense("rent", "Rent", "cat", "2026-10-15", [share("cat", 50000, 50000), share("me", 50000, 0, [pending("me", 50000)])]), + expense("takeaway", "Takeaway", "me", null, [share("ben", 1000), share("cat", 1000), share("lee", 1000)]), + expense("voided", "Voided bill", "ben", "2026-10-02", [share("me", 700)], { voidedAt: "2026-10-03T09:00:00Z" }), + expense("groceries", "Groceries", "ben", "2026-10-02", [share("me", 900, 900)]), + expense("water", "Water", "cat", null, [share("me", 800)]), + ], + operations: [ + task("bins", "Bins", "2026-10-07", "me"), task("hoover", "Hoover", today, "me"), task("mop", "Mop", "2026-10-10", "me"), + task("party", "Party", today, "me", "event"), task("dust", "Dust", "2026-10-01", "ben"), + ], + funds: [ + { id: "boiler", name: "Boiler", targetMinor: 50000, createdBy: "me", archivedAt: null, commitments: [ + { id: "pledge-1", userId: "me", amountMinor: 10000, contributedMinor: 4000, dueOn: "2026-10-05", cancelledAt: null, contributions: [] }, + { id: "pledge-2", userId: "ben", amountMinor: 10000, contributedMinor: 0, dueOn: "2026-10-05", cancelledAt: null, contributions: [] }, + ] }, + { id: "old", name: "Old sofa", targetMinor: null, createdBy: "me", archivedAt: "2026-09-01T00:00:00Z", commitments: [ + { id: "pledge-3", userId: "me", amountMinor: 5000, contributedMinor: 0, dueOn: "2026-09-01", cancelledAt: null, contributions: [] }, + ] }, + ] as unknown as Snapshot["funds"], +}); +// Another commune, seen on Home through its summary: Ben says he has sent Maya his share of the ferry. +const tripSummary = () => ({ ...snapshotOf(trip, { + expenses: [expense("ferry", "Ferry", "me", "2026-10-30", [share("me", 2500, 2500), share("ben", 2500, 0, [pending("ben", 2500)])])], +}), detail: "summary" as const }); + +const rows = (need: { title: string; kind: string; when: string; amount?: string }[]) => need.map((item) => [item.kind, item.title, item.when, item.amount]); + +describe("the Needs you rule", () => { + it("puts payments to confirm first, then shares to pay, late and today's tasks, then pledges due", () => { + const { actions } = needsYou(homeSnapshot(), "me", today); + expect(rows(actions)).toEqual([ + ["confirm", "Electricity", "Ben Baker says they sent it", "£20.00"], + ["pay", "Internet", "You owe Ben Baker · overdue", "£15.00"], + ["pay", "Water", "You owe Cat Cole", "£8.00"], + ["task", "Bins", "Overdue · your turn", undefined], + ["task", "Hoover", "Your turn today", undefined], + ["pledge", "Boiler", "Remaining on your pledge · overdue", "£60.00"], + ]); + expect(actions.map((item) => item.late)).toEqual([false, true, false, true, false, true]); + }); + + it("keeps what others owe you, and what you said you sent, waiting on them", () => { + const { waiting } = needsYou(homeSnapshot(), "me", today); + expect(rows(waiting)).toEqual([ + // Ben's report is to confirm; Cat's share is still owed. + ["owed", "Electricity", "Cat Cole owes you", "£20.00"], + ["owed", "Takeaway", "3 people owe you", "£30.00"], + ["reported", "Rent", "You said you sent it · waiting for Cat Cole", "£500.00"], + ]); + }); +}); + +function mount(path: string) { + const location = memoryLocation({ path, record: true }); + render(); + return location; +} +const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal"); +beforeAll(() => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } }); +}); +afterAll(() => { + if (modalDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "showModal", modalDescriptor); + else delete (HTMLDialogElement.prototype as unknown as Record).showModal; +}); +beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(2026, 9, 9, 12, 0)); + vi.resetAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: [home, trip] })); + vi.mocked(loadSnapshot).mockImplementation(async (commune) => commune.id === home.id ? homeSnapshot() : tripSummary()); + vi.mocked(api).mockImplementation(async (path) => { + if (path === "/auth/me") return { user }; + if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null }; + if (path.endsWith("/invites")) return { invites: [] }; + return { ok: true }; + }); +}); +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +const feed = (row: HTMLElement) => [row.querySelector(".pill")?.className, row.querySelector(":scope > div > strong")?.textContent, + row.querySelector(".when")?.textContent]; + +describe("Needs you on Home", () => { + it("lists every commune's actions in the same order, then what others owe, more quietly", async () => { + mount("/"); + const card = (await screen.findByRole("heading", { name: "Needs you" })).closest(".a-card") as HTMLElement; + await within(card).findByText("Ferry"); + const waiting = within(card).getByRole("region", { name: "Waiting on others" }); + const actions = [...card.querySelectorAll(".hub-feed-row")].filter((row) => !waiting.contains(row)); + expect(actions.map(feed)).toEqual([ + // Both reports, the trip's too, come before a share that was due earlier. + ["pill open", "Electricity", "Ben Baker says they sent it · £20.00"], + ["pill open", "Ferry", "Ben Baker says they sent it · €25.00"], + ["pill warn", "Internet", "You owe Ben Baker · overdue · £15.00"], + ["pill open", "Water", "You owe Cat Cole · £8.00"], + ["pill warn", "Bins", "Overdue · your turn"], + ["pill open", "Hoover", "Your turn today"], + ]); + expect(within(card).getByText("And 1 more.")).toBeTruthy(); + expect([...waiting.querySelectorAll(".hub-feed-row")].map(feed)).toEqual([ + ["pill", "Electricity", "Cat Cole owes you · £20.00"], + ["pill", "Takeaway", "3 people owe you · £30.00"], + ["pill", "Rent", "You said you sent it · waiting for Cat Cole · £500.00"], + ]); + }); +}); + +describe("Needs you on This Hub", () => { + it("counts only what needs the viewer, in the same order, and keeps what others owe apart", async () => { + mount("/hub"); + const needs = await screen.findByRole("region", { name: "Needs you" }); + await within(needs).findAllByText("Electricity"); + expect(within(needs).getByText("6 items")).toBeTruthy(); + const waiting = within(needs).getByRole("region", { name: "Waiting on others" }); + const money = [...needs.querySelectorAll(".c-hub-attention-row")].map((row) => + [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent, row.querySelector(".c-hub-attention-end strong")?.textContent]); + expect(money).toEqual([ + ["Electricity", "Ben Baker says they sent it", "£20.00"], + ["Internet", "You owe Ben Baker · overdue", "£15.00"], + ["Water", "You owe Cat Cole", "£8.00"], + ]); + const others = [...needs.querySelectorAll(".c-hub-note-row")].filter((row) => !waiting.contains(row)) + .map((row) => [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent]); + expect(others).toEqual([ + ["Bins", "Overdue · your turn"], + ["Hoover", "Your turn today"], + ["Boiler", "Remaining on your pledge · overdue · £60.00"], + ]); + expect([...waiting.querySelectorAll(".c-hub-note-row")].map((row) => row.querySelector("small")?.textContent)).toEqual([ + "Cat Cole owes you · £20.00", "3 people owe you · £30.00", "You said you sent it · waiting for Cat Cole · £500.00", + ]); + }); +}); From d7e25fdfbf00b24c3d37c7160cd3742a0a1ef7c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:53:27 +0000 Subject: [PATCH 05/10] Name former members from the snapshot wherever their records appear Settle, expenses, templates, recurring bills, pledges, announcements, notices, note and task history fell back to "Former member", "Member" or "the bill payer" for someone who had left, because they looked people up among current members only, while the snapshot keeps everyone's name (0031). One helper, personName, now names anyone the snapshot knows, adds "(left)" where it matters that they are no longer a member (who owes or is owed, shares, template participants, bill payers, pledges), and keeps the generic words only for someone the snapshot does not know. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/live/model.ts | 16 ++- apps/web/src/screens/Funds.tsx | 8 +- apps/web/src/screens/Hub.tsx | 4 +- apps/web/src/screens/Members.tsx | 7 +- apps/web/src/screens/Money.tsx | 41 +++--- apps/web/src/screens/Recurring.tsx | 21 ++- apps/web/src/screens/Templates.tsx | 9 +- apps/web/src/state.tsx | 10 +- apps/web/src/ui/NoteHistory.tsx | 4 +- apps/web/src/windows/OperationsWindows.tsx | 3 +- apps/web/src/windows/Windows.tsx | 45 +++---- apps/web/tests/former-members.test.tsx | 147 +++++++++++++++++++++ 12 files changed, 237 insertions(+), 78 deletions(-) create mode 100644 apps/web/tests/former-members.test.tsx diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 9d29420f..4afbcf07 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -353,6 +353,19 @@ export type Need = { operationId?: string; fundId?: string; }; +/** + * The name of anyone a snapshot knows, former members included (0031). With + * left, someone who is no longer a member is named "(left)", where a screen + * needs to make that clear. Only someone the snapshot does not know gets the + * fallback words, such as "a former member". + */ +export function personName(members: Member[] | undefined, userId: string | null | undefined, fallback: string, + options: { left?: boolean } = {}): string { + const member = userId ? members?.find((item) => item.userId === userId) : undefined; + if (!member?.name.trim()) return fallback; + return options.left && member.active === false ? `${member.name} (left)` : member.name; +} + const NEED_ORDER: Need["kind"][] = ["confirm", "pay", "task", "pledge", "owed", "reported"]; /** Actions in their order, then the earliest date first; anything undated after the dated ones. */ export function compareNeeds(a: Need, b: Need): number { @@ -373,8 +386,7 @@ function namesList(names: string[]): string { */ export function needsYou(snapshot: Snapshot, viewerId: string, today = todayIso()): { actions: Need[]; waiting: Need[] } { const currency = snapshot.commune.currency; - const names = new Map(snapshot.members.map((member) => [member.userId, member.name])); - const nameOf = (userId: string) => names.get(userId) ?? "a former member"; + const nameOf = (userId: string) => personName(snapshot.members, userId, "a former member", { left: true }); const actions: Need[] = []; const waiting: Need[] = []; for (const expense of snapshot.expenses) { diff --git a/apps/web/src/screens/Funds.tsx b/apps/web/src/screens/Funds.tsx index 62782be3..797dda38 100644 --- a/apps/web/src/screens/Funds.tsx +++ b/apps/web/src/screens/Funds.tsx @@ -72,6 +72,7 @@ export function FundsScreen() { fundKey, closeWindow, contentLoading, + personName, } = useApp(); const [mode, setMode] = useState(null); const [name, setName] = useState(""); @@ -117,7 +118,7 @@ export function FundsScreen() { const pledgerMember = commitment ? hub.members.find((item) => item.userId === commitment.userId) : undefined; - const pledger = pledgerMember?.name ?? "the person who made it"; + const pledger = commitment ? personName(commitment.userId, "the person who made it") : "the person who made it"; const recorded = commitment?.contributedMinor ? money(commitment.contributedMinor, hub.currency) : ""; const pledgeAmountHint = !commitment ? undefined : othersPledge ? `Up to ${money(commitment.amountMinor, hub.currency)}${recorded ? `, and not below the ${recorded} already recorded` : ""}.` @@ -325,7 +326,6 @@ export function FundsScreen() { // Render inline, not as a new component type on every state change: a Chase // dialog must be able to return keyboard focus to the same pledge button. function renderPledge(pledge: Commitment) { - const member = hub.members.find((item) => item.userId === pledge.userId); const remaining = pledge.amountMinor - pledge.contributedMinor; const isMine = pledge.userId === session?.userId; const target = reminderTargets.find( @@ -344,7 +344,7 @@ export function FundsScreen() {
- {member?.name ?? "Former member"} + {personName(pledge.userId, "Former member", { left: true })} {isMine ? " (you)" : ""} @@ -393,7 +393,7 @@ export function FundsScreen() { kind: "chase", sourceId: pledge.id, recipientId: pledge.userId, - name: member?.name ?? "Member", + name: personName(pledge.userId, "Member"), subject: fund!.name, amount: money(remaining, hub.currency), dueOn: pledge.dueOn, diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index 3a40fadd..e27a00b0 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -113,12 +113,12 @@ const announcementDate = new Intl.DateTimeFormat("en-GB", { day: "numeric", mont /** Notices from an admin. Important ones ask each member to confirm they have seen them. */ function Announcements() { - const { announcements, hub, you, acknowledgeAnnouncement, archiveAnnouncement, go } = useApp(); + const { announcements, hub, you, acknowledgeAnnouncement, archiveAnnouncement, go, personName } = useApp(); const [pending, setPending] = useState(null); const [confirmDown, setConfirmDown] = useState(null); const [error, setError] = useState(""); if (!announcements.length) return null; - const nameOf = (userId: string) => hub.members.find(member => member.userId === userId)?.name ?? "A former member"; + const nameOf = (userId: string) => personName(userId, "A former member"); async function run(id: string, action: () => Promise) { setPending(id); setError(""); try { await action(); setConfirmDown(null); } diff --git a/apps/web/src/screens/Members.tsx b/apps/web/src/screens/Members.tsx index 18636342..25350730 100644 --- a/apps/web/src/screens/Members.tsx +++ b/apps/web/src/screens/Members.tsx @@ -85,7 +85,7 @@ function NewInviteLinkDialog({ invite, onClose, onDone }: { } export function MembersScreen() { - const { hub, go, session, snapshots, invites, revokeInvite, manageMember } = + const { hub, go, session, snapshots, invites, revokeInvite, manageMember, personName } = useApp(); const [pending, setPending] = useState(false); const [error, setError] = useState(""); @@ -96,11 +96,10 @@ export function MembersScreen() { (invite.status === "pending" || invite.status === "expired") && invite.email !== null); const pastInvites = invites.filter((invite) => invite.status === "accepted" || invite.status === "revoked").slice(0, 12); // Someone who accepted and has since left keeps their name, not their address. - const invitee = (invite: PendingInvite) => invite.email - ?? snapshots[hub.id]?.members.find((member) => member.userId === invite.acceptedBy)?.name ?? "Former member"; + const invitee = (invite: PendingInvite) => invite.email ?? personName(invite.acceptedBy, "Former member"); // Who sent an invitation. It stays open only while they are an admin. const sender = (invite: PendingInvite) => invite.invitedBy === session?.userId ? "you" - : invite.invitedByName ?? (invite.invitedBy ? "a former member" : ""); + : invite.invitedByName ?? (invite.invitedBy ? personName(invite.invitedBy, "a former member") : ""); const pendingSentBy = (userId: string) => openInvites.filter((invite) => invite.status === "pending" && invite.invitedBy === userId).length; const [action, setAction] = useState<{ diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx index 4ca62d38..b240304e 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -9,6 +9,7 @@ import { ReminderFlowDialog, useReminderFlow } from "../ui/ReminderConfirm"; import { formatReminderDay, reminderEligibility } from "../data/reminders"; import { LatestReportAnswer, pendingReport, reportSummary, useReceiptActions } from "../ui/Receipts"; import { CalendarDate } from "../ui/CalendarDate"; +import { initialsOf } from "../data/household"; import "./Money.css"; function formatMoney(minor: number, currency: string): string { @@ -142,7 +143,7 @@ export function ExpensesEmptyScreen() { } export function SettleScreen() { - const { go, hub, expenses, session, reminderTargets, reminderUsage, sentReminders, sendReminder } = useApp(); + const { go, hub, expenses, session, reminderTargets, reminderUsage, sentReminders, sendReminder, personName } = useApp(); const reminder = useReminderFlow(sendReminder); const [message, setMessage] = useState(""); const receipts = useReceiptActions(setMessage); @@ -177,6 +178,9 @@ export function SettleScreen() { {moves.filter(({ expense }) => (expense.paidByUserId === me) === receivingGroup).map(({ expense, share, outstanding }) => { const payer = hub.members.find((member) => member.userId === expense.paidByUserId); const debtor = hub.members.find((member) => member.userId === share.userId); + // Someone who has left keeps their name, marked as having left. + const payerName = personName(expense.paidByUserId, "the bill payer"); + const debtorName = personName(share.userId, "this member"); const receiving = expense.paidByUserId === me; const target = receiving ? reminderTargets.find((item) => item.kind === "nudge" && item.sourceId === share.id) @@ -189,15 +193,15 @@ export function SettleScreen() { className="a-move" key={share.id} > - +
-
{receiving ? `${debtor?.name ?? "Member"} owes you` : `You owe ${payer?.name ?? "the bill payer"}`}
+
{receiving ? `${personName(share.userId, "A former member", { left: true })} owes you` : `You owe ${personName(expense.paidByUserId, "the bill payer", { left: true })}`}
{expense.name}{expense.dueOn ? ` · due ${expense.dueOn}` : ""}
{share.settledMinor > 0 ?
{formatMoney(share.settledMinor, hub.currency)} of {formatMoney(share.amountMinor, hub.currency)} received
: null} {report ?
{receiving - ? `${reportSummary(report, hub.currency, debtor?.name ?? "A member")}. Not yet confirmed.` - : `You said you sent ${formatMoney(report.amountMinor, hub.currency)} on ${formatReminderDay(report.sentOn)}. Waiting for ${payer?.name ?? "the bill payer"}.`}
: null} - {!receiving ? hub.members.find((member) => member.userId === userId)?.name ?? "The bill payer"} /> : null} + ? `${reportSummary(report, hub.currency, personName(share.userId, "A member"))}. Not yet confirmed.` + : `You said you sent ${formatMoney(report.amountMinor, hub.currency)} on ${formatReminderDay(report.sentOn)}. Waiting for ${payerName}.`}
: null} + {!receiving ? personName(userId, "The bill payer")} /> : null} {receiving && (target || (reminderUsage?.recipientUsage[share.userId] ?? 0) > 0) ? ( ) : null} @@ -205,20 +209,20 @@ export function SettleScreen() {
{formatMoney(outstanding, hub.currency)}
{receiving && report ? (
- -
) : receiving ? (
- - {reminderReason &&

{reminderReason}

}
@@ -230,8 +234,8 @@ export function SettleScreen() {
) : (
-
)} @@ -246,9 +250,10 @@ export function SettleScreen() { const debtor = hub.members.find((member) => member.userId === share.userId); const payer = hub.members.find((member) => member.userId === expense.paidByUserId); const mine = share.userId === me; + const other = mine ? personName(expense.paidByUserId, "the bill payer", { left: true }) : personName(share.userId, "A former member", { left: true }); return
diff --git a/apps/web/src/screens/Templates.tsx b/apps/web/src/screens/Templates.tsx index 1b3db664..3d9b5184 100644 --- a/apps/web/src/screens/Templates.tsx +++ b/apps/web/src/screens/Templates.tsx @@ -142,7 +142,7 @@ function TemplateEditor({ } export function TemplatesScreen() { - const { hub, splitTemplates, saveSplitTemplate, session, go } = useApp(); + const { hub, splitTemplates, saveSplitTemplate, session, go, personName } = useApp(); const [editor, setEditor] = useState(null); const [pending, setPending] = useState(false); const [error, setError] = useState(""); @@ -244,12 +244,7 @@ export function TemplatesScreen() {

{template.method} ·{" "} {template.participants - .map( - (item) => - hub.members.find( - (member) => member.userId === item.userId, - )?.name ?? "Former member", - ) + .map((item) => personName(item.userId, "Former member", { left: true })) .join(", ")}

{missing && ( diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 2603160f..748f631e 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -55,6 +55,7 @@ import { expenseRows, hubFromSnapshot, money, + personName, type Commune, type CurrencyCode, type Expense, @@ -311,6 +312,12 @@ export type AppState = { acknowledgeAnnouncement: (id: string) => Promise; archiveAnnouncement: (id: string) => Promise; notifications: Notification[]; + /** + * The name of anyone the open commune's records know, former members + * included. left: name someone who has left as "Name (left)". fallback: + * the words for someone the records do not know. + */ + personName: (userId: string | null | undefined, fallback: string, options?: { left?: boolean }) => string; /** * Unread notices in each of the reader's communes, by commune id. Each * comes with the commune list, so the bell counts every commune. @@ -1795,7 +1802,8 @@ export function AppProvider({ children }: { children: ReactNode }) { lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses, expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory, funds, recurringBills, splitTemplates, expenseTemplateId, reminderTargets, reminderUsage, sentReminders, notifications, - noticeCounts, loadUnreadNotices, signOut, signOutUnconfirmed, retrySignOut: confirmSignOut, sessionNotice, go, + noticeCounts, loadUnreadNotices, personName: (userId, fallback, options) => personName(data?.snapshots[activeId]?.members, userId, fallback, options), + signOut, signOutUnconfirmed, retrySignOut: confirmSignOut, sessionNotice, go, changePassword, signOutOtherDevices, closeWindow: () => { if (!mutationCount) leaveRecord(); }, watchTypedText, leaveAsked, answerLeave, setMoreOpen, setNavMode: (navMode) => saveDevice({ navMode }), diff --git a/apps/web/src/ui/NoteHistory.tsx b/apps/web/src/ui/NoteHistory.tsx index 273ba5de..1b8da28b 100644 --- a/apps/web/src/ui/NoteHistory.tsx +++ b/apps/web/src/ui/NoteHistory.tsx @@ -11,7 +11,7 @@ function maskSecrets(body: string, facts: Array<[string, string]>): string { /** Earlier versions of a note: read any of them, or restore one as the newest. */ export function NoteHistory({ noteId }: { noteId: string }) { - const { notes, hub, loadNoteRevision, updateNote } = useApp(); + const { notes, personName, loadNoteRevision, updateNote } = useApp(); const note = notes[noteId]; const [open, setOpen] = useState(null); const [busy, setBusy] = useState(false); @@ -19,7 +19,7 @@ export function NoteHistory({ noteId }: { noteId: string }) { const [message, setMessage] = useState(""); const revisions = note?.revisions ?? []; if (!note || !revisions.length) return null; - const name = (userId: string | null | undefined) => hub.members.find((member) => member.userId === userId)?.name ?? "a former member"; + const name = (userId: string | null | undefined) => personName(userId, "a former member"); async function view(id: string) { setBusy(true); setError(""); setMessage(""); try { diff --git a/apps/web/src/windows/OperationsWindows.tsx b/apps/web/src/windows/OperationsWindows.tsx index 0385ac3a..68d32e05 100644 --- a/apps/web/src/windows/OperationsWindows.tsx +++ b/apps/web/src/windows/OperationsWindows.tsx @@ -47,6 +47,7 @@ export function OperationView() { hub, mutationPending, you, + personName, } = useApp(); const [confirmDelete, setConfirmDelete] = useState(false); // One small panel at a time: skip with a reason, or hand over the turn. @@ -178,7 +179,7 @@ export function OperationView() { hub.members.find((member) => member.userId === userId)?.name ?? "A former member"} + nameOf={(userId) => personName(userId, "A former member")} disabled={mutationPending} onUndo={(occurrenceId) => run(() => undoOperation(occurrenceId), "Undone. The task is back as it was.")} /> diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx index 40dd6381..fd4b057f 100644 --- a/apps/web/src/windows/Windows.tsx +++ b/apps/web/src/windows/Windows.tsx @@ -1,5 +1,5 @@ import { useEffect, useId, useRef, useState, type CSSProperties } from "react"; -import { NOTE_BODY_HINT, type NoteCard } from "../data/household"; +import { NOTE_BODY_HINT, initialsOf, type NoteCard } from "../data/household"; import { LIMITS } from "../data/limits"; import { useApp } from "../state"; import { Icon } from "../ui/Icon"; @@ -268,7 +268,7 @@ function NoticeRow({ notice, currency, actor, onOpen }: { * from another commune switches to that commune. */ function Notices() { - const { go, hub, places, funds, notifications, noticeCounts, markNotificationRead, loadUnreadNotices } = useApp(); + const { go, hub, places, funds, notifications, noticeCounts, markNotificationRead, loadUnreadNotices, personName } = useApp(); const [error, setError] = useState(""); const others = places.filter((place) => place.id !== hub.id && (noticeCounts[place.id] ?? 0) > 0); // Read again whenever another commune's count changes while this is open. @@ -298,7 +298,7 @@ function Notices() {

{hub.name}

{notifications.length ? notifications.map((notice) => { - const actor = hub.members.find((member) => member.userId === notice.senderUserId)?.name ?? "a former member"; + const actor = personName(notice.senderUserId, "a former member"); const ledger = notice.kind !== "nudge" && notice.kind !== "chase"; // A pledge notice opens the fund that holds the pledge. const pledge = notice.kind === "pledge-changed" || notice.kind === "pledge-cancelled"; @@ -485,12 +485,12 @@ function AddExpense() { function Expense() { const { go, closeWindow, hub, selectedExpense, session, - reminderTargets, reminderUsage, sentReminders, sendReminder, + reminderTargets, reminderUsage, sentReminders, sendReminder, personName, } = useApp(); const reminder = useReminderFlow(sendReminder); const [message, setMessage] = useState(""); const receipts = useReceiptActions(setMessage); - const fullName = (userId: string) => hub.members.find((item) => item.userId === userId)?.name ?? "A former member"; + const fullName = (userId: string) => personName(userId, "A former member"); const change = useExpenseChange(fullName, setMessage); if (!selectedExpense) { return ( @@ -499,12 +499,11 @@ function Expense() {
); } - const payer = hub.members.find((member) => member.userId === selectedExpense.paidByUserId); const isPayer = session?.userId === selectedExpense.paidByUserId; const remaining = selectedExpense.shares.reduce( (total, share) => total + Math.max(0, share.amountMinor - share.settledMinor), 0, ); - const nameOf = (userId: string) => userId === session?.userId ? "you" : hub.members.find((item) => item.userId === userId)?.name ?? "a former member"; + const nameOf = (userId: string) => userId === session?.userId ? "you" : personName(userId, "a former member"); const voided = !!selectedExpense.voidedAt; const canChange = !voided && (isPayer || hub.role === "admin"); @@ -516,7 +515,7 @@ function Expense() { {selectedExpense.dueOn ? `Due ${selectedExpense.dueOn}` : "No due date"} {selectedExpense.name}

- {payer?.name ?? selectedExpense.paidBy} paid {formatMoney(selectedExpense.amountMinor, hub.currency)}. {voided ? "This expense is voided and no longer counts towards any balance." : remaining ? `${formatMoney(remaining, hub.currency)} of shares remain open.` : "All shares are settled."} + {personName(selectedExpense.paidByUserId, selectedExpense.paidBy, { left: true })} paid {formatMoney(selectedExpense.amountMinor, hub.currency)}. {voided ? "This expense is voided and no longer counts towards any balance." : remaining ? `${formatMoney(remaining, hub.currency)} of shares remain open.` : "All shares are settled."}

{selectedExpense.statusLabel} @@ -526,6 +525,7 @@ function Expense() {
{selectedExpense.shares.map((share) => { const member = hub.members.find((item) => item.userId === share.userId); + const memberName = personName(share.userId, "this member"); const outstanding = voided ? 0 : share.amountMinor - share.settledMinor; const isOwnPayerShare = share.userId === selectedExpense.paidByUserId; const partPaid = !isOwnPayerShare && share.settledMinor > 0 && outstanding > 0; @@ -538,9 +538,9 @@ function Expense() { return (
- +
- {member?.name ?? "Member"}{share.userId === session?.userId ? " (you)" : ""} + {personName(share.userId, "Former member", { left: true })}{share.userId === session?.userId ? " (you)" : ""}
{formatMoney(share.amountMinor, hub.currency)} share · {voided ? "not counted" : outstanding ? `${formatMoney(outstanding, hub.currency)} outstanding` : isOwnPayerShare ? "paid the bill" : "settled"}
{isPayer && (target || (reminderUsage?.recipientUsage[share.userId] ?? 0) > 0) ? ( @@ -550,23 +550,23 @@ function Expense() { {isPayer && !isOwnPayerShare && outstanding && !report ? (
- + onClick={() => { setMessage(""); reminder.open({ kind: "nudge", sourceId: share.id, recipientId: share.userId, name: personName(share.userId, "your housemate"), subject: selectedExpense.name, amount: formatMoney(outstanding, hub.currency), dueOn: selectedExpense.dueOn }); }}>Nudge{" "}{memberName} + {eligibility.reason &&

{eligibility.reason}

}
) : ownDebt && !report ? (
- +
) : null}
{report ? { setMessage(""); receipts.answer({ report: item, debtorName: member?.name ?? "this member", expenseName: selectedExpense.name, currency: hub.currency, decision }); }} + onAnswer={(item, decision) => { setMessage(""); receipts.answer({ report: item, debtorName: memberName, expenseName: selectedExpense.name, currency: hub.currency, decision }); }} onWithdraw={(item) => { setMessage(""); void receipts.withdraw(item); }} /> : null} {ownDebt ? : null} {!isOwnPayerShare && { setMessage(""); receipts.reverse({ receipt, debtorName: member?.name ?? "this member", expenseName: selectedExpense.name, currency: hub.currency }); } : undefined} />} + onReverse={isPayer && !voided ? (receipt) => { setMessage(""); receipts.reverse({ receipt, debtorName: memberName, expenseName: selectedExpense.name, currency: hub.currency }); } : undefined} />}
); })} @@ -833,7 +833,7 @@ function Profile() { } function Breakdown() { - const { go, hub, expenses, voidedExpenses, session, snapshots, expenseHistory, loadFullHistory } = useApp(); + const { go, hub, expenses, voidedExpenses, session, snapshots, expenseHistory, loadFullHistory, personName } = useApp(); // A statement covers everything shared with that person, so it waits for every older expense. const [historyFailed, setHistoryFailed] = useState(false); useEffect(() => { @@ -880,8 +880,6 @@ function Breakdown() {
{moves.map(({ expense, share }) => { - const debtor = hub.members.find((member) => member.userId === share.userId); - const payer = hub.members.find((member) => member.userId === expense.paidByUserId); return (
{accountRole && {accountRole}} - {accountRole && reminderUsage && } -
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index e5405dcc..70bc948a 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -527,8 +527,7 @@ font-size: 13px; } -.a-side-account-role, -.a-side-account-separator { +.a-side-account-role { font-size: 11px; color: rgba(255, 255, 255, 0.6); } @@ -541,8 +540,7 @@ white-space: nowrap; } -.a-side-account-role, -.a-side-account-separator { +.a-side-account-role { flex-shrink: 0; } @@ -557,38 +555,6 @@ position: relative; } -.a-side-account .a-reminder-usage--sidebar { - position: static; - margin: 0; - width: auto; - font-size: 10px; - color: rgba(255, 255, 255, 0.65); -} - -/* 24px tall, reaching into the space below, so the line keeps its place. */ -.a-side-account .a-reminder-usage--sidebar summary { - min-height: 24px; - gap: 3px; - padding: 0 0 6px; - margin-bottom: -6px; - border: 0; - border-radius: 4px; -} - -.a-side-account .a-reminder-usage--sidebar summary strong { color: inherit; } -.a-side-account .a-reminder-usage--sidebar summary:hover { color: #fff; } -.a-side-account .a-reminder-usage--sidebar .a-reminder-usage-details { - top: auto; - bottom: calc(100% + 8px); - right: auto; - left: 0; - width: 100%; - max-width: calc(100vw - 24px); - max-height: calc(100vh - 160px); - overflow-y: auto; - color: var(--ink); -} - .a-main { min-width: 0; display: flex; diff --git a/apps/web/src/styles/design-system.css b/apps/web/src/styles/design-system.css index 3293d14d..98cd4b39 100644 --- a/apps/web/src/styles/design-system.css +++ b/apps/web/src/styles/design-system.css @@ -257,6 +257,16 @@ letter-spacing: 0.6px; text-transform: uppercase; } +/* The reminder allowance, said where reminders are sent once it runs low. */ +.c-allowance-note { + margin: 12px 0; + padding: 8px 12px; + border-radius: var(--radius-control); + background: var(--paper); + color: var(--ink); + font-size: 12px; + line-height: 1.6; +} .c-scope-label { font-size: 12px; color: var(--ink-soft); diff --git a/apps/web/src/ui/ReminderUsage.tsx b/apps/web/src/ui/ReminderUsage.tsx index 15590f42..445a0248 100644 --- a/apps/web/src/ui/ReminderUsage.tsx +++ b/apps/web/src/ui/ReminderUsage.tsx @@ -7,13 +7,18 @@ export function reminderLimitReached(usage: Usage | null, recipientId: string): ); } -export function ReminderUsageMeter({ usage, placement = "default" }: { usage: Usage | null; placement?: "default" | "sidebar" | "account" }) { - if (!usage) return null; - const full = usage.totalUsed >= usage.totalLimit; +// When the oldest reminder in the last 30 days stops counting. +function nextSlotLabel(usage: Usage): string | null { const next = usage.nextSlotAt ? new Date(usage.nextSlotAt) : null; - const nextLabel = next && !Number.isNaN(next.valueOf()) + return next && !Number.isNaN(next.valueOf()) ? new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }).format(next) : null; +} + +export function ReminderUsageMeter({ usage, placement = "default" }: { usage: Usage | null; placement?: "default" | "account" }) { + if (!usage) return null; + const full = usage.totalUsed >= usage.totalLimit; + const nextLabel = nextSlotLabel(usage); return (
{ // Close only this popover, not the window or dialog around it. @@ -26,9 +31,7 @@ export function ReminderUsageMeter({ usage, placement = "default" }: { usage: Us }}> {/* Named from what it shows, with the allowance in words after it. */} - {placement === "sidebar" - ? {Math.max(0, usage.totalLimit - usage.totalUsed)} reminders left - : <>Reminders} + Reminders {" "}({usage.totalUsed} of {usage.totalLimit} used in the last 30 days)
@@ -50,6 +53,24 @@ export function ReminderUsageMeter({ usage, placement = "default" }: { usage: Us ); } +/** + * Where reminders are sent: a short notice once a fifth of the allowance or + * less is left. Until then nothing; the confirmation shows the allowance. + */ +export function ReminderAllowanceNotice({ usage }: { usage: Usage | null }) { + if (!usage || usage.totalLimit <= 0) return null; + const left = Math.max(0, usage.totalLimit - usage.totalUsed); + if (left * 5 > usage.totalLimit) return null; + const next = nextSlotLabel(usage); + return ( +

+ {left + ? `${left} of your ${usage.totalLimit} reminders left. Each one comes back 30 days after it was sent.` + : `No reminders left.${next ? ` The next one comes back on ${next}.` : ""}`} +

+ ); +} + export function RecipientReminderCount({ usage, recipientId }: { usage: Usage | null; recipientId: string }) { if (!usage) return null; return ( diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx index fd4b057f..b9d77f9b 100644 --- a/apps/web/src/windows/Windows.tsx +++ b/apps/web/src/windows/Windows.tsx @@ -9,7 +9,7 @@ import { cx } from "../ui/cx"; import { OperationEdit, OperationNew, OperationView } from "./OperationsWindows"; import { AnnouncementNew } from "./AnnouncementWindows"; import { CommuneLocationFields, EMPTY_COMMUNE_LOCATION } from "../ui/CommuneLocationFields"; -import { RecipientReminderCount, ReminderUsageMeter } from "../ui/ReminderUsage"; +import { RecipientReminderCount, ReminderAllowanceNotice, ReminderUsageMeter } from "../ui/ReminderUsage"; import { ReminderFlowDialog, useReminderFlow } from "../ui/ReminderConfirm"; import { reminderEligibility } from "../data/reminders"; import { LatestReportAnswer, PendingReport, ReceiptHistory, pendingReport, useReceiptActions } from "../ui/Receipts"; @@ -522,6 +522,8 @@ function Expense() {
{voided ?

Voided by {fullName(selectedExpense.voidedBy ?? "")} on {formatTimestampDay(selectedExpense.voidedAt)}: {selectedExpense.voidReason}

: null} {isPayer && !voided ? : null} + {isPayer && !voided && selectedExpense.shares.some((share) => share.userId !== selectedExpense.paidByUserId && + share.amountMinor > share.settledMinor && !pendingReport(share)) ? : null}
{selectedExpense.shares.map((share) => { const member = hub.members.find((item) => item.userId === share.userId); diff --git a/apps/web/tests/former-members.test.tsx b/apps/web/tests/former-members.test.tsx index 215ee037..4d2f998b 100644 --- a/apps/web/tests/former-members.test.tsx +++ b/apps/web/tests/former-members.test.tsx @@ -5,7 +5,8 @@ import { memoryLocation } from "wouter/memory-location"; import { App } from "../src/App"; import { AppProvider, useApp, type AppState } from "../src/state"; import { api, loadCommunes, loadSnapshot } from "../src/live/api"; -import { personName, type Commune, type Snapshot } from "../src/live/model"; +import { hubFromSnapshot, personName, type Commune, type Snapshot } from "../src/live/model"; +import { initialsOf } from "../src/data/household"; vi.mock("../src/live/api", async (importOriginal) => ({ ...(await importOriginal()), @@ -97,6 +98,14 @@ describe("naming people", () => { expect(personName([{ userId: "user-4", name: " ", role: "member" }], "user-4", "Someone")).toBe("Someone"); }); + it("gives someone without a name a neutral mark, not letters from nowhere", () => { + expect(initialsOf("Lee Lane")).toBe("LL"); + expect(initialsOf("")).toBe("?"); + expect(initialsOf(" ")).toBe("?"); + const hub = hubFromSnapshot({ ...current, members: [...members, { userId: "user-4", name: " ", role: "member", active: true }] }, user); + expect(hub.members.find((member) => member.userId === "user-4")?.ini).toBe("?"); + }); + it("names someone who has left on Settle, in what they owe and what they paid back", async () => { mount("/settle"); const owed = await screen.findByRole("region", { name: "You’re owed" }); diff --git a/apps/web/tests/loading.test.tsx b/apps/web/tests/loading.test.tsx index 2f095c89..66ebaa81 100644 --- a/apps/web/tests/loading.test.tsx +++ b/apps/web/tests/loading.test.tsx @@ -186,39 +186,6 @@ describe("commune loading", () => { expect(check.disabled).toBe(false); }); - it.each(["/funds", "/settle"])("keeps the overall reminder allowance in the sidebar instead of the %s toolbar", async (path) => { - mount(path); - // Named from what it shows, then the allowance in words. - const summary = (await screen.findByText("60 reminders left")).closest("summary")!; - expect(summary.closest(".a-side-account")).not.toBeNull(); - expect(summary.hasAttribute("aria-label")).toBe(false); - expect(summary.textContent).toBe("60 reminders left (0 of 60 used in the last 30 days)"); - const metadata = summary.closest(".a-side-account-meta")!; - expect(metadata.querySelector(".a-side-account-role")?.textContent).toBe("Admin"); - expect(metadata.querySelector(".a-side-account-separator")?.getAttribute("aria-hidden")).toBe("true"); - expect(metadata.textContent).toContain("Admin·60 reminders left"); - expect(summary.closest("details")?.parentElement).toBe(metadata); - expect(summary.closest("button")).toBeNull(); - expect(summary.closest(".a-side-account")?.textContent).not.toContain(commune.name); - expect(document.querySelector(`${path.replace("/", "#")} .a-reminder-usage`)).toBeNull(); - expect(document.querySelectorAll(".a-reminder-usage")).toHaveLength(1); - }); - - it("does not show a separator or an invented count while the reminder allowance is pending", async () => { - const usage = deferred<{ totalUsed: number; totalLimit: number; recipientLimit: number; recipientUsage: Record; nextSlotAt: null }>(); - const baseApi = vi.mocked(api).getMockImplementation()!; - vi.mocked(api).mockImplementation(async (path, options) => path === "/reminders/usage" ? usage.promise : baseApi(path, options)); - mount(); - await screen.findByRole("heading", { name: commune.name }); - const metadata = document.querySelector(".a-side-account-meta")!; - expect(metadata.textContent).toBe("Admin"); - expect(metadata.querySelector(".a-side-account-separator")).toBeNull(); - await act(async () => usage.resolve({ totalUsed: 12, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt: null })); - expect((await screen.findByText("48 reminders left")).closest("summary")?.textContent).toContain("12 of 60 used in the last 30 days"); - expect(metadata.textContent).toContain("Admin·48 reminders left"); - expect(metadata.querySelectorAll(".a-side-account-separator")).toHaveLength(1); - }); - it("offers the same allowance in account settings and closes its details with Escape", async () => { mount("/settings"); await screen.findByRole("heading", { name: "Settings", exact: true }); diff --git a/apps/web/tests/reminder-allowance.test.tsx b/apps/web/tests/reminder-allowance.test.tsx new file mode 100644 index 00000000..e0876b01 --- /dev/null +++ b/apps/web/tests/reminder-allowance.test.tsx @@ -0,0 +1,150 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +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, loadCommunes, loadSnapshot } from "../src/live/api"; +import type { Commune, ReminderTarget, ReminderUsage, Snapshot } from "../src/live/model"; +import { ReminderAllowanceNotice } from "../src/ui/ReminderUsage"; + +vi.mock("../src/live/api", async (importOriginal) => ({ + ...(await importOriginal()), + api: vi.fn(), post: vi.fn(), loadCommunes: vi.fn(), loadSnapshot: vi.fn(), +})); + +// The reminder allowance is not part of who you are: it is said where +// reminders are sent, once a fifth of it or less is left. +const user = { id: "user-1", name: "Test Person", email: "test@example.test", emailVerified: true }; +const housemate = "user-2"; +const commune: Commune = { id: "home-1", name: "Our home", kind: "household", currency: "GBP", role: "admin" }; +const target = { recipientUserId: housemate, amountMinor: 1500, currency: "GBP", label: "", dueOn: "2026-10-01", lastSentAt: null } as const; +const reminderTargets: ReminderTarget[] = [{ ...target, kind: "nudge", sourceId: "share-2" }, { ...target, kind: "chase", sourceId: "pledge-1" }]; +const snapshot: Snapshot = { + commune, reminderTargets, notifications: [], notes: [], operations: [], recurringBills: [], splitTemplates: [], + activityEvents: [], announcements: [], sentReminders: [], + members: [{ userId: user.id, name: user.name, role: "admin" }, { userId: housemate, name: "Housemate", role: "member" }], + // The viewer paid for the gas, and admins chase pledges to the boiler fund. + expenses: [{ id: "expense-1", title: "Gas", amountMinor: 3000, paidByUserId: user.id, dueOn: "2026-10-01", shares: [ + { id: "share-1", userId: user.id, amountMinor: 1500, settledMinor: 1500 }, + { id: "share-2", userId: housemate, amountMinor: 1500, settledMinor: 0 }, + ] }], + funds: [{ id: "fund-1", name: "Boiler fund", targetMinor: 50000, createdBy: user.id, archivedAt: null, commitments: [ + { id: "pledge-1", userId: housemate, amountMinor: 5000, contributedMinor: 0, dueOn: "2026-10-01", cancelledAt: null, contributions: [] }, + ] }], +}; +const allowance = (totalUsed: number, nextSlotAt: string | null = null): ReminderUsage => + ({ totalUsed, totalLimit: 60, recipientLimit: 6, recipientUsage: {}, nextSlotAt }); +const nextSlotAt = "2026-11-02T10:00:00Z"; +// When the next one comes back, in the member's own time zone. +const nextLabel = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }) + .format(new Date(nextSlotAt)); +const low = "12 of your 60 reminders left. Each one comes back 30 days after it was sent."; + +let usage: ReminderUsage; +let app: AppState; +function Capture() { app = useApp(); return null; } +function mount(path: string) { + const location = memoryLocation({ path, record: true }); + render(); + return location; +} +const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal"); +const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close"); +beforeAll(() => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: function (this: HTMLDialogElement) { this.removeAttribute("open"); } }); +}); +afterAll(() => { + for (const [name, descriptor] of [["showModal", modalDescriptor], ["close", closeDescriptor]] as const) { + if (descriptor) Object.defineProperty(HTMLDialogElement.prototype, name, descriptor); + else delete (HTMLDialogElement.prototype as unknown as Record)[name]; + } +}); +beforeEach(() => { + vi.resetAllMocks(); + sessionStorage.clear(); + localStorage.clear(); + usage = allowance(0); + vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: [commune] })); + vi.mocked(loadSnapshot).mockImplementation(async () => snapshot); + vi.mocked(api).mockImplementation(async (path) => { + if (path === "/auth/me") return { user }; + if (path === "/reminders/usage") return usage; + if (path.endsWith("/invites")) return { invites: [] }; + return { ok: true }; + }); +}); +afterEach(cleanup); + +describe("the reminder allowance", () => { + it("says nothing until a fifth or fewer are left, then how many and when they come back", () => { + const { container, rerender } = render(); + expect(container.textContent).toBe(""); + rerender(); + expect(screen.getByRole("note").textContent).toBe(low); + rerender(); + expect(screen.getByRole("note").textContent).toBe("1 of your 60 reminders left. Each one comes back 30 days after it was sent."); + rerender(); + expect(screen.getByRole("note").textContent).toBe("No reminders left."); + rerender(); + expect(screen.getByRole("note").textContent).toBe(`No reminders left. The next one comes back on ${nextLabel}.`); + rerender(); + expect(container.textContent).toBe(""); + }); + + it.each(["/funds", "/settle"])("stays out of the sidebar's account line and the %s toolbar", async (path) => { + mount(path); + await waitFor(() => expect(app.reminderUsage).not.toBeNull()); + // Only the role sits under the name: a new member has nothing to count yet. + expect(document.querySelector(".a-side-account-meta")?.textContent).toBe("Admin"); + expect(document.querySelector(".a-side-account .a-reminder-usage")).toBeNull(); + expect(document.querySelector(`${path.replace("/", "#")} .a-toolbar .a-reminder-usage`)).toBeNull(); + expect(screen.queryByText(/reminders left/)).toBeNull(); + }); + + it("is said on Settle, the expense and the fund once it runs low", async () => { + usage = allowance(47); + mount("/settle"); + const owed = await screen.findByRole("region", { name: "You’re owed" }); + await within(owed).findByText("Housemate owes you"); + await waitFor(() => expect(app.reminderUsage?.totalUsed).toBe(47)); + // Thirteen of sixty left is more than a fifth: nothing to say yet. + expect(screen.queryByRole("note")).toBeNull(); + usage = allowance(48); + await act(async () => app.refresh()); + expect((await within(owed).findByRole("note")).textContent).toBe(low); + // Only where reminders are sent: not on what you owe. + expect(within(screen.getByRole("region", { name: "You owe" })).queryByRole("note")).toBeNull(); + await act(async () => app.go("expense", { expense: "expense-1" })); + expect(within(await screen.findByRole("dialog", { name: "Gas" })).getByRole("note").textContent).toBe(low); + await act(async () => app.go("funds")); + fireEvent.click(await screen.findByRole("button", { name: "View fund Boiler fund" })); + const fund = await screen.findByRole("dialog", { name: "Boiler fund" }); + expect(within(fund).getByRole("note").textContent).toBe(low); + // None left: when the next one comes back. + usage = allowance(60, nextSlotAt); + await act(async () => app.refresh()); + await waitFor(() => expect(within(fund).getByRole("note").textContent).toBe(`No reminders left. The next one comes back on ${nextLabel}.`)); + }); + + it("is not offered to someone who cannot send one", async () => { + usage = allowance(55); + // A member's own pledge, and an expense someone else paid for. + vi.mocked(loadCommunes).mockImplementation(async () => ({ communes: [{ ...commune, role: "member" }] })); + vi.mocked(loadSnapshot).mockImplementation(async () => ({ ...snapshot, commune: { ...commune, role: "member" }, + expenses: [{ ...snapshot.expenses[0], paidByUserId: housemate, shares: [ + { id: "share-1", userId: user.id, amountMinor: 1500, settledMinor: 0 }, + { id: "share-2", userId: housemate, amountMinor: 1500, settledMinor: 1500 }, + ] }] })); + mount("/settle"); + await within(await screen.findByRole("region", { name: "You owe" })).findByText("You owe Housemate"); + await waitFor(() => expect(app.reminderUsage?.totalUsed).toBe(55)); + expect(screen.queryByRole("note")).toBeNull(); + await act(async () => app.go("expense", { expense: "expense-1" })); + expect(within(await screen.findByRole("dialog", { name: "Gas" })).queryByRole("note")).toBeNull(); + await act(async () => app.go("funds")); + fireEvent.click(await screen.findByRole("button", { name: "View fund Boiler fund" })); + expect(within(await screen.findByRole("dialog", { name: "Boiler fund" })).queryByRole("note")).toBeNull(); + }); +}); From c2e5a0ef8aedc84282c76c21cc49259cd59fae80 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:04:31 +0000 Subject: [PATCH 07/10] Filter saved splits with the same side-by-side chips as Funds The Active and Archived filters were toolbar buttons, which the toolbar pushed apart, and both looked the same whichever was on. They now use the chip control Funds uses, side by side, with the selected one marked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/screens/Templates.tsx | 7 ++++--- apps/web/tests/workflow-screens.test.tsx | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/web/src/screens/Templates.tsx b/apps/web/src/screens/Templates.tsx index 3d9b5184..543293cd 100644 --- a/apps/web/src/screens/Templates.tsx +++ b/apps/web/src/screens/Templates.tsx @@ -185,14 +185,15 @@ export function TemplatesScreen() {
{[false, true].map((value) => ( - + ))}
diff --git a/apps/web/tests/design-reference.test.ts b/apps/web/tests/design-reference.test.ts index b3bd3d92..38f41da3 100644 --- a/apps/web/tests/design-reference.test.ts +++ b/apps/web/tests/design-reference.test.ts @@ -101,6 +101,6 @@ describe("design language reference", () => { expect(html).not.toContain("typography.css"); expect(html).toContain("font-weight: 450;"); expect(html).toContain("font-weight: 550;"); - expect(find(".account").textContent).toContain("Admin · 58 reminders left"); + expect(find(".account small").textContent).toBe("Admin"); }); }); From a3db9b2aad8d34dd3865ca6fe077e367d55338ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:10:12 +0000 Subject: [PATCH 10/10] Record the Home, notices and activity changes in the docs - infra/neon/README.md: 0039 joins the local-only range and the release steps, with what it adds and when to deploy the client. - infra/local/README.md: 0039 in the local setup order. - DESIGN-LANGUAGE.md: the contrast rule for text over cover photos, which the owner approved, activity rows with a type chip, and filter chips. - HANDOFF.md: a Start here entry for notices across communes, Needs you, activity types, former members' names, the reminder allowance and the photo shade. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- docs/DESIGN-LANGUAGE.md | 3 +++ docs/HANDOFF.md | 11 +++++++++++ infra/local/README.md | 3 ++- infra/neon/README.md | 14 +++++++++++--- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/DESIGN-LANGUAGE.md b/docs/DESIGN-LANGUAGE.md index 2c8ac46a..d016f070 100644 --- a/docs/DESIGN-LANGUAGE.md +++ b/docs/DESIGN-LANGUAGE.md @@ -40,6 +40,7 @@ date ranges and user-written content. Keep the wording plain and concise. | Focus and green text | `--focus-ring` and `--text-green`: today's sage in light mode, the Hub's #649578 in dark mode and on navy surfaces | | Error text | `--danger` and `--danger-soft`, one error colour in both themes | | Touch areas | On phones (560px and under) small controls reach 44px through an invisible `::before` area; on desktop small links reach 24px through padding with a matching negative margin. Nothing moves visually | +| Text over cover photos | Small text keeps 4.5:1, and large text and buttons 3:1, over a white stand-in (`apps/web/tests/cover-contrast.test.ts`). The shade is darkest behind the text and anchored in pixels from the bottom; type labels and hero buttons have their own dark translucent backdrop. The owner approved the darker shade on 9 October | Use semantic tokens with explicit dark-mode counterparts when consolidating CSS. Do not flatten dark mode with hard-coded light overrides. Normal text must reach 4.5:1 contrast; control boundaries/focus must remain discernible. Small metadata must not rely on very low opacity. @@ -61,6 +62,8 @@ These are system responsibilities, not a demand to rename every existing compone | SummaryAmount | Label, real amount, concise meaning, optional primary action. Money hierarchy through size rather than excessive boldness | | ListRow | Leading identity/icon if informative, primary text, secondary metadata, trailing value/status. No nested conflicting click targets | | StatusBadge | Compact labelled state. Green for settled/complete, amber for pending/attention, red for failure/overdue where appropriate; colour never the sole signal | +| ActivityRow | A type chip leads: slate for money, sage for plans, cream for shared things. The type word comes first in the small line and the time sits at the end, under day headings in the search-results heading style | +| FilterChips | Active/Archived and similar filters use `.a-chip` with `is-on` and `aria-pressed`, side by side, as on Funds and Saved splits | | DateBadge | Shared `CalendarDate`: Operations' weekday strip, white face, date and Today/Tomorrow; add the month for dates beyond the week. 60px face / 52px below 1000px. Operations date column stays 72px / 56px below 1000px, no shadow or coloured edge | | NoteCard / NoteReader | Existing board and reader. Type-specific fields without changing the basic interaction. Contacts keep editable roles; confidential values stay masked | | FundCard | Purpose, contributed/target, progress, outstanding pledges and relevant next action. Explicitly records contributions; never implies Commune holds money | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index d379bfcf..39d35b26 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -92,6 +92,17 @@ lifecycle pack. Work is paused for a handoff, not a production release. Billing and Analytics are hidden, the unused Currency preference is gone, and the app has a home-screen name and icon (redraw them with `CHROMIUM=/path/to/chrome node apps/web/scripts/render-icons.mjs`). +- Also from 9 October, for review: Home, notices and activity, from the same + review. The bell and Notices cover every commune (migration 0039, + `GET /api/notices/unread`). Needs you on Home and This Hub comes from one + rule, `needsYou()` in `apps/web/src/live/model.ts`: actions first, then + Waiting on others. Activity rows show the record type and time, grouped by + day (`apps/web/src/ui/ActivityLog.tsx`). `personName` names former members, + with "(left)" where it matters. The reminder allowance is in account + settings, on an expense its payer can nudge from, in the reminder + confirmation, and as a notice once a fifth or less is left; it is no longer + in the sidebar. Text over commune photos has a darker shade, measured by + `apps/web/tests/cover-contrast.test.ts` against a white stand-in. - 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 diff --git a/infra/local/README.md b/infra/local/README.md index 67c449c1..0090604e 100644 --- a/infra/local/README.md +++ b/infra/local/README.md @@ -43,7 +43,8 @@ files in order using a database owner connection: 37. `infra/neon/migrations/0036_lighter_loading.sql` 38. `infra/neon/migrations/0037_task_command_lock_order.sql` 39. `infra/neon/migrations/0038_invite_hint_and_client_reports.sql` -40. `infra/local/after-core.sql` +40. `infra/neon/migrations/0039_notices_and_activity.sql` +41. `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 59d4a991..e9606fbf 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 0038 are local only**. They have not been applied to any -Neon branch. Release order when they are: apply 0013 to 0038 in numeric order +Migrations **0015 to 0039 are local only**. They have not been applied to any +Neon branch. Release order when they are: apply 0013 to 0039 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`, @@ -109,6 +109,14 @@ reports. A report names its release from Vercel's commit SHA, so expose Vercel's system environment variables to the build; otherwise reports say "local". +0039 adds `unreadNotices` to `my_communes()`, `commune_summary()` and the +snapshot's commune; `public.my_unread_notices()` for Notices across +communes; a `recordType` on each snapshot activity event; and keeps a share's +pending payment reports in `commune_summary()`. All of it is additive, so the +previous client keeps working. The new client calls `my_unread_notices` only +when another commune has unread notices: deploy it after 0039 and the schema +cache refresh. + ## Last recorded production evidence — 27 September 2026 The app is served at `https://app.ourcommune.io` from `main`. The latest @@ -272,7 +280,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/0038_invite_hint_and_client_reports.sql` in numeric order to the + `migrations/0039_notices_and_activity.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