From 46f30f8a8d69851941ae4ed2b319e6749028d594 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:29:10 +0000 Subject: [PATCH 1/7] Use packages/core by name as @commune/core, with its own tests The web app reached the shared rules through relative paths, and one test's path pointed outside the repository. A TypeScript path alias, a Vite alias and the same alias in Vitest now resolve "@commune/core" to packages/core/src/index.ts, without a package file or a lockfile change. packages/core/tests runs with the web app's tests, in Node: the split arithmetic (moved from apps/web/tests, with checks that every split adds up to its total), reading amounts people type, and balances read from either side. The two test files that imported modules that no longer exist, and the onboarding copy that nothing imports, are removed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/data/moneyInput.ts | 2 +- apps/web/src/data/recurring.ts | 2 +- apps/web/src/live/model.ts | 2 +- apps/web/src/screens/Recurring.tsx | 2 +- apps/web/src/screens/Templates.tsx | 6 +- apps/web/src/state.tsx | 2 +- apps/web/src/ui/ExpenseChange.tsx | 2 +- apps/web/src/ui/SplitEditor.tsx | 2 +- apps/web/src/windows/Windows.tsx | 3 +- apps/web/tests/balances.test.ts | 2 +- apps/web/tests/expenseSplit.test.tsx | 29 -- apps/web/tests/money-input.test.ts | 2 +- apps/web/tests/split-editor.test.tsx | 2 +- apps/web/tsconfig.json | 5 +- apps/web/vite.config.ts | 6 + apps/web/vitest.config.ts | 10 +- packages/core/src/index.ts | 9 + packages/core/src/onboarding-copy.ts | 260 ------------- packages/core/src/proration.test.ts | 380 ------------------- packages/core/src/settlement.test.ts | 450 ----------------------- packages/core/tests/balances.test.ts | 53 +++ packages/core/tests/expenseSplit.test.ts | 62 ++++ packages/core/tests/money.test.ts | 45 +++ 23 files changed, 200 insertions(+), 1138 deletions(-) delete mode 100644 apps/web/tests/expenseSplit.test.tsx create mode 100644 packages/core/src/index.ts delete mode 100644 packages/core/src/onboarding-copy.ts delete mode 100644 packages/core/src/proration.test.ts delete mode 100644 packages/core/src/settlement.test.ts create mode 100644 packages/core/tests/balances.test.ts create mode 100644 packages/core/tests/expenseSplit.test.ts create mode 100644 packages/core/tests/money.test.ts 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/live/model.ts b/apps/web/src/live/model.ts index 4afbcf07..15555ff5 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 { 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. */ 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/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..6cefb4e3 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -10,7 +10,7 @@ import { 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, 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/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/Windows.tsx b/apps/web/src/windows/Windows.tsx index 446a9930..8e496852 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, netBalances, type SplitMethod } from "@commune/core"; import "./ExpenseEditor.css"; import { SplitEditor } from "../ui/SplitEditor"; import { moneyInputError, parseMoneyInput } from "../data/moneyInput"; diff --git a/apps/web/tests/balances.test.ts b/apps/web/tests/balances.test.ts index 48ef5328..c8065055 100644 --- a/apps/web/tests/balances.test.ts +++ b/apps/web/tests/balances.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { netBalances, overallNet, type BalanceExpense } from "../../../packages/core/src/balances"; +import { netBalances, overallNet, type BalanceExpense } from "@commune/core"; import { buildStatement } from "../src/data/statement"; import { hubFromSnapshot, type Commune, type Snapshot } from "../src/live/model"; diff --git a/apps/web/tests/expenseSplit.test.tsx b/apps/web/tests/expenseSplit.test.tsx deleted file mode 100644 index 09ed1e04..00000000 --- a/apps/web/tests/expenseSplit.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -// @vitest-environment node -import { describe, expect, it } from "vitest"; -import { allocateExpense } from "../../../packages/core/src/expenseSplit"; -describe("expense allocation in integer minor units", () => { - it("keeps every penny in equal splits", () => expect(allocateExpense(10001, ["a", "b", "c", "d"], "Equal").map(s => s.amountMinor)).toEqual([2501,2500,2500,2500])); - it("accepts exact shares and rejects a mismatched total", () => { - expect(allocateExpense(1000,["a","b"],"Exact",{a:"4.25",b:"5.75"}).map(s=>s.amountMinor)).toEqual([425,575]); - expect(()=>allocateExpense(1000,["a","b"],"Exact",{a:"4",b:"5"})).toThrow("add up"); - }); - it("allocates fractional percentages without float loss",()=>expect(allocateExpense(10000,["a","b","c"],"Percentage",{a:"33.33",b:"33.33",c:"33.34"}).map(s=>s.amountMinor)).toEqual([3333,3333,3334])); - it("supports weighted shares",()=>expect(allocateExpense(1000,["a","b"],"Shares",{a:"1",b:"3"}).map(s=>s.amountMinor)).toEqual([250,750])); - it("gives spare pennies to the same people whatever order they are listed in", () => { - const split = (total: number, ids: string[], method: "Equal" | "Shares", values: Record = {}) => - Object.fromEntries(allocateExpense(total, ids, method, values).map(share => [share.userId, share.amountMinor])); - for (const ids of [["a", "b", "c"], ["c", "a", "b"], ["b", "c", "a"]]) { - expect(split(1000, ids, "Equal")).toEqual({ a: 334, b: 333, c: 333 }); - // a and b tie for the spare penny. - expect(split(1002, ids, "Shares", { a: "1", b: "1", c: "2" })).toEqual({ a: 251, b: 250, c: 501 }); - } - // The result keeps the order the people were given in. - expect(allocateExpense(1000, ["c", "a", "b"], "Equal").map(share => share.userId)).toEqual(["c", "a", "b"]); - }); - it("rejects invalid percentages, duplicates, zero shares and negative input",()=>{ - expect(()=>allocateExpense(1000,["a","b"],"Percentage",{a:"40",b:"40"})).toThrow("100%"); - expect(()=>allocateExpense(1000,["a","a"],"Equal")).toThrow("only once"); - expect(()=>allocateExpense(1,["a","b"],"Equal")).toThrow("one penny"); - expect(()=>allocateExpense(1000,["a","b"],"Shares",{a:"-1",b:"2"})).toThrow(); - }); -}); diff --git a/apps/web/tests/money-input.test.ts b/apps/web/tests/money-input.test.ts index db672f66..f44026bc 100644 --- a/apps/web/tests/money-input.test.ts +++ b/apps/web/tests/money-input.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { moneyInputError, parseMoneyInput } from "../src/data/moneyInput"; import { LIMITS } from "../src/data/limits"; -import { allocateExpense } from "../../../packages/core/src/expenseSplit"; +import { allocateExpense } from "@commune/core"; const fallback = "Enter the amount, for example 25.00."; diff --git a/apps/web/tests/split-editor.test.tsx b/apps/web/tests/split-editor.test.tsx index 029f65f6..980a9049 100644 --- a/apps/web/tests/split-editor.test.tsx +++ b/apps/web/tests/split-editor.test.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import type { SplitMethod } from "../../../../packages/core/src/expenseSplit"; +import type { SplitMethod } from "@commune/core"; import type { LiveHub } from "../src/live/model"; import { SplitEditor } from "../src/ui/SplitEditor"; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 549b1d47..9a0c8ff5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -8,7 +8,10 @@ "allowJs": false, "resolveJsonModule": true, "strictFunctionTypes": true, - "noUnusedLocals": true + "noUnusedLocals": true, + "paths": { + "@commune/core": ["../../packages/core/src/index.ts"] + } }, "include": ["src"] } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 2bbe38fc..69f1afa1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -95,7 +95,13 @@ export function releaseId(env: Record): string { return /^[0-9a-f]{7,40}$/.test(commit) ? commit.slice(0, 12) : "local"; } +/** packages/core by name, as tsconfig.json's paths and vitest.config.ts name it. */ +export const sharedCode = [ + { find: /^@commune\/core$/, replacement: fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)) }, +]; + export default defineConfig(({ mode }) => ({ + resolve: { alias: sharedCode }, plugins: [ react(), listenIpv6Too(), diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 7e15c678..f3cb53cd 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,5 +1,13 @@ import { defineConfig } from "vitest/config"; +import { sharedCode } from "./vite.config"; export default defineConfig({ - test: { environment: "jsdom", include: ["tests/**/*.test.{ts,tsx}"], setupFiles: ["tests/setup.ts"], restoreMocks: true }, + resolve: { alias: sharedCode }, + test: { + environment: "jsdom", + // packages/core's own tests run here too, in Node (each file says so). + include: ["tests/**/*.test.{ts,tsx}", "../../packages/core/tests/**/*.test.ts"], + setupFiles: ["tests/setup.ts"], + restoreMocks: true, + }, }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..2dbe2f99 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,9 @@ +/** + * Commune's shared rules, used by name as "@commune/core" (an alias in + * apps/web's tsconfig.json, vite.config.ts and vitest.config.ts): splitting an + * expense, balances between members, and reading the amounts people type. + * Its tests live in packages/core/tests and run with the web app's tests. + */ +export * from "./balances"; +export * from "./expenseSplit"; +export * from "./money"; diff --git a/packages/core/src/onboarding-copy.ts b/packages/core/src/onboarding-copy.ts deleted file mode 100644 index a36935a9..00000000 --- a/packages/core/src/onboarding-copy.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Onboarding contextual-sell copy. Keyed on `${type}-${subtype}` for type+subtype - * specificity, with a generic fallback per type and a final fallback for unknown - * combinations. Used by mobile and web onboarding screen 8 ("Here's how Commune - * works for you"). - */ - -import { GroupType } from '@commune/types'; - -export interface ContextualSellCopy { - /** Tag shown above the headline, e.g. "For flatmates · in a flat" */ - tag: string; - /** Three numbered selling points, each title + caption */ - points: Array<{ title: string; caption: string }>; - /** Optional reassurance line under the points */ - reassurance?: string; -} - -export interface SubtypeOption { - value: string; - label: string; - caption: string; -} - -/** - * Subtype options shown on screen 7. Empty array means the type has no subtypes - * and screen 7 should be skipped automatically. - */ -export const SUBTYPE_OPTIONS_BY_TYPE: Record = { - [GroupType.HOME]: [ - { value: 'flat', label: 'Flat or apartment', caption: 'Rent + utilities split monthly' }, - { value: 'house', label: 'House', caption: 'Council tax, garden, repairs — bigger ticket items' }, - { value: 'student', label: 'Student halls', caption: 'Term-based rent, group orders, settle on the 30th' }, - { value: 'other', label: 'Other living arrangement', caption: "We'll keep things general" }, - ], - [GroupType.COUPLE]: [ - { value: 'cohabiting', label: 'Married or cohabiting', caption: 'Share day-to-day finances' }, - { value: 'long_distance', label: 'Long-distance', caption: 'Multi-currency, settle when you visit' }, - { value: 'other', label: 'Other', caption: "We'll keep things flexible" }, - ], - [GroupType.TRIP]: [ - { value: 'holiday', label: 'Holiday', caption: 'A getaway with friends or family' }, - { value: 'business', label: 'Business trip', caption: 'Reimbursable expenses, per-diem tracking' }, - { value: 'roadtrip', label: 'Roadtrip', caption: 'Petrol pot, per-driver split' }, - { value: 'other', label: 'Other trip', caption: 'Pick this if none fit' }, - ], - [GroupType.PROJECT]: [ - { value: 'vendors', label: 'Vendors', caption: 'Track invoices and approval thresholds' }, - { value: 'project_team', label: 'Project team', caption: 'A delivery with a defined end date' }, - { value: 'agency', label: 'Agency', caption: 'Per-client groups, pass-through expenses' }, - { value: 'other', label: 'Other workspace', caption: "We'll keep things flexible" }, - ], - [GroupType.OTHER]: [], -}; - -/** - * Contextual sell copy. Use the type+subtype key when both are known; the bare - * type key as fallback. - */ -const COPY: Record = { - // Flatmates - [`${GroupType.HOME}-flat`]: { - tag: 'For flatmates · in a flat', - points: [ - { title: 'Rent posts itself every month', caption: 'Add it once as a recurring expense. Auto-splits between everyone, on rent day.' }, - { title: 'Bills + groceries on the go', caption: "Snap a receipt or just type the amount. Pick who paid and who's in." }, - { title: 'Settle up monthly — one tap each', caption: 'Smart-suggest reduces 6 messy payments to 2 clean ones. Pay through Revolut, Monzo, PayPal directly.' }, - ], - reassurance: 'Average flat saves ~3 hours/month of "who paid what" admin.', - }, - [`${GroupType.HOME}-house`]: { - tag: 'For housemates', - points: [ - { title: 'Council tax + utilities split', caption: 'Set them up once as recurring. Bigger bills handled the same way as rent.' }, - { title: 'Repairs, garden, one-offs', caption: 'Add ad-hoc costs in seconds. Split equally or by who used what.' }, - { title: 'Settle on bill cycle', caption: 'Smart-suggest minimises the number of payments. Free P2P on most rails.' }, - ], - }, - [`${GroupType.HOME}-student`]: { - tag: 'For student halls', - points: [ - { title: 'Term-based rent + bills', caption: 'Set the cycle to match your term. Auto-splits between everyone every period.' }, - { title: 'Group orders + supplies', caption: 'Tesco runs, ramen night, that one big shop — log them in 10 seconds.' }, - { title: 'Settle term-end with one tap', caption: 'Smart-suggest reduces N payments to ~2 each. No spreadsheets.' }, - ], - }, - [`${GroupType.HOME}-other`]: { - tag: 'For your shared home', - points: [ - { title: 'Recurring bills auto-split', caption: 'Set them up once and Commune handles the rest.' }, - { title: 'Bills + groceries on the go', caption: 'Add anything in seconds. Snap a receipt or type the amount.' }, - { title: 'Settle up cleanly', caption: 'Smart-suggest minimises payments. Pay through your wallet of choice.' }, - ], - }, - - // Couple - [`${GroupType.COUPLE}-cohabiting`]: { - tag: 'For couples · living together', - points: [ - { title: 'Joint bills auto-split', caption: 'Rent, utilities, groceries — set them up once and forget.' }, - { title: 'Personal vs shared', caption: 'Separate what you split from what each of you pays alone.' }, - { title: 'See your monthly balance', caption: 'Who paid more this month? Who owes whom? One number to check.' }, - ], - }, - [`${GroupType.COUPLE}-long_distance`]: { - tag: 'For long-distance partners', - points: [ - { title: 'Multi-currency awareness', caption: 'Add expenses in your local currency. Commune handles the conversion at log time.' }, - { title: 'Asynchronous logging', caption: 'Add what you spent when you can. Your partner sees it on their next open.' }, - { title: 'Settle when you visit', caption: 'Smart-suggest gives you one clean transaction next time you see each other.' }, - ], - }, - [`${GroupType.COUPLE}-other`]: { - tag: 'For your shared finances', - points: [ - { title: 'Joint bills auto-split', caption: 'Set up recurring expenses once. Commune does the rest.' }, - { title: 'Personal vs shared', caption: 'Separate joint costs from individual ones cleanly.' }, - { title: 'Settle up easily', caption: 'Pay through your wallet of choice. Free P2P on most rails.' }, - ], - }, - - // Travel - [`${GroupType.TRIP}-holiday`]: { - tag: 'For your holiday', - points: [ - { title: 'Quick add on the go', caption: 'Type the amount, pick who paid, done. Commune fills in the rest.' }, - { title: 'Currency awareness', caption: 'Add expenses in any currency. Auto-converts at log time.' }, - { title: 'Auto-settle on return', caption: 'Smart-suggest minimises payments. Close the trip and archive.' }, - ], - }, - [`${GroupType.TRIP}-business`]: { - tag: 'For your business trip', - points: [ - { title: 'Receipt capture', caption: 'Snap every receipt — they stay attached to the expense for reimbursement.' }, - { title: 'Per-diem tracking', caption: 'Set a daily allowance, see how the trip is tracking.' }, - { title: 'Reimbursement export', caption: 'Export a clean spreadsheet at the end. Email or save.' }, - ], - }, - [`${GroupType.TRIP}-roadtrip`]: { - tag: 'For your roadtrip', - points: [ - { title: 'Petrol pot', caption: 'Track who filled up where. Equal split or by-driver, your call.' }, - { title: 'Per-person spend', caption: 'See how much each person has shouldered without doing maths.' }, - { title: 'Settle at the end', caption: 'One clean transaction each. Free P2P on most rails.' }, - ], - }, - [`${GroupType.TRIP}-other`]: { - tag: 'For your trip', - points: [ - { title: 'Add expenses fast', caption: 'Receipts, transport, food — type the amount, pick who paid.' }, - { title: 'Auto currency conversion', caption: 'Log in any currency. Commune handles the rest.' }, - { title: 'Settle when you get back', caption: 'Smart-suggest minimises payments. Archive the trip when done.' }, - ], - }, - - // Workspace / Project - [`${GroupType.PROJECT}-vendors`]: { - tag: 'For vendor management', - points: [ - { title: 'Vendor + invoice fields', caption: 'Every expense captures vendor, invoice number, invoice date, payment due.' }, - { title: 'Approval thresholds', caption: 'Set a threshold above which expenses need explicit approval before settle.' }, - { title: 'Audit trail', caption: 'Every change logged. Export at any time for accounting.' }, - ], - }, - [`${GroupType.PROJECT}-project_team`]: { - tag: 'For project teams', - points: [ - { title: 'Per-project ledger', caption: 'Keep delivery costs separate from your other groups.' }, - { title: 'Time-bounded', caption: 'Set a project end date. Auto-archive on close.' }, - { title: 'Close + report on delivery', caption: 'Export a clean expense report when the project wraps.' }, - ], - }, - [`${GroupType.PROJECT}-agency`]: { - tag: 'For agencies', - points: [ - { title: 'Per-client groups', caption: 'Each client is its own group. Keep ledgers clean.' }, - { title: 'Pass-through expenses', caption: 'Track what you spent on the client vs what you billed back.' }, - { title: 'Markup tracking', caption: 'See your margin per client. Export for invoicing.' }, - ], - }, - [`${GroupType.PROJECT}-other`]: { - tag: 'For your workspace', - points: [ - { title: 'Vendor + invoice fields', caption: 'Capture vendor, invoice number, dates on every expense.' }, - { title: 'Approval thresholds', caption: 'Big-ticket items need approval before settle.' }, - { title: 'Audit trail', caption: 'Every change logged. Export at any time.' }, - ], - }, - - // Other - [`${GroupType.OTHER}-`]: { - tag: 'For your group', - points: [ - { title: 'Split anything fairly', caption: 'Equal, percentages, fixed amounts, or one person paid. Commune does the maths.' }, - { title: 'Add receipts on the go', caption: 'Snap or type. Pick who paid and who shares.' }, - { title: 'Settle in one tap', caption: 'Pay through Revolut, Monzo, PayPal directly. Free P2P on most rails.' }, - ], - }, -}; - -/** - * Generic per-type fallback used when the subtype copy isn't found. - */ -const FALLBACK_BY_TYPE: Record = { - [GroupType.HOME]: COPY[`${GroupType.HOME}-other`]!, - [GroupType.COUPLE]: COPY[`${GroupType.COUPLE}-other`]!, - [GroupType.TRIP]: COPY[`${GroupType.TRIP}-other`]!, - [GroupType.PROJECT]: COPY[`${GroupType.PROJECT}-other`]!, - [GroupType.OTHER]: COPY[`${GroupType.OTHER}-`]!, -}; - -/** - * Resolve contextual-sell copy for a given type + subtype combination. - * Falls back to a generic per-type message if the subtype isn't recognised, - * or to the OTHER copy if the type itself isn't one of the known group types. - */ -export function getContextualSellCopy( - type: string | null | undefined, - subtype: string | null | undefined, -): ContextualSellCopy { - const t = (type ?? GroupType.OTHER) as string; - if (subtype) { - const exact = COPY[`${t}-${subtype}`]; - if (exact) return exact; - } - return FALLBACK_BY_TYPE[t] ?? COPY[`${GroupType.OTHER}-`]!; -} - -/** - * Acquisition source values stored on `profiles.acquisition_source`. - * Frontend constrains to this list; the column is free-text for forward compat. - */ -export const ACQUISITION_SOURCES = [ - 'tiktok', - 'instagram', - 'search', - 'reddit', - 'friend', - 'app_store', - 'youtube', - 'other', -] as const; - -export type AcquisitionSource = (typeof ACQUISITION_SOURCES)[number]; - -export interface AcquisitionSourceOption { - value: AcquisitionSource; - label: string; -} - -export const ACQUISITION_SOURCE_OPTIONS: AcquisitionSourceOption[] = [ - { value: 'tiktok', label: 'TikTok' }, - { value: 'instagram', label: 'Instagram' }, - { value: 'search', label: 'Search (Google)' }, - { value: 'reddit', label: 'Reddit' }, - { value: 'friend', label: 'Friend or family' }, - { value: 'app_store', label: 'App Store / Play Store' }, - { value: 'youtube', label: 'YouTube' }, - { value: 'other', label: 'Other' }, -]; diff --git a/packages/core/src/proration.test.ts b/packages/core/src/proration.test.ts deleted file mode 100644 index 74b225f8..00000000 --- a/packages/core/src/proration.test.ts +++ /dev/null @@ -1,380 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - calculateDaysPresent, - calculateProration, - needsProration, - getProrationInfo, -} from './proration'; - -// ─── Small seeded LCG for deterministic property-style tests ───────────────── -function lcg(seed: number): () => number { - let state = seed >>> 0; - return () => { - state = (Math.imul(state, 1664525) + 1013904223) >>> 0; - return state / 0x1_0000_0000; - }; -} - -// ─── calculateDaysPresent ──────────────────────────────────────────────────── - -describe('calculateDaysPresent', () => { - it('returns total days for a member present through a full 30-day period', () => { - // April has 30 days. Period is [2026-04-01, 2026-05-01) = 30 days. - expect( - calculateDaysPresent(null, null, '2026-04-01', '2026-05-01'), - ).toBe(30); - }); - - it('returns zero when period is degenerate (start >= end)', () => { - expect( - calculateDaysPresent(null, null, '2026-04-01', '2026-04-01'), - ).toBe(0); - expect( - calculateDaysPresent(null, null, '2026-04-05', '2026-04-01'), - ).toBe(0); - }); - - it('handles a member joining mid-cycle (joined on day 16 of a 30-day month)', () => { - // Joined 2026-04-16, period [2026-04-01, 2026-05-01) - // Days present = [2026-04-16, 2026-05-01) = 15 days - expect( - calculateDaysPresent('2026-04-16', null, '2026-04-01', '2026-05-01'), - ).toBe(15); - }); - - it('handles a member leaving mid-cycle', () => { - // Left 2026-04-16, period [2026-04-01, 2026-05-01) - // Days present = [2026-04-01, 2026-04-16) = 15 days - expect( - calculateDaysPresent(null, '2026-04-16', '2026-04-01', '2026-05-01'), - ).toBe(15); - }); - - it('cycle boundary: joined on day 1 (== periodStart) → full period', () => { - expect( - calculateDaysPresent('2026-04-01', null, '2026-04-01', '2026-05-01'), - ).toBe(30); - }); - - it('cycle boundary: left on periodEnd → full period', () => { - // effectiveUntil === periodEnd, which is exclusive — member is present - // through the final day, so expect 30 days. - expect( - calculateDaysPresent(null, '2026-05-01', '2026-04-01', '2026-05-01'), - ).toBe(30); - }); - - it('cycle boundary: left on periodStart → zero days', () => { - expect( - calculateDaysPresent(null, '2026-04-01', '2026-04-01', '2026-05-01'), - ).toBe(0); - }); - - it('member joined after the period entirely → zero days', () => { - expect( - calculateDaysPresent('2026-06-01', null, '2026-04-01', '2026-05-01'), - ).toBe(0); - }); - - it('member left before the period entirely → zero days', () => { - expect( - calculateDaysPresent(null, '2026-03-01', '2026-04-01', '2026-05-01'), - ).toBe(0); - }); - - it('clamps effectiveFrom earlier than periodStart', () => { - expect( - calculateDaysPresent('2025-01-01', null, '2026-04-01', '2026-05-01'), - ).toBe(30); - }); - - it('clamps effectiveUntil later than periodEnd', () => { - expect( - calculateDaysPresent(null, '2030-01-01', '2026-04-01', '2026-05-01'), - ).toBe(30); - }); - - it('leap-year February 2024 has 29 days', () => { - expect( - calculateDaysPresent(null, null, '2024-02-01', '2024-03-01'), - ).toBe(29); - }); - - it('non-leap February 2026 has 28 days', () => { - expect( - calculateDaysPresent(null, null, '2026-02-01', '2026-03-01'), - ).toBe(28); - }); - - it('31-day month (January) has 31 days', () => { - expect( - calculateDaysPresent(null, null, '2026-01-01', '2026-02-01'), - ).toBe(31); - }); - - it('member present for 14 of 28 days in February gives 14', () => { - expect( - calculateDaysPresent( - '2026-02-15', - null, - '2026-02-01', - '2026-03-01', - ), - ).toBe(14); - }); -}); - -// ─── calculateProration ────────────────────────────────────────────────────── - -describe('calculateProration', () => { - it('returns full share for a full-period member', () => { - expect( - calculateProration(null, null, '2026-04-01', '2026-05-01', 100), - ).toBe(100); - }); - - it('returns zero when member had no presence in the period', () => { - expect( - calculateProration( - '2026-06-01', - null, - '2026-04-01', - '2026-05-01', - 100, - ), - ).toBe(0); - }); - - it('returns zero when the period is degenerate', () => { - expect( - calculateProration(null, null, '2026-04-01', '2026-04-01', 100), - ).toBe(0); - }); - - it('prorates a half-period join correctly', () => { - // 15 of 30 days → half the share - expect( - calculateProration( - '2026-04-16', - null, - '2026-04-01', - '2026-05-01', - 100, - ), - ).toBe(50); - }); - - it('prorates a half-period departure correctly', () => { - expect( - calculateProration( - null, - '2026-04-16', - '2026-04-01', - '2026-05-01', - 100, - ), - ).toBe(50); - }); - - it('rounds to 2 decimal places using banker-ish rounding', () => { - // 10 / 30 * 100 = 33.3333... → rounds to 33.33 - expect( - calculateProration( - '2026-04-21', - null, - '2026-04-01', - '2026-05-01', - 100, - ), - ).toBe(33.33); - }); - - it('handles leap-year February correctly', () => { - // 2024-02-15 → 2024-03-01 is 15 days of a 29-day month - // 15/29 * 100 = 51.724... → 51.72 - expect( - calculateProration( - '2024-02-15', - null, - '2024-02-01', - '2024-03-01', - 100, - ), - ).toBe(51.72); - }); - - it('handles non-leap February correctly (14/28 = 50)', () => { - expect( - calculateProration( - '2026-02-15', - null, - '2026-02-01', - '2026-03-01', - 100, - ), - ).toBe(50); - }); - - it('sum of active-member prorated shares equals sum of per-day billings (±1 cent)', () => { - // Simulate 3 members in a 30-day April cycle, full share = 60. - // m1: full period - // m2: joined on day 11 (20 days) - // m3: left on day 21 (20 days) - // Expected per-member (rounded to 2dp): 60, 40, 40 → total 140. - const full = 60; - const m1 = calculateProration( - null, - null, - '2026-04-01', - '2026-05-01', - full, - ); - const m2 = calculateProration( - '2026-04-11', - null, - '2026-04-01', - '2026-05-01', - full, - ); - const m3 = calculateProration( - null, - '2026-04-21', - '2026-04-01', - '2026-05-01', - full, - ); - expect(m1).toBe(60); - expect(m2).toBe(40); - expect(m3).toBe(40); - expect(Math.round((m1 + m2 + m3) * 100) / 100).toBe(140); - }); - - it('property: prorated ≤ fullShare and prorated ≥ 0 for any random period', () => { - const rand = lcg(0xfeed); - for (let trial = 0; trial < 40; trial++) { - // Period of 28..31 days - const year = 2024 + Math.floor(rand() * 6); - const month = 1 + Math.floor(rand() * 12); - const pStart = `${year}-${String(month).padStart(2, '0')}-01`; - const nextMonth = month === 12 ? 1 : month + 1; - const nextYear = month === 12 ? year + 1 : year; - const pEnd = `${nextYear}-${String(nextMonth).padStart(2, '0')}-01`; - - const fullShare = Math.round(rand() * 50000) / 100; // 0..500.00 - const joinDay = 1 + Math.floor(rand() * 28); - const effectiveFrom = `${year}-${String(month).padStart(2, '0')}-${String(joinDay).padStart(2, '0')}`; - - const prorated = calculateProration( - effectiveFrom, - null, - pStart, - pEnd, - fullShare, - ); - expect(prorated).toBeGreaterThanOrEqual(0); - expect(prorated).toBeLessThanOrEqual(fullShare + 0.01); - } - }); - - it('property: prorated amount in cents equals round(fullCents * days / total)', () => { - // 23 of 31 days, full share 123.45 - // cents: 12345 * 23 / 31 = 9159.19... → round to 9159 → 91.59 - expect( - calculateProration( - '2026-01-09', - null, - '2026-01-01', - '2026-02-01', - 123.45, - ), - ).toBe(91.59); - }); -}); - -// ─── needsProration ────────────────────────────────────────────────────────── - -describe('needsProration', () => { - it('full-period membership does not need proration', () => { - expect( - needsProration(null, null, '2026-04-01', '2026-05-01'), - ).toBe(false); - }); - - it('joined before period and left after period does not need proration', () => { - expect( - needsProration( - '2020-01-01', - '2030-01-01', - '2026-04-01', - '2026-05-01', - ), - ).toBe(false); - }); - - it('joined exactly on periodStart does not need proration', () => { - expect( - needsProration('2026-04-01', null, '2026-04-01', '2026-05-01'), - ).toBe(false); - }); - - it('joined mid-period needs proration', () => { - expect( - needsProration('2026-04-16', null, '2026-04-01', '2026-05-01'), - ).toBe(true); - }); - - it('left mid-period needs proration', () => { - expect( - needsProration(null, '2026-04-16', '2026-04-01', '2026-05-01'), - ).toBe(true); - }); - - it('member not present at all during period needs proration (zero share)', () => { - expect( - needsProration('2026-06-01', null, '2026-04-01', '2026-05-01'), - ).toBe(true); - }); - - it('left exactly on periodEnd does not need proration', () => { - // periodEnd is exclusive, so leaving on that day == staying through the full period - expect( - needsProration(null, '2026-05-01', '2026-04-01', '2026-05-01'), - ).toBe(false); - }); -}); - -// ─── getProrationInfo ──────────────────────────────────────────────────────── - -describe('getProrationInfo', () => { - it('returns null when proration is not needed', () => { - expect( - getProrationInfo(null, null, '2026-04-01', '2026-05-01'), - ).toBeNull(); - }); - - it('returns days/total/ratio for a mid-cycle joiner', () => { - const info = getProrationInfo( - '2026-04-16', - null, - '2026-04-01', - '2026-05-01', - ); - expect(info).not.toBeNull(); - expect(info!.daysPresent).toBe(15); - expect(info!.totalDays).toBe(30); - expect(info!.ratio).toBe(0.5); - }); - - it('returns ratio=0 for a member who was never present in the period', () => { - const info = getProrationInfo( - '2026-06-01', - null, - '2026-04-01', - '2026-05-01', - ); - expect(info).not.toBeNull(); - expect(info!.daysPresent).toBe(0); - expect(info!.totalDays).toBe(30); - expect(info!.ratio).toBe(0); - }); -}); diff --git a/packages/core/src/settlement.test.ts b/packages/core/src/settlement.test.ts deleted file mode 100644 index 04ebf282..00000000 --- a/packages/core/src/settlement.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { LinkedPair } from '@commune/types'; -import { - calculateNetBalances, - simplifyDebts, - calculateSettlement, - calculateSettlementWithCouples, - mergeLinkedBalances, - type NetBalance, -} from './settlement'; - -// ─── Small seeded LCG for deterministic property-style tests ───────────────── -// Numerical Recipes constants. Good enough for non-cryptographic randomness. -function lcg(seed: number): () => number { - let state = seed >>> 0; - return () => { - state = (Math.imul(state, 1664525) + 1013904223) >>> 0; - return state / 0x1_0000_0000; - }; -} - -function round2(n: number): number { - return Number(n.toFixed(2)); -} - -function sumCents(nums: number[]): number { - return nums.reduce((s, n) => s + Math.round(n * 100), 0); -} - -// ─── calculateNetBalances ──────────────────────────────────────────────────── - -describe('calculateNetBalances', () => { - it('returns empty when no expenses', () => { - expect(calculateNetBalances([])).toEqual([]); - }); - - it('excludes users whose balance nets to zero', () => { - // Alice pays 30, split equally between Alice/Bob/Charlie - // then Bob pays 30 split the same way, etc. — everyone nets 0 - const balances = calculateNetBalances([ - { - payerId: 'alice', - participants: [ - { userId: 'alice', shareAmount: 10 }, - { userId: 'bob', shareAmount: 10 }, - { userId: 'charlie', shareAmount: 10 }, - ], - }, - { - payerId: 'bob', - participants: [ - { userId: 'alice', shareAmount: 10 }, - { userId: 'bob', shareAmount: 10 }, - { userId: 'charlie', shareAmount: 10 }, - ], - }, - { - payerId: 'charlie', - participants: [ - { userId: 'alice', shareAmount: 10 }, - { userId: 'bob', shareAmount: 10 }, - { userId: 'charlie', shareAmount: 10 }, - ], - }, - ]); - expect(balances).toEqual([]); - }); - - it('credits payer and debits participants correctly', () => { - // Alice pays 30; Alice/Bob/Charlie each owe 10 - // Alice nets +20 (paid 30, owes 10); Bob nets -10; Charlie nets -10 - const balances = calculateNetBalances([ - { - payerId: 'alice', - participants: [ - { userId: 'alice', shareAmount: 10 }, - { userId: 'bob', shareAmount: 10 }, - { userId: 'charlie', shareAmount: 10 }, - ], - }, - ]); - const byUser = Object.fromEntries(balances.map((b) => [b.userId, b.amount])); - expect(byUser.alice).toBe(20); - expect(byUser.bob).toBe(-10); - expect(byUser.charlie).toBe(-10); - }); - - it('sum of net balances is zero (conservation of money)', () => { - const balances = calculateNetBalances([ - { - payerId: 'a', - participants: [ - { userId: 'a', shareAmount: 33.34 }, - { userId: 'b', shareAmount: 33.33 }, - { userId: 'c', shareAmount: 33.33 }, - ], - }, - { - payerId: 'b', - participants: [ - { userId: 'a', shareAmount: 5 }, - { userId: 'b', shareAmount: 5 }, - ], - }, - ]); - expect(sumCents(balances.map((b) => b.amount))).toBe(0); - }); -}); - -// ─── simplifyDebts ─────────────────────────────────────────────────────────── - -describe('simplifyDebts', () => { - it('returns no transfers when balances are empty', () => { - expect(simplifyDebts([])).toEqual([]); - }); - - it('returns no transfers when there is only one member', () => { - expect(simplifyDebts([{ userId: 'a', amount: 50 }])).toEqual([]); - }); - - it('handles a single creditor with a single debtor', () => { - const txns = simplifyDebts([ - { userId: 'a', amount: 50 }, - { userId: 'b', amount: -50 }, - ]); - expect(txns).toEqual([{ fromUserId: 'b', toUserId: 'a', amount: 50 }]); - }); - - it('handles multiple creditors and debtors with a greedy match', () => { - // a owes 100, b owes 40 ; c is owed 90, d is owed 50 - const txns = simplifyDebts([ - { userId: 'a', amount: -100 }, - { userId: 'b', amount: -40 }, - { userId: 'c', amount: 90 }, - { userId: 'd', amount: 50 }, - ]); - - // Sum of transfer amounts must equal sum of |balances| / 2 - const absSum = sumCents([100, 40, 90, 50]) / 2; - expect(sumCents(txns.map((t) => t.amount))).toBe(absSum); - - // Transfers ≤ n - 1 (classic debt-minimization upper bound) - expect(txns.length).toBeLessThanOrEqual(3); - - // No phantom zero rows - for (const t of txns) { - expect(t.amount).toBeGreaterThan(0); - } - - // Every transfer is from a true debtor to a true creditor - for (const t of txns) { - expect(['a', 'b']).toContain(t.fromUserId); - expect(['c', 'd']).toContain(t.toUserId); - } - }); - - it('produces no zero-amount phantom rows when balances cancel exactly', () => { - const txns = simplifyDebts([ - { userId: 'a', amount: 25 }, - { userId: 'b', amount: 25 }, - { userId: 'c', amount: -25 }, - { userId: 'd', amount: -25 }, - ]); - expect(txns.length).toBe(2); - for (const t of txns) { - expect(t.amount).toBe(25); - } - }); - - it('skips zero-balance members entirely (no self-settlement)', () => { - // 'zero' shouldn't appear anywhere in the transactions - const txns = simplifyDebts([ - { userId: 'zero', amount: 0 }, - { userId: 'creditor', amount: 10 }, - { userId: 'debtor', amount: -10 }, - ]); - for (const t of txns) { - expect(t.fromUserId).not.toBe('zero'); - expect(t.toUserId).not.toBe('zero'); - expect(t.fromUserId).not.toBe(t.toUserId); - } - expect(txns).toHaveLength(1); - }); - - it('never produces a self-transfer (fromUserId === toUserId)', () => { - const txns = simplifyDebts([ - { userId: 'a', amount: 30 }, - { userId: 'b', amount: -20 }, - { userId: 'c', amount: -10 }, - ]); - for (const t of txns) { - expect(t.fromUserId).not.toBe(t.toUserId); - } - }); - - it('is deterministic: same input produces the same output', () => { - const input: NetBalance[] = [ - { userId: 'a', amount: 17.5 }, - { userId: 'b', amount: -12.25 }, - { userId: 'c', amount: -5.25 }, - { userId: 'd', amount: 33.33 }, - { userId: 'e', amount: -33.33 }, - ]; - const first = simplifyDebts([...input]); - const second = simplifyDebts([...input]); - expect(second).toEqual(first); - }); - - it('transfer count ≤ n - 1 across many random balance vectors', () => { - const rand = lcg(0xc0ffee); - for (let trial = 0; trial < 20; trial++) { - const n = 3 + Math.floor(rand() * 8); // 3..10 members - const cents: number[] = []; - let running = 0; - for (let i = 0; i < n - 1; i++) { - // random in [-5000, 5000] cents - const c = Math.floor(rand() * 10001) - 5000; - cents.push(c); - running += c; - } - // final member absorbs remainder so the total sums to zero - cents.push(-running); - const balances: NetBalance[] = cents - .map((c, i) => ({ userId: `u${i}`, amount: round2(c / 100) })) - .filter((b) => b.amount !== 0); - - const txns = simplifyDebts(balances); - expect(txns.length).toBeLessThanOrEqual(Math.max(0, balances.length - 1)); - - const absCents = sumCents(balances.map((b) => Math.abs(b.amount))); - // Transfers equal |balances|/2 exactly (all ledgers sum to zero) - expect(sumCents(txns.map((t) => t.amount))).toBe(absCents / 2); - - for (const t of txns) { - expect(t.amount).toBeGreaterThan(0); - expect(t.fromUserId).not.toBe(t.toUserId); - } - } - }); - - it('handles rounding with 3-way fractional-penny splits', () => { - // a paid 10.00 split 3 ways → 3.34 / 3.33 / 3.33 - // nets: a = +6.66, b = -3.33, c = -3.33 - const txns = simplifyDebts([ - { userId: 'a', amount: 6.66 }, - { userId: 'b', amount: -3.33 }, - { userId: 'c', amount: -3.33 }, - ]); - expect(sumCents(txns.map((t) => t.amount))).toBe(666); - }); -}); - -// ─── calculateSettlement ───────────────────────────────────────────────────── - -describe('calculateSettlement', () => { - it('returns empty isSettled=true when there are no expenses', () => { - const result = calculateSettlement([], new Map()); - expect(result).toEqual({ - transactions: [], - transactionCount: 0, - isSettled: true, - }); - }); - - it('returns isSettled=true when all balances cancel', () => { - // Everyone pays their own share of something split equally - const result = calculateSettlement( - [ - { - payerId: 'a', - participants: [{ userId: 'a', shareAmount: 10 }], - }, - ], - new Map([['a', 'Alice']]), - ); - expect(result.isSettled).toBe(true); - expect(result.transactions).toEqual([]); - }); - - it('attaches display names', () => { - const result = calculateSettlement( - [ - { - payerId: 'a', - participants: [ - { userId: 'a', shareAmount: 10 }, - { userId: 'b', shareAmount: 10 }, - ], - }, - ], - new Map([ - ['a', 'Alice'], - ['b', 'Bob'], - ]), - ); - expect(result.transactionCount).toBe(1); - expect(result.transactions[0]).toMatchObject({ - fromUserId: 'b', - toUserId: 'a', - amount: 10, - fromUserName: 'Bob', - toUserName: 'Alice', - }); - }); - - it('falls back to "Unknown" for missing names', () => { - const result = calculateSettlement( - [ - { - payerId: 'ghost', - participants: [ - { userId: 'ghost', shareAmount: 5 }, - { userId: 'real', shareAmount: 5 }, - ], - }, - ], - new Map([['real', 'Real']]), - ); - expect(result.transactions[0]!.toUserName).toBe('Unknown'); - expect(result.transactions[0]!.fromUserName).toBe('Real'); - }); - - it('produces a self-skip: a user who pays only for themselves has no transfer', () => { - // Alice pays 100, only Alice participates. Net is 0. No transfers. - const result = calculateSettlement( - [ - { - payerId: 'alice', - participants: [{ userId: 'alice', shareAmount: 100 }], - }, - ], - new Map([['alice', 'Alice']]), - ); - expect(result.transactions).toEqual([]); - expect(result.isSettled).toBe(true); - }); -}); - -// ─── mergeLinkedBalances ───────────────────────────────────────────────────── - -describe('mergeLinkedBalances', () => { - it('returns input unchanged when there are no linked pairs', () => { - const balances: NetBalance[] = [{ userId: 'a', amount: 10 }]; - const names = new Map([['a', 'Alice']]); - const { mergedBalances, mergedNames } = mergeLinkedBalances( - balances, - [], - names, - ); - expect(mergedBalances).toEqual(balances); - expect(mergedNames).toEqual(names); - }); - - it('sums linked partners and attributes to the canonical (lower-id) user', () => { - const linkedPairs: LinkedPair[] = [{ userIdA: 'b', userIdB: 'a' }]; - const balances: NetBalance[] = [ - { userId: 'a', amount: 5 }, - { userId: 'b', amount: -3 }, - { userId: 'c', amount: -2 }, - ]; - const names = new Map([ - ['a', 'Alice'], - ['b', 'Bob'], - ['c', 'Charlie'], - ]); - const { mergedBalances, mergedNames } = mergeLinkedBalances( - balances, - linkedPairs, - names, - ); - const byUser = Object.fromEntries( - mergedBalances.map((b) => [b.userId, b.amount]), - ); - expect(byUser.a).toBe(2); // canonical = 'a' (lower id) - expect(byUser.c).toBe(-2); - expect(mergedNames.get('a')).toBe('Alice & Bob'); - }); - - it('drops a linked pair whose combined balance is zero', () => { - const linkedPairs: LinkedPair[] = [{ userIdA: 'a', userIdB: 'b' }]; - const balances: NetBalance[] = [ - { userId: 'a', amount: 5 }, - { userId: 'b', amount: -5 }, - { userId: 'c', amount: 0 }, - ]; - const { mergedBalances } = mergeLinkedBalances( - balances, - linkedPairs, - new Map(), - ); - expect(mergedBalances.find((b) => b.userId === 'a')).toBeUndefined(); - }); -}); - -// ─── calculateSettlementWithCouples ────────────────────────────────────────── - -describe('calculateSettlementWithCouples', () => { - it('returns an empty result when there are no expenses', () => { - const result = calculateSettlementWithCouples([], new Map(), []); - expect(result.isSettled).toBe(true); - expect(result.transactions).toEqual([]); - }); - - it('reduces the number of transactions when partners are linked', () => { - // Without linking: alice +15, bob +0, carol -10, dave -5 - // With alice↔bob linked (canonical = alice): alice +15, carol -10, dave -5 - const expenses = [ - { - payerId: 'alice', - participants: [ - { userId: 'alice', shareAmount: 5 }, - { userId: 'bob', shareAmount: 5 }, - { userId: 'carol', shareAmount: 5 }, - { userId: 'dave', shareAmount: 5 }, - ], - }, - { - payerId: 'bob', - participants: [ - { userId: 'bob', shareAmount: 5 }, - { userId: 'carol', shareAmount: 5 }, - ], - }, - ]; - const names = new Map([ - ['alice', 'Alice'], - ['bob', 'Bob'], - ['carol', 'Carol'], - ['dave', 'Dave'], - ]); - const plain = calculateSettlement(expenses, names); - const coupled = calculateSettlementWithCouples(expenses, names, [ - { userIdA: 'alice', userIdB: 'bob' }, - ]); - expect(coupled.transactionCount).toBeLessThanOrEqual(plain.transactionCount); - for (const t of coupled.transactions) { - expect(t.amount).toBeGreaterThan(0); - expect(t.fromUserId).not.toBe(t.toUserId); - } - // Composite name should appear whenever 'alice' is either side - for (const t of coupled.transactions) { - if (t.fromUserId === 'alice') { - expect(t.fromUserName).toBe('Alice & Bob'); - } - if (t.toUserId === 'alice') { - expect(t.toUserName).toBe('Alice & Bob'); - } - } - }); -}); diff --git a/packages/core/tests/balances.test.ts b/packages/core/tests/balances.test.ts new file mode 100644 index 00000000..c77ce1f7 --- /dev/null +++ b/packages/core/tests/balances.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { netBalances, overallNet, type BalanceExpense } from "@commune/core"; + +// The same figures on every run. +function sequence(seed: number) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 0x1_0000_0000; + }; +} + +describe("balances between members", () => { + const people = ["a", "b", "c", "d"]; + const random = sequence(7); + const expenses: BalanceExpense[] = Array.from({ length: 200 }, (_, index) => { + const payer = people[Math.floor(random() * people.length)]; + return { + paidByUserId: payer, + voidedAt: index % 17 === 0 ? "2026-10-01T09:00:00Z" : null, + shares: people.map((userId) => { + const amountMinor = 100 + Math.floor(random() * 5000); + return { userId, amountMinor, settledMinor: userId === payer ? amountMinor : Math.floor(random() * 2) * amountMinor }; + }), + }; + }); + + it("reads the same between two people from either side", () => { + for (const viewer of people) { + const nets = netBalances(expenses, viewer); + for (const other of people.filter((id) => id !== viewer)) { + expect(nets.get(other) ?? 0).toBe(-(netBalances(expenses, other).get(viewer) ?? 0)); + } + } + }); + + it("adds up to the viewer's overall position, and the positions add up to nothing", () => { + let everyone = 0; + for (const viewer of people) { + const nets = netBalances(expenses, viewer); + expect(overallNet(nets)).toBe([...nets.values()].reduce((sum, net) => sum + net, 0)); + everyone += overallNet(nets); + } + expect(everyone).toBe(0); + }); + + it("never counts a voided expense or a repaid share", () => { + const voided: BalanceExpense = { paidByUserId: "a", voidedAt: "2026-10-01T09:00:00Z", shares: [{ userId: "b", amountMinor: 900, settledMinor: 0 }] }; + const repaid: BalanceExpense = { paidByUserId: "a", shares: [{ userId: "b", amountMinor: 900, settledMinor: 900 }] }; + expect(netBalances([voided, repaid], "a").get("b") ?? 0).toBe(0); + }); +}); diff --git a/packages/core/tests/expenseSplit.test.ts b/packages/core/tests/expenseSplit.test.ts new file mode 100644 index 00000000..c740909d --- /dev/null +++ b/packages/core/tests/expenseSplit.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { allocateExpense, validateExpenseAllocation } from "@commune/core"; + +// The same figures on every run. +function sequence(seed: number) { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 0x1_0000_0000; + }; +} +describe("expense allocation in integer minor units", () => { + it("keeps every penny in equal splits", () => expect(allocateExpense(10001, ["a", "b", "c", "d"], "Equal").map(s => s.amountMinor)).toEqual([2501,2500,2500,2500])); + it("accepts exact shares and rejects a mismatched total", () => { + expect(allocateExpense(1000,["a","b"],"Exact",{a:"4.25",b:"5.75"}).map(s=>s.amountMinor)).toEqual([425,575]); + expect(()=>allocateExpense(1000,["a","b"],"Exact",{a:"4",b:"5"})).toThrow("add up"); + }); + it("allocates fractional percentages without float loss",()=>expect(allocateExpense(10000,["a","b","c"],"Percentage",{a:"33.33",b:"33.33",c:"33.34"}).map(s=>s.amountMinor)).toEqual([3333,3333,3334])); + it("supports weighted shares",()=>expect(allocateExpense(1000,["a","b"],"Shares",{a:"1",b:"3"}).map(s=>s.amountMinor)).toEqual([250,750])); + it("gives spare pennies to the same people whatever order they are listed in", () => { + const split = (total: number, ids: string[], method: "Equal" | "Shares", values: Record = {}) => + Object.fromEntries(allocateExpense(total, ids, method, values).map(share => [share.userId, share.amountMinor])); + for (const ids of [["a", "b", "c"], ["c", "a", "b"], ["b", "c", "a"]]) { + expect(split(1000, ids, "Equal")).toEqual({ a: 334, b: 333, c: 333 }); + // a and b tie for the spare penny. + expect(split(1002, ids, "Shares", { a: "1", b: "1", c: "2" })).toEqual({ a: 251, b: 250, c: 501 }); + } + // The result keeps the order the people were given in. + expect(allocateExpense(1000, ["c", "a", "b"], "Equal").map(share => share.userId)).toEqual(["c", "a", "b"]); + }); + it("rejects invalid percentages, duplicates, zero shares and negative input",()=>{ + expect(()=>allocateExpense(1000,["a","b"],"Percentage",{a:"40",b:"40"})).toThrow("100%"); + expect(()=>allocateExpense(1000,["a","a"],"Equal")).toThrow("only once"); + expect(()=>allocateExpense(1,["a","b"],"Equal")).toThrow("one penny"); + expect(()=>allocateExpense(1000,["a","b"],"Shares",{a:"-1",b:"2"})).toThrow(); + }); + it("always adds up to the total, and an equal split never differs by more than a penny", () => { + const random = sequence(64); + for (let run = 0; run < 500; run += 1) { + const people = Array.from({ length: 1 + Math.floor(random() * 9) }, (_, index) => `person-${index}`); + const total = people.length + Math.floor(random() * 10_000_000); + const equal = allocateExpense(total, people, "Equal").map((share) => share.amountMinor); + expect(equal.reduce((sum, amount) => sum + amount, 0)).toBe(total); + expect(Math.max(...equal) - Math.min(...equal)).toBeLessThanOrEqual(1); + const weights = Object.fromEntries(people.map((id) => [id, String(1 + Math.floor(random() * 5))])); + const weighted = allocateExpense(total, people, "Shares", weights).map((share) => share.amountMinor); + expect(weighted.reduce((sum, amount) => sum + amount, 0)).toBe(total); + } + }); + it("reads exact amounts as the money fields do", () => { + expect(allocateExpense(150000, ["a", "b"], "Exact", { a: "1,200.00", b: "300" }, "GBP").map((share) => share.amountMinor)).toEqual([120000, 30000]); + expect(allocateExpense(2500, ["a", "b"], "Exact", { a: "12,50", b: "12.50" }, "EUR").map((share) => share.amountMinor)).toEqual([1250, 1250]); + expect(() => allocateExpense(2500, ["a", "b"], "Exact", { a: "12,50", b: "12.50" }, "GBP")).toThrow("Use a full stop for pence, like 12.50."); + }); + it("checks a split before it is saved", () => { + expect(validateExpenseAllocation(1000, [{ userId: "a", amountMinor: 400 }, { userId: "b", amountMinor: 600 }])).toHaveLength(2); + expect(() => validateExpenseAllocation(1000, [{ userId: "a", amountMinor: 400 }, { userId: "b", amountMinor: 500 }])).toThrow("add up to the total"); + expect(() => validateExpenseAllocation(1000, [{ userId: "a", amountMinor: 1000 }, { userId: "b", amountMinor: 0 }])).toThrow("at least one penny"); + expect(() => validateExpenseAllocation(1000, [{ userId: "a", amountMinor: 500 }, { userId: "a", amountMinor: 500 }])).toThrow("only once"); + }); +}); diff --git a/packages/core/tests/money.test.ts b/packages/core/tests/money.test.ts new file mode 100644 index 00000000..66f3ed03 --- /dev/null +++ b/packages/core/tests/money.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { MAX_MONEY_MINOR, acceptsDecimalComma, moneyProblemMessage, plainAmount, readMoneyInput } from "@commune/core"; + +// Every money field (Add expense, corrections, funds, pledges, receipts, +// recurring bills and exact splits) reads amounts with readMoneyInput, so +// these are the amounts each of them accepts and refuses. +describe("reading an amount someone typed", () => { + it("reads the same amounts in every currency", () => { + for (const currency of ["GBP", "USD", "EUR", null, undefined]) { + expect(readMoneyInput("12.5", currency)).toEqual({ minor: 1250, problem: null }); + expect(readMoneyInput("1,200", currency)).toEqual({ minor: 120000, problem: null }); + expect(readMoneyInput(" 12,345,678.90 ", currency)).toEqual({ minor: 1234567890, problem: null }); + expect(readMoneyInput("0.01", currency)).toEqual({ minor: 1, problem: null }); + } + }); + + it("says why an amount cannot be used", () => { + expect(readMoneyInput(" ", "GBP").problem).toBe("empty"); + expect(readMoneyInput("0.00", "GBP").problem).toBe("zero"); + for (const value of ["-5", "12.", ".5", "12.345", "1,20,0", "1 200", "£12", "1e3", "0,500"]) { + expect(readMoneyInput(value, "EUR").problem, value).toBe("format"); + } + expect(readMoneyInput("12,50", "GBP").problem).toBe("decimal-comma"); + expect(readMoneyInput("12,50", "EUR")).toEqual({ minor: 1250, problem: null }); + expect(readMoneyInput("1.200,50", "EUR")).toEqual({ minor: 120050, problem: null }); + expect(acceptsDecimalComma("EUR")).toBe(true); + expect(acceptsDecimalComma("USD")).toBe(false); + }); + + it("keeps to the database's limit, without losing precision near it", () => { + expect(readMoneyInput("10,000,000,000.00", "GBP")).toEqual({ minor: MAX_MONEY_MINOR, problem: null }); + expect(readMoneyInput("10000000000.01", "GBP").problem).toBe("too-large"); + expect(readMoneyInput("99999999999999999999.99", "USD").problem).toBe("too-large"); + expect(moneyProblemMessage("too-large", "GBP")).toBe("Enter an amount no larger than 10,000,000,000.00."); + expect(moneyProblemMessage("decimal-comma", "USD")).toBe("Use a full stop for cents, like 12.50."); + expect(moneyProblemMessage("format", "GBP")).toBeNull(); + }); + + it("writes an amount back the way exact split templates store it", () => { + expect(plainAmount(120050)).toBe("1200.50"); + expect(plainAmount(7)).toBe("0.07"); + expect(readMoneyInput(plainAmount(MAX_MONEY_MINOR), "GBP").minor).toBe(MAX_MONEY_MINOR); + }); +}); From 835d143b240ee0415d4ee0a0123e6785a5160a8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:43:32 +0000 Subject: [PATCH 2/7] Write money and dates one way, with one cached formatter each Money was formatted by a new Intl.NumberFormat on every call, in four copies, and US dollar communes read "US$32.10". Dates came in three styles: "Due 2026-10-06" in the expense window, "09/10/2026" for notices and fund contributions, and "9 Oct" elsewhere. @commune/core now has formatMoney, which makes one formatter per currency and keeps it, with each offered currency's narrow symbol ("$32.10"); a currency that shares a symbol with an offered one keeps its code-prefixed form ("CA$"). formatDay writes a calendar date as "9 Oct", with the year when it is not this year, never moved by a time zone. Every screen formats through them: the live model, the expense window, Settle, notices, funds and pledges, search results, receipts, announcements, task history and the activity log. The reminder worker writes amounts the same way through its own copy, checked against the shared one. Tests that compared dates with a fixed year now compare with the app's own date text, so they hold in any year. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/reminder-worker/src/index.ts | 5 +- apps/reminder-worker/src/money.ts | 24 ++++ apps/reminder-worker/tests/email.mjs | 17 +++ apps/web/src/data/reminders.ts | 10 +- apps/web/src/live/model.ts | 25 ++-- apps/web/src/screens/Funds.tsx | 6 +- apps/web/src/screens/Hub.tsx | 5 +- apps/web/src/screens/Money.tsx | 10 +- apps/web/src/ui/ActivityLog.tsx | 6 +- apps/web/src/ui/SearchResults.tsx | 3 +- apps/web/src/windows/OperationsWindows.tsx | 10 +- apps/web/src/windows/Windows.tsx | 10 +- apps/web/tests/dates-and-amounts.test.tsx | 113 ++++++++++++++++++ apps/web/tests/formatting.test.ts | 53 ++++++++ .../tests/invites-pledges-requests.test.tsx | 6 +- apps/web/tests/money-lifecycle.test.tsx | 15 +-- apps/web/tests/recurring-bills.test.ts | 3 +- apps/web/tests/reminder-confirm.test.tsx | 6 +- apps/web/tests/workflow-screens.test.tsx | 4 +- packages/core/src/format.ts | 61 ++++++++++ packages/core/src/index.ts | 4 +- packages/core/tests/format.test.ts | 82 +++++++++++++ 22 files changed, 408 insertions(+), 70 deletions(-) create mode 100644 apps/reminder-worker/src/money.ts create mode 100644 apps/web/tests/dates-and-amounts.test.tsx create mode 100644 apps/web/tests/formatting.test.ts create mode 100644 packages/core/src/format.ts create mode 100644 packages/core/tests/format.test.ts 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/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 15555ff5..712f01b8 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 "@commune/core"; +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,19 +446,14 @@ 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); + return formatMoney(minor, currency); } -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); +/** 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 equalShares( @@ -502,7 +497,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", @@ -652,7 +647,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 +659,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 +667,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)), 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..3cfdb1a0 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -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`); @@ -199,7 +193,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/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/SearchResults.tsx b/apps/web/src/ui/SearchResults.tsx index a56767c0..27ffd074 100644 --- a/apps/web/src/ui/SearchResults.tsx +++ b/apps/web/src/ui/SearchResults.tsx @@ -1,5 +1,6 @@ import { CircleAlert, FileText, House, ReceiptText, Repeat2, ShoppingBag, SquareCheck, UsersRound, Files } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +import { formatMoney } from "@commune/core"; import { SEARCH_KINDS, SEARCH_LABELS, type SearchKind, type SearchResponse, type SearchResult } from "../live/search"; import { Button } from "./components"; const ICONS: Record = { @@ -39,7 +40,7 @@ export function SearchResults({ result, pending, error, query, retry, onOpen, li

{SEARCH_LABELS[kind]}

{items.map((item, index) => { const position = start + index; - const amount = item.amountMinor === null ? null : new Intl.NumberFormat("en-GB", { style: "currency", currency: item.currency }).format(item.amountMinor / 100); + const amount = item.amountMinor === null ? null : formatMoney(item.amountMinor, item.currency); return ; })}
; diff --git a/apps/web/tests/search-dates.test.tsx b/apps/web/tests/search-dates.test.tsx new file mode 100644 index 00000000..779e7545 --- /dev/null +++ b/apps/web/tests/search-dates.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { formatDay } from "@commune/core"; +import { parseSearchResponse, searchMeta, type SearchResult } from "../src/live/search"; +import { SearchResults } from "../src/ui/SearchResults"; + +// Since 0040 each search result carries its date on its own, so the finder +// writes it as every screen does instead of the database's "Due 2026-11-04". +const result = (fields: Partial): SearchResult => ({ + kind: "recurring", id: "bill-1", communeId: "home-1", communeName: "Brooklyn loft", title: "Internet", subtitle: "Monthly", + meta: "Due 2026-11-04", label: "Due", date: "2026-11-04", amountMinor: 3210, currency: "USD", ...fields, +}); +afterEach(cleanup); + +describe("dates in search results", () => { + it("writes a recurring bill's and a task's date the app's way", () => { + expect(searchMeta(result({}))).toBe(`Due ${formatDay("2026-11-04")}`); + expect(searchMeta(result({ kind: "operation", meta: "Task · 2026-10-20", label: "Task", date: "2026-10-20" }))) + .toBe(`Task · ${formatDay("2026-10-20")}`); + expect(searchMeta(result({ meta: "Paused", label: "Paused", date: null }))).toBe("Paused"); + expect(searchMeta(result({ kind: "expense", meta: "Open", label: "Open", date: null }))).toBe("Open"); + }); + + it("shows a database's own text while it sends no separate date", () => { + const { label: _label, date: _date, ...older } = result({}); + expect(searchMeta(older)).toBe("Due 2026-11-04"); + expect(parseSearchResponse({ query: "int", items: [older], total: 1, hasMore: false }).items[0].meta).toBe("Due 2026-11-04"); + }); + + it("refuses a malformed date or label", () => { + for (const bad of [{ date: "04/11/2026" }, { date: 20261104 }, { label: 7 }]) { + expect(() => parseSearchResponse({ query: "int", items: [{ ...result({}), ...bad }], total: 1, hasMore: false })).toThrow("invalid response"); + } + }); + + it("shows the date and amount in the finder", () => { + render( undefined} onOpen={() => undefined} listId="results" />); + const row = screen.getByRole("button", { name: /Internet/ }); + expect(row.textContent).toContain(`$32.10Due ${formatDay("2026-11-04")}`); + expect(row.textContent).not.toContain("2026-11-04"); + }); +}); diff --git a/infra/neon/migrations/0040_search_dates.sql b/infra/neon/migrations/0040_search_dates.sql new file mode 100644 index 00000000..f88109bd --- /dev/null +++ b/infra/neon/migrations/0040_search_dates.sql @@ -0,0 +1,117 @@ +-- The quick finder writes dates the way every screen does (app review +-- finding 47). search_commune_records put dates in its text as the database +-- writes them: "Due 2026-11-04" for a recurring bill, "Task · 2026-11-04" for +-- a task or event. Each result now also carries: +-- label its words without the date ("Due", "Task", "Open", "Target") +-- date the date on its own, YYYY-MM-DD, or null +-- so the app writes the date as it does everywhere else ("4 Nov", with the +-- year when it is not this year). meta keeps the old text, unchanged, for the +-- app versions released before this one; a later migration can drop it once +-- no client reads it. The function is otherwise as 0030 left it (the latest +-- definition, written out in full). Apply after 0039. +BEGIN; + +CREATE OR REPLACE FUNCTION public.search_commune_records(p_query text, p_commune_id uuid DEFAULT NULL::uuid, p_limit integer DEFAULT 8) + RETURNS jsonb + LANGUAGE plpgsql + STABLE SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' +AS $function$ +DECLARE + v_actor text := commune_private.data_api_user_id(); + v_query text := btrim(coalesce(p_query, '')); + v_pattern text; + v_result jsonb; +BEGIN + IF v_actor IS NULL OR v_actor = '' THEN + RAISE EXCEPTION 'Sign in to search' USING ERRCODE = '42501'; + END IF; + IF p_commune_id IS NOT NULL AND NOT public.is_commune_member(p_commune_id) THEN + RAISE EXCEPTION 'Not a commune member' USING ERRCODE = '42501'; + END IF; + IF length(v_query) > 80 OR p_limit IS NULL OR p_limit NOT BETWEEN 1 AND 25 THEN + RAISE EXCEPTION 'Invalid search query or result limit' USING ERRCODE = '22023'; + END IF; + IF length(v_query) < 2 THEN + RETURN jsonb_build_object('query',v_query,'items','[]'::jsonb,'total',0,'hasMore',false); + END IF; + -- Treat %, _ and backslash as literal user text, never SQL wildcards. + v_pattern := '%' || replace(replace(replace(v_query, E'\\', E'\\\\'), '%', E'\\%'), '_', E'\\_') || '%'; + WITH allowed AS MATERIALIZED ( + SELECT co.id, co.name, co.kind, co.currency FROM public.communes co + JOIN public.commune_members m ON m.commune_id = co.id + WHERE m.user_id = v_actor AND m.active + AND (p_commune_id IS NULL OR co.id = p_commune_id) + ), records AS NOT MATERIALIZED ( + SELECT 'commune'::text AS kind, co.id::text AS id, co.id AS commune_id, co.name AS commune_name, + co.name AS title, initcap(co.kind) AS subtitle, 'Commune'::text AS meta, + 'Commune'::text AS label, NULL::date AS on_date, + NULL::bigint AS amount_minor, co.currency, co.name || ' ' || co.kind AS searchable + FROM allowed co + UNION ALL + SELECT 'expense', e.id::text, co.id, co.name, e.title, e.category, + CASE WHEN e.voided_at IS NOT NULL THEN 'Voided' WHEN EXISTS(SELECT 1 FROM public.expense_shares s WHERE s.expense_id=e.id AND s.settled_minorp_limit)) INTO v_result; + RETURN v_result; +END; +$function$; + +REVOKE ALL ON FUNCTION public.search_commune_records(text,uuid,integer) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.search_commune_records(text,uuid,integer) TO authenticated; + +NOTIFY pgrst, 'reload schema'; +COMMIT; diff --git a/infra/neon/schema.sql b/infra/neon/schema.sql index a7c14aa4..b130f91f 100644 --- a/infra/neon/schema.sql +++ b/infra/neon/schema.sql @@ -3492,42 +3492,50 @@ BEGIN ), records AS NOT MATERIALIZED ( SELECT 'commune'::text AS kind, co.id::text AS id, co.id AS commune_id, co.name AS commune_name, co.name AS title, initcap(co.kind) AS subtitle, 'Commune'::text AS meta, + 'Commune'::text AS label, NULL::date AS on_date, NULL::bigint AS amount_minor, co.currency, co.name || ' ' || co.kind AS searchable FROM allowed co UNION ALL SELECT 'expense', e.id::text, co.id, co.name, e.title, e.category, CASE WHEN e.voided_at IS NOT NULL THEN 'Voided' WHEN EXISTS(SELECT 1 FROM public.expense_shares s WHERE s.expense_id=e.id AND s.settled_minorp_limit)) INTO v_result; diff --git a/infra/neon/tests/search_dates.sql b/infra/neon/tests/search_dates.sql new file mode 100644 index 00000000..ffcb2415 --- /dev/null +++ b/infra/neon/tests/search_dates.sql @@ -0,0 +1,73 @@ +-- The quick finder's dates (0040). Run only in an empty disposable local DB +-- built from infra/neon/tests/local_auth_stub.sql and every migration. +-- Each search result carries its date on its own (date, YYYY-MM-DD or null) +-- and its words without the date (label), so the app writes the date as every +-- other screen does; meta keeps the old text for app versions released +-- before 0040. Everything is rolled back. +BEGIN; + +CREATE FUNCTION pg_temp.result(p_query text, p_commune uuid, p_title text) RETURNS jsonb LANGUAGE sql AS $$ + SELECT i FROM jsonb_array_elements(public.search_commune_records(p_query, p_commune, 25)->'items') i WHERE i->>'title' = p_title; +$$; +CREATE FUNCTION pg_temp.expect(p_item jsonb, p_label text, p_date text, p_meta text, p_case text) RETURNS void LANGUAGE plpgsql AS $$ +BEGIN + IF p_item IS NULL OR NOT (p_item ? 'label' AND p_item ? 'date' AND p_item ? 'meta') + OR p_item->>'label' IS DISTINCT FROM p_label OR p_item->>'date' IS DISTINCT FROM p_date + OR p_item->>'meta' IS DISTINCT FROM p_meta THEN + RAISE EXCEPTION '%: expected label %, date % and meta %, got %', p_case, p_label, coalesce(p_date, 'null'), p_meta, p_item; + END IF; +END; +$$; + +DO $$ +DECLARE + v_alice constant text := '0a000000-0000-4000-8000-0000000000d1'; + v_home uuid; + v_shares jsonb := jsonb_build_array(jsonb_build_object('userId', '0a000000-0000-4000-8000-0000000000d1', 'amountMinor', 9000)); + v_undated jsonb; +BEGIN + INSERT INTO neon_auth."user" (id, name, email, "emailVerified") VALUES (v_alice, 'Alice Zebra', 'alice@search-dates.test', true); + PERFORM set_config('request.jwt.claims', json_build_object('sub', v_alice, 'role', 'authenticated')::text, true); + v_home := public.create_commune('Zebra house', 'household', 'GBP'); + INSERT INTO public.recurring_bills (commune_id, title, amount_minor, paid_by_user_id, shares, frequency, next_due_on, anchor_day, created_by, paused) + VALUES (v_home, 'Zebra electricity', 9000, v_alice, v_shares, 'monthly', '2026-11-04', 4, v_alice, false), + (v_home, 'Zebra water', 9000, v_alice, v_shares, 'monthly', '2026-12-01', 1, v_alice, true); + INSERT INTO public.commune_operations (commune_id, kind, category, title, date, created_by) + VALUES (v_home, 'task', 'chore', 'Zebra bins', '2026-10-20', v_alice), + (v_home, 'event', 'visit', 'Zebra visit', '2027-01-02', v_alice); + PERFORM public.create_expense(v_home, 'Zebra shopping', 3000, + jsonb_build_array(jsonb_build_object('user_id', v_alice, 'amount_minor', 3000)), '2026-11-30'); + PERFORM public.create_fund(v_home, 'Zebra fund', 50000); + + -- A recurring bill that is due, and one that is paused. + PERFORM pg_temp.expect(pg_temp.result('Zebra', v_home, 'Zebra electricity'), 'Due', '2026-11-04', 'Due 2026-11-04', 'A due bill'); + PERFORM pg_temp.expect(pg_temp.result('Zebra', v_home, 'Zebra water'), 'Paused', NULL, 'Paused', 'A paused bill'); + -- Tasks and events, this year and next. + PERFORM pg_temp.expect(pg_temp.result('Zebra', v_home, 'Zebra bins'), 'Task', '2026-10-20', 'Task · 2026-10-20', 'A task'); + PERFORM pg_temp.expect(pg_temp.result('Zebra', v_home, 'Zebra visit'), 'Event', '2027-01-02', 'Event · 2027-01-02', 'An event'); + + -- Every other kind has no date, and its label is its old text. + SELECT jsonb_agg(i) INTO v_undated FROM jsonb_array_elements(public.search_commune_records('Zebra', NULL, 25)->'items') i + WHERE i->>'kind' NOT IN ('recurring', 'operation'); + IF (SELECT array_agg(DISTINCT i->>'kind' ORDER BY i->>'kind') FROM jsonb_array_elements(v_undated) i) <> ARRAY['commune', 'expense', 'fund', 'member'] + OR EXISTS (SELECT 1 FROM jsonb_array_elements(v_undated) i + WHERE NOT (i ? 'date') OR jsonb_typeof(i->'date') <> 'null' OR i->>'label' IS DISTINCT FROM i->>'meta') THEN + RAISE EXCEPTION 'Results without a date were wrong: %', v_undated; + END IF; +END; +$$; + +-- The date is written the same way whatever the session's date style. +SET LOCAL DateStyle = 'SQL, DMY'; +DO $$ +DECLARE + v_bill jsonb := (SELECT i FROM jsonb_array_elements(public.search_commune_records('Zebra electricity', NULL, 8)->'items') i + WHERE i->>'kind' = 'recurring'); +BEGIN + IF v_bill->>'date' IS DISTINCT FROM '2026-11-04' OR v_bill->>'label' IS DISTINCT FROM 'Due' THEN + RAISE EXCEPTION 'The date depended on the date style: %', v_bill; + END IF; +END; +$$; + +ROLLBACK; From 8dd7e42d550e581151cc8c7e24bb53a890ffb9d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:27:07 +0000 Subject: [PATCH 4/7] Re-render only what shows a change, and keep each screen's text in it The app's state was one context value rebuilt on every change, so typing in a filter, the minute check or a notice count in another commune re-rendered every screen, card and icon, and rebuilt every commune's views. - One store replaces the context. useApp keeps its shape, and each component follows only the fields it reads (useSyncExternalStore), so a change re-renders only what shows it. - A read that finds nothing new keeps the data it had, field by field, so the minute check changes nothing. Derived values keep their objects while what they are built from is the same, and a commune's views are built once per snapshot (readyPlace), for the member's date. - Commands keep their identity between renders (they read the records on screen when they run). - Notes keeps its search text in the Notes screen and Search keeps its own words; the app keeps a copy so Back to Search finds the same search, and forgets it when the account signs out. The finder opens Search with its words through openFullSearch. - Expense cards and note tiles are memoised, so typing in a filter does not re-render them. Render counts (components rendered, measured by tests/render-counts): typing in the Expenses filter 217 to 5; a minute check that finds nothing new 312 to 0; a notice count in another commune 312 to 6; typing in the Notes filter 124 to 5. New tests check the filters survive a window opened over their screen, and that Back to Search keeps its words. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A6P7aTcMBccooYYr4xYnVS --- apps/web/src/live/model.ts | 28 +- apps/web/src/screens/Money.tsx | 23 +- apps/web/src/screens/Notes.tsx | 58 ++-- apps/web/src/screens/Search.tsx | 24 +- apps/web/src/state.tsx | 442 +++++++++++++++++++------- apps/web/src/ui/QuickSearch.tsx | 4 +- apps/web/tests/loading.test.tsx | 2 +- apps/web/tests/quick-search.test.tsx | 7 +- apps/web/tests/render-counts.test.tsx | 189 +++++++++++ apps/web/tests/screen-text.test.tsx | 158 +++++++++ 10 files changed, 775 insertions(+), 160 deletions(-) create mode 100644 apps/web/tests/render-counts.test.tsx create mode 100644 apps/web/tests/screen-text.test.tsx diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 712f01b8..70da9c09 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -475,11 +475,10 @@ export function equalShares( })); } -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. @@ -522,6 +521,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. @@ -531,7 +531,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; @@ -595,7 +595,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); @@ -615,7 +614,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, @@ -681,3 +680,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/screens/Money.tsx b/apps/web/src/screens/Money.tsx index 3cfdb1a0..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"; @@ -92,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 ?
@@ -110,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) { 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/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/state.tsx b/apps/web/src/state.tsx index 6cefb4e3..da8b773e 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -3,8 +3,11 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, + useMemo, useRef, useState, + useSyncExternalStore, type ReactNode, } from "react"; import { useLocation, useRouter, useSearch } from "wouter"; @@ -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/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