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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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`: 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`.
- **Hosted data and auth:** Neon (Managed Auth, Postgres, Data API). Schema and functions live in `infra/neon/migrations`: 41 migrations, 0001 to 0041, 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, 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`.
Expand Down
2 changes: 1 addition & 1 deletion apps/local-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ browser code sends same-origin `/api` requests. Non-2xx errors are
| POST | `/api/communes/:id/operations` | `{kind,category,title,date,time,allDay,assigneeId,rotationIds,repeat,notes,requestId}`; any member; at most 1,000 tasks and events per commune; a new task is open; a repeated `requestId` returns the same one, or 409 once it has been removed | `{id,version,duplicate}` (201, or 200 for a repeat) |
| PUT | `/api/operations/:id` | the same fields with `expectedVersion`; changes what, when and who only, never status or completion; any occurrence or handover since makes the edit stale (409) | `{id,version,duplicate}` |
| DELETE | `/api/operations/:id` | the person who added it or an admin; it and its occurrences leave every view and are kept, as is the activity; repeating is harmless | `{id,version,duplicate}` |
| POST | `/api/operations/:id/occurrences` | `{action:"completed"\|"skipped"\|"reopened",expectedDate,today,requestId,reason?}`; the database advances the date and rotation; a stale `expectedDate` is 409; only a skip takes a `reason` (up to 200 characters) | `{id,duplicate,date,assignee,assigneeId,turnOwnerId,status}` |
| POST | `/api/operations/:id/occurrences` | `{action:"completed"\|"skipped"\|"reopened",expectedDate,today,requestId,reason?,doneBy?}`; the database advances the date and rotation; a stale `expectedDate` is 409; only a skip takes a `reason` (up to 200 characters); completing a task someone else holds needs `doneBy` (`"me"` or `"assignee"`, 0041), and without it is 400 | `{id,duplicate,date,assignee,assigneeId,turnOwnerId,status}` |
| POST | `/api/operations/:id/handovers` | `{toMemberId,expectedDate,expectedAssigneeId,today,reason?,requestId}`; any member hands the current occurrence to another active member; on a task that takes turns the rotation then carries on from the person whose turn it was; a stale date or holder is 409 | `{id,duplicate,date,assignee,assigneeId,turnOwnerId,status}` |
| POST | `/api/communes/:id/announcements` | `{title,body?,needsAcknowledgement,requestId}` admin only; title up to 80, body up to 1000 characters; at most 20 up at once; a repeated `requestId` returns the same notice | `{id,duplicate,archived}` (201, or 200 for a repeat) |
| POST | `/api/announcements/:id/acknowledge` | `{}` member confirms an announcement that asks for it; repeating is harmless; refused once taken down | `{id,duplicate,archived}` |
Expand Down
6 changes: 4 additions & 2 deletions apps/local-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -793,10 +793,12 @@ route("POST", /^\/api\/operations\/([0-9a-f-]{36})\/occurrences$/, async (req, r
throw new ApiError(400, "Invalid task action");
}
if (input.reason != null && typeof input.reason !== "string") throw new ApiError(400, "Invalid reason");
// Marking someone else's task done says who did it: "me" or "assignee".
if (input.doneBy != null && !["me", "assignee"].includes(String(input.doneBy))) throw new ApiError(400, "Invalid doneBy");
const result = await userAction(req, async (client) => client.query(
"SELECT public.record_operation_occurrence($1, $2, $3::date, $4::date, $5, $6) AS result",
"SELECT public.record_operation_occurrence($1, $2, $3::date, $4::date, $5, $6, $7) AS result",
[uuid(match[1]), input.action, isoDate(input.expectedDate, false, "expectedDate"),
isoDate(input.today, false, "today"), uuid(input.requestId, "requestId"), input.reason ?? null]));
isoDate(input.today, false, "today"), uuid(input.requestId, "requestId"), input.reason ?? null, input.doneBy ?? null]));
const occurrence = result.rows[0].result as { duplicate: boolean };
send(res, occurrence, occurrence.duplicate ? 200 : 201);
});
Expand Down
10 changes: 5 additions & 5 deletions apps/local-api/tests/note-and-task-commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,21 @@ assert.equal(ok(await b("POST", `/communes/${id}/operations`, { ...chore, reques
let row = (await snapshot()).operations.find(item => item.id === task.id);
assert.deepEqual([row.version, row.status, row.lastDone, row.createdBy], [1, "open", null, blake.id]);

// Blake opens the task to edit it; meanwhile Casey marks it done. Blake's
// Blake opens the task to edit it; meanwhile Casey marks it done for him. Blake's
// save would put the task back, so it is refused and the completion stays.
ok(await c("POST", `/operations/${task.id}/occurrences`, { action: "completed", expectedDate: today, today, requestId: randomUUID() }), 201);
ok(await c("POST", `/operations/${task.id}/occurrences`, { action: "completed", expectedDate: today, today, requestId: randomUUID(), doneBy: "assignee" }), 201);
const staleTask = await b("PUT", `/operations/${task.id}`, { ...chore, title: "Hoover the stairs and hall", expectedVersion: 1 });
assert.equal(staleTask.status, 409);
assert.match(staleTask.error, /This task was changed by someone else/);
row = (await snapshot()).operations.find(item => item.id === task.id);
assert.deepEqual([row.version, row.title, row.assigneeId, row.lastDone], [2, "Hoover the stairs", casey.id, { by: blake.name, date: today }]);
assert.deepEqual([row.version, row.title, row.assigneeId, row.lastDone], [2, "Hoover the stairs", casey.id, { by: blake.name, date: today, markedBy: casey.name }]);
// Saved on top of the latest version, an edit changes what, when and who,
// and leaves the completion as it is.
const edited = ok(await b("PUT", `/operations/${task.id}`, { ...chore, title: "Hoover the stairs and hall", date: row.date,
assigneeId: row.assigneeId, status: "done", lastDone: null, expectedVersion: row.version }));
assert.deepEqual([edited.version, edited.duplicate], [3, false]);
row = (await snapshot()).operations.find(item => item.id === task.id);
assert.deepEqual([row.title, row.status, row.lastDone], ["Hoover the stairs and hall", "open", { by: blake.name, date: today }]);
assert.deepEqual([row.title, row.status, row.lastDone], ["Hoover the stairs and hall", "open", { by: blake.name, date: today, markedBy: casey.name }]);

// Casey cannot remove a task Blake added; Avery, an admin, can. It leaves
// the calendar, keeps its history and takes no further actions; a late retry
Expand All @@ -129,7 +129,7 @@ assert.equal(late.status, 409);
assert.match(late.error, /This task has been removed/);
const lateAdd = await b("POST", `/communes/${id}/operations`, { ...chore, requestId: taskRequest });
assert.deepEqual([lateAdd.status, lateAdd.error], [409, "This task has been removed"]);
assert.deepEqual(await events(task.id), ["Completed. Next: Casey Commands", "Created", "Removed", "Updated"]);
assert.deepEqual(await events(task.id), ["Created", "Marked done for Blake Commands. Next: Casey Commands", "Removed", "Updated"]);
// The person who added a task can remove it too.
const visit = ok(await c("POST", `/communes/${id}/operations`, { kind: "event", category: "visit", title: "Gas safety check", date: today,
allDay: true, repeat: "none", notes: "", requestId: randomUUID() }), 201);
Expand Down
26 changes: 18 additions & 8 deletions apps/local-api/tests/operations-history.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ assert.deepEqual([done.duplicate, done.date, done.assignee, done.taskStatus], [f
const again = ok(await act(bins, "completed", today, a, { requestId }), 200);
assert.deepEqual([again.id, again.duplicate, again.date, again.assignee], [done.id, true, day(7), blake.name]);
// A second person's stale click cannot advance the rotation twice.
const stale = await act(bins, "completed", today, b);
const stale = await act(bins, "completed", today, b, { doneBy: "assignee" });
assert.equal(stale.status, 409);
assert.match(stale.error, /already moved on/);
let saved = await read(bins, b);
Expand All @@ -108,7 +108,8 @@ assert.equal(saved.history[0].undoneBy, blake.id);
assert.ok(saved.history[0].undoneAt);

// Six people-at-once completions of one occurrence: exactly one counts.
const burst = await Promise.all(Array.from({ length: 6 }, (_, index) => act(bins, "completed", today, index % 2 ? b : a)));
const burst = await Promise.all(Array.from({ length: 6 }, (_, index) =>
index % 2 ? act(bins, "completed", today, b, { doneBy: "assignee" }) : act(bins, "completed", today, a)));
assert.equal(burst.filter(result => result.status === 201).length, 1, JSON.stringify(burst));
assert.equal(burst.filter(result => result.status === 409).length, 5);
saved = await read(bins);
Expand All @@ -127,18 +128,24 @@ assert.equal((await read(bins)).undoOccurrenceId, null);
assert.equal((await a("POST", `/occurrences/${second.id}/undo`, {})).status, 409);
assert.deepEqual([(await read(bins)).date, (await read(bins)).assignee], [day(15), casey.name]);
// The rotation wraps round to the first person.
const wrapped = ok(await act(bins, "completed", day(15)), 201);
const wrapped = ok(await act(bins, "completed", day(15), a, { doneBy: "assignee" }), 201);
assert.equal(wrapped.assignee, avery.name);


// ------------------------------------------------- monthly, overdue, skip
const late = await create({ title: "Deep clean", date: day(-40), assigneeId: blake.id, repeat: "monthly" });
let expected = day(-40);
do { expected = addMonth(expected); } while (expected <= today);
const monthly = ok(await act(late, "completed", day(-40)), 201);
// Marking Blake's task done says who did it (0041): without an answer it is
// refused, and with one both names go on it.
const unanswered = await act(late, "completed", day(-40));
assert.equal(unanswered.status, 400);
assert.equal(unanswered.error, "This is Blake's turn. Say whether you did it or they did.");
assert.equal((await act(late, "completed", day(-40), a, { doneBy: "someone" })).status, 400);
const monthly = ok(await act(late, "completed", day(-40), a, { doneBy: "assignee" }), 201);
assert.deepEqual([monthly.date, monthly.assignee, monthly.taskStatus], [expected, blake.name, "open"]);
// The assignee, not whoever ticked the box, is credited for a repeating task.
assert.deepEqual((await read(late)).lastDone, { by: blake.name, date: today });
assert.deepEqual((await read(late)).lastDone, { by: blake.name, date: today, markedBy: avery.name });
assert.deepEqual([(await read(late)).history[0].actorId, (await read(late)).history[0].doneById], [avery.id, blake.id]);
ok(await a("POST", `/occurrences/${monthly.id}/undo`, {}));
assert.deepEqual([(await read(late)).date, (await read(late)).lastDone], [day(-40), null]);
const skipped = ok(await act(late, "skipped", day(-40)), 201);
Expand Down Expand Up @@ -170,10 +177,13 @@ assert.equal((await read(late)).date, day(-40));
// ---------------------------------------- one-off tasks, reopen, and events
const key = await create({ title: "Return the spare key", date: today, assigneeId: blake.id });
assert.equal((await act(key, "skipped", today)).status, 409);
const finished = ok(await act(key, "completed", today), 201);
const finished = ok(await act(key, "completed", today, a, { doneBy: "me" }), 201);
assert.deepEqual([finished.taskStatus, finished.date], ["done", today]);
assert.deepEqual((await read(key)).lastDone, { by: avery.name, date: today });
assert.deepEqual((await read(key)).lastDone, { by: avery.name, date: today, covering: blake.name });
assert.equal((await act(key, "completed", today, b)).status, 409);
// The activity names both people.
assert.ok(ok(await a("GET", `/communes/${id}/snapshot`)).activityEvents.some(item => item.entityId === key
&& item.description === `Completed, covering ${blake.name}`));
ok(await b("POST", `/occurrences/${finished.id}/undo`, {}));
assert.deepEqual([(await read(key)).status, (await read(key)).lastDone], ["open", null]);
// Status and completion cannot be sent with an add or an edit: a new task
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/data/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,25 @@ export type OperationOccurrence = {
nextAssigneeId?: string | null;
/** Why it was skipped or handed over, when someone said. */
reason?: string | null;
/**
* Who a completion credits: the person who marked it, or the holder they
* marked it for. Missing on older entries and on tasks held by a name only.
*/
doneById?: string | null;
undoneAt: string | null;
undoneBy: string | null;
};
/**
* Who did a task someone else holds, said by the person marking it done:
* themselves, covering the turn, or the holder.
*/
export type DoneBy = "me" | "assignee";

/** Marking this task done has to say who did it: someone else holds it. */
export function needsDoneBy(item: OperationItem, youId: string): boolean {
return item.kind === "task" && item.status === "open" && Boolean(item.assigneeId) && item.assigneeId !== youId;
}

/** What the server reports after a task action or its undo. */
export type OperationChange = {
occurrenceId: string;
Expand Down Expand Up @@ -61,7 +77,11 @@ export type OperationItem = {
*/
turnOwnerId?: string | null;
turnOwner?: string | null;
lastDone?: { by: string; date: string };
/**
* The last completion: who did it, and who marked it done for them or
* whose turn they covered, when that was someone else.
*/
lastDone?: { by: string; date: string; markedBy?: string; covering?: string };
repeat: OperationRepeat;
status: OperationStatus;
notes: string;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/live/neon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,8 @@ async function route<T>(path: string, init: RequestInit): Promise<T> {
p_expected_date: body.expectedDate, p_today: body.today, p_request_id: body.requestId,
// Sent only with a reason, so a skip without one matches the 0018 signature.
...(body.reason ? { p_reason: body.reason } : {}),
// Sent only when marking someone else's task done (0041).
...(body.doneBy ? { p_done_by: body.doneBy } : {}),
})));
} else if (method === "POST" && pathId(path, /^\/operations\/([^/]+)\/handovers$/)) {
response = asObject(unwrap(await db().rpc("hand_over_operation", {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/screens/Hub.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
.c-hub-task.is-done .c-hub-task-copy strong { color: var(--hub-muted); text-decoration: line-through; }
.c-hub-task input { width: 17px; height: 17px; flex: none; margin: 0; accent-color: #426d50; cursor: pointer; }
.c-hub-late { color: var(--hub-warning); }
.c-hub-doneby { margin: 0 0 16px; }
.c-hub-pinned { margin-top: 18px; padding: 0 15px; border-radius: 9px; background: var(--hub-sage); }
.c-hub-pinned .c-hub-note-row { border: 0; }
.c-hub-recent { padding: 6px 2px; }
Expand Down
Loading
Loading