diff --git a/apps/local-api/README.md b/apps/local-api/README.md index 8730d8a2..f0df233f 100644 --- a/apps/local-api/README.md +++ b/apps/local-api/README.md @@ -17,11 +17,11 @@ browser code sends same-origin `/api` requests. Non-2xx errors are | POST | `/api/auth/login` | `{email,password}` | `{user}` + cookie | | POST | `/api/auth/logout` | `{}` | `{ok:true}` + cleared cookie | | GET | `/api/auth/me` | public | `{user}` or `{user:null}` | -| GET | `/api/communes` | | `{communes:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark}]}`; a commune's `changeMark` moves whenever anything in it changes | +| GET | `/api/communes` | | `{communes:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark,unreadNotices}]}`; a commune's `changeMark` moves whenever anything in it changes; `unreadNotices` is the reader's unread notices there (0039) | | GET | `/api/search?q=...&commune=...&limit=8` | session required; optional commune UUID; 2–80 chars, 1–25 results per type | `{query,items,total,hasMore}`; only active memberships, safe note excerpts | | POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}`; one account creates at most 10 communes an hour, 30 a day and 100 in all (409) | `{id}` | | PATCH | `/api/communes/:id` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` admin; the currency is fixed (400) once the commune has an expense, fund, recurring bill or exact split template, voided or archived ones included | `{ok:true}` | -| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents,expenseHistory,changeMark}`; `expenses` are the open ones, those created or changed since the start of last month (UTC) and the five newest that are not voided; `expenseHistory` is `{since,olderCount,olderVoidedCount}` | +| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show, keeping a share's pending payment reports (0039) | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents,expenseHistory,changeMark}`; `expenses` are the open ones, those created or changed since the start of last month (UTC) and the five newest that are not voided; `expenseHistory` is `{since,olderCount,olderVoidedCount}` | | GET | `/api/communes/:id/expenses/history?before=&months=` | member; `before` (a date) defaults to the snapshot's `since`; `months` 1 to 12, or left out for every older expense | `{expenses,before,from,olderCount,olderVoidedCount}`: whole months (UTC) before `before`, reaching back past months with none; `from` is null once nothing older remains | | GET | `/api/communes/:id/expenses/records?ids=` | member; 1 to 50 comma-separated expense ids; ids from another commune are left out | `{expenses}` in the snapshot's shape | | POST | `/api/communes/:id/expenses` | `{title,amountMinor,paidByUserId?,category?,shares:[{userId,amountMinor}],dueOn?,requestId?}`; recording for another payer requires admin; a repeated `requestId` returns the same expense, and reusing it for different details is 400 | `{id,duplicate}` (201, or 200 for a repeat) | @@ -45,6 +45,7 @@ browser code sends same-origin `/api` requests. Non-2xx errors are | POST | `/api/communes/:id/members/:userId` | `{action:"admin"\|"member"\|"remove"\|"leave"}`; admin changes roles/removes, member can leave; an admin who leaves, is removed or becomes a member loses their open invitations | `{ok:true}` | | POST | `/api/reminders` | `{kind:"nudge"\|"chase",sourceId,requestId?}`; a repeated `requestId` returns the reminder it already created | `{id,duplicate,delivery:"in-app",emailStatus:"none",email:"not-configured"}` (201, or 200 for a repeat) | | POST | `/api/notifications/:id/read` | `{}` | `{ok:true}` | +| GET | `/api/notices/unread` | | `{notices}`: the unread notices of every commune the reader belongs to, newest first, each as in `snapshot.notifications` with `senderName` and, for a pledge notice whose fund is open, `fundId` (0039) | | GET | `/api/communes/:id/invites` | admin | `{invites:[...]}` with `invitedBy` and `invitedByName`; an invitation works only while its sender is an admin | | POST | `/api/communes/:id/invites` | `{email,role}` admin | `{token,url}` to copy; no email sent | | GET | `/api/invites/:token` | matching signed-in email | `{invite}` or `{invite:null}` | @@ -71,7 +72,9 @@ carry `version`, `voidedAt/voidedBy/voidReason` and their last 20 `revisions` (`{version,action,actorId,reason,before,after,createdAt}`). Expense shares have `{id,userId,amountMinor,settledMinor,settledAt,receipts}`; `settledMinor` is the sum of receipts that have not been reversed. Operations carry `history` -(recent occurrences) and `undoOccurrenceId`. `sentReminders` lists the +(recent occurrences) and `undoOccurrenceId`. Each of `activityEvents` names its +`recordType` (expense, repayment, fund, pledge, contribution, recurring, template, +task, event, note, member or announcement; 0039). `sentReminders` lists the signed-in sender's own recent reminders with their email state. Funds contain commitments with `contributedMinor`, cancellation and contribution-correction history. Notifications and reminder targets include the IDs @@ -102,6 +105,7 @@ API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/operations-history.mjs API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/invites-pledges-requests.mjs API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/session-viewer.mjs API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/lighter-loading.mjs +API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/notices-and-activity.mjs ``` `money-lifecycle.mjs` needs migrations 0015 to 0017 and covers reminder @@ -131,6 +135,11 @@ the signed-in account and requests without the header are still served. snapshot, the overview summary, pages of older expenses, named expense records and the limit on creating communes. It refuses port 3002. +`notices-and-activity.mjs` needs 0039 and covers each commune's unread count in +the list and the snapshot, the unread notices of every commune, the record type +of each activity event and the pending payment reports an overview summary +keeps. It refuses port 3002. + Search integration requires migration 0014. It covers all eight result types, active membership, selected/all-commune scopes, removed-member denial, literal wildcards, result caps and private-note masking. Its loopback guard refuses diff --git a/apps/local-api/src/server.ts b/apps/local-api/src/server.ts index f8749c16..b895a3d8 100644 --- a/apps/local-api/src/server.ts +++ b/apps/local-api/src/server.ts @@ -435,6 +435,14 @@ route("GET", /^\/api\/reminders\/usage$/, async (req, res) => { }); }); +// Unread notices from every commune the caller belongs to (0039), read when +// Notices opens. The commune list carries each commune's count. +route("GET", /^\/api\/notices\/unread$/, async (req, res) => { + const notices = await userAction(req, async (client) => (await client.query( + "SELECT public.my_unread_notices() AS notices")).rows[0].notices); + send(res, { notices }); +}); + route("POST", /^\/api\/communes\/([0-9a-f-]{36})\/expenses$/, async (req, res, match) => { const input = await body(req); const communeId = uuid(match[1]); diff --git a/apps/local-api/tests/lighter-loading.mjs b/apps/local-api/tests/lighter-loading.mjs index 67681de2..8567c244 100644 --- a/apps/local-api/tests/lighter-loading.mjs +++ b/apps/local-api/tests/lighter-loading.mjs @@ -48,7 +48,7 @@ async function listed(call) { } const first = await listed(owner); assert.deepEqual(Object.keys(first).sort(), ["address", "changeMark", "currency", "destination", "id", "kind", - "memberCount", "name", "role", "stayAddress", "stayName"]); + "memberCount", "name", "role", "stayAddress", "stayName", "unreadNotices"]); assert.deepEqual([first.name, first.role, first.memberCount], [`Loading ${suffix}`, "admin", 2]); assert.match(first.changeMark, /^\d{4}-\d{2}-\d{2}:\d+:\d+$/); assert.equal((await listed(owner)).changeMark, first.changeMark, "Reading moved the mark"); diff --git a/apps/local-api/tests/notices-and-activity.mjs b/apps/local-api/tests/notices-and-activity.mjs new file mode 100644 index 00000000..79eb958d --- /dev/null +++ b/apps/local-api/tests/notices-and-activity.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +// Unread notices across communes, activity record types and the pending +// payment reports an overview summary keeps (0039). Creates accounts and +// communes, so it refuses the normal application API. +const base = process.env.API_BASE; +if (!base) throw new Error("Set API_BASE to an isolated local test API, not the user's database."); +const url = new URL(base); +if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname) || !url.port || + url.port === "3002" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) { + throw new Error("Notice and activity tests require an isolated loopback API port (not 3002)."); +} +// Letters only, so a commune name that carries it never holds a phone-like run of digits. +const suffix = randomUUID().slice(0, 8).replace(/[0-9]/g, (digit) => "ghijklmnop"[digit]); +function session() { + let cookie = ""; + return async (method, path, input) => { + const response = await fetch(`${base.replace(/\/$/, "")}/api${path}`, { + method, + headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) }, + body: input === undefined ? undefined : JSON.stringify(input), + }); + const set = response.headers.get("set-cookie"); + if (set) cookie = set.split(";")[0]; + const data = await response.json(); + return { ...data, reportStatus: data.status, status: response.status }; + }; +} +const ok = (result, status = 200) => { assert.equal(result.status, status, JSON.stringify(result)); return result; }; +async function signup(call, name, slug) { + return ok(await call("POST", "/auth/signup", { name, email: `${slug}-${suffix}@example.test`, + password: "DisposableNoticeFixture-2026!" }), 201).user; +} +const a = session(), b = session(), outsider = session(), guest = session(); +const avery = await signup(a, "Avery Notices", "notices-avery"); +const blake = await signup(b, "Blake Notices", "notices-blake"); +await signup(outsider, "Casey Notices", "notices-casey"); +async function commune(call, name, currency, member, memberCall) { + const id = ok(await call("POST", "/communes", { name, kind: "household", currency }), 201).id; + const invite = ok(await call("POST", `/communes/${id}/invites`, { email: member.email, role: "member" }), 201); + ok(await memberCall("POST", `/invites/${invite.token}/accept`, {})); + return id; +} +// Avery's home, with Blake, and Blake's trip, with Avery. +const home = await commune(a, `Home ${suffix}`, "GBP", blake, b); +const trip = await commune(b, `Trip ${suffix}`, "EUR", avery, a); +const today = new Date().toISOString().slice(0, 10); +const snapshot = async (call, id) => ok(await call("GET", `/communes/${id}/snapshot`)); +const counts = async (call) => Object.fromEntries((ok(await call("GET", "/communes")).communes) + .filter((item) => [home, trip].includes(item.id)).map((item) => [item.id, item.unreadNotices])); +assert.deepEqual(await counts(a), { [home]: 0, [trip]: 0 }); +assert.deepEqual(ok(await a("GET", "/notices/unread")).notices, []); + +// Home: Blake says he has sent his share of the gas, which Avery has to confirm. +const gas = ok(await a("POST", `/communes/${home}/expenses`, { title: "Gas", amountMinor: 3000, + shares: [{ userId: avery.id, amountMinor: 1500 }, { userId: blake.id, amountMinor: 1500 }], requestId: randomUUID() }), 201).id; +const gasShare = (await snapshot(b, home)).expenses.find((item) => item.id === gas).shares.find((item) => item.userId === blake.id); +const report = ok(await b("POST", `/shares/${gasShare.id}/payment-reports`, + { amountMinor: 1500, sentOn: today, reference: "Bank transfer", requestId: randomUUID() }), 201); +// Trip: Blake nudges Avery about her share of the ferry. +const ferry = ok(await b("POST", `/communes/${trip}/expenses`, { title: "Ferry", amountMinor: 4000, dueOn: today, + shares: [{ userId: avery.id, amountMinor: 2000 }, { userId: blake.id, amountMinor: 2000 }], requestId: randomUUID() }), 201).id; +const ferryShare = (await snapshot(b, trip)).expenses.find((item) => item.id === ferry).shares.find((item) => item.userId === avery.id); +ok(await b("POST", "/reminders", { kind: "nudge", sourceId: ferryShare.id, requestId: randomUUID() }), 201); +// Home again: Avery moves Blake's pledge to a later date, which tells Blake. +const fund = ok(await a("POST", `/communes/${home}/funds`, { name: "Boiler", targetMinor: 50000, requestId: randomUUID() }), 201).id; +const inDays = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10); +const pledge = ok(await b("POST", `/funds/${fund}/pledges`, { amountMinor: 10000, dueOn: inDays(30), requestId: randomUUID() }), 201).id; +ok(await a("PATCH", `/commitments/${pledge}`, { amountMinor: 10000, dueOn: inDays(60), expectedAmountMinor: 10000, expectedDueOn: inDays(30) })); + +// ------------------------------------------------------------ counts +// Each commune in the list carries the reader's own unread count, as does the snapshot. +assert.deepEqual(await counts(a), { [home]: 1, [trip]: 1 }); +assert.deepEqual(await counts(b), { [home]: 1, [trip]: 0 }); +assert.equal((await snapshot(a, trip)).commune.unreadNotices, 1); +assert.equal(ok(await a("GET", `/communes/${home}/snapshot?detail=summary`)).commune.unreadNotices, 1); + +// ------------------------------------------------------------ every commune's unread notices +const averys = ok(await a("GET", "/notices/unread")).notices; +assert.deepEqual(averys.map((item) => [item.communeId, item.kind, item.senderName, item.fundId]), + [[trip, "nudge", "Blake Notices", null], [home, "payment-reported", "Blake Notices", null]]); +// Each is the snapshot's notice with the sender's name and a pledge notice's fund. +const { senderName, fundId, ...nudge } = averys[0]; +assert.deepEqual(nudge, (await snapshot(a, trip)).notifications[0]); +const blakes = ok(await b("GET", "/notices/unread")).notices; +assert.deepEqual(blakes.map((item) => [item.communeId, item.kind, item.senderName, item.fundId]), + [[home, "pledge-changed", "Avery Notices", fund]]); +assert.deepEqual(ok(await outsider("GET", "/notices/unread")).notices, []); +assert.equal((await guest("GET", "/notices/unread")).status, 401); + +// Reading one changes only its own commune. +ok(await a("POST", `/notifications/${averys[0].id}/read`, {})); +assert.deepEqual(await counts(a), { [home]: 1, [trip]: 0 }); +assert.deepEqual(ok(await a("GET", "/notices/unread")).notices.map((item) => item.id), [averys[1].id]); + +// ------------------------------------------------------------ record types +// Each activity event names the kind of record it is about. +const events = (await snapshot(a, home)).activityEvents; +assert.ok(events.every((event) => typeof event.recordType === "string"), JSON.stringify(events)); +const types = Object.fromEntries(events.map((event) => [`${event.title}: ${event.description}`, event.recordType])); +assert.deepEqual([types["Boiler: Created"], types["Boiler: Pledge made"], types["Boiler: Pledge updated"], + types["Blake Notices: Joined the commune"]], ["fund", "pledge", "pledge", "member"], JSON.stringify(types)); + +// ------------------------------------------------------------ the summary keeps a pending report +const reportsIn = (body) => body.expenses.find((item) => item.id === gas).shares + .find((item) => item.userId === blake.id).paymentReports.map((item) => [item.id, item.status]); +assert.deepEqual(reportsIn(ok(await a("GET", `/communes/${home}/snapshot?detail=summary`))), [[report.id, "pending"]]); +ok(await a("POST", `/payment-reports/${report.id}/resolve`, { decision: "confirm", requestId: randomUUID() })); +assert.deepEqual(reportsIn(ok(await a("GET", `/communes/${home}/snapshot?detail=summary`))), []); +assert.deepEqual(reportsIn(await snapshot(a, home)), [[report.id, "confirmed"]]); +// Confirming it records a repayment, named as one. +const repaid = (await snapshot(a, home)).activityEvents.filter((event) => event.title === "Gas"); +assert.deepEqual(repaid.map((event) => event.recordType), ["repayment"], JSON.stringify(repaid)); +console.log("Notices and activity: counts, notices across communes, record types and pending reports behave as expected."); diff --git a/apps/web/reference/design-language/index.html b/apps/web/reference/design-language/index.html index ab2552ca..3a1b8e4d 100644 --- a/apps/web/reference/design-language/index.html +++ b/apps/web/reference/design-language/index.html @@ -613,7 +613,7 @@
ACAlex ChenAdmin · 58 reminders leftAlex ChenAdmin
diff --git a/apps/web/src/data/household.ts b/apps/web/src/data/household.ts index 9c5fa7b8..de180b34 100644 --- a/apps/web/src/data/household.ts +++ b/apps/web/src/data/household.ts @@ -60,6 +60,11 @@ export type SettlementMove = { expenseId?: string; }; +/** The kinds of record an activity row can be about (0039 names one for each event). */ +export type ActivityType = + | "expense" | "repayment" | "fund" | "pledge" | "contribution" | "recurring" | "template" + | "task" | "event" | "note" | "member" | "announcement"; + export type Hub = { id: HubId; currency?: "GBP" | "USD" | "EUR"; @@ -104,6 +109,8 @@ export type Hub = { time: string; createdAt?: string; expenseId?: string; + /** The kind of record the row is about. */ + type?: ActivityType; }>; notes: Array<{ go: "note"; note: string; title: string; when: string }>; jumps: Array<{ go: ScreenId | WindowId; kicker: string; title: string; hint: string }>; @@ -745,7 +752,8 @@ export type CreatedHub = { export function initialsOf(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); - if (!parts.length) return "MC"; + // No name, no letters: a neutral mark rather than someone else's initials. + if (!parts.length) return "?"; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); } diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 5e7439e6..4afbcf07 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -1,6 +1,7 @@ import { HUBS, initialsOf, + type ActivityType, type AvatarTone, type ExpenseRow, type Hub, @@ -34,6 +35,8 @@ export type Commune = { stayAddress?: string; /** Moves whenever anything in the commune changes; compared with a snapshot's own. */ changeMark?: string; + /** The reader's unread notices in this commune (0039), for the bell across communes. */ + unreadNotices?: number; /** In a snapshot: what the database says keeps the currency as it is (0035). */ currencyLockedBy?: CurrencyLock[]; }; @@ -248,6 +251,12 @@ export type Notification = { createdAt: string; readAt: string | null; }; +/** + * An unread notice from any of the reader's communes (0039), with what the + * reader may not have loaded: the sender's name, and for a pledge notice the + * fund that holds the pledge while it is open. + */ +export type UnreadNotice = Notification & { senderName?: string | null; fundId?: string | null }; /** A note's earlier content, as a summary; the content is read on demand. */ export type NoteRevisionSummary = { id: string; version: number; title: string; @@ -284,7 +293,8 @@ export type Snapshot = { recurringBills?: RecurringBill[]; splitTemplates?: SplitTemplate[]; announcements?: Announcement[]; - activityEvents?: Array<{id:string;actorId:string;kind:"funds"|"members"|"recurring"|"templates"|"notes"|"operations"|"expenses"|"announcements";entityId:string;title:string;description:string;createdAt:string}>; + /** recordType: the kind of record the event is about (0039). */ + activityEvents?: Array<{id:string;actorId:string;kind:ActivityEventKind;entityId:string;title:string;description:string;createdAt:string;recordType?:ActivityType}>; /** * The snapshot holds every expense created since `since`, every open one * and the five newest that are not voided. @@ -309,12 +319,129 @@ export type LiveExpenseRow = Omit & { +export type LiveHub = Omit & { currency: CurrencyCode; role: MemberRole; activityState?: "loading" | "error" | "ready"; memberCount?: number; members: Array; + activity: Array; + /** Needs you: what the viewer has to do here, in order (see needsYou). */ + now: Need[]; + /** What the viewer is waiting on others for, with nothing to do. */ + waiting: Need[]; +}; + +/** + * One row of "Needs you". confirm: someone says they have sent a payment + * only the viewer, the bill payer, can confirm. pay: the viewer's unpaid + * share. task: the viewer's task, late or due today. pledge: the viewer's + * pledge that is due. owed and reported wait on others: what people owe the + * viewer, and a payment the viewer said they sent. + */ +export type Need = { + kind: "confirm" | "pay" | "task" | "pledge" | "owed" | "reported"; + go: "expense" | "operation" | "funds"; + title: string; + /** Who and what, without the amount. */ + when: string; + amount?: string; + dueOn?: string; + /** Past its date. */ + late: boolean; + expenseId?: string; + operationId?: string; + fundId?: string; +}; +/** + * The name of anyone a snapshot knows, former members included (0031). With + * left, someone who is no longer a member is named "(left)", where a screen + * needs to make that clear. Only someone the snapshot does not know gets the + * fallback words, such as "a former member". + */ +export function personName(members: Member[] | undefined, userId: string | null | undefined, fallback: string, + options: { left?: boolean } = {}): string { + const member = userId ? members?.find((item) => item.userId === userId) : undefined; + if (!member?.name.trim()) return fallback; + return options.left && member.active === false ? `${member.name} (left)` : member.name; +} + +const NEED_ORDER: Need["kind"][] = ["confirm", "pay", "task", "pledge", "owed", "reported"]; +/** Actions in their order, then the earliest date first; anything undated after the dated ones. */ +export function compareNeeds(a: Need, b: Need): number { + return NEED_ORDER.indexOf(a.kind) - NEED_ORDER.indexOf(b.kind) || (a.dueOn ?? "9999").localeCompare(b.dueOn ?? "9999"); +} +function namesList(names: string[]): string { + return names.length < 3 ? names.join(" and ") : `${names.length} people`; +} + +/** + * "Needs you", one rule for Home and This Hub. Actions first, in this order: + * payments someone says they have sent, which only the bill payer can + * confirm; the viewer's unpaid shares; their tasks that are late or due + * today; then their pledges that are due. "Waiting on others" holds what + * people owe the viewer, and payments the viewer said they sent, while + * there is nothing for the viewer to do. A summary snapshot keeps pending + * payment reports (0039), so Home sees them for every commune. + */ +export function needsYou(snapshot: Snapshot, viewerId: string, today = todayIso()): { actions: Need[]; waiting: Need[] } { + const currency = snapshot.commune.currency; + const nameOf = (userId: string) => personName(snapshot.members, userId, "a former member", { left: true }); + const actions: Need[] = []; + const waiting: Need[] = []; + for (const expense of snapshot.expenses) { + if (expense.voidedAt) continue; + const dueOn = expense.dueOn ?? undefined; + const late = !!dueOn && dueOn < today; + const base = { go: "expense" as const, title: expense.title, expenseId: expense.id, dueOn, late }; + const owing: Array<{ userId: string; minor: number }> = []; + for (const share of expense.shares) { + const outstanding = share.amountMinor - share.settledMinor; + if (share.userId === expense.paidByUserId || outstanding <= 0) continue; + const report = share.paymentReports?.find((item) => item.status === "pending"); + if (expense.paidByUserId === viewerId) { + if (report) { + actions.push({ ...base, kind: "confirm", when: `${nameOf(share.userId)} says they sent it`, amount: money(report.amountMinor, currency) }); + } else { + owing.push({ userId: share.userId, minor: outstanding }); + } + } else if (share.userId === viewerId) { + const payer = nameOf(expense.paidByUserId); + if (report) { + waiting.push({ ...base, kind: "reported", when: `You said you sent it · waiting for ${payer}`, amount: money(report.amountMinor, currency) }); + } else { + actions.push({ ...base, kind: "pay", when: `You owe ${payer}${late ? " · overdue" : ""}`, amount: money(outstanding, currency) }); + } + } + } + if (owing.length) { + const who = namesList(owing.map((item) => nameOf(item.userId))); + waiting.push({ ...base, kind: "owed", when: `${who.charAt(0).toUpperCase()}${who.slice(1)} ${owing.length === 1 ? "owes" : "owe"} you`, + amount: money(owing.reduce((total, item) => total + item.minor, 0), currency) }); + } + } + for (const item of snapshot.operations ?? []) { + if (item.kind !== "task" || item.status !== "open" || item.assigneeId !== viewerId || item.date > today) continue; + const late = item.date < today; + actions.push({ kind: "task", go: "operation", title: item.title, when: late ? "Overdue · your turn" : "Your turn today", + operationId: item.id, dueOn: item.date, late }); + } + for (const fund of snapshot.funds) { + if (fund.archivedAt) continue; + for (const pledge of fund.commitments) { + if (pledge.cancelledAt || pledge.userId !== viewerId || pledge.amountMinor <= pledge.contributedMinor || pledge.dueOn > today) continue; + const late = pledge.dueOn < today; + actions.push({ kind: "pledge", go: "funds", title: fund.name, when: `Remaining on your pledge${late ? " · overdue" : ""}`, + amount: money(pledge.amountMinor - pledge.contributedMinor, currency), fundId: fund.id, dueOn: pledge.dueOn, late }); + } + } + return { actions: actions.sort(compareNeeds), waiting: waiting.sort(compareNeeds) }; +} +export type ActivityEventKind = "funds" | "members" | "recurring" | "templates" | "notes" | "operations" | "expenses" | "announcements"; +/** The record type an event's area suggests, while the database does not name one (before 0039). */ +const EVENT_TYPES: Record = { + funds: "fund", members: "member", recurring: "recurring", templates: "template", notes: "note", + operations: "task", expenses: "expense", announcements: "announcement", }; const TONES: AvatarTone[] = ["jordan", "alex", "priya", "sam", "ness"]; @@ -473,9 +600,10 @@ export function hubFromSnapshot( }); const fundTotal = snapshot.funds.reduce((sum, fund) => sum + fund.commitments.reduce((subtotal, commitment) => subtotal + commitment.contributedMinor, 0), 0); + const today = todayIso(); // This month where the member is: an expense entered late on the last // evening of a month belongs to that month. - const month = todayIso().slice(0, 7); + const month = today.slice(0, 7); const monthExpenses = snapshot.expenses.filter((expense) => timestampDay(expense.createdAt).startsWith(month)); const monthTotal = monthExpenses .reduce((sum, expense) => sum + expense.amountMinor, 0); @@ -492,21 +620,7 @@ export function hubFromSnapshot( title: note.title, when: notePreview(note), })); - const today = todayIso(); - const personalNeeds: Hub["now"] = [ - ...openRows.filter(row => row.paidByUserId === viewer.id || row.shares.some(share => share.userId === viewer.id && share.settledMinor < share.amountMinor)).map(row => ({ - go: "expense" as const, pill: row.status, tag: row.due, title: row.name, - when: `${row.amount} · ${row.paidBy} paid`, expenseId: row.id, dueOn: row.dueOn ?? undefined, - })), - ...(snapshot.operations ?? []).filter(item => item.kind === "task" && item.status === "open" && item.assigneeId === viewer.id && item.date <= today).map(item => ({ - go: "operation" as const, pill: item.date < today ? "warn" : "open", tag: item.date < today ? "Overdue" : "Today", - title: item.title, when: item.date < today ? "Overdue · your turn" : "Your turn today", operationId: item.id, dueOn: item.date, - })), - ...snapshot.funds.filter(fund => !fund.archivedAt).flatMap(fund => fund.commitments.filter(pledge => !pledge.cancelledAt && pledge.userId === viewer.id && pledge.amountMinor > pledge.contributedMinor && pledge.dueOn <= today).map(pledge => ({ - go: "funds" as const, pill: pledge.dueOn < today ? "warn" : "open", tag: dateLabel(pledge.dueOn), title: fund.name, - when: `${money(pledge.amountMinor - pledge.contributedMinor, currency)} remaining on your pledge`, dueOn: pledge.dueOn, - }))), - ]; + const needs = needsYou(source, viewer.id); return { id: commune.id, currency, @@ -540,7 +654,8 @@ export function hubFromSnapshot( // A date that has passed says so rather than reading as still to come. { go: "expenses", tone: "ink", kicker: "Next due", num: nextDue?.dueOn ? nextDue.dueOn < today ? `Overdue since ${dateLabel(nextDue.dueOn)}` : dateLabel(nextDue.dueOn) : "-", hint: nextDue?.name ?? "No dated open bills" }, ], - now: personalNeeds.sort((a, b) => (a.dueOn ?? "9999").localeCompare(b.dueOn ?? "9999")), + now: needs.actions, + waiting: needs.waiting, moves, members, activity: [...snapshot.expenses.map((expense) => ({ @@ -552,12 +667,14 @@ export function hubFromSnapshot( time: expense.createdAt ? dateLabel(timestampDay(expense.createdAt)) : "Recently", createdAt: expense.createdAt, expenseId: expense.id, + type: "expense" as const, })), ...(snapshot.activityEvents ?? []).map(event => ({ // Announcements live on This Hub. go:event.kind === "announcements" ? "hub" as const : event.kind,cls:members.find(member=>member.userId===event.actorId)?.cls ?? "jordan" as const, ini:initialsOf(names.get(event.actorId) ?? "Member"), title:event.title, when:`${event.description} · ${names.get(event.actorId) ?? "Member"}`, time:dateLabel(timestampDay(event.createdAt)),createdAt:event.createdAt, expenseId:event.kind==="expenses" ? event.entityId : undefined, + type:event.recordType ?? EVENT_TYPES[event.kind], }))].sort((a,b)=>(Date.parse(b.createdAt ?? "")||0)-(Date.parse(a.createdAt ?? "")||0)), notes, jumps: [ diff --git a/apps/web/src/live/neon.ts b/apps/web/src/live/neon.ts index 92cd60d1..6b3fd313 100644 --- a/apps/web/src/live/neon.ts +++ b/apps/web/src/live/neon.ts @@ -171,6 +171,7 @@ function communal(row: Row): Commune { currency: row.currency as CurrencyCode, role: row.role as Commune["role"], memberCount: minor(row.memberCount), address: str(row.address), destination: str(row.destination), stayName: str(row.stayName), stayAddress: str(row.stayAddress), changeMark: str(row.changeMark), + ...(row.unreadNotices == null ? {} : { unreadNotices: minor(row.unreadNotices) }), }; } @@ -471,6 +472,11 @@ async function route(path: string, init: RequestInit): Promise { }); } else if (path === "/reminders/usage" && method === "GET") { response = await reminderUsage(); + } else if (path === "/notices/unread" && method === "GET") { + // Unread notices from every commune the reader belongs to, for Notices. + const notices = unwrap(await db().rpc("my_unread_notices")); + if (!Array.isArray(notices)) throw new ApiError("Notices could not be verified. Please try again.", 502); + response = { notices }; } else if (path === "/preferences" && method === "GET") { // No row yet means the default: reminder emails on. const viewer = await requireUser(); diff --git a/apps/web/src/screens/Funds.tsx b/apps/web/src/screens/Funds.tsx index 62782be3..c01352ba 100644 --- a/apps/web/src/screens/Funds.tsx +++ b/apps/web/src/screens/Funds.tsx @@ -17,7 +17,7 @@ import { } from "../ui/components"; import { Pill } from "../ui/widgets"; import { ReminderFlowDialog, useReminderFlow } from "../ui/ReminderConfirm"; -import { RecipientReminderCount } from "../ui/ReminderUsage"; +import { RecipientReminderCount, ReminderAllowanceNotice } from "../ui/ReminderUsage"; import { formatReminderDay, reminderEligibility } from "../data/reminders"; import { newRequestId } from "../data/requestId"; import { commandFailure, UNCERTAIN_RETRY_SAFE } from "../data/commands"; @@ -72,6 +72,7 @@ export function FundsScreen() { fundKey, closeWindow, contentLoading, + personName, } = useApp(); const [mode, setMode] = useState(null); const [name, setName] = useState(""); @@ -117,7 +118,7 @@ export function FundsScreen() { const pledgerMember = commitment ? hub.members.find((item) => item.userId === commitment.userId) : undefined; - const pledger = pledgerMember?.name ?? "the person who made it"; + const pledger = commitment ? personName(commitment.userId, "the person who made it") : "the person who made it"; const recorded = commitment?.contributedMinor ? money(commitment.contributedMinor, hub.currency) : ""; const pledgeAmountHint = !commitment ? undefined : othersPledge ? `Up to ${money(commitment.amountMinor, hub.currency)}${recorded ? `, and not below the ${recorded} already recorded` : ""}.` @@ -325,7 +326,6 @@ export function FundsScreen() { // Render inline, not as a new component type on every state change: a Chase // dialog must be able to return keyboard focus to the same pledge button. function renderPledge(pledge: Commitment) { - const member = hub.members.find((item) => item.userId === pledge.userId); const remaining = pledge.amountMinor - pledge.contributedMinor; const isMine = pledge.userId === session?.userId; const target = reminderTargets.find( @@ -344,7 +344,7 @@ export function FundsScreen() {
- {member?.name ?? "Former member"} + {personName(pledge.userId, "Former member", { left: true })} {isMine ? " (you)" : ""} @@ -393,7 +393,7 @@ export function FundsScreen() { kind: "chase", sourceId: pledge.id, recipientId: pledge.userId, - name: member?.name ?? "Member", + name: personName(pledge.userId, "Member"), subject: fund!.name, amount: money(remaining, hub.currency), dueOn: pledge.dueOn, @@ -673,6 +673,12 @@ export function FundsScreen() { . These are confirmations, not money held by Commune.

+ {/* An admin chases others' pledges from here. */} + {isAdmin && !fund.archivedAt && fund.commitments.some((pledge) => + pledge.userId !== session?.userId && !pledge.cancelledAt && + reminderTargets.some((item) => item.kind === "chase" && item.sourceId === pledge.id)) ? ( + + ) : null} {fund.commitments.map(renderPledge)} {!fund.commitments.length && (

No pledges yet.

diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index dc143bb3..fc77324e 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -2,9 +2,10 @@ import type { ReactNode } from "react"; import { useApp } from "../state"; import { Avatar, Pill } from "../ui/widgets"; import { CalendarDate } from "../ui/CalendarDate"; -import type { LiveHub } from "../live/model"; +import { compareNeeds, type LiveHub, type Need } from "../live/model"; import { cx } from "../ui/cx"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; +import { ActivityTypeChip, activityStamp, activityWord } from "../ui/ActivityLog"; function PhotoFace({ hub, focus, children }: { hub: LiveHub; focus?: boolean; children: ReactNode }) { return ( @@ -45,13 +46,29 @@ function PlaceCard({ hub, focus = false, level = 3 }: { hub: LiveHub; focus?: bo ); } +/** One row of Needs you or Waiting on others, opening the record it is about in its commune. */ +function NeedRow({ hub, item, quiet = false }: { hub: LiveHub; item: Need; quiet?: boolean }) { + const { go } = useApp(); + return ( + + ); +} + export function HomeScreen() { const { go, places, hubId } = useApp(); if (!places.length) return ; const first = places.find(place => place.id === hubId) ?? places[0]; const others = places.filter(place => place.id !== first.id); - const needs = places.flatMap((hub) => hub.now.map((item) => ({ hub, item }))) - .sort((a, b) => (a.item.dueOn ?? "9999").localeCompare(b.item.dueOn ?? "9999")).slice(0, 6); + // The same rule as This Hub, across every commune: actions in their order, then what others owe. + const needs = places.flatMap((hub) => hub.now.map((item) => ({ hub, item }))).sort((a, b) => compareNeeds(a.item, b.item)); + const waiting = places.flatMap((hub) => hub.waiting.map((item) => ({ hub, item }))).sort((a, b) => compareNeeds(a.item, b.item)); const activity = places.flatMap((hub) => hub.activity.map((item) => ({ hub, item }))) .sort((a, b) => (Date.parse(b.item.createdAt ?? "") || 0) - (Date.parse(a.item.createdAt ?? "") || 0)).slice(0, 6); const incomplete = places.some((hub) => hub.activityState !== "ready"); @@ -64,19 +81,22 @@ export function HomeScreen() {

Needs you

-

Open items across your communes.

+

What you need to do, across your communes.

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

Nothing waiting right now.

: null} + ) : !incomplete ?

Nothing for you to do right now.

: null} + {needs.length > 6 ?

And {needs.length - 6} more.

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

Waiting on others

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

And {waiting.length - 4} more.

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

{partialMessage}

: null}
@@ -86,9 +106,9 @@ export function HomeScreen() {
{activity.length ? activity.map(({ hub, item }, index) => ( )) : !incomplete ?

Activity will appear after something is shared.

: null} {incomplete ?

{partialMessage}

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

No activity yet.

The log starts when something is shared.

; return ( -
- {hub.activity.map((row, index) => ( - - ))} -
+ ({ + key: `${row.title}-${row.createdAt ?? row.time}-${index}`, + item: row, + onOpen: () => go(row.expenseId ? "expense" : row.go, { expense: row.expenseId }), + }))} /> ); } diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css index 43c7fbb9..16be2a76 100644 --- a/apps/web/src/screens/Hub.css +++ b/apps/web/src/screens/Hub.css @@ -46,20 +46,23 @@ /* The commune type's tone shows until the cover arrives, or if it cannot. */ .c-hub-identity.has-photo { color: #fff; background: var(--cover-tone, #1a1e2b); } .c-hub-cover { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center 60%; z-index: -2; } -.c-hub-identity.has-photo::after { content: ''; position: absolute; inset: 0; background: linear-gradient(0deg,#14251acc,transparent 90%); z-index: -1; } +.c-hub-identity.has-photo::after { content: ''; position: absolute; inset: 0; background: linear-gradient(0deg, rgba(20, 37, 26, 0.8) 0, rgba(20, 37, 26, 0.7) 116px, rgba(20, 37, 26, 0.5) 166px, transparent 90%); z-index: -1; } .c-hub-identity-copy { min-width: 0; width: 100%; text-shadow: 0 1px 5px #0004; } .c-hub-type { display: block; font-size: 12px; font-weight: 400; } +/* Over a photo, the shade behind the text is darkest at the bottom; the type + and the buttons above it carry their own (tests/cover-contrast.test.ts). */ +.c-hub-identity.has-photo .c-hub-type { width: fit-content; padding: 2px 9px; border-radius: var(--radius-pill); background: rgba(20, 37, 26, 0.64); } .c-hub .c-hub-identity h2 { margin: 3px 0 6px; font-size: 35px; font-weight: 500; letter-spacing: -1.2px; line-height: 1.2; overflow-wrap: anywhere; } .c-hub-people-count { font-size: 14px; } .c-hub-location { font-size: 14px; } .c-hub-location p { overflow-wrap: anywhere; } -.c-hub-location span { margin-right: 8px; opacity: .75; } +.c-hub-location span { margin-right: 8px; opacity: .85; } .c-hub-people-link { display: flex; align-items: center; gap: 12px; width: fit-content; margin-top: 17px; padding: 0; border: 0; background: none; color: inherit; text-shadow: none; font-size: 12px !important; } .c-hub-people-link .avatar-stack { display: flex; align-items: center; } .c-hub-people-link .avatar { width: 28px; height: 28px; margin-right: -5px; border: 2px solid #fff; font-size: 9px; } .c-hub-people-link .avatar:last-child { margin-right: 0; } .c-hub-hero-actions { position: absolute; right: 22px; top: 20px; display: flex; gap: 7px; } -.c-hub-settings { display: flex; align-items: center; gap: 6px; padding: 9px 12px; min-height: 38px; border: 1px solid #ffffff55; border-radius: var(--radius-control); background: #15221c66; color: #fff; font-size: 12px !important; } +.c-hub-settings { display: flex; align-items: center; gap: 6px; padding: 9px 12px; min-height: 38px; border: 1px solid #ffffff55; border-radius: var(--radius-control); background: rgba(21, 34, 28, 0.65); color: #fff; font-size: 12px !important; } .c-hub-overview { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 20px; } /* The balance card keeps the prototype's pale sage so it reads first. */ @@ -120,6 +123,9 @@ .c-hub-attention-copy { display: flex; align-items: center; gap: 12px; } .c-hub-icon-chip { width: 37px; height: 37px; flex: none; border-radius: 10px; background: var(--hub-quiet); color: var(--hub-heading-icon); display: grid; place-items: center; } .c-hub-empty .c-hub-button { margin-top: 18px; } +/* Needs you: what others owe, with nothing to do, comes after the actions and quieter. */ +.c-hub-waiting { margin-top: 18px; } +.c-hub .c-hub-waiting h3 { margin-bottom: 2px; color: var(--hub-heading-icon); font-size: 11px; font-weight: 550; letter-spacing: .6px; text-transform: uppercase; } .c-hub-note-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 16px 0; border-right: 0; border-bottom: 0; border-left: 0; background: none; color: var(--hub-ink); text-align: left; } .c-hub-note-row > span { min-width: 0; } diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index 37e2ee7f..e27a00b0 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -30,7 +30,7 @@ function HubSection({ title, icon, context, count, action, children }: { ; } -function HubLocation({ hub }: { hub: Hub }) { +function HubLocation({ hub }: { hub: Pick }) { if (hub.type === "Trip") { if (!hub.destination && !hub.stayName && !hub.stayAddress) return null; return
@@ -113,12 +113,12 @@ const announcementDate = new Intl.DateTimeFormat("en-GB", { day: "numeric", mont /** Notices from an admin. Important ones ask each member to confirm they have seen them. */ function Announcements() { - const { announcements, hub, you, acknowledgeAnnouncement, archiveAnnouncement, go } = useApp(); + const { announcements, hub, you, acknowledgeAnnouncement, archiveAnnouncement, go, personName } = useApp(); const [pending, setPending] = useState(null); const [confirmDown, setConfirmDown] = useState(null); const [error, setError] = useState(""); if (!announcements.length) return null; - const nameOf = (userId: string) => hub.members.find(member => member.userId === userId)?.name ?? "A former member"; + const nameOf = (userId: string) => personName(userId, "A former member"); async function run(id: string, action: () => Promise) { setPending(id); setError(""); try { await action(); setConfirmDown(null); } @@ -159,7 +159,7 @@ function Announcements() { } export function HubScreen() { - const { hub, go, notes, expenses, operations, you, announcements } = useApp(); + const { hub, go, notes, expenses, you, announcements } = useApp(); const entries = Object.entries(notes); const pinned = entries.filter(([, note]) => isNotePinned(note)); const priority = ["Emergency", "Keys / door", "Wi-Fi", "Contacts", "Bins", "House rules", "Other"]; @@ -167,13 +167,15 @@ export function HubScreen() { const rank = (note: typeof a[1]) => { const index = priority.indexOf(noteCategory(note)); return index < 0 ? priority.length : index; }; return rank(a[1]) - rank(b[1]); }); - const outstanding = hub.moves.filter(move => !move.even); - const yours = operations.filter(item => item.status === "open" && item.kind === "task" && item.assigneeId === you.id && item.date <= todayIso()); - const yourPledges = hub.now.filter(item => item.go === "funds"); + // Needs you follows the same rule as Home: payments to confirm and shares + // to pay, then tasks, then pledges. What others owe waits separately. + const money = hub.now.filter(item => item.kind === "confirm" || item.kind === "pay"); + const yours = hub.now.filter(item => item.kind === "task"); + const yourPledges = hub.now.filter(item => item.kind === "pledge"); // Important notices this member has not yet confirmed. const toConfirm = announcements.filter(item => item.needsAcknowledgement && item.authorId !== you.id && !item.acknowledgements.some(entry => entry.userId === you.id)); - const attentionCount = outstanding.length + yours.length + yourPledges.length + toConfirm.length; + const attentionCount = hub.now.length + toConfirm.length; const canInvite = hub.role === "admin"; return
@@ -199,19 +201,26 @@ export function HubScreen() {
- {outstanding.slice(0, 3).map((move, index) =>
- {move.why}{move.names} - {move.amt} + {money.slice(0, 3).map((item, index) =>
+ {item.title}{item.when} + {item.amount}
)} {toConfirm.slice(0, 2).map(item =>
{item.title}Announcement · please confirm you have seen it
)} - {yours.slice(0, 2).map(item => )} - {yourPledges.slice(0, 2).map((item, index) => )} + {yours.slice(0, 2).map(item => )} + {yourPledges.slice(0, 2).map((item, index) => )} {!attentionCount &&

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

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

Waiting on others

+ {hub.waiting.slice(0, 3).map((item, index) => )} + {hub.waiting.length > 3 && } +
}
diff --git a/apps/web/src/screens/Members.tsx b/apps/web/src/screens/Members.tsx index 18636342..25350730 100644 --- a/apps/web/src/screens/Members.tsx +++ b/apps/web/src/screens/Members.tsx @@ -85,7 +85,7 @@ function NewInviteLinkDialog({ invite, onClose, onDone }: { } export function MembersScreen() { - const { hub, go, session, snapshots, invites, revokeInvite, manageMember } = + const { hub, go, session, snapshots, invites, revokeInvite, manageMember, personName } = useApp(); const [pending, setPending] = useState(false); const [error, setError] = useState(""); @@ -96,11 +96,10 @@ export function MembersScreen() { (invite.status === "pending" || invite.status === "expired") && invite.email !== null); const pastInvites = invites.filter((invite) => invite.status === "accepted" || invite.status === "revoked").slice(0, 12); // Someone who accepted and has since left keeps their name, not their address. - const invitee = (invite: PendingInvite) => invite.email - ?? snapshots[hub.id]?.members.find((member) => member.userId === invite.acceptedBy)?.name ?? "Former member"; + const invitee = (invite: PendingInvite) => invite.email ?? personName(invite.acceptedBy, "Former member"); // Who sent an invitation. It stays open only while they are an admin. const sender = (invite: PendingInvite) => invite.invitedBy === session?.userId ? "you" - : invite.invitedByName ?? (invite.invitedBy ? "a former member" : ""); + : invite.invitedByName ?? (invite.invitedBy ? personName(invite.invitedBy, "a former member") : ""); const pendingSentBy = (userId: string) => openInvites.filter((invite) => invite.status === "pending" && invite.invitedBy === userId).length; const [action, setAction] = useState<{ diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx index 4ca62d38..5d629f5b 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -2,13 +2,14 @@ import { useState } from "react"; import { useApp } from "../state"; import { Avatar, Pill } from "../ui/widgets"; import { cx } from "../ui/cx"; -import { RecipientReminderCount } from "../ui/ReminderUsage"; +import { RecipientReminderCount, ReminderAllowanceNotice } from "../ui/ReminderUsage"; import { Plus, ReceiptText, ChevronRight, Search } from "lucide-react"; import { LIMITS } from "../data/limits"; import { ReminderFlowDialog, useReminderFlow } from "../ui/ReminderConfirm"; import { formatReminderDay, reminderEligibility } from "../data/reminders"; import { LatestReportAnswer, pendingReport, reportSummary, useReceiptActions } from "../ui/Receipts"; import { CalendarDate } from "../ui/CalendarDate"; +import { initialsOf } from "../data/household"; import "./Money.css"; function formatMoney(minor: number, currency: string): string { @@ -142,7 +143,7 @@ export function ExpensesEmptyScreen() { } export function SettleScreen() { - const { go, hub, expenses, session, reminderTargets, reminderUsage, sentReminders, sendReminder } = useApp(); + const { go, hub, expenses, session, reminderTargets, reminderUsage, sentReminders, sendReminder, personName } = useApp(); const reminder = useReminderFlow(sendReminder); const [message, setMessage] = useState(""); const receipts = useReceiptActions(setMessage); @@ -159,6 +160,8 @@ export function SettleScreen() { const youAreOwed = moves .filter(({ expense }) => expense.paidByUserId === me) .reduce((total, { outstanding }) => total + outstanding, 0); + // Nudges are offered on what you are owed, unless they say they have sent it. + const nudgeable = moves.some(({ expense, share }) => expense.paidByUserId === me && !pendingReport(share)); // The receipt ledger is the confirmation history: every entry the viewer // recorded or had recorded against their own share, newest first. const recent = expenses.flatMap((expense) => expense.shares @@ -174,9 +177,13 @@ export function SettleScreen() {

{receivingGroup ? "You’re owed" : "You owe"}

{formatMoney(receivingGroup ? youAreOwed : youOwe, hub.currency)}

{receivingGroup ? "Record each repayment after you receive it, in full or in part." : "Pay outside Commune, then tell the bill payer. They confirm receipt."}

+ {receivingGroup && nudgeable ? : null} {moves.filter(({ expense }) => (expense.paidByUserId === me) === receivingGroup).map(({ expense, share, outstanding }) => { const payer = hub.members.find((member) => member.userId === expense.paidByUserId); const debtor = hub.members.find((member) => member.userId === share.userId); + // Someone who has left keeps their name, marked as having left. + const payerName = personName(expense.paidByUserId, "the bill payer"); + const debtorName = personName(share.userId, "this member"); const receiving = expense.paidByUserId === me; const target = receiving ? reminderTargets.find((item) => item.kind === "nudge" && item.sourceId === share.id) @@ -189,15 +196,15 @@ export function SettleScreen() { className="a-move" key={share.id} > - +
-
{receiving ? `${debtor?.name ?? "Member"} owes you` : `You owe ${payer?.name ?? "the bill payer"}`}
+
{receiving ? `${personName(share.userId, "A former member", { left: true })} owes you` : `You owe ${personName(expense.paidByUserId, "the bill payer", { left: true })}`}
{expense.name}{expense.dueOn ? ` · due ${expense.dueOn}` : ""}
{share.settledMinor > 0 ?
{formatMoney(share.settledMinor, hub.currency)} of {formatMoney(share.amountMinor, hub.currency)} received
: null} {report ?
{receiving - ? `${reportSummary(report, hub.currency, debtor?.name ?? "A member")}. Not yet confirmed.` - : `You said you sent ${formatMoney(report.amountMinor, hub.currency)} on ${formatReminderDay(report.sentOn)}. Waiting for ${payer?.name ?? "the bill payer"}.`}
: null} - {!receiving ? hub.members.find((member) => member.userId === userId)?.name ?? "The bill payer"} /> : null} + ? `${reportSummary(report, hub.currency, personName(share.userId, "A member"))}. Not yet confirmed.` + : `You said you sent ${formatMoney(report.amountMinor, hub.currency)} on ${formatReminderDay(report.sentOn)}. Waiting for ${payerName}.`}
: null} + {!receiving ? personName(userId, "The bill payer")} /> : null} {receiving && (target || (reminderUsage?.recipientUsage[share.userId] ?? 0) > 0) ? ( ) : null} @@ -205,20 +212,20 @@ export function SettleScreen() {
{formatMoney(outstanding, hub.currency)}
{receiving && report ? (
- -
) : receiving ? (
- - {reminderReason &&

{reminderReason}

}
@@ -230,8 +237,8 @@ export function SettleScreen() {
) : (
-
)} @@ -246,9 +253,10 @@ export function SettleScreen() { const debtor = hub.members.find((member) => member.userId === share.userId); const payer = hub.members.find((member) => member.userId === expense.paidByUserId); const mine = share.userId === me; + const other = mine ? personName(expense.paidByUserId, "the bill payer", { left: true }) : personName(share.userId, "A former member", { left: true }); return
diff --git a/apps/web/src/screens/Search.tsx b/apps/web/src/screens/Search.tsx index 16358500..92978e93 100644 --- a/apps/web/src/screens/Search.tsx +++ b/apps/web/src/screens/Search.tsx @@ -1,6 +1,6 @@ import { useApp } from "../state"; -import { Button, EmptyState, Panel } from "../ui/components"; -import { Avatar } from "../ui/widgets"; +import { Button, EmptyState } from "../ui/components"; +import { ActivityLog } from "../ui/ActivityLog"; import { SEARCH_MAX_QUERY } from "../live/search"; import { SearchResults } from "../ui/SearchResults"; import { useRecordSearch } from "../ui/useRecordSearch"; @@ -20,30 +20,18 @@ export function AllActivityScreen() { <>

All your communes

{rows.length ? ( - - {rows.map(({ hub, item }, index) => ( - - ))} - + ({ + key: `${hub.id}-${index}`, + item, + commune: hub.name, + onOpen: () => + go(item.expenseId ? "expense" : item.go, { + hub: hub.id, + expense: item.expenseId, + }), + }))} + /> ) : ( !incomplete && ( (null); const [pending, setPending] = useState(false); const [error, setError] = useState(""); @@ -185,14 +185,15 @@ export function TemplatesScreen() {
{[false, true].map((value) => ( - + ))}
{accountRole && {accountRole}} - {accountRole && reminderUsage && } -
@@ -245,14 +242,15 @@ export function Header({ screen }: { screen: ScreenId }) { native, you, noticesOn, - notifications, + noticeCounts, hubId, session, window: currentWindow, } = useApp(); const [searchOpen, setSearchOpen] = useState(false); const searchingAll = ["home", "home-empty", "communes", "home-activity"].includes(screen); - const unread = noticesOn ? notifications.filter((notice) => !notice.readAt).length : 0; + // Every commune's unread notices, wherever the reader is. + const unread = noticesOn ? Object.values(noticeCounts).reduce((total, count) => total + count, 0) : 0; useEffect(() => { function shortcut(event: KeyboardEvent) { if (event.isComposing || event.repeat) return; diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 13583d4c..748f631e 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -55,6 +55,7 @@ import { expenseRows, hubFromSnapshot, money, + personName, type Commune, type CurrencyCode, type Expense, @@ -79,6 +80,7 @@ import { type SplitTemplate, type Announcement, type AnnouncementResult, + type UnreadNotice, } from "./live/model"; export type Seed = "new"; @@ -310,6 +312,19 @@ export type AppState = { acknowledgeAnnouncement: (id: string) => Promise; archiveAnnouncement: (id: string) => Promise; notifications: Notification[]; + /** + * The name of anyone the open commune's records know, former members + * included. left: name someone who has left as "Name (left)". fallback: + * the words for someone the records do not know. + */ + personName: (userId: string | null | undefined, fallback: string, options?: { left?: boolean }) => string; + /** + * Unread notices in each of the reader's communes, by commune id. Each + * comes with the commune list, so the bell counts every commune. + */ + noticeCounts: Record; + /** Unread notices from every commune the reader belongs to, for Notices. */ + loadUnreadNotices: () => Promise; /** Clears this tab, then signs out. Never throws: see signOutUnconfirmed. */ signOut: () => Promise; /** The last sign-out cleared this tab but the server did not confirm it. */ @@ -416,7 +431,8 @@ export type AppState = { manageMember: (userId: string, action: "admin" | "member" | "leave" | "remove") => Promise; /** requestId stays the same across retries so one decision sends once. */ sendReminder: (kind: "nudge" | "chase", sourceId: string, requestId: string) => Promise; - markNotificationRead: (id: string) => Promise; + /** communeId: the notice's commune, when it is not the open one. */ + markNotificationRead: (id: string, communeId?: string) => Promise; pageTitle: string; pageLede: string; }; @@ -469,6 +485,7 @@ function emptyHub(): LiveHub { credit: false, kpis: [], now: [], + waiting: [], moves: [], members: [], activity: [], @@ -1140,6 +1157,10 @@ export function AppProvider({ children }: { children: ReactNode }) { const sentReminders = snapshot?.sentReminders ?? []; const announcements = snapshot?.announcements ?? []; const notifications = snapshot?.notifications ?? []; + // The open commune's count is the snapshot's own until the database sends + // counts (0039); every refresh of a commune brings its count up to date. + const noticeCounts: Record = Object.fromEntries((data?.communes ?? []).map((commune) => [commune.id, + commune.unreadNotices ?? (commune.id === activeId ? notifications.filter((notice) => !notice.readAt).length : 0)])); const you = { id: data?.user.id ?? "", name: data?.user.name ?? "", @@ -1150,11 +1171,12 @@ export function AppProvider({ children }: { children: ReactNode }) { // A normal mutation does not need another sign-in/list/invitations waterfall. // Refresh just its commune, and keep confirmed content visible throughout. - const refreshRecords = useCallback(async () => { + // Usually the open one; reading a notice from another commune refreshes that. + const refreshCommune = useCallback(async (communeId: string) => { const current = dataRef.current; - const commune = current?.communes.find(item => item.id === activeId); + const commune = current?.communes.find(item => item.id === communeId); if (!current || !commune) return; - const cacheKey = `${current.user.id}:${activeId}`; + const cacheKey = `${current.user.id}:${communeId}`; const revision = (snapshotRevisions.current.get(cacheKey) ?? 0) + 1; snapshotRevisions.current.set(cacheKey, revision); const sequence = refreshSequence.current; @@ -1163,23 +1185,24 @@ export function AppProvider({ children }: { children: ReactNode }) { try { const snapshot = await loadSnapshot(commune); const latest = dataRef.current; - if (!stillCurrent() || !latest || !latest.communes.some(item => item.id === activeId)) return; - if (snapshot.commune.id !== activeId) throw new ApiError("Commune response did not match the request", 502); - publishData({ ...latest, communes: latest.communes.map(item => item.id === activeId ? { ...item, ...snapshot.commune } : item), snapshots: { ...latest.snapshots, [activeId]: snapshot } }); - setSnapshotErrors(errors => { const next = { ...errors }; delete next[activeId]; return next; }); + if (!stillCurrent() || !latest || !latest.communes.some(item => item.id === communeId)) return; + if (snapshot.commune.id !== communeId) throw new ApiError("Commune response did not match the request", 502); + publishData({ ...latest, communes: latest.communes.map(item => item.id === communeId ? { ...item, ...snapshot.commune } : item), snapshots: { ...latest.snapshots, [communeId]: snapshot } }); + setSnapshotErrors(errors => { const next = { ...errors }; delete next[communeId]; return next; }); } catch (error) { const latest = dataRef.current; if (!stillCurrent() || !latest) return; - setSnapshotErrors(errors => ({ ...errors, [activeId]: error instanceof Error ? error.message : "Could not refresh saved activity" })); + setSnapshotErrors(errors => ({ ...errors, [communeId]: error instanceof Error ? error.message : "Could not refresh saved activity" })); if (error instanceof ApiError && [401, 403, 404].includes(error.status)) { const snapshots = { ...latest.snapshots }; - delete snapshots[activeId]; + delete snapshots[communeId]; publishData({ ...latest, snapshots }); } // The write already committed. Do not invite a duplicate submission when // only the following read failed; expose Retry without losing the data. } - }, [activeId, publishData]); + }, [publishData]); + const refreshRecords = useCallback(() => refreshCommune(activeId), [activeId, refreshCommune]); // Older expenses of one commune, a page at a time: whole months (UTC) going // back from where the loaded ones start, or all of them when months is left @@ -1584,10 +1607,13 @@ export function AppProvider({ children }: { children: ReactNode }) { })]); return result; }), [mutate, refreshRecords, publishData]); - const markNotificationRead = useCallback((id: string) => mutate(async () => { + // A notice from another commune changes that commune's notices and count. + const markNotificationRead = useCallback((id: string, communeId?: string) => mutate(async () => { await post(`/notifications/${encodeURIComponent(id)}/read`, {}); - await refreshRecords(); - }), [mutate, refreshRecords]); + await refreshCommune(communeId ?? activeId); + }), [activeId, mutate, refreshCommune]); + const loadUnreadNotices = useCallback(async () => + (await api<{ notices: UnreadNotice[] }>("/notices/unread")).notices, []); const addNote = useCallback((card: NoteCard & { id: string }, requestId: string) => mutate(async () => { if (!activeId) throw new Error("Choose a commune first"); @@ -1776,6 +1802,7 @@ export function AppProvider({ children }: { children: ReactNode }) { lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses, expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory, funds, recurringBills, splitTemplates, expenseTemplateId, reminderTargets, reminderUsage, sentReminders, notifications, + noticeCounts, loadUnreadNotices, personName: (userId, fallback, options) => personName(data?.snapshots[activeId]?.members, userId, fallback, options), signOut, signOutUnconfirmed, retrySignOut: confirmSignOut, sessionNotice, go, changePassword, signOutOtherDevices, closeWindow: () => { if (!mutationCount) leaveRecord(); }, watchTypedText, leaveAsked, answerLeave, setMoreOpen, diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index e5405dcc..8a16f64c 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -527,8 +527,7 @@ font-size: 13px; } -.a-side-account-role, -.a-side-account-separator { +.a-side-account-role { font-size: 11px; color: rgba(255, 255, 255, 0.6); } @@ -541,8 +540,7 @@ white-space: nowrap; } -.a-side-account-role, -.a-side-account-separator { +.a-side-account-role { flex-shrink: 0; } @@ -557,38 +555,6 @@ position: relative; } -.a-side-account .a-reminder-usage--sidebar { - position: static; - margin: 0; - width: auto; - font-size: 10px; - color: rgba(255, 255, 255, 0.65); -} - -/* 24px tall, reaching into the space below, so the line keeps its place. */ -.a-side-account .a-reminder-usage--sidebar summary { - min-height: 24px; - gap: 3px; - padding: 0 0 6px; - margin-bottom: -6px; - border: 0; - border-radius: 4px; -} - -.a-side-account .a-reminder-usage--sidebar summary strong { color: inherit; } -.a-side-account .a-reminder-usage--sidebar summary:hover { color: #fff; } -.a-side-account .a-reminder-usage--sidebar .a-reminder-usage-details { - top: auto; - bottom: calc(100% + 8px); - right: auto; - left: 0; - width: 100%; - max-width: calc(100vw - 24px); - max-height: calc(100vh - 160px); - overflow-y: auto; - color: var(--ink); -} - .a-main { min-width: 0; display: flex; @@ -3221,6 +3187,8 @@ button.a-item { z-index: 0; } +/* Darkest behind the text at the bottom, so it stays readable over a bright + photo (tests/cover-contrast.test.ts). The top of the photo is as before. */ .a-place-hero::after { content: ""; position: absolute; @@ -3228,9 +3196,11 @@ button.a-item { z-index: 1; pointer-events: none; background: linear-gradient( - 180deg, - rgba(26, 30, 46, 0.08) 0%, - rgba(26, 30, 46, 0.72) 100% + 0deg, + rgba(26, 30, 46, 0.72) 0, + rgba(26, 30, 46, 0.66) 104px, + rgba(26, 30, 46, 0.3) 140px, + rgba(26, 30, 46, 0.08) 100% ); } @@ -3246,7 +3216,7 @@ button.a-item { border-radius: 999px; display: inline-flex; align-items: center; - background: rgba(245, 241, 234, 0.16); + background: rgba(26, 30, 46, 0.64); border: 1px solid rgba(245, 241, 234, 0.2); font-size: 10px; font-weight: 700; @@ -3264,7 +3234,7 @@ button.a-item { .a-place-hero p { margin: 0; font-size: 14px; - color: rgba(248, 245, 240, 0.82); + color: rgba(248, 245, 240, 0.9); } .a-place-hero .a-place-location, @@ -3406,6 +3376,7 @@ button.a-item { transform: scale(1.03); } +/* As the smaller cards: darkest behind the text (tests/cover-contrast.test.ts). */ .a-focus-hero::after { content: ""; position: absolute; @@ -3413,9 +3384,13 @@ button.a-item { z-index: 1; pointer-events: none; background: linear-gradient( - 180deg, - rgba(26, 30, 46, 0.08) 0%, - rgba(26, 30, 46, 0.7) 100% + 0deg, + rgba(26, 30, 46, 0.72) 0, + rgba(26, 30, 46, 0.68) 112px, + rgba(26, 30, 46, 0.64) 130px, + rgba(26, 30, 46, 0.52) 178px, + rgba(26, 30, 46, 0.26) 214px, + rgba(26, 30, 46, 0.08) 100% ); } @@ -3431,7 +3406,7 @@ button.a-item { border-radius: 999px; display: inline-flex; align-items: center; - background: rgba(245, 241, 234, 0.16); + background: rgba(26, 30, 46, 0.64); border: 1px solid rgba(245, 241, 234, 0.2); font-size: 11px; font-weight: 700; @@ -3450,7 +3425,7 @@ button.a-item { margin: 0; max-width: 34rem; font-size: 15px; - color: rgba(248, 245, 240, 0.82); + color: rgba(248, 245, 240, 0.9); } .a-focus-pair { diff --git a/apps/web/src/styles/design-system.css b/apps/web/src/styles/design-system.css index ebee3f2d..98cd4b39 100644 --- a/apps/web/src/styles/design-system.css +++ b/apps/web/src/styles/design-system.css @@ -186,7 +186,7 @@ .c-result-row:last-child { border-bottom: 0; } -.c-result-row > span:not(.avatar) { +.c-result-row > span:not(.avatar, .c-type-chip) { flex: 1; min-width: 0; } @@ -208,6 +208,65 @@ .c-result-row:hover { background: var(--paper); } +/* The kind of record behind an activity row, as a coloured icon chip: + money in slate, plans in sage, what everyone shares in cream. */ +.c-type-chip { + width: 36px; + height: 36px; + flex: none; + border-radius: 10px; + display: grid; + place-items: center; +} +.c-type-chip.is-money { + background: #edf0f3; + color: #637689; +} +.c-type-chip.is-plan { + background: #edf3eb; + color: #2d6a4f; +} +.c-type-chip.is-shared { + background: #f5f1ea; + color: #60706b; +} +.a-body.is-dark .c-type-chip.is-money { + background: #2a3440; + color: #b5c6bb; +} +.a-body.is-dark .c-type-chip.is-plan { + background: #253b34; + color: #a8cbb5; +} +.a-body.is-dark .c-type-chip.is-shared { + background: #2e3646; + color: #b5bdca; +} +/* Activity, a day at a time; Notices, a commune at a time; Home's Waiting on + others after what needs you. Each headed like search results. */ +.c-log-day + .c-log-day, +.c-notice-group + .c-notice-group, +.c-needs-waiting { + margin-top: 22px; +} +:is(.c-log-day, .c-notice-group, .c-needs-waiting) > h3 { + margin: 0 0 8px; + color: var(--ink-soft); + font-size: 11px; + font-weight: 550; + letter-spacing: 0.6px; + text-transform: uppercase; +} +/* The reminder allowance, said where reminders are sent once it runs low. */ +.c-allowance-note { + margin: 12px 0; + padding: 8px 12px; + border-radius: var(--radius-control); + background: var(--paper); + color: var(--ink); + font-size: 12px; + line-height: 1.6; +} .c-scope-label { font-size: 12px; color: var(--ink-soft); diff --git a/apps/web/src/ui/ActivityLog.tsx b/apps/web/src/ui/ActivityLog.tsx new file mode 100644 index 00000000..c0e6fb18 --- /dev/null +++ b/apps/web/src/ui/ActivityLog.tsx @@ -0,0 +1,122 @@ +import { useId } from "react"; +import { + ArrowRightLeft, CalendarDays, Clock3, FileText, Files, HandCoins, Handshake, Megaphone, ReceiptText, Repeat2, + ShoppingBag, SquareCheck, UsersRound, type LucideIcon, +} from "lucide-react"; +import type { ActivityType } from "../data/household"; +import { addDays, dateFromIso, timestampDay, todayIso } from "../data/operations"; +import type { LiveHub } from "../live/model"; +import { Panel } from "./components"; + +type ActivityItem = LiveHub["activity"][number]; + +/** + * Each kind of record: its word, the icon its own screen uses, and the chip + * colour that sets money apart from plans and from what everyone shares. + */ +const TYPES: Record = { + expense: { word: "Expense", icon: ReceiptText, tone: "money" }, + repayment: { word: "Repayment", icon: ArrowRightLeft, tone: "money" }, + fund: { word: "Fund", icon: ShoppingBag, tone: "money" }, + pledge: { word: "Pledge", icon: Handshake, tone: "money" }, + contribution: { word: "Contribution", icon: HandCoins, tone: "money" }, + recurring: { word: "Recurring bill", icon: Repeat2, tone: "money" }, + template: { word: "Saved split", icon: Files, tone: "money" }, + task: { word: "Task", icon: SquareCheck, tone: "plan" }, + event: { word: "Event", icon: CalendarDays, tone: "plan" }, + note: { word: "Note", icon: FileText, tone: "shared" }, + member: { word: "Member", icon: UsersRound, tone: "shared" }, + announcement: { word: "Announcement", icon: Megaphone, tone: "shared" }, +}; +// A kind this version of the app does not know yet still gets a calm row. +const UNKNOWN = { word: "", icon: Clock3, tone: "shared" as const }; + +/** The word for a kind of record, such as "Event". */ +export function activityWord(type: ActivityType | undefined): string { + return (type && TYPES[type]?.word) || UNKNOWN.word; +} + +/** The kind of record as a coloured icon chip; the row names it in words. */ +export function ActivityTypeChip({ type }: { type: ActivityType | undefined }) { + const { icon: Glyph, tone } = (type && TYPES[type]) || UNKNOWN; + return ; +} + +/** The time of day something was recorded, where the member is ("14:05"). */ +export function activityClock(createdAt: string | undefined): string { + const date = createdAt ? new Date(createdAt) : null; + if (!date || Number.isNaN(date.getTime())) return ""; + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +function relativeDay(day: string, today: string): string { + return day === today ? "Today" : day === addDays(today, -1) ? "Yesterday" : ""; +} + +// A calendar day (YYYY-MM-DD) in words, with the year only when it is not this one. +function dayWords(day: string, today: string, style: "long" | "short"): string { + const options: Intl.DateTimeFormatOptions = style === "long" + ? { weekday: "long", day: "numeric", month: "long" } : { day: "numeric", month: "short" }; + if (day.slice(0, 4) !== today.slice(0, 4)) options.year = "numeric"; + return new Intl.DateTimeFormat("en-GB", options).format(dateFromIso(day)); +} + +/** A day heading: Today, Yesterday, or the weekday and date. */ +export function activityDayHeading(day: string, today = todayIso()): string { + if (!day) return "Earlier"; + return relativeDay(day, today) || dayWords(day, today, "long"); +} + +/** When, in one short line for a row without a day heading: "Today, 14:05" or "5 Oct, 09:30". */ +export function activityStamp(item: Pick, today = todayIso()): string { + const day = timestampDay(item.createdAt); + if (!day) return item.time; + return `${relativeDay(day, today) || dayWords(day, today, "short")}, ${activityClock(item.createdAt)}`; +} + +/** Rows already in order, grouped under the local day each was recorded on. */ +export function groupByDay(rows: T[], createdAt: (row: T) => string | undefined): Array<{ day: string; rows: T[] }> { + const groups: Array<{ day: string; rows: T[] }> = []; + for (const row of rows) { + const day = timestampDay(createdAt(row)); + const last = groups[groups.length - 1]; + if (last && last.day === day) last.rows.push(row); + else groups.push({ day, rows: [row] }); + } + return groups; +} + +export type ActivityLogRow = { key: string; item: ActivityItem; commune?: string; onOpen: () => void }; + +/** + * The activity log: rows newest first under a heading for each day, each + * naming the kind of record in an icon and a word, and the time it was recorded. + */ +export function ActivityLog({ rows }: { rows: ActivityLogRow[] }) { + const headingId = useId(); + const today = todayIso(); + return ( +
+ {groupByDay(rows, (row) => row.item.createdAt).map((group, index) => ( +
+

{activityDayHeading(group.day, today)}

+ + {group.rows.map(({ key, item, commune, onOpen }) => { + const time = activityClock(item.createdAt); + return ( + + ); + })} + +
+ ))} +
+ ); +} diff --git a/apps/web/src/ui/NoteHistory.tsx b/apps/web/src/ui/NoteHistory.tsx index 273ba5de..1b8da28b 100644 --- a/apps/web/src/ui/NoteHistory.tsx +++ b/apps/web/src/ui/NoteHistory.tsx @@ -11,7 +11,7 @@ function maskSecrets(body: string, facts: Array<[string, string]>): string { /** Earlier versions of a note: read any of them, or restore one as the newest. */ export function NoteHistory({ noteId }: { noteId: string }) { - const { notes, hub, loadNoteRevision, updateNote } = useApp(); + const { notes, personName, loadNoteRevision, updateNote } = useApp(); const note = notes[noteId]; const [open, setOpen] = useState(null); const [busy, setBusy] = useState(false); @@ -19,7 +19,7 @@ export function NoteHistory({ noteId }: { noteId: string }) { const [message, setMessage] = useState(""); const revisions = note?.revisions ?? []; if (!note || !revisions.length) return null; - const name = (userId: string | null | undefined) => hub.members.find((member) => member.userId === userId)?.name ?? "a former member"; + const name = (userId: string | null | undefined) => personName(userId, "a former member"); async function view(id: string) { setBusy(true); setError(""); setMessage(""); try { diff --git a/apps/web/src/ui/ReminderUsage.tsx b/apps/web/src/ui/ReminderUsage.tsx index 15590f42..445a0248 100644 --- a/apps/web/src/ui/ReminderUsage.tsx +++ b/apps/web/src/ui/ReminderUsage.tsx @@ -7,13 +7,18 @@ export function reminderLimitReached(usage: Usage | null, recipientId: string): ); } -export function ReminderUsageMeter({ usage, placement = "default" }: { usage: Usage | null; placement?: "default" | "sidebar" | "account" }) { - if (!usage) return null; - const full = usage.totalUsed >= usage.totalLimit; +// When the oldest reminder in the last 30 days stops counting. +function nextSlotLabel(usage: Usage): string | null { const next = usage.nextSlotAt ? new Date(usage.nextSlotAt) : null; - const nextLabel = next && !Number.isNaN(next.valueOf()) + return next && !Number.isNaN(next.valueOf()) ? new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }).format(next) : null; +} + +export function ReminderUsageMeter({ usage, placement = "default" }: { usage: Usage | null; placement?: "default" | "account" }) { + if (!usage) return null; + const full = usage.totalUsed >= usage.totalLimit; + const nextLabel = nextSlotLabel(usage); return (
{ // Close only this popover, not the window or dialog around it. @@ -26,9 +31,7 @@ export function ReminderUsageMeter({ usage, placement = "default" }: { usage: Us }}> {/* Named from what it shows, with the allowance in words after it. */} - {placement === "sidebar" - ? {Math.max(0, usage.totalLimit - usage.totalUsed)} reminders left - : <>Reminders} + Reminders {" "}({usage.totalUsed} of {usage.totalLimit} used in the last 30 days)
@@ -50,6 +53,24 @@ export function ReminderUsageMeter({ usage, placement = "default" }: { usage: Us ); } +/** + * Where reminders are sent: a short notice once a fifth of the allowance or + * less is left. Until then nothing; the confirmation shows the allowance. + */ +export function ReminderAllowanceNotice({ usage }: { usage: Usage | null }) { + if (!usage || usage.totalLimit <= 0) return null; + const left = Math.max(0, usage.totalLimit - usage.totalUsed); + if (left * 5 > usage.totalLimit) return null; + const next = nextSlotLabel(usage); + return ( +

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

+ ); +} + export function RecipientReminderCount({ usage, recipientId }: { usage: Usage | null; recipientId: string }) { if (!usage) return null; return ( diff --git a/apps/web/src/windows/OperationsWindows.tsx b/apps/web/src/windows/OperationsWindows.tsx index 0385ac3a..68d32e05 100644 --- a/apps/web/src/windows/OperationsWindows.tsx +++ b/apps/web/src/windows/OperationsWindows.tsx @@ -47,6 +47,7 @@ export function OperationView() { hub, mutationPending, you, + personName, } = useApp(); const [confirmDelete, setConfirmDelete] = useState(false); // One small panel at a time: skip with a reason, or hand over the turn. @@ -178,7 +179,7 @@ export function OperationView() { hub.members.find((member) => member.userId === userId)?.name ?? "A former member"} + nameOf={(userId) => personName(userId, "A former member")} disabled={mutationPending} onUndo={(occurrenceId) => run(() => undoOperation(occurrenceId), "Undone. The task is back as it was.")} /> diff --git a/apps/web/src/windows/Windows.tsx b/apps/web/src/windows/Windows.tsx index f5761397..b9d77f9b 100644 --- a/apps/web/src/windows/Windows.tsx +++ b/apps/web/src/windows/Windows.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState, type CSSProperties } from "react"; -import { NOTE_BODY_HINT, type NoteCard } from "../data/household"; +import { useEffect, useId, useRef, useState, type CSSProperties } from "react"; +import { NOTE_BODY_HINT, initialsOf, type NoteCard } from "../data/household"; import { LIMITS } from "../data/limits"; import { useApp } from "../state"; import { Icon } from "../ui/Icon"; @@ -9,7 +9,7 @@ import { cx } from "../ui/cx"; import { OperationEdit, OperationNew, OperationView } from "./OperationsWindows"; import { AnnouncementNew } from "./AnnouncementWindows"; import { CommuneLocationFields, EMPTY_COMMUNE_LOCATION } from "../ui/CommuneLocationFields"; -import { RecipientReminderCount, ReminderUsageMeter } from "../ui/ReminderUsage"; +import { RecipientReminderCount, ReminderAllowanceNotice, ReminderUsageMeter } from "../ui/ReminderUsage"; import { ReminderFlowDialog, useReminderFlow } from "../ui/ReminderConfirm"; import { reminderEligibility } from "../data/reminders"; import { LatestReportAnswer, PendingReport, ReceiptHistory, pendingReport, useReceiptActions } from "../ui/Receipts"; @@ -28,6 +28,7 @@ import { netBalances } from "../../../../packages/core/src/balances"; import "./ExpenseEditor.css"; import { SplitEditor } from "../ui/SplitEditor"; import { moneyInputError, parseMoneyInput } from "../data/moneyInput"; +import type { Notification, UnreadNotice } from "../live/model"; const NOTE_TYPES = [ ["wifi", "Wi-Fi"], @@ -238,51 +239,95 @@ function ledgerNoticeLine(kind: string, amount: string, actor: string): string { return `Corrected by ${actor}${amount ? ` · your share is now ${amount}` : ""}`; } +/** One notice: what changed, by whom, for the reader, and when. */ +function NoticeRow({ notice, currency, actor, onOpen }: { + notice: Notification; currency: string; actor: string; onOpen: () => void; +}) { + const copy = NOTICE_COPY[notice.kind] ?? { pill: "Notice", title: "Update" }; + const ledger = notice.kind !== "nudge" && notice.kind !== "chase"; + const amount = notice.amountMinor != null ? formatMoney(notice.amountMinor, currency) : ""; + const date = new Date(notice.createdAt).toLocaleDateString("en-GB"); + return ( + + ); +} + +/** + * The bell: this commune's notices first, then the unread ones from the + * reader's other communes, each under its commune's name. Opening a notice + * from another commune switches to that commune. + */ function Notices() { - const { go, hub, funds, notifications, markNotificationRead } = useApp(); + const { go, hub, places, funds, notifications, noticeCounts, markNotificationRead, loadUnreadNotices, personName } = useApp(); const [error, setError] = useState(""); - if (!notifications.length) { - return ( -
-

Bell

-

No notices for you in this commune.

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

Bell

{error ?

{error}

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

{hub.name}

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

No notices for you in this commune.

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

{place.name}

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

{elsewhereError}

+
+ ) : !elsewhere ?

Loading notices…

+ :

No unread notices here now.

} +
); })}
@@ -440,12 +485,12 @@ function AddExpense() { function Expense() { const { go, closeWindow, hub, selectedExpense, session, - reminderTargets, reminderUsage, sentReminders, sendReminder, + reminderTargets, reminderUsage, sentReminders, sendReminder, personName, } = useApp(); const reminder = useReminderFlow(sendReminder); const [message, setMessage] = useState(""); const receipts = useReceiptActions(setMessage); - const fullName = (userId: string) => hub.members.find((item) => item.userId === userId)?.name ?? "A former member"; + const fullName = (userId: string) => personName(userId, "A former member"); const change = useExpenseChange(fullName, setMessage); if (!selectedExpense) { return ( @@ -454,12 +499,11 @@ function Expense() { ); } - const payer = hub.members.find((member) => member.userId === selectedExpense.paidByUserId); const isPayer = session?.userId === selectedExpense.paidByUserId; const remaining = selectedExpense.shares.reduce( (total, share) => total + Math.max(0, share.amountMinor - share.settledMinor), 0, ); - const nameOf = (userId: string) => userId === session?.userId ? "you" : hub.members.find((item) => item.userId === userId)?.name ?? "a former member"; + const nameOf = (userId: string) => userId === session?.userId ? "you" : personName(userId, "a former member"); const voided = !!selectedExpense.voidedAt; const canChange = !voided && (isPayer || hub.role === "admin"); @@ -471,16 +515,19 @@ function Expense() { {selectedExpense.dueOn ? `Due ${selectedExpense.dueOn}` : "No due date"} {selectedExpense.name}

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

{selectedExpense.statusLabel}
{voided ?

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

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

{eligibility.reason}

}
) : ownDebt && !report ? (
- +
) : null}
{report ? { setMessage(""); receipts.answer({ report: item, debtorName: member?.name ?? "this member", expenseName: selectedExpense.name, currency: hub.currency, decision }); }} + onAnswer={(item, decision) => { setMessage(""); receipts.answer({ report: item, debtorName: memberName, expenseName: selectedExpense.name, currency: hub.currency, decision }); }} onWithdraw={(item) => { setMessage(""); void receipts.withdraw(item); }} /> : null} {ownDebt ? : null} {!isOwnPayerShare && { setMessage(""); receipts.reverse({ receipt, debtorName: member?.name ?? "this member", expenseName: selectedExpense.name, currency: hub.currency }); } : undefined} />} + onReverse={isPayer && !voided ? (receipt) => { setMessage(""); receipts.reverse({ receipt, debtorName: memberName, expenseName: selectedExpense.name, currency: hub.currency }); } : undefined} />}
); })} @@ -788,7 +835,7 @@ function Profile() { } function Breakdown() { - const { go, hub, expenses, voidedExpenses, session, snapshots, expenseHistory, loadFullHistory } = useApp(); + const { go, hub, expenses, voidedExpenses, session, snapshots, expenseHistory, loadFullHistory, personName } = useApp(); // A statement covers everything shared with that person, so it waits for every older expense. const [historyFailed, setHistoryFailed] = useState(false); useEffect(() => { @@ -835,8 +882,6 @@ function Breakdown() {
{moves.map(({ expense, share }) => { - const debtor = hub.members.find((member) => member.userId === share.userId); - const payer = hub.members.find((member) => member.userId === expense.paidByUserId); return (