diff --git a/CLAUDE.md b/CLAUDE.md index 23116711..6ece437c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,10 +16,10 @@ Commune is a shared communal expense management and group management platform. The application was migrated. The stack below is what runs today. - **Web (active app):** `apps/web`. React 19 + Vite 7 + TypeScript + wouter, plain CSS with shared tokens, tested with Vitest. pnpm. Vercel serves the production build (`apps/web/vercel.json`). -- **Hosted data and auth:** Neon (Managed Auth, Postgres, Data API). Schema and functions live in `infra/neon/migrations`: 39 migrations, 0001 to 0039, applied in numeric order. `infra/neon/schema.sql` is the schema they produce, with every function in full. Production builds use `apps/web/src/live/neon.ts`. +- **Hosted data and auth:** Neon (Managed Auth, Postgres, Data API). Schema and functions live in `infra/neon/migrations`: 40 migrations, 0001 to 0040, applied in numeric order. `infra/neon/schema.sql` is the schema they produce, with every function in full. Production builds use `apps/web/src/live/neon.ts`. - **Local development backend:** `apps/local-api`, a loopback-only PostgreSQL API on port 3002 that calls the same database functions. Vite proxies `/api` to it. Its database is set up as `infra/local/README.md` describes. It is never deployed. - **Email:** `apps/reminder-worker`, a Neon Function that sends reminder and invitation emails from the outbox through Resend, with one-click unsubscribe and a bounce webhook. -- **Shared logic:** `packages/core` (split arithmetic, balances and money input), imported by relative path. +- **Shared logic:** `packages/core` (split arithmetic, balances, reading typed amounts, and writing money and dates), imported as `@commune/core` through a path in `apps/web/tsconfig.json` and an alias in `apps/web/vite.config.ts`. It has no `package.json`. Amounts and day-and-month dates are written by its `formatMoney` and `formatDay`; `apps/web/tests/formatting.test.ts` fails if app code uses `Intl.NumberFormat`, `toLocaleString` or `toLocaleDateString`. - **Mobile:** `apps/mobile-v2` (Expo). Separate work. It does not use Commune's database or share code with `apps/web`. - **Website:** `web/` is the marketing website: the editorial homepage and its company pages (moved from `apps/landing-monzo-study` on 5 October 2026). The previous v2 site is kept in `web-backup/`. Neither is in the pnpm workspace. Separate work from the app. @@ -61,7 +61,7 @@ Earlier versions used Mantine 9, TanStack Router/Query, Zustand, Zod, Turborepo ## Tests and CI `.github/workflows/ci.yml` runs on every pull request and every push to main. Nothing in it deploys or reaches Neon, Vercel or Resend, and each step is a command you can run locally: -- **Web:** in `apps/web`, `pnpm run typecheck`, `pnpm test` and `pnpm run build`, or the tools directly (`node node_modules/vitest/vitest.mjs run`). Tests run in jsdom by default. A test file that needs no browser (nothing rendered, no `localStorage`) starts with `// @vitest-environment node` and runs in Node, which is faster. `pnpm run typecheck` in `apps/local-api` and `apps/reminder-worker` checks those two. +- **Web:** in `apps/web`, `pnpm run typecheck`, `pnpm test` and `pnpm run build`, or the tools directly (`node node_modules/vitest/vitest.mjs run`). Tests run in jsdom by default. A test file that needs no browser (nothing rendered, no `localStorage`) starts with `// @vitest-environment node` and runs in Node, which is faster. `packages/core/tests` run in the same command. `pnpm run typecheck` in `apps/local-api` and `apps/reminder-worker` checks those two. - **Database:** `infra/neon/tests/run-local.sh` builds databases from the migrations on a loopback PostgreSQL 16 server (PG* variables; its header explains) and runs each test in `infra/neon/tests` on its own copy. `infra/neon/dump-schema.sh --check` fails while `infra/neon/schema.sql` differs from what the migrations produce; without `--check` it rewrites the file. - **Local API:** the HTTP tests in `apps/local-api/tests` run against an API started on another port with a fresh database (`run-local.sh --create local`); they refuse port 3002. - **Worker:** `node --test tests/email.mjs tests/unsubscribe.mjs` in `apps/reminder-worker`, then its database tests on stub databases, as the CI step shows. diff --git a/apps/local-api/README.md b/apps/local-api/README.md index f0df233f..340ae805 100644 --- a/apps/local-api/README.md +++ b/apps/local-api/README.md @@ -18,7 +18,7 @@ browser code sends same-origin `/api` requests. Non-2xx errors are | 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,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 | +| 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; since 0040 each item also carries `label` and `date` (YYYY-MM-DD or null), and `meta` is the older text | | 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, 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}` | @@ -135,6 +135,9 @@ 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. +`search-dates.mjs` needs 0040 and covers the `label` and `date` of recurring +bills, paused bills and tasks beside the older `meta`. 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 diff --git a/apps/local-api/tests/search-dates.mjs b/apps/local-api/tests/search-dates.mjs new file mode 100644 index 00000000..195717d5 --- /dev/null +++ b/apps/local-api/tests/search-dates.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +// The quick finder's dates (0040): each result carries its date on its own +// (date, YYYY-MM-DD or null) and its words without it (label), so the app +// writes dates the way every screen does; meta keeps the old text. +// Fixture writes must never run against the user's normal local API. +const base = process.env.API_BASE; +const origin = new URL(base ?? "http://invalid"); +if (!base || origin.protocol !== "http:" || !["localhost", "127.0.0.1"].includes(origin.hostname) || + !origin.port || origin.port === "3002" || origin.pathname !== "/" || origin.username || origin.password || origin.search || origin.hash) { + throw new Error("Search date tests require an isolated loopback API_BASE, not port 3002."); +} +const suffix = randomUUID().slice(0, 8); +let cookie = ""; +async function call(method, path, input) { + const response = await fetch(`${base}/api${path}`, { method, + headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) }, + body: input === undefined ? undefined : JSON.stringify(input) }); + const set = response.headers.get("set-cookie"); if (set) cookie = set.split(";")[0]; + return { status: response.status, ...await response.json() }; +} +const day = (offset) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); + +const owner = await call("POST", "/auth/signup", { name: "Quince owner", email: `quince-${suffix}@example.test`, password: "DisposableSearchFixture-2026!" }); +assert.equal(owner.status, 201, JSON.stringify(owner)); +const commune = await call("POST", "/communes", { name: `Quince ${suffix}`, kind: "household", currency: "USD" }); +assert.equal(commune.status, 201, JSON.stringify(commune)); +const bill = (title, nextDueOn, paused) => call("POST", `/communes/${commune.id}/recurring`, { title, amountMinor: 3210, + paidByUserId: owner.user.id, allocations: [{ userId: owner.user.id, amountMinor: 3210 }], frequency: "monthly", nextDueOn, paused, + requestId: randomUUID() }); +const due = day(20); +assert.equal((await bill("Quince internet", due, false)).status, 201); +assert.equal((await bill("Quince water", day(25), true)).status, 201); +const taskDate = day(3); +const task = await call("POST", `/communes/${commune.id}/operations`, { kind: "task", category: "chore", title: "Quince bins", + date: taskDate, allDay: true, repeat: "none", notes: "", requestId: randomUUID() }); +assert.equal(task.status, 201, JSON.stringify(task)); + +const result = await call("GET", `/search?${new URLSearchParams({ q: "Quince", commune: commune.id, limit: "25" })}`); +assert.equal(result.status, 200, JSON.stringify(result)); +const find = (title) => result.items.find((item) => item.title === title); +assert.deepEqual( + ["Quince internet", "Quince water", "Quince bins"].map((title) => { const { label, date, meta } = find(title) ?? {}; return { label, date, meta }; }), + [ + { label: "Due", date: due, meta: `Due ${due}` }, + { label: "Paused", date: null, meta: "Paused" }, + { label: "Task", date: taskDate, meta: `Task · ${taskDate}` }, + ], +); +// Everything else has no date, and its words are its old text. +const undated = result.items.filter((item) => !["recurring", "operation"].includes(item.kind)); +assert.ok(undated.length >= 2, JSON.stringify(result)); +for (const item of undated) { + assert.equal(item.date, null, JSON.stringify(item)); + assert.equal(item.label, item.meta, JSON.stringify(item)); +} +assert.equal(find(`Quince ${suffix}`)?.label, "Commune"); +console.log("Search dates: dates arrive on their own, with the old text kept"); diff --git a/apps/reminder-worker/src/index.ts b/apps/reminder-worker/src/index.ts index 1b0be474..ef9b5f38 100644 --- a/apps/reminder-worker/src/index.ts +++ b/apps/reminder-worker/src/index.ts @@ -3,6 +3,7 @@ import { createRemoteJWKSet, jwtVerify } from "jose"; import { Pool } from "pg"; import { renderReminderEmail } from "./email.ts"; import { InvitationEmailError, sendInvitationEmail } from "./invitations.ts"; +import { formatMoney } from "./money.ts"; import { type UnsubscribeConfig, unsubscribeConfig, unsubscribePage, unsubscribeRecipient, unsubscribeUrl } from "./unsubscribe.ts"; import { resendWebhook } from "./webhooks.ts"; @@ -197,9 +198,7 @@ async function buildPayload( }, config: MailConfig, ) { - const amount = new Intl.NumberFormat("en-GB", { - style: "currency", currency: detail.currency, - }).format(Number(detail.outstanding_minor) / 100); + const amount = formatMoney(Number(detail.outstanding_minor), detail.currency); const unsubscribe = config.unsubscribe ? unsubscribeUrl(config.unsubscribe, detail.recipient_user_id) : undefined; const content = renderReminderEmail({ kind: detail.kind, diff --git a/apps/reminder-worker/src/money.ts b/apps/reminder-worker/src/money.ts new file mode 100644 index 00000000..cc805b75 --- /dev/null +++ b/apps/reminder-worker/src/money.ts @@ -0,0 +1,24 @@ +/** + * Amounts in reminder emails, written as the app writes them: "$32.10", + * "£42.00", "€8.50". This is a copy of formatMoney in + * packages/core/src/format.ts, because the worker runs from its own folder + * and imports nothing from outside it; apps/web/tests/formatting.test.ts + * checks that the two agree. + */ +const NARROW_SYMBOL = new Set(["GBP", "USD", "EUR"]); + +const moneyFormats = new Map(); + +/** An amount in minor units (pence or cents) in its currency. */ +export function formatMoney(minor: number, currency: string): string { + let format = moneyFormats.get(currency); + if (!format) { + format = new Intl.NumberFormat("en-GB", { + style: "currency", + currency, + currencyDisplay: NARROW_SYMBOL.has(currency) ? "narrowSymbol" : "symbol", + }); + moneyFormats.set(currency, format); + } + return format.format(minor / 100); +} diff --git a/apps/reminder-worker/tests/email.mjs b/apps/reminder-worker/tests/email.mjs index cd4d3b7a..f51e2107 100644 --- a/apps/reminder-worker/tests/email.mjs +++ b/apps/reminder-worker/tests/email.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { renderReminderEmail } from "../src/email.ts"; +import { formatMoney } from "../src/money.ts"; test("nudge has branded HTML and a useful plain-text fallback", () => { const email = renderReminderEmail({ @@ -75,3 +76,19 @@ test("offers a one-click link to turn reminder emails off when one is given", () appUrl: "https://ourcommune.io", unsubscribeUrl: "http://worker.example.test/unsubscribe", }), /HTTPS/); }); + +test("amounts read as the app writes them, with each currency's own symbol", () => { + assert.equal(formatMoney(3210, "USD"), "$32.10"); + assert.equal(formatMoney(4200, "GBP"), "£42.00"); + assert.equal(formatMoney(123456, "EUR"), "€1,234.56"); + const email = renderReminderEmail({ + kind: "nudge", + senderName: "Maya Chen", + communeName: "Brooklyn loft", + label: "Internet", + amount: formatMoney(3210, "USD"), + appUrl: "https://ourcommune.io", + }); + assert.match(email.text, /\$32\.10 expense share for Internet in Brooklyn loft/); + assert.doesNotMatch(email.text, /US\$/); +}); diff --git a/apps/web/src/data/moneyInput.ts b/apps/web/src/data/moneyInput.ts index 4a2b2d1b..a026ab8c 100644 --- a/apps/web/src/data/moneyInput.ts +++ b/apps/web/src/data/moneyInput.ts @@ -1,4 +1,4 @@ -import { moneyProblemMessage, readMoneyInput } from "../../../../packages/core/src/money"; +import { moneyProblemMessage, readMoneyInput } from "@commune/core"; /** * An amount typed in the commune's currency, in minor units, or null when it diff --git a/apps/web/src/data/recurring.ts b/apps/web/src/data/recurring.ts index fc1c7289..2643f12c 100644 --- a/apps/web/src/data/recurring.ts +++ b/apps/web/src/data/recurring.ts @@ -1,4 +1,4 @@ -import type { SplitMethod } from "../../../../packages/core/src/expenseSplit"; +import type { SplitMethod } from "@commune/core"; import { dateLabel, money, type CurrencyCode, type RecurringBill, type RecurringPeriodResult } from "../live/model"; import { addDays, todayIso } from "./operations"; import { formatReminderDay } from "./reminders"; diff --git a/apps/web/src/data/reminders.ts b/apps/web/src/data/reminders.ts index abb9c54e..b032437b 100644 --- a/apps/web/src/data/reminders.ts +++ b/apps/web/src/data/reminders.ts @@ -1,3 +1,4 @@ +import { formatDay } from "@commune/core"; import { commandFailure } from "./commands"; import { timestampDay } from "./operations"; import type { ReminderEmailStatus, ReminderResult, ReminderTarget, ReminderUsage, SentReminder } from "../live/model"; @@ -15,23 +16,20 @@ export type ReminderEligibility = { }; const whenFormat = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); -const dayFormat = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" }); export function reminderWord(kind: ReminderKind): "Nudge" | "Chase" { return kind === "chase" ? "Chase" : "Nudge"; } -/** A timestamp shown as the member's local calendar day. */ +/** A timestamp shown as the member's local calendar day: "9 Oct", or "9 Oct 2025" in another year. */ export function formatTimestampDay(timestamp: string | null | undefined): string { const day = timestampDay(timestamp); return day ? formatReminderDay(day) : timestamp ?? ""; } -/** A date-only value (due, received). Never shifted by time zone. */ +/** A date-only value (due, received) as "9 Oct", with the year when it is not this year. Never shifted by time zone. */ export function formatReminderDay(iso: string | null | undefined): string { - if (!iso) return ""; - const date = new Date(`${iso.slice(0, 10)}T12:00:00`); - return Number.isNaN(date.getTime()) ? iso : dayFormat.format(date); + return iso ? formatDay(iso) : ""; } /** diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 4afbcf07..68d2fe91 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -10,7 +10,7 @@ import { import type { OperationItem } from "../data/operations"; import { notePreview } from "../data/notePresentation"; import { timestampDay, todayIso } from "../data/operations"; -import { netBalances, overallNet } from "../../../../packages/core/src/balances"; +import { formatDay, formatMoney, netBalances, overallNet } from "@commune/core"; export type CurrencyCode = "GBP" | "USD" | "EUR"; /** Records that keep a commune's currency as it is, voided or archived ones too. */ @@ -446,45 +446,20 @@ const EVENT_TYPES: Record = { const TONES: AvatarTone[] = ["jordan", "alex", "priya", "sam", "ness"]; +/** An amount in the commune's currency: "£12.50", "$32.10". The shared, cached formatter. */ export function money(minor: number, currency: CurrencyCode): string { - return new Intl.NumberFormat("en-GB", { - style: "currency", - currency, - }).format(minor / 100); -} - -export function dateLabel(iso: string | null | undefined): string { - if (!iso) return "No due date"; - const date = new Date(`${iso.slice(0, 10)}T12:00:00`); - return Number.isNaN(date.getTime()) - ? iso - : new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short" }).format(date); + return formatMoney(minor, currency); } -export function equalShares( - amountMinor: number, - userIds: string[], -): Array<{ userId: string; amountMinor: number }> { - const ids = [...userIds]; - if (new Set(ids).size !== ids.length) { - throw new Error("Each person may appear only once in a split."); - } - if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0 || !ids.length || amountMinor < ids.length) { - throw new Error("The amount must have at least one penny for each person in the split."); - } - const base = Math.floor(amountMinor / ids.length); - const remainder = amountMinor % ids.length; - return ids.map((userId, index) => ({ - userId, - amountMinor: base + (index < remainder ? 1 : 0), - })); +/** A due date as "9 Oct" (with the year when it is not this year), or "No due date". */ +export function dateLabel(iso: string | null | undefined, today?: string): string { + return iso ? formatDay(iso, today) : "No due date"; } -export function expenseRows(snapshot: Snapshot, viewerId: string): LiveExpenseRow[] { +/** today: the member's own calendar date, as tasks and pledges use. */ +export function expenseRows(snapshot: Snapshot, viewerId: string, today = todayIso()): LiveExpenseRow[] { const currency = snapshot.commune.currency; const names = new Map(snapshot.members.map((member) => [member.userId, member.name])); - // The member's own calendar date, as tasks and pledges use. - const today = todayIso(); return snapshot.expenses.map((expense) => { const voided = !!expense.voidedAt; // A voided expense keeps its shares for history but is never outstanding. @@ -502,7 +477,7 @@ export function expenseRows(snapshot: Snapshot, viewerId: string): LiveExpenseRo paidByUserId: expense.paidByUserId, amount: money(expense.amountMinor, currency), amountMinor: expense.amountMinor, - due: dateLabel(expense.dueOn), + due: dateLabel(expense.dueOn, today), dueOn: expense.dueOn, createdAt: expense.createdAt, status: outstanding ? (overdue ? "warn" : "open") : "settled", @@ -527,6 +502,7 @@ export function hubFromSnapshot( source: Snapshot, viewer: User, photo?: string, + today = todayIso(), ): LiveHub { // Voided expenses stay in the ledger as history but never count towards a // balance, a total or an outstanding share anywhere on the hub. @@ -536,7 +512,7 @@ export function hubFromSnapshot( const { commune } = snapshot; const currency = commune.currency; const names = new Map(snapshot.members.map((member) => [member.userId, member.name])); - const rows = expenseRows(snapshot, viewer.id); + const rows = expenseRows(snapshot, viewer.id, today); const openRows = rows.filter((row) => row.open); const moves: Hub["moves"] = []; let youOwe = 0; @@ -600,7 +576,6 @@ 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 = today.slice(0, 7); @@ -620,7 +595,7 @@ export function hubFromSnapshot( title: note.title, when: notePreview(note), })); - const needs = needsYou(source, viewer.id); + const needs = needsYou(source, viewer.id, today); return { id: commune.id, currency, @@ -652,7 +627,7 @@ export function hubFromSnapshot( { go: "expenses", tone: "lilac", kicker: "This month", num: money(monthTotal, currency), hint: `${monthExpenses.length} ${monthExpenses.length === 1 ? "expense" : "expenses"} logged` }, { go: "expenses", tone: "sage", kicker: "Open bills", num: String(openRows.length), hint: openRows[0]?.name ?? "Nothing waiting" }, // 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" }, + { go: "expenses", tone: "ink", kicker: "Next due", num: nextDue?.dueOn ? nextDue.dueOn < today ? `Overdue since ${dateLabel(nextDue.dueOn, today)}` : dateLabel(nextDue.dueOn, today) : "-", hint: nextDue?.name ?? "No dated open bills" }, ], now: needs.actions, waiting: needs.waiting, @@ -664,7 +639,7 @@ export function hubFromSnapshot( ini: initialsOf(names.get(expense.paidByUserId) ?? "Member"), title: expense.title, when: `${names.get(expense.paidByUserId) ?? "Member"} paid`, - time: expense.createdAt ? dateLabel(timestampDay(expense.createdAt)) : "Recently", + time: expense.createdAt ? dateLabel(timestampDay(expense.createdAt), today) : "Recently", createdAt: expense.createdAt, expenseId: expense.id, type: "expense" as const, @@ -672,7 +647,7 @@ export function hubFromSnapshot( // 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, + when:`${event.description} · ${names.get(event.actorId) ?? "Member"}`, time:dateLabel(timestampDay(event.createdAt), today),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)), @@ -686,3 +661,20 @@ export function hubFromSnapshot( ], }; } + +/** A commune whose snapshot is loaded, as the app shows it. */ +export type ReadyPlace = LiveHub & { activityState: "ready" }; +const readyPlaces = new WeakMap(); +/** + * hubFromSnapshot, kept with the snapshot it was built from. A commune's + * views are built again only when the commune is read again (a new + * snapshot), its photo changes or the date does, and never for a change + * anywhere else in the app. + */ +export function readyPlace(snapshot: Snapshot, viewer: User, photo?: string, today = todayIso()): ReadyPlace { + const held = readyPlaces.get(snapshot); + if (held && held.viewerId === viewer.id && held.photo === photo && held.today === today) return held.place; + const place: ReadyPlace = { ...hubFromSnapshot(snapshot, viewer, photo, today), activityState: "ready" }; + readyPlaces.set(snapshot, { viewerId: viewer.id, photo, today, place }); + return place; +} diff --git a/apps/web/src/live/search.ts b/apps/web/src/live/search.ts index 6da58216..af757022 100644 --- a/apps/web/src/live/search.ts +++ b/apps/web/src/live/search.ts @@ -1,3 +1,4 @@ +import { formatDay } from "@commune/core"; import { ApiError } from "./api"; export const SEARCH_KINDS = ["commune", "expense", "note", "operation", "fund", "recurring", "member", "template"] as const; @@ -10,7 +11,11 @@ export type SearchResult = { communeName: string; title: string; subtitle: string; + /** The database's own text, with any date in its own style ("Due 2026-11-04"). Kept for older app versions. */ meta: string; + /** Since 0040: the words without the date ("Due", "Task"), and the date on its own (YYYY-MM-DD) or null. */ + label?: string; + date?: string | null; amountMinor: number | null; currency: string; }; @@ -31,8 +36,21 @@ export function parseSearchResponse(value: unknown): SearchResponse { [item.id, item.communeId, item.communeName, item.title, item.subtitle, item.meta, item.currency] .some(field => typeof field !== "string") || !item.id || !item.communeId || !["GBP", "USD", "EUR"].includes(item.currency) || + (item.label !== undefined && typeof item.label !== "string") || + (item.date !== undefined && item.date !== null && (typeof item.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(item.date))) || (item.amountMinor !== null && (!Number.isSafeInteger(item.amountMinor) || item.amountMinor < 0)))) { throw new ApiError("Search returned an invalid response. Please try again.", 502); } return result as SearchResponse; } + +/** + * A result's right-hand line, with any date written as every screen writes it: + * "Due 4 Nov" for a recurring bill, "Task · 20 Oct" for a task or event. A + * database from before 0040 sends only its own text, which is shown as it is. + */ +export function searchMeta(item: SearchResult): string { + if (item.label === undefined) return item.meta; + if (!item.date) return item.label; + return `${item.label}${item.kind === "recurring" ? " " : " · "}${formatDay(item.date)}`; +} diff --git a/apps/web/src/screens/Funds.tsx b/apps/web/src/screens/Funds.tsx index c01352ba..00c24b20 100644 --- a/apps/web/src/screens/Funds.tsx +++ b/apps/web/src/screens/Funds.tsx @@ -18,7 +18,7 @@ import { import { Pill } from "../ui/widgets"; import { ReminderFlowDialog, useReminderFlow } from "../ui/ReminderConfirm"; import { RecipientReminderCount, ReminderAllowanceNotice } from "../ui/ReminderUsage"; -import { formatReminderDay, reminderEligibility } from "../data/reminders"; +import { formatReminderDay, formatTimestampDay, reminderEligibility } from "../data/reminders"; import { newRequestId } from "../data/requestId"; import { commandFailure, UNCERTAIN_RETRY_SAFE } from "../data/commands"; @@ -349,7 +349,7 @@ export function FundsScreen() { Pledged {money(pledge.amountMinor, hub.currency)} · due{" "} - {pledge.dueOn} + {formatReminderDay(pledge.dueOn)} {money(entry.amountMinor, hub.currency)} ·{" "} - {new Date(entry.contributedAt).toLocaleDateString("en-GB")} + {formatTimestampDay(entry.contributedAt)} {entry.voidedAt ? ` · corrected: ${entry.voidReason}` : ""} {isAdmin && !entry.voidedAt && !fund?.archivedAt && ( diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index e27a00b0..fd07abfd 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -7,6 +7,7 @@ import { CalendarDate } from "../ui/CalendarDate"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; import { isNotePinned, noteCategory, notePreview } from "../data/notePresentation"; import { addDays, dateFromIso, todayIso } from "../data/operations"; +import { formatTimestampDay } from "../data/reminders"; import type { Hub, RouteId } from "../data/household"; import "./Hub.css"; @@ -109,8 +110,6 @@ function Responsibilities() { ; } -const announcementDate = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short" }); - /** Notices from an admin. Important ones ask each member to confirm they have seen them. */ function Announcements() { const { announcements, hub, you, acknowledgeAnnouncement, archiveAnnouncement, go, personName } = useApp(); @@ -139,7 +138,7 @@ function Announcements() { return
{item.title} {item.body &&

{item.body}

} - {mine ? "You" : nameOf(item.authorId)} · {announcementDate.format(new Date(item.createdAt))} + {mine ? "You" : nameOf(item.authorId)} · {formatTimestampDay(item.createdAt)} {item.needsAcknowledgement &&
Seen by {audience.length - waiting.length} of {audience.length}{waiting.length && (mine || hub.role === "admin") ? `. Waiting for ${waiting.map(member => member.name.split(" ")[0]).join(", ")}` : ""} diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx index 5d629f5b..cae82245 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useApp } from "../state"; +import { memo, useState } from "react"; +import { useApp, type AppState, type CurrencyCode, type LiveExpenseRow } from "../state"; import { Avatar, Pill } from "../ui/widgets"; import { cx } from "../ui/cx"; import { RecipientReminderCount, ReminderAllowanceNotice } from "../ui/ReminderUsage"; @@ -10,15 +10,9 @@ 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 { formatMoney } from "@commune/core"; import "./Money.css"; -function formatMoney(minor: number, currency: string): string { - return new Intl.NumberFormat("en-GB", { - style: "currency", - currency, - }).format(minor / 100); -} - // Months start at midnight UTC, as the shared records count them. function monthName(month: string): string { const date = new Date(`${month}T00:00:00Z`); @@ -98,11 +92,7 @@ export function ExpensesScreen() { ? `${rows.length} voided ${rows.length === 1 ? "expense" : "expenses"} · kept for history, not counted in any balance` : `${rows.length} ${rows.length === 1 ? "expense" : "expenses"} · ${formatMoney(rows.reduce((sum, row) => sum + row.amountMinor, 0), hub.currency)} total recorded in this view`}

- {rows.map(row => )} + {rows.map(row => )} {!rows.length &&

No expenses match this view. Try another filter or search.

}
{more ?
@@ -116,6 +106,19 @@ export function ExpensesScreen() { ); } +// One expense in the list. It re-renders when its row does, not when the +// filter around it changes. +const ExpenseCard = memo(function ExpenseCard({ row, viewerId, currency, go }: { + row: LiveExpenseRow; viewerId: string | undefined; currency: CurrencyCode; go: AppState["go"]; +}) { + const yours = row.shares.find(share => share.userId === viewerId); + return ; +}); + export function ExpensesEmptyScreen() { const { go, session } = useApp(); if (!session?.hasHubs) { @@ -199,7 +202,7 @@ export function SettleScreen() {
{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}` : ""}
+
{expense.name}{expense.dueOn ? ` · due ${expense.due}` : ""}
{share.settledMinor > 0 ?
{formatMoney(share.settledMinor, hub.currency)} of {formatMoney(share.amountMinor, hub.currency)} received
: null} {report ?
{receiving ? `${reportSummary(report, hub.currency, personName(share.userId, "A member"))}. Not yet confirmed.` diff --git a/apps/web/src/screens/Notes.tsx b/apps/web/src/screens/Notes.tsx index 15859669..bf4d2dcb 100644 --- a/apps/web/src/screens/Notes.tsx +++ b/apps/web/src/screens/Notes.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { ArrowLeft, Copy, @@ -17,7 +17,7 @@ import { } from "lucide-react"; import type { NoteCard } from "../data/household"; import { LIMITS } from "../data/limits"; -import { useApp } from "../state"; +import { useApp, type AppState } from "../state"; import { NoteHistory } from "../ui/NoteHistory"; import { isNotePinned, @@ -49,10 +49,11 @@ export function NotesScreen() { noteKey, window: win, hubId, - noteSearchQuery, - setNoteSearchQuery, } = useApp(); const [filter, setFilter] = useState("All notes"); + // The screen's own search text: a note opened over the screen keeps it, + // and typing re-renders only this screen. + const [noteSearchQuery, setNoteSearchQuery] = useState(""); const selected = win === "note" ? notes[noteKey] : undefined; const entries = Object.entries(notes).filter( ([, note]) => @@ -77,26 +78,7 @@ export function NotesScreen() {

{title}

{rows.map(([id, note]) => ( - + ))}
@@ -178,6 +160,34 @@ export function NotesScreen() { ); } +// One note on the board. It re-renders when its note does, not as someone +// types in the search above it. +const NoteTile = memo(function NoteTile({ id, note, selected, go }: { + id: string; note: NoteCard; selected: boolean; go: AppState["go"]; +}) { + return ( + + ); +}); + function NoteReader({ note, id }: { note: NoteCard; id: string }) { const { closeWindow, go, updateNote } = useApp(); const panel = useRef(null); diff --git a/apps/web/src/screens/Recurring.tsx b/apps/web/src/screens/Recurring.tsx index ddb55936..a0dd753d 100644 --- a/apps/web/src/screens/Recurring.tsx +++ b/apps/web/src/screens/Recurring.tsx @@ -5,7 +5,7 @@ import { useSearchRecord } from "../ui/useSearchRecord"; import { allocateExpense, type SplitMethod, -} from "../../../../packages/core/src/expenseSplit"; +} from "@commune/core"; import { type RecurringBill, money, dateLabel } from "../live/model"; import { Button, diff --git a/apps/web/src/screens/Search.tsx b/apps/web/src/screens/Search.tsx index 92978e93..ab1b1975 100644 --- a/apps/web/src/screens/Search.tsx +++ b/apps/web/src/screens/Search.tsx @@ -1,4 +1,5 @@ -import { useApp } from "../state"; +import { useState } from "react"; +import { useApp, type SearchMemory } from "../state"; import { Button, EmptyState } from "../ui/components"; import { ActivityLog } from "../ui/ActivityLog"; import { SEARCH_MAX_QUERY } from "../live/search"; @@ -51,19 +52,32 @@ export function AllActivityScreen() { } export function SearchScreen() { - const { searchQuery, setSearchQuery, searchScope, setSearchScope, places, go } = useApp(); + const { searchOpened } = useApp(); + // Opened again from the finder, Search starts from the finder's words. + return ; +} + +function FullSearch() { + const { recallSearch, rememberSearch, places, go } = useApp(); + // The screen keeps its own text, so typing re-renders only this screen. The + // app keeps a copy, so going Back to Search finds the same search. + const [{ query: searchQuery, scope: searchScope }, setSearch] = useState(recallSearch); + function change(next: SearchMemory) { + setSearch(next); + rememberSearch(next); + } const search = useRecordSearch(searchQuery, searchScope, 25); return
event.preventDefault()}>
setSearchQuery(event.target.value)} maxLength={SEARCH_MAX_QUERY} + onChange={event => change({ query: event.target.value, scope: searchScope })} maxLength={SEARCH_MAX_QUERY} placeholder="Expense, note, task, fund or person…" /> - {searchQuery && } + {searchQuery && }
{search.result &&

{search.result.total} {search.result.total === 1 ? "match" : "matches"} diff --git a/apps/web/src/screens/Templates.tsx b/apps/web/src/screens/Templates.tsx index 543293cd..27ab7f73 100644 --- a/apps/web/src/screens/Templates.tsx +++ b/apps/web/src/screens/Templates.tsx @@ -3,12 +3,8 @@ import { Plus, UsersRound } from "lucide-react"; import { useApp, type SplitTemplateInput } from "../state"; import { useSearchRecord } from "../ui/useSearchRecord"; import { type SplitTemplate } from "../live/model"; -import { - allocateExpense, - type SplitMethod, -} from "../../../../packages/core/src/expenseSplit"; +import { allocateExpense, plainAmount, type SplitMethod } from "@commune/core"; import { moneyInputError, parseMoneyInput } from "../data/moneyInput"; -import { plainAmount } from "../../../../packages/core/src/money"; import { Button, Dialog, diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 748f631e..da8b773e 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -3,14 +3,17 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, + useMemo, useRef, useState, + useSyncExternalStore, type ReactNode, } from "react"; import { useLocation, useRouter, useSearch } from "wouter"; import { useBrowserLocation } from "wouter/use-browser-location"; import { isConnectionFailure } from "./live/connection"; -import { allocateExpense, validateExpenseAllocation, type ExpenseAllocation } from "../../../packages/core/src/expenseSplit"; +import { allocateExpense, validateExpenseAllocation, type ExpenseAllocation } from "@commune/core"; import { PAGE_LEDES, PAGE_TITLES, @@ -53,9 +56,9 @@ import { } from "./live/preferences"; import { expenseRows, - hubFromSnapshot, money, personName, + readyPlace, type Commune, type CurrencyCode, type Expense, @@ -198,6 +201,8 @@ type GoOpts = { record?: { kind: "fund" | "recurring" | "member" | "template"; id: string }; }; export type SearchTarget = NonNullable & { communeId: string }; +/** Search's words and the commune it searches (null: all of them). */ +export type SearchMemory = { query: string; scope: string | null }; type LiveData = { user: User; communes: Commune[]; @@ -269,10 +274,9 @@ export type AppState = { dark: boolean; theme: Theme; noticesOn: boolean; - searchQuery: string; - searchScope: string | null; searchTarget: SearchTarget | null; - noteSearchQuery: string; + /** Counts the times the finder has opened Search, so Search starts from what it was given. */ + searchOpened: number; invites: PendingInvite[]; payMethods: PayMethod[]; native: boolean; @@ -350,10 +354,16 @@ export type AppState = { setNavMode: (mode: NavMode) => void; setTheme: (theme: Theme) => void; setNoticesOn: (on: boolean) => void; - setSearchQuery: (query: string) => void; - setSearchScope: (id: string | null) => void; clearSearchTarget: () => void; - setNoteSearchQuery: (query: string) => void; + /** Opens Search with these words and scope, as the finder's full search does. */ + openFullSearch: (query: string, scope: string | null) => void; + /** + * What Search showed last in this tab, so going Back to it finds the same + * search. Search keeps its text itself while it is open and passes it on + * with rememberSearch, so typing re-renders nothing else. + */ + recallSearch: () => SearchMemory; + rememberSearch: (search: SearchMemory) => void; savePayMethod: (method: PayMethod) => boolean; setHubPhoto: (id: HubId, dataUrl: string | null) => void; setAvatar: (dataUrl: string | null) => void; @@ -437,7 +447,103 @@ export type AppState = { pageLede: string; }; -const Ctx = createContext(null); +// The app's state lives in one store that each component subscribes to by +// the fields it reads (see useApp), so a change re-renders only what shows it. +type AppStore = { state: AppState | null; listeners: Set<() => void> }; +const StoreCtx = createContext(null); + +// Shared empty values, so "nothing yet" is the same object from one render +// to the next. +const NONE: never[] = []; +const NO_SNAPSHOTS: Record = {}; + +/** Arrays with the same items, in the same order. */ +function sameItems(a: readonly T[], b: readonly T[]): boolean { + return a === b || (a.length === b.length && a.every((item, index) => Object.is(item, b[index]))); +} +/** Objects with the same keys and values, one level deep (arrays by their items). */ +function sameFields(a: T, b: T): boolean { + if (a === b) return true; + const keys = Object.keys(a) as Array; + return keys.length === Object.keys(b).length && keys.every((key) => { + const x = a[key]; + const y = b[key]; + return Object.is(x, y) || (Array.isArray(x) && Array.isArray(y) && sameItems(x, y)); + }); +} +function sameOrBothNull(a: T | null, b: T | null): boolean { + return a === b || (a !== null && b !== null && sameFields(a, b)); +} +/** + * The value this hook returned last time while the new one is the same, so + * a component that reads it keeps the same object and does not re-render. + */ +function useSettled(value: T, same: (a: T, b: T) => boolean): T { + const held = useRef(value); + if (held.current !== value && !same(held.current, value)) held.current = value; + return held.current; +} + +/** + * The account's data after a read, keeping every part that did not change + * from what is held, so a check that finds nothing new changes nothing. + */ +function settledData(held: LiveData | null, next: LiveData | null): LiveData | null { + if (!held || !next || held === next || held.user.id !== next.user.id) return next; + const user = sameFields(held.user, next.user) ? held.user : next.user; + const listed = new Map(held.communes.map((commune) => [commune.id, commune])); + const each = next.communes.map((commune) => { + const before = listed.get(commune.id); + return before && sameFields(before, commune) ? before : commune; + }); + const communes = sameItems(held.communes, each) ? held.communes : each; + const sameRecord = (a: Record, b: Record, same: (x: T, y: T) => boolean) => + a === b || (Object.keys(a).length === Object.keys(b).length && + Object.keys(b).every((key) => key in a && same(a[key], b[key]))); + const snapshots = sameRecord(held.snapshots, next.snapshots, Object.is) ? held.snapshots : next.snapshots; + const invites = sameRecord(held.invites, next.invites, (a, b) => a === b || + (a.length === b.length && a.every((invite, index) => sameFields(invite, b[index])))) ? held.invites : next.invites; + const reminderUsage = held.reminderUsage === next.reminderUsage || + JSON.stringify(held.reminderUsage) === JSON.stringify(next.reminderUsage) ? held.reminderUsage : next.reminderUsage; + const older = sameRecord(held.older, next.older, Object.is) ? held.older : next.older; + const settled: LiveData = { ...next, user, communes, snapshots, invites, reminderUsage, older }; + return sameFields(held, settled) ? held : settled; +} + +// A commune shown before its snapshot arrives: what the commune list says. +// The list's change mark and notice count are left out, so a new count +// changes the bell, not the commune's card. +function loadingPlace(commune: Commune, photo: string | undefined, error: string | undefined): LiveHub { + const { changeMark: _mark, unreadNotices: _count, ...identity } = commune; + const type = commune.kind === "trip" ? "Trip" : commune.kind === "workspace" ? "Workspace" : "Household"; + return { ...emptyHub(), ...identity, + type, + photo: photo ?? "", + activityState: error ? "error" : "loading", + status: error ? "Activity unavailable · Open to retry" : "Loading shared activity…", + switchMeta: `${type}${commune.memberCount == null ? "" : ` · ${commune.memberCount} ${commune.memberCount === 1 ? "person" : "people"}`}`, + youMeta: `${commune.role === "admin" ? "Admin" : "Member"} · ${commune.name}`, + }; +} + +// Everything built from a commune's record is kept with the record it was +// built from, until a read replaces it. A read always replaces it, even one +// that hands back an object it handed out before (as a test's fixture can). +function freshRead(snapshot: Snapshot): Snapshot { + return { ...snapshot }; +} + +// A commune's schedule as the app keeps it, built once per snapshot. +const operationLists = new WeakMap(); +function operationsOf(communeId: string, snapshot: Snapshot | undefined): OperationItem[] { + if (!snapshot?.operations) return NONE; + let list = operationLists.get(snapshot); + if (!list) { + list = snapshot.operations.map((item) => operationFromApi(item, communeId)); + operationLists.set(snapshot, list); + } + return list; +} function endedNotice(unsaved: boolean): string { return unsaved @@ -493,6 +599,7 @@ function emptyHub(): LiveHub { jumps: [], }; } +const EMPTY_HUB = emptyHub(); function addressAt(url: string) { const at = url.indexOf("?"); @@ -581,9 +688,11 @@ export function AppProvider({ children }: { children: ReactNode }) { const [mutationCount, setMutationCount] = useState(0); const [data, setData] = useState(null); const dataRef = useRef(null); + // What did not change keeps its objects, so only what shows a change re-renders. const publishData = useCallback((next: LiveData | null) => { - dataRef.current = next; - setData(next); + const settled = settledData(dataRef.current, next); + dataRef.current = settled; + setData(settled); }, []); const [prefs, setPrefs] = useState(DEFAULT_ACCOUNT); const [device, setDevice] = useState(() => readDevicePrefs() ?? DEFAULT_DEVICE); @@ -591,11 +700,16 @@ export function AppProvider({ children }: { children: ReactNode }) { const [hubId, setHubId] = useState(""); const [snapshotErrors, setSnapshotErrors] = useState>({}); const [moreOpen, setMoreOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [searchScope, setSearchScope] = useState(null); const [searchTarget, setSearchTarget] = useState(null); const clearSearchTarget = useCallback(() => setSearchTarget(null), []); - const [noteSearchQuery, setNoteSearchQuery] = useState(""); + // Search's last words and scope. Search keeps its own text while it is + // open, so typing in it re-renders nothing else; this is what it starts + // from when it opens again. + const searchMemory = useRef({ query: "", scope: null }); + const [searchOpened, setSearchOpened] = useState(0); + // The member's calendar date. Views that say what is overdue or happened + // this month are built for it, and again when it changes. + const [today, setToday] = useState(todayIso); const loadedRef = useRef(false); const refreshSequence = useRef(0); const refreshing = useRef(false); @@ -720,7 +834,7 @@ export function AppProvider({ children }: { children: ReactNode }) { if (snapshot.commune.id !== commune.id) throw new ApiError("Commune response did not match the request", 502); // A summary never replaces the open commune's record; its full read is under way. if (snapshot.detail === "summary" && viewRef.current.hubId === commune.id && dataRef.current.snapshots[commune.id]) return; - publishData({ ...dataRef.current, snapshots: { ...dataRef.current.snapshots, [commune.id]: snapshot } }); + publishData({ ...dataRef.current, snapshots: { ...dataRef.current.snapshots, [commune.id]: freshRead(snapshot) } }); }).catch((error: unknown) => { if (sequence !== refreshSequence.current || dataRef.current?.user.id !== current.user.id || revision !== (snapshotRevisions.current.get(cacheKey) ?? 0)) return; setSnapshotErrors((errors) => ({ ...errors, [commune.id]: error instanceof Error ? error.message : "Activity unavailable" })); @@ -765,6 +879,7 @@ export function AppProvider({ children }: { children: ReactNode }) { setSnapshotErrors({}); snapshotRevisions.current.clear(); setHubId(""); + searchMemory.current = { query: "", scope: null }; // Device preferences stay; the account's go with it. setPrefs(DEFAULT_ACCOUNT); if (userId) forgetAccountPrefs(userId); @@ -937,13 +1052,15 @@ export function AppProvider({ children }: { children: ReactNode }) { // Once a minute, while the tab is visible and someone has used it in the // last ten minutes, check the commune list for changes. Coming back to the // tab checks at once and asks who is signed in; using it again after a - // quiet spell checks at once. + // quiet spell checks at once. The date is looked at every minute, so + // "Overdue" and "This month" move on at midnight. useEffect(() => { if (authStatus !== "authenticated") return; const quiet = () => Date.now() - lastActivityAt.current >= 10 * 60_000; const due = (gap: number) => !refreshing.current && Date.now() - lastRefreshAt.current > gap; const onFocus = () => { if (document.visibilityState !== "visible") return; + setToday(todayIso()); lastActivityAt.current = Date.now(); if (due(15_000)) void refresh({ session: true }); }; @@ -953,6 +1070,7 @@ export function AppProvider({ children }: { children: ReactNode }) { if (returning && document.visibilityState === "visible" && due(60_000)) void refresh({ session: false }); }; const onMinute = () => { + setToday(todayIso()); if (document.visibilityState === "visible" && !quiet() && due(15_000)) void refresh({ session: false }); }; const activity = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"]; @@ -1002,23 +1120,24 @@ export function AppProvider({ children }: { children: ReactNode }) { // Each change starts from what the browser keeps, which another tab may // have changed since. A full or blocked store keeps it for this visit only. + const accountId = data?.user.id; const savePrefs = useCallback((patch: Partial) => { // An effect or handler still holding the account can run after it signed // out, or after someone else signed in here: it keeps nothing. - if (!data || dataRef.current?.user.id !== data.user.id) return; - const next = { ...(readAccountPrefs(data.user.id) ?? prefs), ...patch }; - if (!saveAccountPrefs(data.user.id, next)) { + if (!accountId || dataRef.current?.user.id !== accountId) return; + const next = { ...(readAccountPrefs(accountId) ?? prefs), ...patch }; + if (!saveAccountPrefs(accountId, next)) { console.warn("This browser could not keep a Commune preference"); } setPrefs(next); - }, [data, prefs]); + }, [accountId, prefs]); const saveDevice = useCallback((patch: Partial) => { const next = { ...(readDevicePrefs() ?? device), ...patch }; if (!saveDevicePrefs(next)) console.warn("This browser could not keep a Commune preference"); setDevice(next); }, [device]); // A change made in another tab shows here too. - const viewerId = data?.user.id; + const viewerId = accountId; useEffect(() => { const onStorage = (event: StorageEvent) => { if (event.key === null || event.key === DEVICE_KEY) setDevice(readDevicePrefs() ?? DEFAULT_DEVICE); @@ -1074,7 +1193,10 @@ export function AppProvider({ children }: { children: ReactNode }) { await post("/auth/logout-others", {}); }), [mutate]); - const session: Session | null = data ? { + // Everything below is derived from the data, and keeps its object while + // what it is built from stays the same, so a component that reads it + // re-renders only when it changes. + const session = useSettled(data ? { userId: data.user.id, name: data.user.name, email: data.user.email, @@ -1085,21 +1207,24 @@ export function AppProvider({ children }: { children: ReactNode }) { seed: "new", hubPhotos: prefs.hubPhotos, avatar: prefs.avatar, - } : null; - const places: LiveHub[] = data ? data.communes.map((commune) => { - const snapshot = data.snapshots[commune.id]; - if (snapshot) return { ...hubFromSnapshot(snapshot, data.user, prefs.hubPhotos[commune.id]), activityState: "ready" as const }; - // Render the known identity in the switcher while its content is loading. - return { ...emptyHub(), ...commune, - type: commune.kind === "trip" ? "Trip" : commune.kind === "workspace" ? "Workspace" : "Household", - photo: prefs.hubPhotos[commune.id] ?? "", - activityState: snapshotErrors[commune.id] ? "error" : "loading", - status: snapshotErrors[commune.id] ? "Activity unavailable · Open to retry" : "Loading shared activity…", - switchMeta: `${commune.kind === "trip" ? "Trip" : commune.kind === "workspace" ? "Workspace" : "Household"}${commune.memberCount == null ? "" : ` · ${commune.memberCount} ${commune.memberCount === 1 ? "person" : "people"}`}`, - youMeta: `${commune.role === "admin" ? "Admin" : "Member"} · ${commune.name}`, - }; - }) : []; - const hub = places.find((place) => place.id === chosenId) ?? places[0] ?? emptyHub(); + } : null, sameOrBothNull); + // A commune's views are built from its snapshot once (see readyPlace). + const placesHeld = useRef(NONE); + const places = useMemo(() => { + if (!data) return NONE; + const before = new Map(placesHeld.current.map((place) => [place.id, place])); + const next = data.communes.map((commune) => { + const snapshot = data.snapshots[commune.id]; + const photo = prefs.hubPhotos[commune.id]; + // Render the known identity in the switcher while its content is loading. + const place = snapshot ? readyPlace(snapshot, data.user, photo, today) : loadingPlace(commune, photo, snapshotErrors[commune.id]); + const previous = before.get(commune.id); + return previous && previous !== place && !snapshot && sameFields(previous, place) ? previous : place; + }); + return sameItems(placesHeld.current, next) ? placesHeld.current : next; + }, [data, prefs.hubPhotos, snapshotErrors, today]); + placesHeld.current = places; + const hub = places.find((place) => place.id === chosenId) ?? places[0] ?? EMPTY_HUB; const activeId = hub.id; activeRef.current = activeId; // The open window and the record it shows come from the address. @@ -1118,16 +1243,17 @@ export function AppProvider({ children }: { children: ReactNode }) { // The open commune's own screens never show a summary; they wait for its record. const snapshot = held?.detail === "summary" ? undefined : held; const older = data?.older[activeId]; - const expenseHistory = historyState(snapshot, older); + const expenseHistory = useSettled(historyState(snapshot, older), sameFields); const contentLoading = !!data?.communes.length && !overview && !snapshot; const contentError = snapshotErrors[activeId] ?? null; - const allExpenses = snapshot && data ? expenseRows(withOlderExpenses(snapshot, older), data.user.id) : []; - const expenses = allExpenses.filter((row) => !row.voidedAt); - const voidedExpenses = allExpenses.filter((row) => row.voidedAt); + const allExpenses = useMemo(() => snapshot && viewerId ? expenseRows(withOlderExpenses(snapshot, older), viewerId, today) : NONE, + [snapshot, older, viewerId, today]); + const expenses = useMemo(() => allExpenses.filter((row) => !row.voidedAt), [allExpenses]); + const voidedExpenses = useMemo(() => allExpenses.filter((row) => row.voidedAt), [allExpenses]); const selectedExpense = selectedExpenseId ? allExpenses.find((row) => row.id === selectedExpenseId) ?? null : expenses[0] ?? null; - const lastExpense = selectedExpense + const lastExpense = useSettled(selectedExpense ? { name: selectedExpense.name, amount: selectedExpense.amount, paidBy: selectedExpense.paidBy } - : null; + : null, sameOrBothNull); // A commune whose only expenses are voided, or older than the snapshot // holds, still has a ledger to show. const empty = !!session?.hasHubs && allExpenses.length === 0 && !snapshot?.expenseHistory?.olderCount; @@ -1137,37 +1263,44 @@ export function AppProvider({ children }: { children: ReactNode }) { // Until it is fetched, a linked expense older than what is loaded is on its way. const expenseRead = (windowId === "expense" || windowId === "expense-paid") && !selectedLoaded && snapshot?.expenseHistory && recordResult !== "missing" ? recordResult ?? "loading" : null; - const notes: Record = Object.fromEntries( + const notes = useMemo>(() => Object.fromEntries( (snapshot?.notes ?? []).map((note) => [note.id, note]), - ); - const operationsByHub: OperationStore = Object.fromEntries((data?.communes ?? []).map((commune) => [ - commune.id, - (data?.snapshots[commune.id]?.operations ?? []).map((item) => operationFromApi(item, commune.id)), - ])); - const operations = operationsByHub[activeId] ?? []; + ), [snapshot]); + const operationsByHub = useSettled(useMemo(() => Object.fromEntries((data?.communes ?? []).map((commune) => [ + commune.id, operationsOf(commune.id, data?.snapshots[commune.id]), + ])), [data?.communes, data?.snapshots]), sameFields); + const operations = operationsByHub[activeId] ?? NONE; const selectedOperation = operations.find((item) => item.id === operationKey) ?? null; - const funds: Fund[] = (snapshot?.funds ?? []).map((fund) => ({ + // Commands read the records on screen when they run, so they stay the same + // functions from one render to the next. + const expensesRef = useRef(expenses); + expensesRef.current = expenses; + const operationsRef = useRef(operations); + operationsRef.current = operations; + const mutationCountRef = useRef(mutationCount); + mutationCountRef.current = mutationCount; + const funds = useMemo(() => (snapshot?.funds ?? NONE).map((fund) => ({ ...fund, contributedMinor: fund.commitments.reduce((sum, commitment) => sum + commitment.contributedMinor, 0), - })); - const recurringBills = snapshot?.recurringBills ?? []; - const splitTemplates = snapshot?.splitTemplates ?? []; - const reminderTargets = snapshot?.reminderTargets ?? []; + })), [snapshot]); + const recurringBills = snapshot?.recurringBills ?? NONE; + const splitTemplates = snapshot?.splitTemplates ?? NONE; + const reminderTargets = snapshot?.reminderTargets ?? NONE; const reminderUsage = data?.reminderUsage ?? null; - const sentReminders = snapshot?.sentReminders ?? []; - const announcements = snapshot?.announcements ?? []; - const notifications = snapshot?.notifications ?? []; + const sentReminders = snapshot?.sentReminders ?? NONE; + const announcements = snapshot?.announcements ?? NONE; + const notifications = snapshot?.notifications ?? NONE; // 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 = { + const noticeCounts = useSettled>(Object.fromEntries((data?.communes ?? []).map((commune) => [commune.id, + commune.unreadNotices ?? (commune.id === activeId ? notifications.filter((notice) => !notice.readAt).length : 0)])), sameFields); + const you = useSettled({ id: data?.user.id ?? "", name: data?.user.name ?? "", email: data?.user.email ?? "", initials: data ? initialsOf(data.user.name) : "", photo: prefs.avatar, - }; + }, sameFields); // A normal mutation does not need another sign-in/list/invitations waterfall. // Refresh just its commune, and keep confirmed content visible throughout. @@ -1187,7 +1320,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const latest = dataRef.current; 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 } }); + publishData({ ...latest, communes: latest.communes.map(item => item.id === communeId ? { ...item, ...snapshot.commune } : item), snapshots: { ...latest.snapshots, [communeId]: freshRead(snapshot) } }); setSnapshotErrors(errors => { const next = { ...errors }; delete next[communeId]; return next; }); } catch (error) { const latest = dataRef.current; @@ -1389,6 +1522,7 @@ export function AppProvider({ children }: { children: ReactNode }) { }), [activeId, mutate, refreshRecords]); const recordExpense = useCallback((input: CreateExpenseInput) => mutate(async () => { + const data = dataRef.current; if (!activeId) throw new Error("Create or join a commune first"); if (!data) throw new Error("Sign in first"); const shares = input.allocations ? validateExpenseAllocation(input.amountMinor, input.allocations) : allocateExpense(input.amountMinor, input.shareUserIds, "Equal"); @@ -1404,7 +1538,7 @@ export function AppProvider({ children }: { children: ReactNode }) { }); await refreshRecords(); return result.id; - }), [activeId, data, mutate, refreshRecords]); + }), [activeId, mutate, refreshRecords]); const confirmExpenseShare = useCallback((shareId: string) => mutate(async () => { await post(`/shares/${encodeURIComponent(shareId)}/confirm`, {}); @@ -1483,18 +1617,19 @@ export function AppProvider({ children }: { children: ReactNode }) { return result; }), [mutate, refreshRecords]); const markExpensePaid = useCallback((expenseId: string) => mutate(async () => { - const expense = expenses.find((row) => row.id === expenseId); - if (!expense || expense.paidByUserId !== data?.user.id) { + const expense = expensesRef.current.find((row) => row.id === expenseId); + const viewer = dataRef.current?.user.id; + if (!expense || !viewer || expense.paidByUserId !== viewer) { throw new Error("Only the person who paid can confirm a share"); } const outstanding = expense.shares.filter((share) => - share.userId !== data.user.id && share.settledMinor < share.amountMinor); + share.userId !== viewer && share.settledMinor < share.amountMinor); if (outstanding.length !== 1) { throw new Error("Choose the member’s outstanding share to confirm"); } await post(`/shares/${encodeURIComponent(outstanding[0].id)}/confirm`, {}); await refreshRecords(); - }), [data?.user.id, expenses, mutate, refreshRecords]); + }), [mutate, refreshRecords]); const createInvite = useCallback((email: string, role: "admin" | "member") => mutate(async () => { if (!activeId) throw new Error("Choose a commune first"); @@ -1544,11 +1679,11 @@ export function AppProvider({ children }: { children: ReactNode }) { await refreshRecords(); return result.id; }), [mutate, refreshRecords]); - const updateFund = async (id: string, name: string, targetMinor: number | null) => { await workflow(`/funds/${encodeURIComponent(id)}`,{name,targetMinor},"PATCH"); }; - const archiveFund = async (id: string, archived: boolean) => { await workflow(`/funds/${encodeURIComponent(id)}/archive`,{archived}); }; + const updateFund = useCallback(async (id: string, name: string, targetMinor: number | null) => { await workflow(`/funds/${encodeURIComponent(id)}`,{name,targetMinor},"PATCH"); }, [workflow]); + const archiveFund = useCallback(async (id: string, archived: boolean) => { await workflow(`/funds/${encodeURIComponent(id)}/archive`,{archived}); }, [workflow]); // A pledge changed since the editor opened is a conflict. Load it before the // error reaches the form, so the editor can start again from what is saved. - const updatePledge = (id: string, input: PledgeChange) => mutate(async () => { + const updatePledge = useCallback((id: string, input: PledgeChange) => mutate(async () => { try { await api(`/commitments/${encodeURIComponent(id)}`, { method: "PATCH", body: JSON.stringify(input) }); } catch (error) { @@ -1556,8 +1691,8 @@ export function AppProvider({ children }: { children: ReactNode }) { throw error; } await refreshRecords(); - }); - const voidContribution = async (id: string, reason: string) => { await workflow(`/contributions/${encodeURIComponent(id)}/void`,{reason}); }; + }), [mutate, refreshRecords]); + const voidContribution = useCallback(async (id: string, reason: string) => { await workflow(`/contributions/${encodeURIComponent(id)}/void`,{reason}); }, [workflow]); // A conflict means the bill changed after this screen loaded it (someone // edited, recorded, paused or archived it). Load the latest before the // error reaches the screen, as an expense correction does. @@ -1572,23 +1707,26 @@ export function AppProvider({ children }: { children: ReactNode }) { await refreshRecords(); return result; }), [mutate, refreshRecords]); - const saveRecurringBill = async (input: RecurringBillInput) => (await recurringChange<{id:string}>(`/communes/${encodeURIComponent(activeId)}/recurring`,input)).id; - const pauseRecurringBill = (id: string, paused: boolean) => - recurringChange(`/recurring/${encodeURIComponent(id)}/pause`, { paused }); - const archiveRecurringBill = (id: string, archived: boolean) => - recurringChange(`/recurring/${encodeURIComponent(id)}/archive`, { archived }); - const recordRecurringBill = async (id: string, expectedDueOn: string) => (await workflow<{id:string}>(`/recurring/${encodeURIComponent(id)}/record`,{expectedDueOn})).id; - const recordRecurringPeriod = (id: string, expectedDueOn: string, amountMinor?: number) => - recurringChange(`/recurring/${encodeURIComponent(id)}/periods`, { expectedDueOn, amountMinor: amountMinor ?? null }); - const skipRecurringPeriod = (id: string, expectedDueOn: string, reason: string) => - recurringChange(`/recurring/${encodeURIComponent(id)}/skip`, { expectedDueOn, reason: reason.trim() }); - const saveSplitTemplate = async (input: SplitTemplateInput) => (await workflow<{id:string}>(`/communes/${encodeURIComponent(activeId)}/templates`,input)).id; - const manageMember = async (userId: string, action: "admin" | "member" | "leave" | "remove") => mutate(async () => { + const saveRecurringBill = useCallback(async (input: RecurringBillInput) => + (await recurringChange<{id:string}>(`/communes/${encodeURIComponent(activeId)}/recurring`,input)).id, [activeId, recurringChange]); + const pauseRecurringBill = useCallback((id: string, paused: boolean) => + recurringChange(`/recurring/${encodeURIComponent(id)}/pause`, { paused }), [recurringChange]); + const archiveRecurringBill = useCallback((id: string, archived: boolean) => + recurringChange(`/recurring/${encodeURIComponent(id)}/archive`, { archived }), [recurringChange]); + const recordRecurringBill = useCallback(async (id: string, expectedDueOn: string) => + (await workflow<{id:string}>(`/recurring/${encodeURIComponent(id)}/record`,{expectedDueOn})).id, [workflow]); + const recordRecurringPeriod = useCallback((id: string, expectedDueOn: string, amountMinor?: number) => + recurringChange(`/recurring/${encodeURIComponent(id)}/periods`, { expectedDueOn, amountMinor: amountMinor ?? null }), [recurringChange]); + const skipRecurringPeriod = useCallback((id: string, expectedDueOn: string, reason: string) => + recurringChange(`/recurring/${encodeURIComponent(id)}/skip`, { expectedDueOn, reason: reason.trim() }), [recurringChange]); + const saveSplitTemplate = useCallback(async (input: SplitTemplateInput) => + (await workflow<{id:string}>(`/communes/${encodeURIComponent(activeId)}/templates`,input)).id, [activeId, workflow]); + const manageMember = useCallback(async (userId: string, action: "admin" | "member" | "leave" | "remove") => mutate(async () => { await post(`/communes/${encodeURIComponent(activeId)}/members/${encodeURIComponent(userId)}`,{action}); // Its addresses are no longer yours: go Home before the list drops it. if (action === "leave") navigate(addressOf("home")); await refresh(); - }); + }), [activeId, mutate, navigate, refresh]); const sendReminder = useCallback((kind: "nudge" | "chase", sourceId: string, requestId: string) => mutate(async () => { const actorId = dataRef.current?.user.id; const raw = await post>("/reminders", { kind, sourceId, requestId }); @@ -1623,7 +1761,7 @@ export function AppProvider({ children }: { children: ReactNode }) { return result.id; }), [activeId, mutate, refreshRecords]); const updateNote = useCallback((card: NoteCard & { id: string; expectedVersion?: number }) => mutate(async () => { - const current = data?.snapshots[activeId]?.notes?.find((note) => note.id === card.id); + const current = dataRef.current?.snapshots[activeId]?.notes?.find((note) => note.id === card.id); if (!current) throw new Error("Note not found in this commune"); const { id, expectedVersion, ...payload } = card; try { @@ -1636,7 +1774,7 @@ export function AppProvider({ children }: { children: ReactNode }) { } await refreshRecords(); return id; - }), [activeId, data, mutate, refreshRecords]); + }), [activeId, mutate, refreshRecords]); const loadNoteRevision = useCallback(async (noteId: string, revisionId: string) => (await api<{ revision: NoteRevision }>(`/notes/${encodeURIComponent(noteId)}/revisions/${encodeURIComponent(revisionId)}`)).revision, []); const addOperation = useCallback((item: NewOperation, requestId: string) => mutate(async () => { @@ -1646,7 +1784,7 @@ export function AppProvider({ children }: { children: ReactNode }) { return result.id; }), [activeId, mutate, refreshRecords]); const updateOperation = useCallback((id: string, item: NewOperation, expectedVersion?: number) => mutate(async () => { - const existing = operations.find((row) => row.id === id); + const existing = operationsRef.current.find((row) => row.id === id); if (!existing) throw new Error("Schedule item not found"); try { await api(`/operations/${encodeURIComponent(id)}`, { @@ -1660,7 +1798,7 @@ export function AppProvider({ children }: { children: ReactNode }) { throw error; } await refreshRecords(); - }), [mutate, operations, refreshRecords]); + }), [mutate, refreshRecords]); // Task actions are commands: the database advances the date and rotation // and rejects a stale click, so two people cannot complete one occurrence. const occurrenceCommand = useCallback((path: string, body: unknown) => mutate(async () => { @@ -1682,31 +1820,31 @@ export function AppProvider({ children }: { children: ReactNode }) { } satisfies OperationChange; }), [mutate, refreshRecords]); const taskAction = useCallback((id: string, action: "completed" | "skipped" | "reopened", reason?: string) => { - const item = operations.find((row) => row.id === id); + const item = operationsRef.current.find((row) => row.id === id); if (!item) return Promise.reject(new Error("Schedule item not found")); return occurrenceCommand(`/operations/${encodeURIComponent(id)}/occurrences`, { action, expectedDate: item.date, today: todayIso(), requestId: newRequestId(), ...(reason?.trim() ? { reason: reason.trim() } : {}), }); - }, [occurrenceCommand, operations]); + }, [occurrenceCommand]); // The expected date and holder reject a handover made from a stale screen. const handOverOperation = useCallback((id: string, toMemberId: string, reason?: string) => { - const item = operations.find((row) => row.id === id); + const item = operationsRef.current.find((row) => row.id === id); if (!item) return Promise.reject(new Error("Schedule item not found")); return occurrenceCommand(`/operations/${encodeURIComponent(id)}/handovers`, { toMemberId, expectedDate: item.date, expectedAssigneeId: item.assigneeId ?? null, today: todayIso(), reason: reason?.trim() || null, requestId: newRequestId(), }); - }, [occurrenceCommand, operations]); + }, [occurrenceCommand]); const undoOperation = useCallback((occurrenceId: string) => occurrenceCommand(`/occurrences/${encodeURIComponent(occurrenceId)}/undo`, {}), [occurrenceCommand]); const toggleOperation = useCallback((id: string) => { - const item = operations.find((row) => row.id === id); + const item = operationsRef.current.find((row) => row.id === id); if (item?.status !== "done") return taskAction(id, "completed"); // Undo the completion when it is still the latest state; a task finished // before occurrences existed is reopened as a new recorded action. return item.undoOccurrenceId ? undoOperation(item.undoOccurrenceId) : taskAction(id, "reopened"); - }, [operations, taskAction, undoOperation]); + }, [taskAction, undoOperation]); const skipOperation = useCallback((id: string, reason?: string) => taskAction(id, "skipped", reason), [taskAction]); const removeOperation = useCallback((id: string) => mutate(async () => { await api(`/operations/${encodeURIComponent(id)}`, { method: "DELETE" }); @@ -1743,7 +1881,7 @@ export function AppProvider({ children }: { children: ReactNode }) { navigate(authPath(target)); return; } - const commune = opts?.hub && places.some((place) => place.id === opts.hub) ? opts.hub : activeId; + const commune = opts?.hub && dataRef.current?.communes.some((item) => item.id === opts.hub) ? opts.hub : activeId; // Screens that do not name a commune still show this one. if (commune !== hubId) setHubId(commune); const here = shownRef.current; @@ -1766,7 +1904,24 @@ export function AppProvider({ children }: { children: ReactNode }) { returning: Boolean(here.window || here.record), fallback: here.window && FORM_WINDOWS.has(here.window) ? "replace" : "push", }); - }, [activeId, hubId, navigate, places]); + }, [activeId, hubId, navigate]); + // The finder hands its words to Search, which starts from them even when + // it is already open. + const openFullSearch = useCallback((query: string, scope: string | null) => { + searchMemory.current = { query, scope }; + setSearchOpened((count) => count + 1); + go("search"); + }, [go]); + const recallSearch = useCallback(() => searchMemory.current, []); + const rememberSearch = useCallback((search: SearchMemory) => { searchMemory.current = search; }, []); + const clearApiError = useCallback(() => setApiError(null), []); + const closeWindow = useCallback(() => { if (!mutationCountRef.current) leaveRecord(); }, [leaveRecord]); + const setNavMode = useCallback((navMode: NavMode) => saveDevice({ navMode }), [saveDevice]); + const setTheme = useCallback((theme: Theme) => saveDevice({ theme }), [saveDevice]); + const setNoticesOn = useCallback((noticesOn: boolean) => saveDevice({ noticesOn }), [saveDevice]); + const members = data?.snapshots[activeId]?.members; + const nameOf = useCallback((userId: string | null | undefined, fallback: string, options?: { left?: boolean }) => + personName(members, userId, fallback, options), [members]); // Dialogs have their own heading; the page behind them remains the page. const isHome = screen === "home" || screen === "home-empty"; @@ -1791,25 +1946,23 @@ export function AppProvider({ children }: { children: ReactNode }) { ? hub.status : PAGE_LEDES[screen] ?? ""; - return 0, clearApiError: () => setApiError(null), refresh: fullRefresh, + const state: AppState = { + authStatus, apiError, mutationPending: mutationCount > 0, clearApiError, refresh: fullRefresh, signInWithPassword, signInWithGoogle, signUpWithPassword, verifyEmailCode, session, screen, hubId: activeId, - hub, places, snapshots: data?.snapshots ?? {}, contentLoading, contentError, emptyHub: empty, unknownCommune, window: windowId, moreOpen, + hub, places, snapshots: data?.snapshots ?? NO_SNAPSHOTS, contentLoading, contentError, emptyHub: empty, unknownCommune, window: windowId, moreOpen, navMode: device.navMode, noteKey, notes, operationKey, operationDate, operationTime, operationsByHub, operations, selectedOperation, fundKey, dark: device.theme === "dark" || (device.theme === "system" && prefersDark), theme: device.theme, noticesOn: device.noticesOn, - searchQuery, searchScope, searchTarget, noteSearchQuery, invites: data?.invites[activeId] ?? [], payMethods: prefs.payMethods, native, you, + searchTarget, searchOpened, invites: data?.invites[activeId] ?? NONE, payMethods: prefs.payMethods, native, you, lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses, expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory, funds, recurringBills, splitTemplates, expenseTemplateId, reminderTargets, reminderUsage, sentReminders, notifications, - noticeCounts, loadUnreadNotices, personName: (userId, fallback, options) => personName(data?.snapshots[activeId]?.members, userId, fallback, options), + noticeCounts, loadUnreadNotices, personName: nameOf, signOut, signOutUnconfirmed, retrySignOut: confirmSignOut, sessionNotice, go, changePassword, signOutOtherDevices, - closeWindow: () => { if (!mutationCount) leaveRecord(); }, watchTypedText, leaveAsked, answerLeave, setMoreOpen, - setNavMode: (navMode) => saveDevice({ navMode }), - setTheme: (theme) => saveDevice({ theme }), - setNoticesOn: (noticesOn) => saveDevice({ noticesOn }), - setSearchQuery, setSearchScope, clearSearchTarget, setNoteSearchQuery, savePayMethod, setHubPhoto, setAvatar, addNote, updateNote, loadNoteRevision, + closeWindow, watchTypedText, leaveAsked, answerLeave, setMoreOpen, + setNavMode, setTheme, setNoticesOn, + clearSearchTarget, openFullSearch, recallSearch, rememberSearch, savePayMethod, setHubPhoto, setAvatar, addNote, updateNote, loadNoteRevision, addOperation, updateOperation, toggleOperation, skipOperation, handOverOperation, undoOperation, removeOperation, createHub, updateCommune, recordExpense, confirmExpenseShare, correctExpense, voidExpense, recordShareReceipt, reverseShareReceipt, reportSharePayment, withdrawPaymentReport, resolvePaymentReport, @@ -1818,13 +1971,80 @@ export function AppProvider({ children }: { children: ReactNode }) { pledgeFund, recordFundContribution, sendReminder, markNotificationRead, updateFund, archiveFund, updatePledge, voidContribution, saveRecurringBill, pauseRecurringBill, archiveRecurringBill, recordRecurringBill, recordRecurringPeriod, skipRecurringPeriod, saveSplitTemplate, manageMember, pageTitle, pageLede, - }}>{children}; + }; + // Components under this one read the state from the store as they render + // (this render runs first), and are told of it once it is on screen: each + // re-renders only if something it read has changed. + const [store] = useState(() => ({ state: null, listeners: new Set() })); + store.state = state; + useLayoutEffect(() => { + store.state = state; + for (const listener of [...store.listeners]) listener(); + }); + return {children}; } +// What one component read of the state in its last render on screen. +type Reading = { keys: Set; all: boolean }; +type Tracker = { seen: AppState; reading: Reading; version: number }; + +function readingChanged(reading: Reading, before: AppState, after: AppState): boolean { + const keys = reading.all ? Object.keys(after) : reading.keys; + for (const key of keys) { + if (!Object.is(before[key as keyof AppState], after[key as keyof AppState])) return true; + } + return false; +} + +/** + * The app's state. A component re-renders when something it reads from it + * changes, and for nothing else: one that reads `hub` and `go` follows only + * those two, so typing in a filter, the minute check or a notice count in + * another commune leaves the rest of the app alone. A read in a handler or + * an effect sees the latest state. + */ export function useApp(): AppState { - const context = useContext(Ctx); - if (!context) throw new Error("useApp must be used inside AppProvider"); - return context; + const store = useContext(StoreCtx); + if (!store?.state) throw new Error("useApp must be used inside AppProvider"); + const tracker = useRef(null); + tracker.current ??= { seen: store.state, reading: { keys: new Set(), all: false }, version: 0 }; + const followed = tracker.current; + const subscribe = useCallback((onChange: () => void) => { + store.listeners.add(onChange); + return () => { store.listeners.delete(onChange); }; + }, [store]); + // A new version only when something this component read has changed. + const version = useCallback(() => { + const latest = store.state!; + if (latest !== followed.seen) { + if (readingChanged(followed.reading, followed.seen, latest)) followed.version += 1; + followed.seen = latest; + } + return followed.version; + }, [store, followed]); + useSyncExternalStore(subscribe, version, version); + // What this render reads becomes what the component follows once it is on screen. + const reading: Reading = { keys: new Set(), all: false }; + useLayoutEffect(() => { followed.reading = reading; }); + return new Proxy(store.state, { + get(_shown, key) { + if (typeof key === "string") reading.keys.add(key); + return Reflect.get(store.state!, key); + }, + has(_shown, key) { + if (typeof key === "string") reading.keys.add(key); + return Reflect.has(store.state!, key); + }, + ownKeys() { + reading.all = true; + return Reflect.ownKeys(store.state!); + }, + getOwnPropertyDescriptor(_shown, key) { + if (typeof key === "string") reading.keys.add(key); + const descriptor = Reflect.getOwnPropertyDescriptor(store.state!, key); + return descriptor && { ...descriptor, configurable: true }; + }, + }); } export type { CurrencyCode, LiveExpenseRow, LiveHub, ReminderTarget, ReminderResult, ReceiptResult, SentReminder, Notification, Fund }; diff --git a/apps/web/src/ui/ActivityLog.tsx b/apps/web/src/ui/ActivityLog.tsx index c0e6fb18..571378fd 100644 --- a/apps/web/src/ui/ActivityLog.tsx +++ b/apps/web/src/ui/ActivityLog.tsx @@ -1,4 +1,5 @@ import { useId } from "react"; +import { formatDay } from "@commune/core"; import { ArrowRightLeft, CalendarDays, Clock3, FileText, Files, HandCoins, Handshake, Megaphone, ReceiptText, Repeat2, ShoppingBag, SquareCheck, UsersRound, type LucideIcon, @@ -54,9 +55,10 @@ function relativeDay(day: string, today: string): string { } // A calendar day (YYYY-MM-DD) in words, with the year only when it is not this one. +// The short form is the date every screen uses ("5 Oct"). 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 (style === "short") return formatDay(day, today); + const options: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" }; if (day.slice(0, 4) !== today.slice(0, 4)) options.year = "numeric"; return new Intl.DateTimeFormat("en-GB", options).format(dateFromIso(day)); } diff --git a/apps/web/src/ui/ExpenseChange.tsx b/apps/web/src/ui/ExpenseChange.tsx index efe5036b..734fdd09 100644 --- a/apps/web/src/ui/ExpenseChange.tsx +++ b/apps/web/src/ui/ExpenseChange.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; -import { allocateExpense, type ExpenseAllocation, type SplitMethod } from "../../../../packages/core/src/expenseSplit"; +import { allocateExpense, type ExpenseAllocation, type SplitMethod } from "@commune/core"; import { useApp } from "../state"; import { money, type CurrencyCode, type ExpenseRevision, type ExpenseState, type LiveExpenseRow } from "../live/model"; import { moneyInputError, parseMoneyInput } from "../data/moneyInput"; diff --git a/apps/web/src/ui/QuickSearch.tsx b/apps/web/src/ui/QuickSearch.tsx index 41a25541..280e99e2 100644 --- a/apps/web/src/ui/QuickSearch.tsx +++ b/apps/web/src/ui/QuickSearch.tsx @@ -9,7 +9,7 @@ import { openSearchResult } from "./searchNavigation"; import "./QuickSearch.css"; export function QuickSearch({ onClose, initialScope }: { onClose: () => void; initialScope: string | null }) { - const { hub, places, go, setSearchQuery, setSearchScope } = useApp(); + const { hub, places, go, openFullSearch } = useApp(); const [query, setQuery] = useState(""); const [scope, setScope] = useState(initialScope); const [active, setActive] = useState(0); @@ -20,7 +20,7 @@ export function QuickSearch({ onClose, initialScope }: { onClose: () => void; in useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { document.getElementById(`${listId}-${active}`)?.scrollIntoView({ block: "nearest" }); }, [active, listId]); function fullSearch() { - setSearchQuery(query); setSearchScope(scope); onClose(); go("search"); + onClose(); openFullSearch(query, scope); } return

; diff --git a/apps/web/src/ui/SplitEditor.tsx b/apps/web/src/ui/SplitEditor.tsx index 71d9d268..bc6ac9d1 100644 --- a/apps/web/src/ui/SplitEditor.tsx +++ b/apps/web/src/ui/SplitEditor.tsx @@ -1,7 +1,7 @@ import { allocateExpense, type SplitMethod, -} from "../../../../packages/core/src/expenseSplit"; +} from "@commune/core"; import type { LiveHub } from "../live/model"; import { Avatar } from "./widgets"; import { LIMITS } from "../data/limits"; diff --git a/apps/web/src/windows/OperationsWindows.tsx b/apps/web/src/windows/OperationsWindows.tsx index 68d32e05..b74fd5b7 100644 --- a/apps/web/src/windows/OperationsWindows.tsx +++ b/apps/web/src/windows/OperationsWindows.tsx @@ -13,6 +13,7 @@ import { type OperationItem, type OperationKind, } from "../data/operations"; +import { formatDay } from "@commune/core"; import { useApp } from "../state"; import { Avatar, Pill } from "../ui/widgets"; import { cx } from "../ui/cx"; @@ -339,7 +340,6 @@ export function OperationView() { ); } -const occurrenceDay = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short" }); const OCCURRENCE_VERB: Record = { completed: "Completed", skipped: "Skipped", reopened: "Reopened", handed_over: "Handed over", }; @@ -358,11 +358,11 @@ function OccurrenceHistory({ history, undoId, nameOf, disabled, onUndo }: { {history.map((entry) => (
- {OCCURRENCE_VERB[entry.action]} by {nameOf(entry.actorId)} on {occurrenceDay.format(dateFromIso(entry.actedOn))} - {entry.action === "completed" || entry.action === "skipped" ? ` · was due ${occurrenceDay.format(dateFromIso(entry.dueOn))}${entry.assignee ? ` with ${firstNameOf(entry.assignee)}` : ""}` : ""} + {OCCURRENCE_VERB[entry.action]} by {nameOf(entry.actorId)} on {formatDay(entry.actedOn)} + {entry.action === "completed" || entry.action === "skipped" ? ` · was due ${formatDay(entry.dueOn)}${entry.assignee ? ` with ${firstNameOf(entry.assignee)}` : ""}` : ""} {entry.action === "completed" && entry.nextDate !== entry.dueOn - ? ` · next ${occurrenceDay.format(dateFromIso(entry.nextDate))}${entry.nextAssignee ? ` with ${firstNameOf(entry.nextAssignee)}` : ""}` : ""} - {entry.action === "skipped" ? ` · moved to ${occurrenceDay.format(dateFromIso(entry.nextDate))}` : ""} + ? ` · next ${formatDay(entry.nextDate)}${entry.nextAssignee ? ` with ${firstNameOf(entry.nextAssignee)}` : ""}` : ""} + {entry.action === "skipped" ? ` · moved to ${formatDay(entry.nextDate)}` : ""} {entry.action === "handed_over" && entry.nextAssignee ? ` · to ${firstNameOf(entry.nextAssignee)}` : ""} {entry.reason ? ` · “${entry.reason}”` : ""} {entry.undoneAt ? · Undone by {nameOf(entry.undoneBy ?? "")} : null} diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx index 446a9930..254f0185 100644 --- a/apps/web/src/windows/Windows.tsx +++ b/apps/web/src/windows/Windows.tsx @@ -23,8 +23,7 @@ import { NoteHistory } from "../ui/NoteHistory"; import { DiscardQuestion, useFormError, useTypedText } from "../ui/components"; import { RecoveryBoundary, WindowRecovery } from "../shell/Recovery"; import "./NoteEditor.css"; -import { allocateExpense, type SplitMethod } from "../../../../packages/core/src/expenseSplit"; -import { netBalances } from "../../../../packages/core/src/balances"; +import { allocateExpense, formatMoney, netBalances, type SplitMethod } from "@commune/core"; import "./ExpenseEditor.css"; import { SplitEditor } from "../ui/SplitEditor"; import { moneyInputError, parseMoneyInput } from "../data/moneyInput"; @@ -40,10 +39,6 @@ const NOTE_TYPES = [ ["other", "Other"], ] as const; -function formatMoney(minor: number, currency: string): string { - return new Intl.NumberFormat("en-GB", { style: "currency", currency }).format(minor / 100); -} - export function Windows() { const { window, @@ -245,7 +240,7 @@ function NoticeRow({ notice, currency, actor, onOpen }: { 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"); + const date = formatTimestampDay(notice.createdAt); return (