diff --git a/CLAUDE.md b/CLAUDE.md
index 963a66cf..c113484e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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`.
diff --git a/apps/local-api/src/server.ts b/apps/local-api/src/server.ts
index 9bae2bb2..4a3a761d 100644
--- a/apps/local-api/src/server.ts
+++ b/apps/local-api/src/server.ts
@@ -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]]));
diff --git a/apps/native/app/(tabs)/hub.tsx b/apps/native/app/(tabs)/hub.tsx
index c1335c2d..684062da 100644
--- a/apps/native/app/(tabs)/hub.tsx
+++ b/apps/native/app/(tabs)/hub.tsx
@@ -1,30 +1,39 @@
-import { ImageBackground, Text, View } from "react-native";
+import { useState } from "react";
+import { ImageBackground, Linking, Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
-import { Check, Plus } from "lucide-react-native";
-import { activeMembers, oweTotals, owedTotals } from "../../src/lib/derive";
-import { money } from "../../src/lib/format";
-import { useApp } from "../../src/state";
+import * as Clipboard from "expo-clipboard";
+import { CalendarDays, Check, Copy, Eye, EyeOff, Phone, Plus, ReceiptText } from "lucide-react-native";
+import {
+ activeMembers, essentials, hubMoneyLine, oweTotals, owedTotals, people, week, weekSentence,
+ type Essential, type WeekItem,
+} from "../../src/lib/derive";
+import { addDays, calParts, firstName, money } from "../../src/lib/format";
+import { failure, useApp } from "../../src/state";
import { Body, Pad, ScopeRow, TopBar, photoOf } from "../../src/ui/chrome";
-import { Avatar, Button, Empty, Faces, Panel, Row, SectionHead, T } from "../../src/ui/kit";
-import { NeedsList } from "../../src/ui/parts";
-import { KIND_NAME, useTheme } from "../../src/ui/theme";
+import { Avatar, Button, Cal, Empty, Faces, Panel, SectionHead, State, T, tap } from "../../src/ui/kit";
+import { TaskCheck } from "../../src/ui/parts";
+import { PayPrimary } from "../../src/ui/pay";
+import { useToast } from "../../src/ui/toast";
+import { KIND_NAME, radius, useTheme } from "../../src/ui/theme";
+/**
+ * The Hub, the house at a glance: this week's pulse on the photo, your part
+ * with the one payment to record next, the week day by day, the people you
+ * share with, and the details everyone looks up.
+ */
export default function Hub() {
const { c } = useTheme();
- const router = useRouter();
- const { current, needs, waiting, user } = useApp();
+ const { current, user, today } = useApp();
if (!current) return You’re not in a commune yet.;
const { commune } = current;
- const people = activeMembers(current);
- const owe = oweTotals(needs, commune.id)[commune.currency] ?? 0;
- const owed = owedTotals(waiting, commune.id)[commune.currency] ?? 0;
- const mine = needs.filter((need) => need.communeId === commune.id);
- const standing = owe > 0 ? `You owe ${money(owe, commune.currency)}` : owed > 0 ? `You’re owed ${money(owed, commune.currency)}` : "You’re all square";
+ const viewerId = user?.id ?? "";
+ const members = activeMembers(current);
+ const thisWeek = week(current, viewerId, today);
const place = commune.address || commune.destination || "";
return
-
+
@@ -32,31 +41,221 @@ export default function Hub() {
{commune.name}
{place ? {place} : null}
- member.name)} size={28} ring="rgba(255,255,255,0.9)" />
- {people.length} {people.length === 1 ? "person" : "people"}
+ member.name)} size={28} ring="rgba(255,255,255,0.9)" />
+ {members.length} {members.length === 1 ? "person" : "people"}
+
+
+ {weekSentence(thisWeek)}
-
- {standing}
- {owe > 0 && owed > 0 ? You’re also owed {money(owed, commune.currency)} here. : null}
-
- } onPress={() => router.push("/add-expense")} />
-
+
+ ;
+}
+
+/** Where you stand here, and the one payment to record next. */
+function YourPart() {
+ const { c } = useTheme();
+ const router = useRouter();
+ const { current, needs, waiting, resolveReport } = useApp();
+ const toast = useToast();
+ const [busy, setBusy] = useState(false);
+ if (!current) return null;
+ const { commune } = current;
+ const owe = oweTotals(needs, commune.id)[commune.currency] ?? 0;
+ const owed = owedTotals(waiting, commune.id)[commune.currency] ?? 0;
+ const shares = needs.filter((need) => need.communeId === commune.id && need.kind === "share");
+ const first = shares[0]?.kind === "share" ? shares[0] : undefined;
+ const confirms = waiting.filter((entry) => entry.communeId === commune.id).flatMap((entry) => entry.debtors.filter((debtor) => debtor.pending).map((debtor) => ({ entry, debtor })));
+ const toConfirm = !first ? confirms[0] : undefined;
+ async function confirm() {
+ if (!toConfirm?.debtor.pending) return;
+ setBusy(true);
+ try {
+ await resolveReport(commune.id, toConfirm.debtor.pending.id, "confirm");
+ tap("success");
+ toast(`Confirmed. ${firstName(toConfirm.debtor.name)}’s ${money(toConfirm.debtor.pending.amountMinor, commune.currency)} is recorded.`);
+ } catch (cause) {
+ toast(failure(cause, "Couldn’t confirm it. Try again."));
+ } finally {
+ setBusy(false);
+ }
+ }
+ return
+ Your part in {commune.name}
+
+ {owe > 0 ? <>You owe {money(owe, commune.currency)} here.>
+ : owed > 0 ? <>You’re owed {money(owed, commune.currency)} here.>
+ : "You’re all square here."}
+
+ {hubMoneyLine(owe, owed, commune.currency)}
+ {first ?
+ router.push(`/expense/${first.expenseId}`)} style={{ flexDirection: "row", gap: 8, alignItems: "flex-start" }}>
+
+ {first.title}
+
+ You owe {firstName(first.payerName)}
+
-
-
-
- {mine.length ? : } title="Nothing needs you here" body="You’ll see it here when something does." />}
-
-
- {people.map((member, index) =>
}
- title={member.userId === user?.id ? "You" : member.name}
- meta={{member.role === "admin" ? "Admin" : "Member"}} />)}
+ {money(first.amountMinor, first.currency)}
+
+
+ {shares.length > 1 ? : toConfirm?.debtor.pending ?
+
+
+ {toConfirm.entry.title}
+ {firstName(toConfirm.debtor.name)} says they sent it
-
-