Skip to content
Draft
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`: 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`.
- **Hosted data and auth:** Neon (Managed Auth, Postgres, Data API). Schema and functions live in `infra/neon/migrations`: 43 migrations, 0001 to 0043, 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
8 changes: 8 additions & 0 deletions apps/local-api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,14 @@ route("PUT", /^\/api\/pay-links\/(monzo|paypal|revolut|bank)$/, async (req, res,
send(res, { links: result.rows[0].links });
});

// The favourite way to be paid, or none (provider null).
route("PUT", /^\/api\/pay-links-preferred$/, async (req, res) => {
const input = await body(req);
const result = await userAction(req, async (client) => client.query(
"SELECT public.set_preferred_pay_link($1) AS links", [optionalText(input.provider, "provider", 20)]));
send(res, { links: result.rows[0].links });
});

route("DELETE", /^\/api\/pay-links\/(monzo|paypal|revolut|bank)$/, async (req, res, match) => {
const result = await userAction(req, async (client) => client.query(
"SELECT public.remove_pay_link($1) AS links", [match[1]]));
Expand Down
271 changes: 235 additions & 36 deletions apps/native/app/(tabs)/hub.tsx

Large diffs are not rendered by default.

33 changes: 24 additions & 9 deletions apps/native/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ImageBackground, RefreshControl, Text, View } from "react-native";
import { useState } from "react";
import { comingUp, hasAny } from "../../src/lib/derive";
import { cap, feedTime, firstName, longDate, money, moneyList, plural, word } from "../../src/lib/format";
import { aheadBy, comingUp, hasAny, oweTotals, standingClause, type Need } from "../../src/lib/derive";
import { cap, feedTime, firstName, joinAnd, longDate, money, moneyList, plural, word } from "../../src/lib/format";
import type { CurrencyCode } from "../../src/lib/types";
import { useApp } from "../../src/state";
import { Body, TopBar, photoOf } from "../../src/ui/chrome";
import { Avatar, Cal, Panel, Row, SectionHead, T } from "../../src/ui/kit";
import { CommuneTiles, NeedsList, NextStep, WaitingRow } from "../../src/ui/parts";
import { CommuneTiles, NeedsList, NextStep, TaskCheck, WaitingRow } from "../../src/ui/parts";
import { useTheme } from "../../src/ui/theme";

/** Home, Direction B: your standing on the dark band, the next step raised over it. */
Expand All @@ -20,7 +20,7 @@ export default function Home() {
const lateText = late ? `${cap(plural(late, "thing is", "things are"))} overdue.` : "Nothing is overdue.";
const oweCurrencies = Object.keys(owe).filter((key) => (owe[key as CurrencyCode] ?? 0) > 0) as CurrencyCode[];
const places = [...new Set(needs.filter((need) => need.kind === "share").map((need) => need.communeId))];
const chores = needs.filter((need) => need.kind === "task").length;
const choresLeft = needs.filter((need) => need.kind === "task").length;

if (!needs.length && !hasAny(owed)) {
const photo = snapshots[0] ? photoOf(snapshots[0].commune.kind) : undefined;
Expand Down Expand Up @@ -52,10 +52,16 @@ export default function Home() {
? <>You owe <Em>{moneyList(owe)}</Em>.</>
: <>You owe <Em>{money(owe[oweCurrencies[0]]!, oweCurrencies[0])}</Em> {places.length > 1 ? `across ${word(places.length)} communes` : `in ${app.snapshotOf(places[0])?.commune.name ?? "one commune"}`}.</>
: hasAny(owed) ? <>You’re owed <Em>{moneyList(owed)}</Em>.</> : <>You owe nothing.</>;
// Where you stand once both directions count, kept per currency.
const many = new Set([...oweCurrencies, ...(Object.keys(owed) as CurrencyCode[]).filter((key) => (owed[key] ?? 0) > 0)]).size > 1;
const net = joinAnd(aheadBy(owe, owed).map((item) => standingClause(item, many)));
const sub = oweCurrencies.length
? `${hasAny(owed) ? `You’re owed ${moneyList(owed)}. ` : ""}${lateText}`
: `You owe nothing. ${chores ? `${cap(plural(chores, "chore is", "chores are"))} left.` : "Nothing needs you."}`;
? `${hasAny(owed) ? `You’re owed ${moneyList(owed)}, ${net}. ` : ""}${lateText}`
: `You owe nothing. ${choresLeft ? `${cap(plural(choresLeft, "chore is", "chores are"))} left.` : "Nothing needs you."}`;
const rest = needs.slice(1);
const chores = rest.filter((need): need is Extract<Need, { kind: "task" }> => need.kind === "task");
const toPay = rest.filter((need) => need.kind === "share");
const toPayTotal = moneyList(oweTotals(toPay));

return <View style={{ flex: 1, backgroundColor: c.canvas }}>
<TopBar />
Expand All @@ -68,9 +74,18 @@ export default function Home() {
<View style={{ marginTop: -46, marginHorizontal: 16 }}><NextStep need={needs[0]} total={needs.length} /></View>
<View style={{ padding: 16, paddingTop: 24, gap: 24 }}>
<View style={{ gap: 12 }}><SectionHead title="Your communes" /><CommuneTiles /></View>
{rest.length ? <View style={{ gap: 12 }}>
<SectionHead title="Needs you" count={rest.length} />
<Panel><NeedsList items={rest} initial={2} /></Panel>
{chores.length ? <View style={{ gap: 12 }}>
<SectionHead title="Today" count={chores.length} />
<Panel>{chores.map((need, index) => {
const snapshot = app.snapshotOf(need.communeId);
return <TaskCheck key={need.key} first={index === 0} communeId={need.communeId} operationId={need.operationId} date={need.date}
title={need.title} assigneeId={user?.id ?? null} assigneeName={user?.name ?? "You"} late={need.band === "Overdue"}
meta={`${snapshot?.commune.name ?? ""} · ${need.band === "Overdue" ? need.status.text.toLowerCase() : "today"}`} />;
})}</Panel>
</View> : null}
{toPay.length ? <View style={{ gap: 12 }}>
<SectionHead title="Still to pay" count={toPay.length} right={toPayTotal ? <T kind="label" muted>{toPayTotal}</T> : undefined} />
<Panel><NeedsList items={toPay} initial={4} /></Panel>
</View> : null}
{waiting.length ? <View style={{ gap: 12 }}>
<SectionHead title="Waiting on others" count={waiting.length} />
Expand Down
12 changes: 9 additions & 3 deletions apps/native/app/(tabs)/settle.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Alert, Platform, View } from "react-native";
import { useRouter } from "expo-router";
import { hasAny } from "../../src/lib/derive";
import { hasAny, payLinksOf } from "../../src/lib/derive";
import { firstName, money, moneyList, shortDate, word } from "../../src/lib/format";
import type { CurrencyCode } from "../../src/lib/types";
import { failure, useApp } from "../../src/state";
Expand All @@ -23,7 +23,8 @@ function ask(title: string, message: string, yes: string): Promise<boolean> {
export default function Settle() {
const { c } = useTheme();
const router = useRouter();
const { settle, owe, owed, recorded, resolveReport, nudge } = useApp();
const { settle, owe, owed, recorded, resolveReport, nudge, snapshots } = useApp();
const payable = (communeId: string, userId: string) => payLinksOf(snapshots.find((item) => item.commune.id === communeId), userId).length > 0;
const toast = useToast();
const [busy, setBusy] = useState("");
const payTotalCurrencies = Object.keys(owe) as CurrencyCode[];
Expand Down Expand Up @@ -79,7 +80,12 @@ export default function Settle() {
meta={<T kind="label" muted>{row.items.map((item) => item.title).join(", ")}</T>}
right={<View style={{ alignItems: "flex-end", gap: 6 }}>
<T kind="amount">{money(row.amountMinor, currency)}</T>
<Button size="sm" variant="secondary" label="I’ve paid" accessibilityLabel={`I’ve paid ${row.name}`} onPress={() => router.push(`/record/${row.items[0].shareId}`)} />
{payable(row.items[0].communeId, row.userId)
? <View style={{ flexDirection: "row", gap: 6 }}>
<Button size="sm" variant="secondary" label="Already paid" accessibilityLabel={`Already paid ${row.name}`} onPress={() => router.push(`/record/${row.items[0].shareId}`)} />
<Button size="sm" label="Pay" accessibilityLabel={`Pay ${row.name}`} onPress={() => router.push(`/pay/${row.items[0].shareId}`)} />
</View>
: <Button size="sm" variant="secondary" label="I’ve paid" accessibilityLabel={`I’ve paid ${row.name}`} onPress={() => router.push(`/record/${row.items[0].shareId}`)} />}
</View>} />)}</Panel>
</View>)}

Expand Down
48 changes: 46 additions & 2 deletions apps/native/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,50 @@
import { Stack } from "expo-router";
import { useEffect, useRef } from "react";
import { AppState, Platform } from "react-native";
import { Stack, useRouter } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { AppProvider } from "../src/state";
import { AppProvider, useApp } from "../src/state";
import { ToastProvider } from "../src/ui/toast";
import { useTheme } from "../src/ui/theme";

/**
* After a pay link opens another app, coming back asks whether the money
* went. It asks only once the app was actually left, so a link that failed
* to open asks nothing.
*/
function PayReturn() {
const router = useRouter();
const { payingAway, clearPayingAway } = useApp();
const left = useRef(false);
const latest = useRef({ router, clearPayingAway });
latest.current = { router, clearPayingAway };
useEffect(() => {
if (!payingAway) return;
left.current = false;
const back = () => {
if (!left.current) return;
left.current = false;
latest.current.clearPayingAway();
latest.current.router.push({ pathname: "/paid/[shareId]", params: { shareId: payingAway.shareId, provider: payingAway.provider } });
};
const app = AppState.addEventListener("change", (state) => {
if (state === "active") back(); else left.current = true;
});
if (Platform.OS !== "web" || typeof window === "undefined") return () => app.remove();
const away = () => { if (document.visibilityState === "hidden") left.current = true; else back(); };
const blur = () => { left.current = true; };
window.addEventListener("blur", blur);
window.addEventListener("focus", back);
document.addEventListener("visibilitychange", away);
return () => {
app.remove();
window.removeEventListener("blur", blur);
window.removeEventListener("focus", back);
document.removeEventListener("visibilitychange", away);
};
}, [payingAway]);
return null;
}

function Screens() {
const { c } = useTheme();
const pushed = {
Expand All @@ -18,6 +59,7 @@ function Screens() {
const sheet = { ...pushed, presentation: "formSheet" as const, sheetGrabberVisible: true, sheetAllowedDetents: [0.92], headerShown: false };
return <>
<StatusBar style="light" />
<PayReturn />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: c.canvas } }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="sign-in" />
Expand All @@ -32,6 +74,8 @@ function Screens() {
<Stack.Screen name="funds" options={{ ...pushed, title: "Funds" }} />
<Stack.Screen name="templates" options={{ ...pushed, title: "Templates" }} />
<Stack.Screen name="record/[shareId]" options={sheet} />
<Stack.Screen name="pay/[shareId]" options={sheet} />
<Stack.Screen name="paid/[shareId]" options={{ ...sheet, sheetAllowedDetents: "fitToContents" }} />
<Stack.Screen name="add-expense" options={sheet} />
<Stack.Screen name="switch" options={{ ...sheet, sheetAllowedDetents: "fitToContents" }} />
</Stack>
Expand Down
7 changes: 3 additions & 4 deletions apps/native/app/expense/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { useState } from "react";
import { View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useLocalSearchParams } from "expo-router";
import { Check } from "lucide-react-native";
import { memberName, outstanding, pendingReport, statusFor } from "../../src/lib/derive";
import { dayDate, firstName, money, shortDate } from "../../src/lib/format";
import { failure, useApp } from "../../src/state";
import { Body, Pad } from "../../src/ui/chrome";
import { Avatar, Badge, Button, Cal, Kicker, Panel, Row, State, T } from "../../src/ui/kit";
import { PayPrimary } from "../../src/ui/pay";
import { useToast } from "../../src/ui/toast";
import { useTheme } from "../../src/ui/theme";
import { useSafeAreaInsets } from "react-native-safe-area-context";

export default function ExpenseDetail() {
const { c } = useTheme();
const router = useRouter();
const toast = useToast();
const insets = useSafeAreaInsets();
const { id } = useLocalSearchParams<{ id: string }>();
Expand Down Expand Up @@ -81,8 +81,7 @@ export default function ExpenseDetail() {
</Pad>
</Body>
{iOwe && mine ? <View style={{ padding: 16, paddingBottom: Math.max(insets.bottom, 16), gap: 8, backgroundColor: c.canvas, borderTopWidth: 1, borderTopColor: c.outline }}>
<Button size="lg" block label={`I’ve paid ${firstName(payer)} ${money(outstanding(mine), currency)}`} onPress={() => router.push(`/record/${mine.id}`)} />
<T kind="note" muted>Pay {firstName(payer)} however you usually do, then record it here.</T>
<PayPrimary target={{ shareId: mine.id, communeId: snapshot.commune.id, payerId: expense.paidByUserId, payerName: memberName(snapshot, expense.paidByUserId), amountMinor: outstanding(mine), currency, title: expense.title }} />
</View> : null}
</View>;
}
66 changes: 66 additions & 0 deletions apps/native/app/paid/[shareId].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useState } from "react";
import { View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { memberName, outstanding, pendingReport } from "../../src/lib/derive";
import { firstName, money } from "../../src/lib/format";
import { failure, useApp } from "../../src/state";
import { Button, ErrorText, T, tap } from "../../src/ui/kit";
import { useToast } from "../../src/ui/toast";
import { useTheme } from "../../src/ui/theme";

/**
* Asked on coming back from a pay link: did the money go? Yes tells the
* person you paid the whole share today, with the app you used as the
* reference. They still confirm it before anything changes.
*/
export default function Paid() {
const { c } = useTheme();
const router = useRouter();
const toast = useToast();
const { shareId, provider = "" } = useLocalSearchParams<{ shareId: string; provider?: string }>();
const { snapshots, today, reportPayment } = useApp();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const found = snapshots.flatMap((snapshot) => snapshot.expenses.flatMap((expense) => expense.shares
.filter((share) => share.id === shareId).map((share) => ({ snapshot, expense, share }))))[0];
const close = () => (router.canGoBack() ? router.back() : router.replace("/"));
const left = found ? outstanding(found.share) : 0;
if (!found || !left || pendingReport(found.share)) {
return <View style={{ flex: 1, padding: 24, gap: 14, backgroundColor: c.canvas }}>
<T kind="title">{found && pendingReport(found.share) ? "Already recorded" : "Nothing left to pay"}</T>
<Button label="Close" variant="secondary" onPress={close} />
</View>;
}
const { snapshot, expense } = found;
const name = firstName(memberName(snapshot, expense.paidByUserId));
const amount = money(left, snapshot.commune.currency);

async function yes() {
setBusy(true); setError("");
try {
await reportPayment(snapshot.commune.id, shareId, left, today, provider || null);
tap("success");
toast(`Recorded. ${name} will confirm your ${amount}.`);
close();
} catch (cause) {
setError(failure(cause, "Couldn’t record it. Try again."));
} finally {
setBusy(false);
}
}

return <View style={{ width: "100%", padding: 20, paddingTop: 22, gap: 16, backgroundColor: c.canvas }}>
<View style={{ gap: 4 }}>
<T kind="title" accessibilityRole="header">Did {amount} go to {name}?</T>
<T kind="body" muted>{expense.title}{provider ? ` · paid with ${provider}, today` : ""}</T>
</View>
<T kind="body" muted>{provider ? `You opened ${provider} a moment ago. ` : ""}If it went through, tell {name} so they can confirm it.</T>
<ErrorText>{error}</ErrorText>
<View style={{ gap: 10 }}>
<Button size="lg" block label={`Yes, tell ${name}`} busy={busy} onPress={() => void yes()} />
<Button size="lg" block variant="secondary" label="Paid a different amount" disabled={busy} onPress={() => router.replace(`/record/${shareId}`)} />
<Button size="lg" block variant="quiet" label="Not yet" disabled={busy} onPress={close} />
</View>
<T kind="note" muted>Your share stays open until {name} confirms they received it.</T>
</View>;
}
Loading