diff --git a/apps/local-api/README.md b/apps/local-api/README.md index 5e2c696e..8730d8a2 100644 --- a/apps/local-api/README.md +++ b/apps/local-api/README.md @@ -17,11 +17,13 @@ browser code sends same-origin `/api` requests. Non-2xx errors are | POST | `/api/auth/login` | `{email,password}` | `{user}` + cookie | | POST | `/api/auth/logout` | `{}` | `{ok:true}` + cleared cookie | | GET | `/api/auth/me` | public | `{user}` or `{user:null}` | -| GET | `/api/communes` | | `{communes:[...]}` | +| GET | `/api/communes` | | `{communes:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark}]}`; a commune's `changeMark` moves whenever anything in it changes | | GET | `/api/search?q=...&commune=...&limit=8` | session required; optional commune UUID; 2–80 chars, 1–25 results per type | `{query,items,total,hasMore}`; only active memberships, safe note excerpts | -| POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` | `{id}` | +| POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}`; one account creates at most 10 communes an hour, 30 a day and 100 in all (409) | `{id}` | | PATCH | `/api/communes/:id` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` admin; the currency is fixed (400) once the commune has an expense, fund, recurring bill or exact split template, voided or archived ones included | `{ok:true}` | -| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404 | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents}` | +| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents,expenseHistory,changeMark}`; `expenses` are the open ones, 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) | | POST | `/api/shares/:id/confirm` | `{}` payer only; records one receipt for everything outstanding | `{ok:true}` | | POST | `/api/shares/:id/receipts` | `{amountMinor,receivedOn,reference?,requestId}` payer only; amount up to the outstanding share; a repeated `requestId` returns the same entry | `{id,duplicate,settledMinor,outstandingMinor}` (201, or 200 for a repeat) | @@ -99,6 +101,7 @@ API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/money-lifecycle.mjs API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/operations-history.mjs API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/invites-pledges-requests.mjs API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/session-viewer.mjs +API_BASE=http://127.0.0.1:3006 node apps/local-api/tests/lighter-loading.mjs ``` `money-lifecycle.mjs` needs migrations 0015 to 0017 and covers reminder @@ -124,6 +127,10 @@ the signed-in account and requests without the header are still served. `invitation-email-opt-in.mjs` needs no API: `node --test apps/local-api/tests/invitation-email-opt-in.mjs`. +`lighter-loading.mjs` needs 0036 and covers change marks in the list and the +snapshot, the overview summary, pages of older expenses, named expense records +and the limit on creating communes. It refuses port 3002. + Search integration requires migration 0014. It covers all eight result types, active membership, selected/all-commune scopes, removed-member denial, literal wildcards, result caps and private-note masking. Its loopback guard refuses diff --git a/apps/local-api/src/server.ts b/apps/local-api/src/server.ts index afb86001..398a3d6e 100644 --- a/apps/local-api/src/server.ts +++ b/apps/local-api/src/server.ts @@ -210,30 +210,20 @@ function failed(key: string): void { }); } +// The same list the hosted app reads, with each commune's change mark. async function listCommunes(user: User) { return withActor(user.id, async (client) => { - const { rows } = await client.query(` - SELECT c.id, c.name, c.kind, c.currency, c.address, - c.destination, c.stay_name, c.stay_address, m.role, - (SELECT count(*)::integer FROM public.commune_members mm - WHERE mm.commune_id = c.id AND mm.active) AS member_count - FROM public.communes c - JOIN public.commune_members m ON m.commune_id = c.id - WHERE m.user_id = auth.user_id() AND m.active - ORDER BY c.created_at DESC - `); - return { communes: rows.map((row) => ({ - id: row.id, name: row.name, kind: row.kind, currency: row.currency, - address: row.address, destination: row.destination, - stayName: row.stay_name, stayAddress: row.stay_address, - role: row.role, memberCount: row.member_count, - })) }; + const { rows } = await client.query("SELECT public.my_communes() AS communes"); + return { communes: rows[0].communes }; }); } -async function snapshot(user: User, communeId: string) { +// A summary is what the overview screens show of a commune that is not open. +async function snapshot(user: User, communeId: string, summary = false) { return withActor(user.id, async client => { - const result = await client.query("SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_snapshot($1) ELSE NULL END AS snapshot", [communeId]); + const result = await client.query(summary + ? "SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_summary($1) ELSE NULL END AS snapshot" + : "SELECT CASE WHEN public.is_commune_member($1) THEN public.commune_snapshot($1) ELSE NULL END AS snapshot", [communeId]); if (!result.rows[0].snapshot) throw new ApiError(404,"Commune not found"); return result.rows[0].snapshot; }); @@ -404,7 +394,34 @@ route("PATCH", /^\/api\/communes\/([0-9a-f-]{36})$/, async (req, res, match) => }); route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/snapshot$/, async (req, res, match) => { - send(res, await snapshot(await actor(req), uuid(match[1]))); + const detail = new URL(req.url ?? "", "http://127.0.0.1").searchParams.get("detail"); + if (detail !== null && detail !== "summary") throw new ApiError(400, "Invalid detail"); + send(res, await snapshot(await actor(req), uuid(match[1]), detail === "summary")); +}); + +// Expenses older than the snapshot holds: the given number of months before +// `before` (the snapshot's own start when absent), or all of them when months +// is absent, as a statement or an export needs. +route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/expenses\/history$/, async (req, res, match) => { + const params = new URL(req.url ?? "", "http://127.0.0.1").searchParams; + const before = isoDate(params.get("before"), true, "before"); + const months = params.has("months") ? Number(params.get("months")) : null; + if (months !== null && (!Number.isInteger(months) || months < 1 || months > 12)) { + throw new ApiError(400, "Ask for 1 to 12 months of expenses at a time"); + } + const result = await userAction(req, async client => (await client.query( + "SELECT public.commune_expense_page($1, $2::date, $3) AS result", [uuid(match[1]), before, months])).rows[0].result); + send(res, result); +}); + +// Named expenses, for a link to one that is not loaded. +route("GET", /^\/api\/communes\/([0-9a-f-]{36})\/expenses\/records$/, async (req, res, match) => { + const ids = (new URL(req.url ?? "", "http://127.0.0.1").searchParams.get("ids") ?? "").split(",").filter(Boolean); + if (ids.length < 1 || ids.length > 50) throw new ApiError(400, "Ask for 1 to 50 expenses at a time"); + const result = await userAction(req, async client => (await client.query( + "SELECT public.commune_expense_records($1, $2::uuid[]) AS result", + [uuid(match[1]), ids.map((id) => uuid(id, "ids"))])).rows[0].result); + send(res, result); }); route("GET", /^\/api\/reminders\/usage$/, async (req, res) => { diff --git a/apps/local-api/tests/lighter-loading.mjs b/apps/local-api/tests/lighter-loading.mjs new file mode 100644 index 00000000..67681de2 --- /dev/null +++ b/apps/local-api/tests/lighter-loading.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +// Change marks in the commune list, the overview summary, pages of older +// expenses, named expense records and the limit on creating communes (0036). +// Creates accounts and communes, so it refuses the normal application API. +const base = process.env.API_BASE; +if (!base) throw new Error("Set API_BASE to an isolated local test API, not the user's database."); +const url = new URL(base); +if (url.protocol !== "http:" || !["127.0.0.1", "localhost"].includes(url.hostname) || !url.port || + url.port === "3002" || url.pathname !== "/" || url.search || url.hash || url.username || url.password) { + throw new Error("Lighter loading tests require an isolated loopback API port (not 3002)."); +} +// Letters only, so a commune name that carries it never holds a phone-like run of digits. +const suffix = randomUUID().slice(0, 8).replace(/[0-9]/g, (digit) => "ghijklmnop"[digit]); +function session() { + let cookie = ""; + return async (method, path, input) => { + const response = await fetch(`${base}/api${path}`, { + method, + headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) }, + body: input === undefined ? undefined : JSON.stringify(input), + }); + const set = response.headers.get("set-cookie"); + if (set) cookie = set.split(";")[0]; + return { ...(await response.json()), status: response.status }; + }; +} +async function signup(call, name, slug) { + const result = await call("POST", "/auth/signup", { name, email: `${slug}-${suffix}@example.test`, password: "DisposableLoadingFixture-2026!" }); + assert.equal(result.status, 201, JSON.stringify(result)); + return result.user; +} +const owner = session(), member = session(), outsider = session(), maker = session(); +const avery = await signup(owner, "Avery Loading", "loading-owner"); +const blake = await signup(member, "Blake Loading", "loading-member"); +await signup(outsider, "Casey Loading", "loading-outsider"); +const home = await owner("POST", "/communes", { name: `Loading ${suffix}`, kind: "household", currency: "GBP" }); +assert.equal(home.status, 201, JSON.stringify(home)); +const invite = await owner("POST", `/communes/${home.id}/invites`, { email: blake.email, role: "member" }); +assert.equal((await member("POST", `/invites/${invite.token}/accept`, {})).status, 200); + +// The list carries each commune's mark, which stays put until something changes. +async function listed(call) { + const list = await call("GET", "/communes"); + assert.equal(list.status, 200, JSON.stringify(list)); + return list.communes.find((commune) => commune.id === home.id); +} +const first = await listed(owner); +assert.deepEqual(Object.keys(first).sort(), ["address", "changeMark", "currency", "destination", "id", "kind", + "memberCount", "name", "role", "stayAddress", "stayName"]); +assert.deepEqual([first.name, first.role, first.memberCount], [`Loading ${suffix}`, "admin", 2]); +assert.match(first.changeMark, /^\d{4}-\d{2}-\d{2}:\d+:\d+$/); +assert.equal((await listed(owner)).changeMark, first.changeMark, "Reading moved the mark"); +assert.equal((await listed(member)).changeMark, first.changeMark, "Members see the same mark"); +assert.equal((await listed(outsider)), undefined, "Someone outside sees the commune"); +const snapshot = await owner("GET", `/communes/${home.id}/snapshot`); +assert.equal(snapshot.changeMark, first.changeMark); +const today = new Date(); +const since = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1)).toISOString().slice(0, 10); +assert.deepEqual(snapshot.expenseHistory, { since, olderCount: 0, olderVoidedCount: 0 }); + +// A write by anyone in the commune moves it. +const expense = await member("POST", `/communes/${home.id}/expenses`, { title: "Milk", category: "Groceries", amountMinor: 300, + shares: [{ userId: avery.id, amountMinor: 150 }, { userId: blake.id, amountMinor: 150 }], requestId: randomUUID() }); +assert.equal(expense.status, 201, JSON.stringify(expense)); +const moved = (await listed(owner)).changeMark; +assert.notEqual(moved, first.changeMark, "A new expense did not move the mark"); + +// The overview summary: the same parts without the detail of the commune's own screens. +const summary = await owner("GET", `/communes/${home.id}/snapshot?detail=summary`); +assert.equal(summary.status, 200, JSON.stringify(summary)); +assert.equal(summary.detail, "summary"); +assert.equal(summary.changeMark, moved); +assert.deepEqual(summary.expenses.map((item) => item.id), [expense.id]); +assert.deepEqual(summary.expenses[0].shares.map((share) => share.amountMinor), [150, 150]); +assert.equal((await owner("GET", `/communes/${home.id}/snapshot?detail=everything`)).status, 400); +assert.equal((await outsider("GET", `/communes/${home.id}/snapshot?detail=summary`)).status, 404); + +// Older pages. Everything here is new, so there is nothing before the snapshot's start. +const page = await member("GET", `/communes/${home.id}/expenses/history?months=1`); +assert.equal(page.status, 200, JSON.stringify(page)); +assert.deepEqual([page.expenses, page.before, page.from, page.olderCount, page.olderVoidedCount], [[], since, null, 0, 0]); +const all = await member("GET", `/communes/${home.id}/expenses/history`); +assert.deepEqual([all.status, all.expenses, all.from], [200, [], null]); +const earlier = await member("GET", `/communes/${home.id}/expenses/history?before=2026-01-15&months=2`); +assert.deepEqual([earlier.status, earlier.before], [200, "2026-01-01"]); +for (const query of ["months=0", "months=13", "months=one", "before=2026-02-30", "before=yesterday"]) { + assert.equal((await member("GET", `/communes/${home.id}/expenses/history?${query}`)).status, 400, query); +} +assert.equal((await outsider("GET", `/communes/${home.id}/expenses/history?months=1`)).status, 403); + +// Named records come in the snapshot's shape, and only from this commune. +const fresh = await owner("GET", `/communes/${home.id}/snapshot`); +const named = await owner("GET", `/communes/${home.id}/expenses/records?ids=${expense.id},${randomUUID()}`); +assert.equal(named.status, 200, JSON.stringify(named)); +assert.deepEqual(named.expenses, fresh.expenses); +const tooMany = Array.from({ length: 51 }, () => randomUUID()).join(","); +for (const query of ["", "ids=", `ids=${tooMany}`, "ids=not-an-id"]) { + assert.equal((await owner("GET", `/communes/${home.id}/expenses/records?${query}`)).status, 400, query); +} +assert.equal((await outsider("GET", `/communes/${home.id}/expenses/records?ids=${expense.id}`)).status, 403); + +// Ten new communes in an hour is the most; the next one is refused calmly. +await signup(maker, "Morgan Loading", "loading-maker"); +for (const letter of "abcdefghij") { + const made = await maker("POST", "/communes", { name: `Maker ${letter} ${suffix}`, kind: "household", currency: "GBP" }); + assert.equal(made.status, 201, JSON.stringify(made)); +} +const refused = await maker("POST", "/communes", { name: `Maker more ${suffix}`, kind: "household", currency: "GBP" }); +assert.equal(refused.status, 409, JSON.stringify(refused)); +assert.equal(refused.error, "You have created 10 communes in the last hour. Try again later."); +assert.equal((await maker("GET", "/communes")).communes.length, 10); +// Someone else can still create one. +assert.equal((await outsider("POST", "/communes", { name: `Casey ${suffix}`, kind: "trip", currency: "EUR" })).status, 201); +console.log("Lighter loading: marks, summary, older pages, named records and the creation limit behave as expected."); diff --git a/apps/reminder-worker/tests/integration.mjs b/apps/reminder-worker/tests/integration.mjs index 3c79c58b..c5000b52 100644 --- a/apps/reminder-worker/tests/integration.mjs +++ b/apps/reminder-worker/tests/integration.mjs @@ -91,6 +91,12 @@ async function runWorker() { headers: { "x-neon-trigger-invocation-id": "local-test" }, })); assert.equal(response.status, 200); + return response.json(); +} + +async function changeMark(communeId) { + const { rows } = await db.query("SELECT commune_private.change_mark($1) AS mark", [communeId]); + return rows[0].mark; } async function outbox(reminderId) { @@ -428,8 +434,12 @@ try { `, [commune, afterShare, alice, bob])).rows[0].id; await db.query("INSERT INTO public.reminder_email_outbox (reminder_id) VALUES ($1)", [afterReminder]); const callsBefore = providerCalls.length; + // What became of the email shows in the sender's reminders, so the run + // moves the commune's change mark (0036) and open apps read it again. + const markBefore = await changeMark(commune); await runWorker(); assert.equal((await outbox(afterReminder)).status, "suppressed"); + assert.notEqual(await changeMark(commune), markBefore, "A delivery outcome must move the commune's mark"); assert.equal(providerCalls.length, callsBefore); // The sender gets the same answer as when an email went, so it never // tells them the recipient turned email off. @@ -449,6 +459,23 @@ try { assert.equal((await outbox(third.reminderId)).payload_amount_minor, null); assert.notEqual((await outbox(batchIds[0])).payload, null, "A recent email is kept"); + // The same run drops change marks older than 2 days. A mark looks back to + // the start of yesterday at most, so recent ones stay. + const { rows: [oldChange] } = await db.query(` + INSERT INTO commune_private.commune_changes (commune_id, changed_at) + VALUES ($1, now() - interval '3 days') RETURNING id + `, [commune]); + const { rows: [recentChange] } = await db.query(` + INSERT INTO commune_private.commune_changes (commune_id, changed_at) + VALUES ($1, now() - interval '1 day') RETURNING id + `, [commune]); + const sweep = await runWorker(); + assert.equal(sweep.pruned.changeMarks, 1); + const { rows: kept } = await db.query( + "SELECT id FROM commune_private.commune_changes WHERE id IN ($1, $2)", [oldChange.id, recentChange.id], + ); + assert.deepEqual(kept.map((row) => row.id), [recentChange.id]); + // An address the provider reported as bouncing (or as a spam complaint) is // skipped exactly like an opt-out, whatever the case of the account's // address, so the sender cannot tell which happened. diff --git a/apps/web/index.html b/apps/web/index.html index 751d0d1e..9d441d98 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -11,12 +11,6 @@ name="description" content="Hub OS for shared spaces. Track splits, settle balances, and keep house money clear." /> - - -
diff --git a/apps/web/src/data/covers.ts b/apps/web/src/data/covers.ts new file mode 100644 index 00000000..50dcaed2 --- /dev/null +++ b/apps/web/src/data/covers.ts @@ -0,0 +1,65 @@ +import { HUBS, type Hub } from "./household"; + +/** + * Commune covers. A photo someone chose in this browser is drawn as it is. + * The default covers are Unsplash photos, which Unsplash resizes on request, + * so each placement offers a few widths and says how wide it is drawn; the + * browser then fetches the one that suits the screen instead of the full + * photo. Every cover sits on a calm tone for the commune's type (the + * --cover-* tokens in shared.css), which shows while the photo loads and + * stays if it cannot load. + */ +export type CoverPlacement = "featured" | "card" | "hero" | "preview"; +export type CoverTone = "household" | "workspace" | "trip"; +export type CoverHub = Pick; +export type CoverSource = { src: string; srcSet?: string; sizes?: string }; + +const WIDTHS = [480, 800, 1200, 1600]; + +// How wide each placement is drawn, in CSS pixels. The sidebar (248px) goes +// away at 560px. featured: the first card on Home. card: the smaller cards on +// Home and Places (three, then two, then one column). hero: the photo at the +// top of This Hub (at most 1208px). preview: the commune settings preview +// (240px beside the form, full width once the form drops below it at 1000px). +const PLACEMENTS: Record = { + featured: { sizes: "(max-width: 560px) 100vw, calc(100vw - 248px)", fallback: 1200 }, + card: { sizes: "(max-width: 560px) 100vw, (max-width: 850px) 50vw, 33vw", fallback: 800 }, + hero: { sizes: "(max-width: 560px) 100vw, (max-width: 1500px) calc(100vw - 292px), 1208px", fallback: 1200 }, + preview: { sizes: "(max-width: 560px) 100vw, (max-width: 1000px) 560px, 240px", fallback: 480 }, +}; + +export function coverTone(hub: Pick): CoverTone { + return hub.type === "Workspace" ? "workspace" : hub.type === "Trip" ? "trip" : "household"; +} + +const DEFAULT_COVERS: Record = { + household: HUBS.park.photo, + workspace: HUBS.studio.photo, + trip: HUBS.lisbon.photo, +}; + +/** The image to draw for a commune's cover in one placement. */ +export function coverSource(hub: CoverHub, placement: CoverPlacement): CoverSource { + // A commune whose records are still loading has no photo yet; it gets the + // same default it will have once they arrive, so nothing is fetched twice. + const photo = hub.photo || DEFAULT_COVERS[coverTone(hub)]; + const resize = unsplashResizer(photo); + if (!resize) return { src: photo }; + const { sizes, fallback } = PLACEMENTS[placement]; + return { src: resize(fallback), srcSet: WIDTHS.map((width) => `${resize(width)} ${width}w`).join(", "), sizes }; +} + +function unsplashResizer(photo: string): ((width: number) => string) | null { + let url: URL; + try { + url = new URL(photo); + } catch { + return null; + } + if (url.protocol !== "https:" || url.hostname !== "images.unsplash.com") return null; + return (width) => { + const sized = new URL(url); + sized.searchParams.set("w", String(width)); + return sized.href; + }; +} diff --git a/apps/web/src/data/household.ts b/apps/web/src/data/household.ts index 92f7dbfa..bbe785af 100644 --- a/apps/web/src/data/household.ts +++ b/apps/web/src/data/household.ts @@ -757,12 +757,6 @@ export function initialsOf(name: string): string { return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase(); } -export function heroClass(hub: Hub): "park" | "studio" | "lisbon" { - if (hub.id === "studio" || hub.type === "Workspace") return "studio"; - if (hub.id === "lisbon" || hub.type === "Trip") return "lisbon"; - return "park"; -} - export function buildCreatedHub(opts: { name: string; type: Hub["type"]; diff --git a/apps/web/src/legal/LegalPages.tsx b/apps/web/src/legal/LegalPages.tsx index f07eb9fc..996d47ba 100644 --- a/apps/web/src/legal/LegalPages.tsx +++ b/apps/web/src/legal/LegalPages.tsx @@ -58,7 +58,7 @@ export function PrivacyPage() {

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

-

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

+

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

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

diff --git a/apps/web/src/live/api.ts b/apps/web/src/live/api.ts index 82d6f938..bb58b731 100644 --- a/apps/web/src/live/api.ts +++ b/apps/web/src/live/api.ts @@ -1,4 +1,4 @@ -import type { Commune, Snapshot, User } from "./model"; +import type { Commune, Expense, ExpensePage, Snapshot, User } from "./model"; import { currentViewer } from "./session"; // Pass already-verified identity and commune metadata through one refresh. @@ -11,12 +11,28 @@ export async function loadCommunes(user: User): Promise<{ communes: Commune[] }> return api("/communes"); } -export async function loadSnapshot(commune: Commune): Promise { +/** A summary is what the overview screens show of a commune that is not open. */ +export async function loadSnapshot(commune: Commune, detail?: "summary"): Promise { if (import.meta.env.PROD) { const { snapshot } = await import("./neon"); - return snapshot(commune.id, commune); + return snapshot(commune.id, commune, detail); } - return api(`/communes/${encodeURIComponent(commune.id)}/snapshot`); + return api(`/communes/${encodeURIComponent(commune.id)}/snapshot${detail ? "?detail=summary" : ""}`); +} + +/** + * Expenses older than the snapshot holds, created in the `months` calendar + * months (UTC) before `before`, or all of them when months is left out. + * Without `before`, the page starts where the snapshot's expenses do. + */ +export function loadExpensePage(communeId: string, before: string | null, months?: number): Promise { + const query = new URLSearchParams({ ...(before ? { before } : {}), ...(months ? { months: String(months) } : {}) }).toString(); + return api(`/communes/${encodeURIComponent(communeId)}/expenses/history${query ? `?${query}` : ""}`); +} + +/** Named expenses of one commune, at most 50, for links to ones not loaded. */ +export function loadExpenseRecords(communeId: string, ids: string[]): Promise<{ expenses: Expense[] }> { + return api(`/communes/${encodeURIComponent(communeId)}/expenses/records?ids=${ids.map(encodeURIComponent).join(",")}`); } export class ApiError extends Error { diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 5419adf1..72f3f2d0 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -32,6 +32,8 @@ export type Commune = { destination?: string; stayName?: string; stayAddress?: string; + /** Moves whenever anything in the commune changes; compared with a snapshot's own. */ + changeMark?: string; /** In a snapshot: what the database says keeps the currency as it is (0035). */ currencyLockedBy?: CurrencyLock[]; }; @@ -283,7 +285,18 @@ export type Snapshot = { splitTemplates?: SplitTemplate[]; announcements?: Announcement[]; activityEvents?: Array<{id:string;actorId:string;kind:"funds"|"members"|"recurring"|"templates"|"notes"|"operations"|"expenses"|"announcements";entityId:string;title:string;description:string;createdAt:string}>; + /** + * The snapshot holds every expense created since `since`, every open one + * and the five newest that are not voided. + */ + expenseHistory?: ExpenseHistory; + changeMark?: string; + /** An overview summary leaves out what only the commune's own screens show. */ + detail?: "summary"; }; +export type ExpenseHistory = { since: string; olderCount: number; olderVoidedCount: number }; +/** Older expenses: those created from `from` up to `before`, and how many are older still. */ +export type ExpensePage = { expenses: Expense[]; before: string; from: string | null; olderCount: number; olderVoidedCount: number }; export type LiveExpenseRow = Omit & { amountMinor: number; paidByUserId: string; @@ -391,6 +404,8 @@ export function hubFromSnapshot( // Voided expenses stay in the ledger as history but never count towards a // balance, a total or an outstanding share anywhere on the hub. const snapshot: Snapshot = { ...source, expenses: source.expenses.filter((expense) => !expense.voidedAt) }; + // Older settled expenses stay on the server until asked for; they still count as logged. + const olderLogged = (source.expenseHistory?.olderCount ?? 0) - (source.expenseHistory?.olderVoidedCount ?? 0); const { commune } = snapshot; const currency = commune.currency; const names = new Map(snapshot.members.map((member) => [member.userId, member.name])); @@ -545,7 +560,7 @@ export function hubFromSnapshot( }))].sort((a,b)=>(Date.parse(b.createdAt ?? "")||0)-(Date.parse(a.createdAt ?? "")||0)), notes, jumps: [ - { go: "expenses", kicker: "Expenses", title: `${snapshot.expenses.length} logged`, hint: `${openRows.length} open` }, + { go: "expenses", kicker: "Expenses", title: `${snapshot.expenses.length + olderLogged} logged`, hint: `${openRows.length} open` }, { go: "settle", kicker: "Settle", title: moves.length ? oweLabel : "You’re even", hint: moves.length ? money(moneyFocus, currency) : "No outstanding shares involving you" }, { go: "funds", kicker: "Funds", title: `${snapshot.funds.length} funds`, hint: `${money(fundTotal, currency)} recorded in` }, { go: "notes", kicker: "Notes", title: `${notes.length} notes`, hint: "Shared information" }, diff --git a/apps/web/src/live/neon.ts b/apps/web/src/live/neon.ts index f7b80c57..5cae7633 100644 --- a/apps/web/src/live/neon.ts +++ b/apps/web/src/live/neon.ts @@ -5,7 +5,7 @@ import { parseSearchResponse } from "./search"; import { ACCOUNT_CHANGED, currentViewer } from "./session"; import { readStored, removeStored } from "./storage"; import type { - Commune, CurrencyCode, ReminderUsage, Snapshot, User, + Commune, CurrencyCode, Expense, ExpensePage, ReminderUsage, Snapshot, User, } from "./model"; // This module is used only by production builds. The browser sees endpoint @@ -165,12 +165,12 @@ function idResult(value: unknown): { id: string } { if (typeof value !== "string" || !value) throw new ApiError("Database did not return an ID", 502); return { id: value }; } -function communal(row: Row, role: string, memberCount: number): Commune { +function communal(row: Row): Commune { return { id: str(row.id), name: str(row.name), kind: row.kind as Commune["kind"], - currency: row.currency as CurrencyCode, role: role as Commune["role"], memberCount, + currency: row.currency as CurrencyCode, role: row.role as Commune["role"], memberCount: minor(row.memberCount), address: str(row.address), destination: str(row.destination), - stayName: str(row.stay_name), stayAddress: str(row.stay_address), + stayName: str(row.stayName), stayAddress: str(row.stayAddress), changeMark: str(row.changeMark), }; } @@ -197,44 +197,63 @@ async function requireUser(): Promise { return user; } +// One call returns the caller's communes, their role and member count in +// each, and each commune's change mark, read together by the database. export async function listCommunes(knownViewer?: User): Promise { const viewer = await requireUser(); if (knownViewer && knownViewer.id !== viewer.id) throw new ApiError("Your account changed. Reload Commune.", 401); - const [communeResult, memberResult] = await Promise.all([ - db().from("communes").select("id,name,kind,currency,address,destination,stay_name,stay_address,created_at").order("created_at", { ascending: false }), - db().from("commune_members").select("commune_id,user_id,role").eq("active", true), - ]); - const communeData = unwrap(communeResult); - const memberData = unwrap(memberResult); - if (!Array.isArray(communeData) || !Array.isArray(memberData)) { - throw new ApiError("Could not verify your commune list. Please try again.", 502); - } - const communeRows = rows(communeData); - const memberRows = rows(memberData); - if (memberRows.some((member) => member.user_id === viewer.id && !communeRows.some((row) => row.id === member.commune_id))) { - throw new ApiError("Your commune membership changed while loading. Please try again.", 409); - } - return communeRows.map((row) => { - const group = memberRows.filter((member) => member.commune_id === row.id); - const membership = group.find((member) => member.user_id === viewer.id); - if (!membership || (membership.role !== "admin" && membership.role !== "member")) { + const data = unwrap(await db().rpc("my_communes")); + if (!Array.isArray(data)) throw new ApiError("Could not verify your commune list. Please try again.", 502); + return rows(data).map((row) => { + if (typeof row.id !== "string" || !row.id || (row.role !== "admin" && row.role !== "member") || + typeof row.changeMark !== "string") { throw new ApiError("Could not verify your commune membership. Please try again.", 409); } - return communal(row, str(membership.role), group.length); + return communal(row); }); } -export async function snapshot(communeId: string, knownCommune?: Commune): Promise { +export async function snapshot(communeId: string, knownCommune?: Commune, detail?: "summary"): Promise { await requireUser(); if (knownCommune && knownCommune.id !== communeId) throw new ApiError("Commune mismatch", 400); // The RPC re-checks membership against the verified JWT and reads one // consistent database snapshot. No browser-stored identity is authority. - const data = unwrap(await db().rpc("commune_snapshot", { p_commune_id: communeId })) as Snapshot; - if (!data || data.commune?.id !== communeId || !Array.isArray(data.members) || !Array.isArray(data.expenses) || !Array.isArray(data.funds)) { + const data = unwrap(await db().rpc(detail === "summary" ? "commune_summary" : "commune_snapshot", { p_commune_id: communeId })) as Snapshot; + if (!data || data.commune?.id !== communeId || !Array.isArray(data.members) || !Array.isArray(data.expenses) || !Array.isArray(data.funds) || + data.detail !== detail) { throw new ApiError("Shared records could not be verified. Please try again.", 502); } return data; } + +// Older expenses and named ones come in the snapshot's shape, so they are +// checked the same way. +function expenseList(value: unknown): { expenses: Expense[] } { + const item = asObject(value); + if (!Array.isArray(item.expenses) || item.expenses.some((expense) => typeof asObject(expense).id !== "string" || + !Array.isArray(asObject(expense).shares))) { + throw new ApiError("Shared records could not be verified. Please try again.", 502); + } + return item as { expenses: Expense[] }; +} +async function expensePage(communeId: string, params: URLSearchParams): Promise { + await requireUser(); + const months = params.has("months") ? Number(params.get("months")) : null; + if (months !== null && (!Number.isInteger(months) || months < 1 || months > 12)) { + throw new ApiError("Ask for 1 to 12 months of expenses at a time", 400); + } + const page = unwrap(await db().rpc("commune_expense_page", { p_commune_id: communeId, + p_before: params.get("before") || null, p_months: months })); + const item = asObject(page); + return { ...expenseList(item), before: str(item.before), from: typeof item.from === "string" ? item.from : null, + olderCount: minor(item.olderCount), olderVoidedCount: minor(item.olderVoidedCount) }; +} +async function expenseRecords(communeId: string, params: URLSearchParams): Promise<{ expenses: Expense[] }> { + await requireUser(); + const ids = (params.get("ids") ?? "").split(",").filter(Boolean); + if (ids.length < 1 || ids.length > 50) throw new ApiError("Ask for 1 to 50 expenses at a time", 400); + return expenseList(unwrap(await db().rpc("commune_expense_records", { p_commune_id: communeId, p_ids: ids }))); +} async function reminderUsage(): Promise { await requireUser(); const records = rows(unwrap(await db().rpc("reminder_usage"))); @@ -462,8 +481,12 @@ async function route(path: string, init: RequestInit): Promise { } else if (path === "/preferences" && method === "PUT") { if (typeof body.emailReminders !== "boolean") throw new ApiError("Choose whether to get reminder emails", 400); response = { emailReminders: unwrap(await db().rpc("set_email_reminders", { p_enabled: body.emailReminders })) === true }; - } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/snapshot$/)) { - response = await snapshot(pathId(path, /^\/communes\/([^/]+)\/snapshot$/)!); + } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/snapshot(?:\?detail=summary)?$/)) { + response = await snapshot(pathId(path, /^\/communes\/([^/]+)\/snapshot/)!, undefined, path.endsWith("?detail=summary") ? "summary" : undefined); + } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/expenses\/history(?:\?|$)/)) { + response = await expensePage(pathId(path, /^\/communes\/([^/]+)\/expenses\/history/)!, new URL(path, "https://commune.invalid").searchParams); + } else if (method === "GET" && pathId(path, /^\/communes\/([^/]+)\/expenses\/records\?/)) { + response = await expenseRecords(pathId(path, /^\/communes\/([^/]+)\/expenses\/records/)!, new URL(path, "https://commune.invalid").searchParams); } else if (method === "POST" && pathId(path, /^\/communes\/([^/]+)\/expenses$/)) { const shares = Array.isArray(body.shares) ? body.shares.map((share) => { const item = asObject(share); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1cffa68b..0411cf35 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,7 +8,7 @@ import "./styles/auth.css"; import "./styles/legal.css"; import "./styles/overrides.css"; import "./styles/design-system.css"; -import "@fontsource-variable/dm-sans/standard.css"; +import "./styles/fonts.css"; const root = document.getElementById("root"); if (!root) throw new Error("Missing #root"); diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index fd9da2d8..4ff51ffe 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -2,16 +2,14 @@ import type { ReactNode } from "react"; import { useApp } from "../state"; import { Avatar, Pill } from "../ui/widgets"; import { CalendarDate } from "../ui/CalendarDate"; -import { heroClass } from "../data/household"; import type { LiveHub } from "../live/model"; import { cx } from "../ui/cx"; +import { CoverImage, coverToneClass } from "../ui/CoverImage"; -function PhotoFace({ photo, focus, className, children }: { photo: string; focus?: boolean; className?: string; children: ReactNode }) { +function PhotoFace({ hub, focus, children }: { hub: LiveHub; focus?: boolean; children: ReactNode }) { return ( -
+
+ {children}
); @@ -25,7 +23,7 @@ function PlaceCard({ hub, focus = false }: { hub: LiveHub; focus?: boolean }) { const memberCount = ready ? hub.members.length : hub.memberCount; return (
+ {more ?
+ {earlier === "error" ?

Earlier expenses couldn’t be loaded. Try again.

+ :

{earlier === "loading" ? "Loading earlier expenses…" + : `${showVoided ? "Voided" : "Settled"} expenses before ${monthName(shownFrom)} aren’t shown yet.`}

} + +
: null} ); } diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 13810823..8d86bcff 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -3,6 +3,7 @@ import { Link } from "wouter"; import { Icon } from "../ui/Icon"; import { Avatar, Switch } from "../ui/widgets"; import { ImagePick } from "../ui/photos"; +import { CoverImage, coverToneClass } from "../ui/CoverImage"; import { useApp } from "../state"; import { cx } from "../ui/cx"; import { LIMITS } from "../data/limits"; @@ -13,6 +14,7 @@ import { SignInSettings } from "../ui/SignInSettings"; import { communeExportJson, downloadText, expensesCsv } from "../data/export"; import { todayIso } from "../data/operations"; import { currencyHint, currencyLockedBy } from "../data/currency"; +import type { Snapshot } from "../live/model"; export function SettingsScreen() { const { @@ -226,7 +228,7 @@ export function SettingsScreen() { export function CommuneSettingsScreen() { const { - hub, session, go, setHubPhoto, updateCommune, snapshots, + hub, session, go, setHubPhoto, updateCommune, snapshots, loadFullHistory, } = useApp(); const [name, setName] = useState(hub.name); const [type, setType] = useState(hub.type); @@ -240,6 +242,7 @@ export function CommuneSettingsScreen() { const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [saved, setSaved] = useState(false); + const [exporting, setExporting] = useState<"" | "loading" | "error">(""); // The database's rule, as its snapshot reports it. const currencyLocks = currencyLockedBy(snapshots[hub.id]); const canEdit = hub.role === "admin"; @@ -270,6 +273,18 @@ export function CommuneSettingsScreen() { } } + // A file holds every expense, so the older ones are loaded first. + async function download(save: (ledger: Snapshot) => void) { + setExporting("loading"); + try { + const ledger = await loadFullHistory(); + if (ledger) save(ledger); + setExporting(""); + } catch { + setExporting("error"); + } + } + if (!hub.id) { return

No commune selected.

; } @@ -278,7 +293,9 @@ export function CommuneSettingsScreen() {