Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ Commune is a shared communal expense management and group management platform.
The application was migrated. The stack below is what runs today.

- **Web (active app):** `apps/web`. React 19 + Vite 7 + TypeScript + wouter, plain CSS with shared tokens, tested with Vitest. pnpm. Vercel serves the production build (`apps/web/vercel.json`).
- **Hosted data and auth:** Neon (Managed Auth, Postgres, Data API). Schema and functions live in `infra/neon/migrations`: 39 migrations, 0001 to 0039, applied in numeric order. `infra/neon/schema.sql` is the schema they produce, with every function in full. Production builds use `apps/web/src/live/neon.ts`.
- **Hosted data and auth:** Neon (Managed Auth, Postgres, Data API). Schema and functions live in `infra/neon/migrations`: 40 migrations, 0001 to 0040, applied in numeric order. `infra/neon/schema.sql` is the schema they produce, with every function in full. Production builds use `apps/web/src/live/neon.ts`.
- **Local development backend:** `apps/local-api`, a loopback-only PostgreSQL API on port 3002 that calls the same database functions. Vite proxies `/api` to it. Its database is set up as `infra/local/README.md` describes. It is never deployed.
- **Email:** `apps/reminder-worker`, a Neon Function that sends reminder and invitation emails from the outbox through Resend, with one-click unsubscribe and a bounce webhook.
- **Shared logic:** `packages/core` (split arithmetic, balances and money input), imported by relative path.
- **Shared logic:** `packages/core` (split arithmetic, balances, reading typed amounts, and writing money and dates), imported as `@commune/core` through a path in `apps/web/tsconfig.json` and an alias in `apps/web/vite.config.ts`. It has no `package.json`. Amounts and day-and-month dates are written by its `formatMoney` and `formatDay`; `apps/web/tests/formatting.test.ts` fails if app code uses `Intl.NumberFormat`, `toLocaleString` or `toLocaleDateString`.
- **Mobile:** `apps/mobile-v2` (Expo). Separate work. It does not use Commune's database or share code with `apps/web`.
- **Website:** `web/` is the marketing website: the editorial homepage and its company pages (moved from `apps/landing-monzo-study` on 5 October 2026). The previous v2 site is kept in `web-backup/`. Neither is in the pnpm workspace. Separate work from the app.

Expand Down Expand Up @@ -61,7 +61,7 @@ Earlier versions used Mantine 9, TanStack Router/Query, Zustand, Zod, Turborepo
## Tests and CI
`.github/workflows/ci.yml` runs on every pull request and every push to main. Nothing in it deploys or reaches Neon, Vercel or Resend, and each step is a command you can run locally:

- **Web:** in `apps/web`, `pnpm run typecheck`, `pnpm test` and `pnpm run build`, or the tools directly (`node node_modules/vitest/vitest.mjs run`). Tests run in jsdom by default. A test file that needs no browser (nothing rendered, no `localStorage`) starts with `// @vitest-environment node` and runs in Node, which is faster. `pnpm run typecheck` in `apps/local-api` and `apps/reminder-worker` checks those two.
- **Web:** in `apps/web`, `pnpm run typecheck`, `pnpm test` and `pnpm run build`, or the tools directly (`node node_modules/vitest/vitest.mjs run`). Tests run in jsdom by default. A test file that needs no browser (nothing rendered, no `localStorage`) starts with `// @vitest-environment node` and runs in Node, which is faster. `packages/core/tests` run in the same command. `pnpm run typecheck` in `apps/local-api` and `apps/reminder-worker` checks those two.
- **Database:** `infra/neon/tests/run-local.sh` builds databases from the migrations on a loopback PostgreSQL 16 server (PG* variables; its header explains) and runs each test in `infra/neon/tests` on its own copy. `infra/neon/dump-schema.sh --check` fails while `infra/neon/schema.sql` differs from what the migrations produce; without `--check` it rewrites the file.
- **Local API:** the HTTP tests in `apps/local-api/tests` run against an API started on another port with a fresh database (`run-local.sh --create <name> local`); they refuse port 3002.
- **Worker:** `node --test tests/email.mjs tests/unsubscribe.mjs` in `apps/reminder-worker`, then its database tests on stub databases, as the CI step shows.
Expand Down
5 changes: 4 additions & 1 deletion apps/local-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ browser code sends same-origin `/api` requests. Non-2xx errors are
| POST | `/api/auth/logout` | `{}` | `{ok:true}` + cleared cookie |
| GET | `/api/auth/me` | public | `{user}` or `{user:null}` |
| GET | `/api/communes` | | `{communes:[{id,name,kind,currency,role,memberCount,address,destination,stayName,stayAddress,changeMark,unreadNotices}]}`; a commune's `changeMark` moves whenever anything in it changes; `unreadNotices` is the reader's unread notices there (0039) |
| GET | `/api/search?q=...&commune=...&limit=8` | session required; optional commune UUID; 2–80 chars, 1–25 results per type | `{query,items,total,hasMore}`; only active memberships, safe note excerpts |
| GET | `/api/search?q=...&commune=...&limit=8` | session required; optional commune UUID; 2–80 chars, 1–25 results per type | `{query,items,total,hasMore}`; only active memberships, safe note excerpts; since 0040 each item also carries `label` and `date` (YYYY-MM-DD or null), and `meta` is the older text |
| POST | `/api/communes` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}`; one account creates at most 10 communes an hour, 30 a day and 100 in all (409) | `{id}` |
| PATCH | `/api/communes/:id` | `{name,kind,currency,address?,destination?,stayName?,stayAddress?}` admin; the currency is fixed (400) once the commune has an expense, fund, recurring bill or exact split template, voided or archived ones included | `{ok:true}` |
| GET | `/api/communes/:id/snapshot` | membership checked; outsiders get 404; `?detail=summary` leaves out what only the commune's own screens show, keeping a share's pending payment reports (0039) | `{commune,members,expenses,funds,notes,operations,notifications,reminderTargets,recurringBills,splitTemplates,activityEvents,expenseHistory,changeMark}`; `expenses` are the open ones, those created or changed since the start of last month (UTC) and the five newest that are not voided; `expenseHistory` is `{since,olderCount,olderVoidedCount}` |
Expand Down Expand Up @@ -135,6 +135,9 @@ the signed-in account and requests without the header are still served.
snapshot, the overview summary, pages of older expenses, named expense records
and the limit on creating communes. It refuses port 3002.

`search-dates.mjs` needs 0040 and covers the `label` and `date` of recurring
bills, paused bills and tasks beside the older `meta`. It refuses port 3002.

`notices-and-activity.mjs` needs 0039 and covers each commune's unread count in
the list and the snapshot, the unread notices of every commune, the record type
of each activity event and the pending payment reports an overview summary
Expand Down
59 changes: 59 additions & 0 deletions apps/local-api/tests/search-dates.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";

// The quick finder's dates (0040): each result carries its date on its own
// (date, YYYY-MM-DD or null) and its words without it (label), so the app
// writes dates the way every screen does; meta keeps the old text.
// Fixture writes must never run against the user's normal local API.
const base = process.env.API_BASE;
const origin = new URL(base ?? "http://invalid");
if (!base || origin.protocol !== "http:" || !["localhost", "127.0.0.1"].includes(origin.hostname) ||
!origin.port || origin.port === "3002" || origin.pathname !== "/" || origin.username || origin.password || origin.search || origin.hash) {
throw new Error("Search date tests require an isolated loopback API_BASE, not port 3002.");
}
const suffix = randomUUID().slice(0, 8);
let cookie = "";
async function call(method, path, input) {
const response = await fetch(`${base}/api${path}`, { method,
headers: { ...(input === undefined ? {} : { "Content-Type": "application/json" }), ...(cookie ? { Cookie: cookie } : {}) },
body: input === undefined ? undefined : JSON.stringify(input) });
const set = response.headers.get("set-cookie"); if (set) cookie = set.split(";")[0];
return { status: response.status, ...await response.json() };
}
const day = (offset) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10);

const owner = await call("POST", "/auth/signup", { name: "Quince owner", email: `quince-${suffix}@example.test`, password: "DisposableSearchFixture-2026!" });
assert.equal(owner.status, 201, JSON.stringify(owner));
const commune = await call("POST", "/communes", { name: `Quince ${suffix}`, kind: "household", currency: "USD" });
assert.equal(commune.status, 201, JSON.stringify(commune));
const bill = (title, nextDueOn, paused) => call("POST", `/communes/${commune.id}/recurring`, { title, amountMinor: 3210,
paidByUserId: owner.user.id, allocations: [{ userId: owner.user.id, amountMinor: 3210 }], frequency: "monthly", nextDueOn, paused,
requestId: randomUUID() });
const due = day(20);
assert.equal((await bill("Quince internet", due, false)).status, 201);
assert.equal((await bill("Quince water", day(25), true)).status, 201);
const taskDate = day(3);
const task = await call("POST", `/communes/${commune.id}/operations`, { kind: "task", category: "chore", title: "Quince bins",
date: taskDate, allDay: true, repeat: "none", notes: "", requestId: randomUUID() });
assert.equal(task.status, 201, JSON.stringify(task));

const result = await call("GET", `/search?${new URLSearchParams({ q: "Quince", commune: commune.id, limit: "25" })}`);
assert.equal(result.status, 200, JSON.stringify(result));
const find = (title) => result.items.find((item) => item.title === title);
assert.deepEqual(
["Quince internet", "Quince water", "Quince bins"].map((title) => { const { label, date, meta } = find(title) ?? {}; return { label, date, meta }; }),
[
{ label: "Due", date: due, meta: `Due ${due}` },
{ label: "Paused", date: null, meta: "Paused" },
{ label: "Task", date: taskDate, meta: `Task · ${taskDate}` },
],
);
// Everything else has no date, and its words are its old text.
const undated = result.items.filter((item) => !["recurring", "operation"].includes(item.kind));
assert.ok(undated.length >= 2, JSON.stringify(result));
for (const item of undated) {
assert.equal(item.date, null, JSON.stringify(item));
assert.equal(item.label, item.meta, JSON.stringify(item));
}
assert.equal(find(`Quince ${suffix}`)?.label, "Commune");
console.log("Search dates: dates arrive on their own, with the old text kept");
5 changes: 2 additions & 3 deletions apps/reminder-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRemoteJWKSet, jwtVerify } from "jose";
import { Pool } from "pg";
import { renderReminderEmail } from "./email.ts";
import { InvitationEmailError, sendInvitationEmail } from "./invitations.ts";
import { formatMoney } from "./money.ts";
import { type UnsubscribeConfig, unsubscribeConfig, unsubscribePage, unsubscribeRecipient, unsubscribeUrl } from "./unsubscribe.ts";
import { resendWebhook } from "./webhooks.ts";

Expand Down Expand Up @@ -197,9 +198,7 @@ async function buildPayload(
},
config: MailConfig,
) {
const amount = new Intl.NumberFormat("en-GB", {
style: "currency", currency: detail.currency,
}).format(Number(detail.outstanding_minor) / 100);
const amount = formatMoney(Number(detail.outstanding_minor), detail.currency);
const unsubscribe = config.unsubscribe ? unsubscribeUrl(config.unsubscribe, detail.recipient_user_id) : undefined;
const content = renderReminderEmail({
kind: detail.kind,
Expand Down
24 changes: 24 additions & 0 deletions apps/reminder-worker/src/money.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Amounts in reminder emails, written as the app writes them: "$32.10",
* "£42.00", "€8.50". This is a copy of formatMoney in
* packages/core/src/format.ts, because the worker runs from its own folder
* and imports nothing from outside it; apps/web/tests/formatting.test.ts
* checks that the two agree.
*/
const NARROW_SYMBOL = new Set(["GBP", "USD", "EUR"]);

const moneyFormats = new Map<string, Intl.NumberFormat>();

/** An amount in minor units (pence or cents) in its currency. */
export function formatMoney(minor: number, currency: string): string {
let format = moneyFormats.get(currency);
if (!format) {
format = new Intl.NumberFormat("en-GB", {
style: "currency",
currency,
currencyDisplay: NARROW_SYMBOL.has(currency) ? "narrowSymbol" : "symbol",
});
moneyFormats.set(currency, format);
}
return format.format(minor / 100);
}
17 changes: 17 additions & 0 deletions apps/reminder-worker/tests/email.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { renderReminderEmail } from "../src/email.ts";
import { formatMoney } from "../src/money.ts";

test("nudge has branded HTML and a useful plain-text fallback", () => {
const email = renderReminderEmail({
Expand Down Expand Up @@ -75,3 +76,19 @@ test("offers a one-click link to turn reminder emails off when one is given", ()
appUrl: "https://ourcommune.io", unsubscribeUrl: "http://worker.example.test/unsubscribe",
}), /HTTPS/);
});

test("amounts read as the app writes them, with each currency's own symbol", () => {
assert.equal(formatMoney(3210, "USD"), "$32.10");
assert.equal(formatMoney(4200, "GBP"), "£42.00");
assert.equal(formatMoney(123456, "EUR"), "€1,234.56");
const email = renderReminderEmail({
kind: "nudge",
senderName: "Maya Chen",
communeName: "Brooklyn loft",
label: "Internet",
amount: formatMoney(3210, "USD"),
appUrl: "https://ourcommune.io",
});
assert.match(email.text, /\$32\.10 expense share for Internet in Brooklyn loft/);
assert.doesNotMatch(email.text, /US\$/);
});
2 changes: 1 addition & 1 deletion apps/web/src/data/moneyInput.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { moneyProblemMessage, readMoneyInput } from "../../../../packages/core/src/money";
import { moneyProblemMessage, readMoneyInput } from "@commune/core";

/**
* An amount typed in the commune's currency, in minor units, or null when it
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/data/recurring.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SplitMethod } from "../../../../packages/core/src/expenseSplit";
import type { SplitMethod } from "@commune/core";
import { dateLabel, money, type CurrencyCode, type RecurringBill, type RecurringPeriodResult } from "../live/model";
import { addDays, todayIso } from "./operations";
import { formatReminderDay } from "./reminders";
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/data/reminders.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { formatDay } from "@commune/core";
import { commandFailure } from "./commands";
import { timestampDay } from "./operations";
import type { ReminderEmailStatus, ReminderResult, ReminderTarget, ReminderUsage, SentReminder } from "../live/model";
Expand All @@ -15,23 +16,20 @@ export type ReminderEligibility = {
};

const whenFormat = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
const dayFormat = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" });

export function reminderWord(kind: ReminderKind): "Nudge" | "Chase" {
return kind === "chase" ? "Chase" : "Nudge";
}

/** A timestamp shown as the member's local calendar day. */
/** A timestamp shown as the member's local calendar day: "9 Oct", or "9 Oct 2025" in another year. */
export function formatTimestampDay(timestamp: string | null | undefined): string {
const day = timestampDay(timestamp);
return day ? formatReminderDay(day) : timestamp ?? "";
}

/** A date-only value (due, received). Never shifted by time zone. */
/** A date-only value (due, received) as "9 Oct", with the year when it is not this year. Never shifted by time zone. */
export function formatReminderDay(iso: string | null | undefined): string {
if (!iso) return "";
const date = new Date(`${iso.slice(0, 10)}T12:00:00`);
return Number.isNaN(date.getTime()) ? iso : dayFormat.format(date);
return iso ? formatDay(iso) : "";
}

/**
Expand Down
Loading
Loading