diff --git a/CLAUDE.md b/CLAUDE.md index 70ab0b7c..963a66cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ The application was migrated. The stack below is what runs today. - **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`. +- **Phone app:** `apps/native` (Expo, expo-router), built from the approved phone design. It runs on demo data or the local API, not yet on hosted Neon, and keeps its own lockfile outside the workspace. See `apps/native/README.md`. - **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. @@ -28,6 +29,7 @@ commune/ ├── apps/web/ # The product: React 19 + Vite + wouter ├── apps/local-api/ # Local loopback API over PostgreSQL (dev only) ├── apps/reminder-worker/ # Reminder and invitation email sender +├── apps/native/ # Expo phone app (own lockfile) ├── apps/mobile-v2/ # Expo app (separate work) ├── web/ # Marketing website (separate work) ├── web-backup/ # Previous v2 website, kept for reference diff --git a/apps/local-api/README.md b/apps/local-api/README.md index 1b014826..1cee623f 100644 --- a/apps/local-api/README.md +++ b/apps/local-api/README.md @@ -28,6 +28,9 @@ browser code sends same-origin `/api` requests. Non-2xx errors are | 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) | | POST | `/api/receipts/:id/reverse` | `{reason}` payer only; keeps the original entry; refused for a former member's share | `{id,duplicate,settledMinor,outstandingMinor}` | +| GET | `/api/pay-links` | | `{links:[...]}`, your own Monzo, PayPal, Revolut or bank details | +| PUT | `/api/pay-links/:provider` | provider `monzo`, `paypal`, `revolut` or `bank`; `{handle}` or `{accountName,sortCode,accountNumber}`; a pasted link keeps only the username | `{links:[...]}` | +| DELETE | `/api/pay-links/:provider` | removing one that is not there is harmless | `{links:[...]}` | | PUT | `/api/expenses/:id` | `{expectedVersion,title,amountMinor,category,dueOn?,paidByUserId,shares:[{userId,amountMinor}],reason,requestId}` payer or admin; only an admin changes the payer; money fields are locked while repayments exist | `{version,duplicate}`; a stale `expectedVersion` is 409 | | POST | `/api/expenses/:id/void` | `{expectedVersion,reason,requestId}` payer or admin; refused while repayments exist; nothing is deleted | `{version,duplicate}` | | POST | `/api/communes/:id/funds` | `{name,targetMinor?,requestId?}` admin; a repeated `requestId` returns the same fund | `{id,duplicate}` (201, or 200 for a repeat) | diff --git a/apps/local-api/src/server.ts b/apps/local-api/src/server.ts index 5545479e..9bae2bb2 100644 --- a/apps/local-api/src/server.ts +++ b/apps/local-api/src/server.ts @@ -586,6 +586,28 @@ route("POST", /^\/api\/announcements\/([0-9a-f-]{36})\/archive$/, async (req, re send(res, result.rows[0].result); }); +// Pay links: how this person likes to be paid. People who share a commune +// with them read these from the snapshot; the database validates every field. +route("GET", /^\/api\/pay-links$/, async (req, res) => { + const result = await userAction(req, async (client) => client.query("SELECT public.my_pay_links() AS links")); + send(res, { links: result.rows[0].links }); +}); + +route("PUT", /^\/api\/pay-links\/(monzo|paypal|revolut|bank)$/, async (req, res, match) => { + const input = await body(req); + const result = await userAction(req, async (client) => client.query( + "SELECT public.set_pay_link($1, $2, $3, $4, $5) AS links", + [match[1], optionalText(input.handle, "handle", 120), optionalText(input.accountName, "accountName", 60), + optionalText(input.sortCode, "sortCode", 12), optionalText(input.accountNumber, "accountNumber", 12)])); + 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]])); + send(res, { links: result.rows[0].links }); +}); + route("POST", /^\/api\/communes\/([0-9a-f-]{36})\/funds$/, async (req, res, match) => { const input = await body(req); const name = text(input.name, "name", 80); diff --git a/apps/local-api/tests/pay-links.mjs b/apps/local-api/tests/pay-links.mjs new file mode 100644 index 00000000..5be3ea65 --- /dev/null +++ b/apps/local-api/tests/pay-links.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; + +// Pay links: saving your own, who can see them, and leaving a commune. +// Creates accounts and records, 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("Pay link tests require an isolated loopback API port (not 3002)."); +} +const suffix = randomUUID().slice(0, 8); +const password = "DisposablePayLinkFixture-2026!"; +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 }; + }; +} +const ok = (result, status = 200) => { assert.equal(result.status, status, JSON.stringify(result)); return result; }; +const a = session(), b = session(), outsider = session(); +const users = []; +for (const [index, call] of [a, b, outsider].entries()) { + users.push(ok(await call("POST", "/auth/signup", { + name: ["Avery Pay", "Blake Pay", "Outsider"][index], email: `pay-links-${index}-${suffix}@example.test`, password, + }), 201).user); +} +const [avery, blake] = users; +const id = ok(await a("POST", "/communes", { name: `Pay links ${suffix}`, kind: "household", currency: "GBP" }), 201).id; +const invite = ok(await a("POST", `/communes/${id}/invites`, { email: blake.email, role: "member" }), 201); +ok(await b("POST", `/invites/${invite.token}/accept`, {})); + +// Validation happens in the database and comes back as a 400 with a reason. +assert.equal((await a("PUT", "/pay-links/monzo", { handle: "not a username" })).status, 400); +assert.equal((await a("PUT", "/pay-links/bank", { accountName: "Avery Pay", sortCode: "12345", accountNumber: "12345678" })).status, 400); +assert.equal((await a("PUT", "/pay-links/venmo", { handle: "avery" })).status, 404); +assert.equal((await outsider("GET", "/pay-links")).links.length, 0); + +// Avery saves two; a pasted link keeps only the username. +ok(await a("PUT", "/pay-links/monzo", { handle: "https://monzo.me/averypay" })); +const saved = ok(await a("PUT", "/pay-links/bank", { accountName: "Avery Pay", sortCode: "20-45-77", accountNumber: "41029388" })).links; +assert.deepEqual(saved.map((link) => link.provider), ["monzo", "bank"]); +assert.equal(saved[0].handle, "averypay"); +assert.equal(saved[1].sortCode, "204577"); +assert.deepEqual(ok(await a("GET", "/pay-links")).links.map((link) => link.provider), ["monzo", "bank"]); + +// Blake, in the same commune, sees them in the snapshot; the outsider cannot open it. +const seen = ok(await b("GET", `/communes/${id}/snapshot`)).payLinks; +assert.deepEqual(seen[avery.id].map((link) => link.provider), ["monzo", "bank"]); +assert.equal(seen[blake.id], undefined); +assert.equal((await outsider("GET", `/communes/${id}/snapshot`)).status, 404); +assert.equal(ok(await b("GET", "/pay-links")).links.length, 0); + +// Removing is Avery's alone and harmless to repeat. +ok(await b("DELETE", "/pay-links/monzo")); +assert.equal(ok(await a("GET", "/pay-links")).links.length, 2); +assert.equal(ok(await a("DELETE", "/pay-links/monzo")).links.length, 1); +assert.equal(ok(await a("DELETE", "/pay-links/monzo")).links.length, 1); +assert.deepEqual(Object.keys(ok(await b("GET", `/communes/${id}/snapshot`)).payLinks), [avery.id]); + +console.log("Pay link tests passed"); diff --git a/apps/native/.gitignore b/apps/native/.gitignore new file mode 100644 index 00000000..245ee81e --- /dev/null +++ b/apps/native/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.expo/ +dist/ +ios/ +android/ +*.tsbuildinfo diff --git a/apps/native/.npmrc b/apps/native/.npmrc new file mode 100644 index 00000000..d05f5bd5 --- /dev/null +++ b/apps/native/.npmrc @@ -0,0 +1,3 @@ +ignore-workspace=true +auto-install-peers=true +node-linker=hoisted diff --git a/apps/native/README.md b/apps/native/README.md new file mode 100644 index 00000000..55dc1b14 --- /dev/null +++ b/apps/native/README.md @@ -0,0 +1,57 @@ +# Commune phone app (Expo) + +The native iPhone and Android app, built from the approved phone design +(Home Direction B, the five-tab bar with More, and the pay sheet with pay +links). It is separate from `apps/mobile` and `apps/mobile-v2`, which are older +experiments. + +## What it does + +- **Home**: your standing on the dark band, the next step raised over it, your + communes, what needs you (grouped by when it is due), what you are waiting on, + and what other people did. +- **Hub, Expenses, Settle, More**: the commune in view, its expenses, who you + pay and who pays you (each currency kept separate), and the rest of the + commune (chores and tasks, notes, members, recurring bills, funds, templates). +- **"I’ve paid"**: opens a sheet with the bill payer's pay links first (Monzo + and PayPal with the amount filled in, Revolut, or bank details to copy), then + records the payment. It stays a claim until the bill payer confirms it. + Commune never moves money. +- **Confirm or "Not received"**, **Nudge**, **Mark done**, **Add an expense** + (split equally), **How people pay you** (add, change, remove), notifications. + +Uneven splits, receipts, corrections, invitations and settings stay on the web +app for now. + +## Where its data comes from + +Screens never do arithmetic. `src/lib/derive.ts` works everything out from the +same commune snapshots the web app reads, and every command goes to the same +validated database functions with a request id. + +| `EXPO_PUBLIC_COMMUNE_API` | Data | +| --- | --- | +| not set | Maya's demo communes, kept on the phone. Nothing leaves the device. | +| `http://127.0.0.1:3005` | A local API (`apps/local-api`). Use a disposable database and port, never 3002 for tests. | +| `same-origin` | A web build served next to the local API (used for browser checks). | + +The hosted app (Neon Auth and the Data API) is not connected yet. That needs +Neon Auth sign-in to work from a phone, which is the next piece of work. + +## Running it + +It keeps its own lockfile so the web CI install is unchanged. + +```sh +cd apps/native +pnpm install --ignore-workspace +pnpm start # Expo Go or a simulator; press i or a +pnpm typecheck && pnpm test +pnpm export:web # a static web build in dist/ +``` + +`EXPO_PUBLIC_*` values are built into the bundle. After changing one, run +`npx expo start --clear` (or `expo export --clear`) so the old value is not reused. + +The local API only listens on 127.0.0.1, so a real phone cannot reach it; the +iOS simulator can, and the Android emulator reaches it as `10.0.2.2`. diff --git a/apps/native/app.json b/apps/native/app.json new file mode 100644 index 00000000..2999a234 --- /dev/null +++ b/apps/native/app.json @@ -0,0 +1,28 @@ +{ + "expo": { + "name": "Commune", + "slug": "commune", + "scheme": "commune", + "version": "0.1.0", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "backgroundColor": "#1a1e2b", + "newArchEnabled": true, + "ios": { + "bundleIdentifier": "io.ourcommune.app", + "supportsTablet": false, + "config": { "usesNonExemptEncryption": false } + }, + "android": { + "package": "io.ourcommune.app", + "edgeToEdgeEnabled": true + }, + "web": { + "bundler": "metro", + "output": "single", + "name": "Commune" + }, + "plugins": ["expo-router"], + "experiments": { "typedRoutes": false } + } +} diff --git a/apps/native/app/(tabs)/_layout.tsx b/apps/native/app/(tabs)/_layout.tsx new file mode 100644 index 00000000..b22875e3 --- /dev/null +++ b/apps/native/app/(tabs)/_layout.tsx @@ -0,0 +1,61 @@ +import { Redirect, Tabs } from "expo-router"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import type { BottomTabBarProps } from "@react-navigation/bottom-tabs"; +import { ArrowRightLeft, Ellipsis, House, LayoutGrid, ReceiptText } from "lucide-react-native"; +import { useApp } from "../../src/state"; +import { Button, T } from "../../src/ui/kit"; +import { useTheme } from "../../src/ui/theme"; + +const TABS = [ + { name: "index", label: "Home", Icon: LayoutGrid }, + { name: "hub", label: "Hub", Icon: House }, + { name: "expenses", label: "Expenses", Icon: ReceiptText }, + { name: "settle", label: "Settle", Icon: ArrowRightLeft }, + { name: "more", label: "More", Icon: Ellipsis }, +]; + +/** Five tabs on the dark frame, the selected one in a pill, as in the approved mock. */ +function TabBar({ state, navigation }: BottomTabBarProps) { + const { c } = useTheme(); + const insets = useSafeAreaInsets(); + return + {TABS.map((tab, index) => { + const on = state.index === index; + const color = on ? c.onFrame : c.onFrame2; + return { + const event = navigation.emit({ type: "tabPress", target: state.routes[index].key, canPreventDefault: true }); + if (!on && !event.defaultPrevented) navigation.navigate(state.routes[index].name); + }} + style={{ flex: 1, alignItems: "center", gap: 4 }}> + + + + {tab.label} + ; + })} + ; +} + +export default function TabsLayout() { + const { c } = useTheme(); + const { status, error, refresh } = useApp(); + if (status === "signed-out") return ; + if (status === "starting" || status === "loading") { + return + commune. + + ; + } + if (status === "error") { + return + Commune didn’t load + {error} + {groups.map((group) => ( -
+

{group.name} {group.rows.length}

    {group.rows.map((entry, index) => ( ))} {phoneCollapsible ? ( - @@ -429,7 +432,7 @@ const COMING_SHOWN = 5; function ComingUp({ coming, today, onTake, busy }: { coming: Coming[]; today: string; onTake: (entry: Coming) => void; busy: boolean }) { const { go } = useApp(); return ( -
    +

    Coming up

    {coming.length} @@ -438,7 +441,7 @@ function ComingUp({ coming, today, onTake, busy }: { coming: Coming[]; today: st {coming.slice(0, COMING_SHOWN).map((entry) => { const { hub, item } = entry; return ( -
  • +
  • } @@ -546,14 +647,15 @@ export function HomeScreen() {
); - const status = message ?

{message}

: null; + // What was recorded is said in the toast; only what could not be done stays here. + const status = message ?

{message}

: null; // Coming up fills the quiet states, where nothing needs you beyond the next step. const comingUp = coming.length ? void take(entry)} busy={taking} /> : null; if (square) { return (
- + {status}
{comingUp}
diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index b0ce4580..e0008291 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -6,7 +6,8 @@ import { Icon } from "../ui/Icon"; import { CalendarDate } from "../ui/CalendarDate"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; import { isNotePinned, noteCategory, notePreview } from "../data/notePresentation"; -import { addDays, dateFromIso, firstNameOf, needsDoneBy, todayIso, type DoneBy, type OperationItem } from "../data/operations"; +import { addDays, dateFromIso, needsDoneBy, taskDoneCopy, todayIso, type DoneBy, type OperationItem } from "../data/operations"; +import { hideToast, showToast } from "../ui/Toast"; import { DoneByChoice } from "../ui/DoneByChoice"; import { formatTimestampDay } from "../data/reminders"; import type { Hub, RouteId } from "../data/household"; @@ -49,49 +50,37 @@ function Responsibilities() { // Someone else's task, ticked: it asks who did it before it is marked done. const [asking, setAsking] = useState(null); const [error, setError] = useState(""); - const [feedback, setFeedback] = useState<{ text: string; undoId?: string } | null>(null); - // A completed task leaves the list (and its checkbox with it). Move focus to - // the result once the controls are enabled again, so it is not lost. - const [focusTarget, setFocusTarget] = useState<{ kind: "undo" | "feedback" } | { kind: "task"; id: string } | null>(null); - const feedbackRef = useRef(null); - const undoRef = useRef(null); + // Undo puts focus back on the task's checkbox once the controls are enabled again. + const [focusTask, setFocusTask] = useState(null); const listRef = useRef(null); useEffect(() => { - if (pending !== null || !focusTarget) return; - if (focusTarget.kind === "task") { - (listRef.current?.querySelector(`[data-task="${focusTarget.id}"] input`) ?? feedbackRef.current)?.focus(); - } else if (focusTarget.kind === "undo" && undoRef.current) { - undoRef.current.focus(); - } else { - feedbackRef.current?.focus(); - } - setFocusTarget(null); - }, [pending, focusTarget]); + if (pending !== null || focusTask === null) return; + listRef.current?.querySelector(`[data-task="${focusTask}"] input`)?.focus(); + setFocusTask(null); + }, [pending, focusTask]); const today = todayIso(); const end = addDays(today, 6); const items = operations.filter(item => item.status === "open" && item.date <= end && (item.date >= today || item.kind === "task")) .sort((a, b) => a.date.localeCompare(b.date)).slice(0, 5); + // What was recorded is said in the shared toast, with Undo; the completed + // task leaves the list, so Undo takes focus there. Errors stay here. async function complete(item: OperationItem, doneBy?: DoneBy) { - const { id, title } = item; - if (!doneBy && needsDoneBy(item, you.id)) { setAsking(id); setError(""); setFeedback(null); return; } - setAsking(null); setPending(id); setError(""); setFeedback(null); + const { id } = item; + if (!doneBy && needsDoneBy(item, you.id)) { setAsking(id); setError(""); hideToast(); return; } + setAsking(null); setPending(id); setError(""); hideToast(); try { const result = await toggleOperation(id, doneBy); - const holder = firstNameOf(item.assignee); - const said = doneBy === "assignee" ? `${title} marked done for ${holder}.` : doneBy === "me" ? `${title} done, covering ${holder}.` - : result.status === "done" ? `${title} marked done.` : `${title} done.`; - setFeedback({ text: result.status === "done" ? said : `${said} Next: ${result.assignee ?? "unassigned"}.`, undoId: result.occurrenceId }); - setFocusTarget({ kind: "undo" }); + showToast({ text: taskDoneCopy(item, doneBy, result), action: { label: "Undo", busyLabel: "Undoing…", undo: true, run: () => undo(result.occurrenceId, id) } }); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not update the task. Please try again."); } finally { setPending(null); } } - async function undo(occurrenceId: string) { + async function undo(occurrenceId: string, taskId: string) { setPending(occurrenceId); setError(""); try { - const result = await undoOperation(occurrenceId); - setFeedback({ text: "Undone. The task is back as it was." }); - setFocusTarget({ kind: "task", id: operations.find(item => item.history?.some(entry => entry.id === result.occurrenceId))?.id ?? "" }); + await undoOperation(occurrenceId); + setFocusTask(taskId); + showToast({ text: "Undone. The task is back as it was." }); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not undo that. Please try again."); } finally { setPending(null); } @@ -101,10 +90,10 @@ function Responsibilities() { {items.map(item => { const choice = asking === item.id && void complete(item, doneBy)} - onCancel={() => { setAsking(null); setFocusTarget({ kind: "task", id: item.id }); }} />; + onCancel={() => { setAsking(null); setFocusTask(item.id); }} />; const due = item.date === today ? "Today" : new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" }).format(dateFromIso(item.date)); const late = item.status === "open" && item.date < today; - return [
+ return [
{!items.length &&

Nothing planned for this week.

} {error &&

{error}

} -

{feedback?.text}{feedback?.undoId - ? : null}

; } diff --git a/apps/web/src/screens/Money.css b/apps/web/src/screens/Money.css index 606e0092..6829dc82 100644 --- a/apps/web/src/screens/Money.css +++ b/apps/web/src/screens/Money.css @@ -205,3 +205,28 @@ .a-body.is-dark .a-window :is(.c-product-dialog, .c-reminder-dialog) .a-field-row { position: static; margin: 16px 0 0; padding: 0; background: transparent; border-top: 0; } .c-period-split .c-form-help { margin: 0 0 6px; } } + +@layer screens { +/* Pay links: how the bill payer likes to be paid, and your own in Profile. */ +.c-pay-links, .c-pay-editor { display: grid; gap: 0; margin: 0 0 18px; border: 1px solid var(--border); border-radius: var(--radius-control); background: #fff; } +.c-pay-link { display: flex; align-items: center; gap: 10px; padding: 10px 12px; min-height: 56px; } +.c-pay-link + .c-pay-link, .c-pay-form + .c-pay-link, .c-pay-link + .c-pay-form { border-top: 1px solid var(--border); } +.c-pay-copy { display: grid; min-width: 0; flex: 1; font-size: 13.5px; line-height: 1.4; } +.c-pay-copy span { color: var(--ink-soft); overflow-wrap: anywhere; } +.c-pay-copy small { color: var(--ink-soft); font-size: 12px; } +.c-pay-link .a-btn { flex-shrink: 0; display: inline-flex; align-items: center; text-decoration: none; } +.c-pay-mark { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; flex: none; font-weight: 700; font-size: 14px; color: #fff; background: #3c4a46; } +.c-pay-mark.is-monzo { background: #14233c; } +.c-pay-mark.is-paypal { background: #1d4f91; } +.c-pay-mark.is-revolut { background: #191c1f; } +.c-pay-form { display: grid; gap: 4px; padding: 12px; } +.c-pay-form > strong { font-size: 13.5px; margin-bottom: 6px; } +.c-pay-none { font-size: 13px; line-height: 1.6; color: var(--ink-soft); margin: 0 0 18px; } +.is-dark :is(.c-pay-links, .c-pay-editor) { background: transparent; } +.is-dark .c-pay-mark { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); } +@media (max-width: 520px) { + .c-pay-link { flex-wrap: wrap; row-gap: 8px; } + .c-pay-copy { flex: 1 1 calc(100% - 40px); } + .c-pay-link .a-btn:first-of-type { margin-left: 40px; } +} +} diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx index e62a32aa..347ae72b 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -88,14 +88,14 @@ export function ExpensesScreen() {
-

{showVoided +

{showVoided ? `${rows.length} voided ${rows.length === 1 ? "expense" : "expenses"} · kept for history, not counted in any balance` : `${rows.length} ${rows.length === 1 ? "expense" : "expenses"} · ${formatMoney(rows.reduce((sum, row) => sum + row.amountMinor, 0), hub.currency)} total recorded in this view`}

{rows.map(row => )} {!rows.length &&

No expenses match this view. Try another filter or search.

}
- {more ?
+ {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.`}

} @@ -112,7 +112,7 @@ const ExpenseCard = memo(function ExpenseCard({ row, viewerId, currency, go }: { row: LiveExpenseRow; viewerId: string | undefined; currency: CurrencyCode; go: AppState["go"]; }) { const yours = row.shares.find(share => share.userId === viewerId); - return - {reminderReason &&

{reminderReason}

} @@ -240,7 +244,7 @@ export function SettleScreen() {
) : (
-
@@ -248,16 +252,16 @@ export function SettleScreen() {
); })} - {!(receivingGroup ? youAreOwed : youOwe) &&

{receivingGroup ? "Nothing waiting to collect." : "Nothing to pay."}

} + {!(receivingGroup ? youAreOwed : youOwe) &&

{receivingGroup ? "Nothing waiting to collect." : "Nothing to pay."}

} )}
- {recent.length > 0 &&
+ {recent.length > 0 &&

Recent repayments

{recent.map(({ expense, share, receipt }) => { const debtor = hub.members.find((member) => member.userId === share.userId); const payer = hub.members.find((member) => member.userId === expense.paidByUserId); const mine = share.userId === me; const other = mine ? personName(expense.paidByUserId, "the bill payer", { left: true }) : personName(share.userId, "A former member", { left: true }); - return
} -

Each share is tracked separately. Commune does not move money.

+

Each share is tracked separately. Commune does not move money.

{receipts.dialogs} diff --git a/apps/web/src/screens/Notes.css b/apps/web/src/screens/Notes.css index 1039fc93..2444f6a8 100644 --- a/apps/web/src/screens/Notes.css +++ b/apps/web/src/screens/Notes.css @@ -68,8 +68,8 @@ font: inherit; cursor: pointer; transition: - border-color 140ms, - background-color 140ms; + border-color var(--motion-short) var(--ease-standard), + background-color var(--motion-short) var(--ease-standard); } .note-tile:hover { border-color: #91a99b; diff --git a/apps/web/src/screens/Operations.tsx b/apps/web/src/screens/Operations.tsx index a27ea680..22178bd4 100644 --- a/apps/web/src/screens/Operations.tsx +++ b/apps/web/src/screens/Operations.tsx @@ -9,6 +9,8 @@ import { needsDoneBy, nextPerson, overdueDays, + shortDay, + taskDoneCopy, todayIso, type DoneBy, type OperationItem, @@ -19,6 +21,7 @@ import { Avatar } from "../ui/widgets"; import { CalendarDate } from "../ui/CalendarDate"; import { cx } from "../ui/cx"; import { DoneByChoice } from "../ui/DoneByChoice"; +import { hideToast, showToast } from "../ui/Toast"; type Cut = "week" | "people" | "month"; /** The late card, and which of its buttons, an Undo puts focus back on. */ @@ -63,15 +66,13 @@ export function OperationsScreen() { const [cut, setCut] = useState("week"); const [month, setMonth] = useState(today.slice(0, 7)); const [selectedDate, setSelectedDate] = useState(today); - const [toast, setToast] = useState<{ text: string; undoId?: string; from?: LateAction } | null>(null); const [error, setError] = useState(""); - const [undoing, setUndoing] = useState(false); + // Which late card's action is being recorded, so its button can say so. + const [working, setWorking] = useState(null); + // After Undo, the button that was used, once its card is back. + const [refocus, setRefocus] = useState(null); // The late task whose card is asking who did it. const [asking, setAsking] = useState(null); - const toastTimer = useRef(undefined); - const toastRef = useRef(null); - const toastHeld = useRef(false); - const refocus = useRef(null); const ordered = useMemo( () => @@ -117,67 +118,34 @@ export function OperationsScreen() { (item) => item.date <= weekEnd || item.lastDone?.date === today, ); - useEffect(() => () => window.clearTimeout(toastTimer.current), []); - - // A late card's buttons go once its task moves on, so a message with an - // Undo takes focus, where the keyboard can reach it in time. Undo puts focus - // back on the button that was used. The message stays while focus or the - // pointer is on it. useEffect(() => { - const box = toastRef.current; - if (!toast || !box) return; - const active = document.activeElement; - const lost = !active || active === document.body || box.contains(active); - const back = refocus.current; - refocus.current = null; - if (back && lost) { - const card = [...document.querySelectorAll(".ops-late")].find((item) => item.dataset.late === back.id); - (card?.querySelector(`[data-late-action="${back.action}"]`) - ?? document.querySelector(".ops-cuts [aria-pressed=\"true\"]"))?.focus(); - } else if (toast.undoId && (lost || active?.closest(".ops-late"))) { - box.querySelector(".ops-toast-undo")?.focus(); - } - toastHeld.current = box.contains(document.activeElement) || box.matches(":hover"); - }, [toast]); + if (!refocus) return; + setRefocus(null); + const card = [...document.querySelectorAll(".ops-late")].find((item) => item.dataset.late === refocus.id); + (card?.querySelector(`[data-late-action="${refocus.action}"]`) + ?? document.querySelector(".ops-cuts [aria-pressed=\"true\"]"))?.focus(); + }, [refocus, operations]); - // Messages with an Undo stay long enough to read and use. The timer pauses - // while the pointer or keyboard focus is on the toast. - function scheduleToastClose(ms: number) { - window.clearTimeout(toastTimer.current); - toastTimer.current = window.setTimeout(() => { - if (toastHeld.current) return; - const hadFocus = toastRef.current?.contains(document.activeElement); - setToast(null); - if (hadFocus) document.querySelector(".ops-prod-head button")?.focus(); - }, ms); - } + // What was recorded is said in the shared toast, with Undo. A late card's + // buttons go once its task moves on, so Undo takes focus then (the toast + // does that when focus is lost); Undo puts focus back on the button used. function flash(text: string, undoId?: string, from?: LateAction) { setError(""); - setToast({ text, undoId, from }); - scheduleToastClose(undoId ? 9000 : 2600); + showToast({ text, action: undoId ? { label: "Undo", busyLabel: "Undoing…", undo: true, run: () => undo(undoId, from) } : undefined }); } // An error stays on the screen, as a form's does, until the next action. function fail(text: string) { - window.clearTimeout(toastTimer.current); - setToast(null); + hideToast(); setError(text); } - function holdToast(held: boolean) { - toastHeld.current = held; - if (!held && toast) scheduleToastClose(4000); - } async function undo(occurrenceId: string, from?: LateAction) { - if (undoing) return; - setUndoing(true); try { const result = await undoOperation(occurrenceId); - refocus.current = from ?? null; - flash(`Undone · back with ${firstNameOf(result.assignee ?? undefined) || "the commune"} for ${shortDateFormatter.format(dateFromIso(result.date))}`); + if (from) setRefocus(from); + flash(`Undone. Back with ${firstNameOf(result.assignee ?? undefined) || "the commune"} for ${shortDay(result.date)}.`); } catch (error) { fail(error instanceof Error ? error.message : "Could not undo that"); - } finally { - setUndoing(false); } } @@ -199,33 +167,27 @@ export function OperationsScreen() { return; } setAsking(null); + setWorking({ id: item.id, action: "done" }); try { // The server decides the next date and person; report what it saved. const result = await toggleOperation(item.id, doneBy); - const handed = item.repeat !== "none" && item.rotation?.length ? firstNameOf(result.assignee ?? undefined) : ""; - const holder = firstNameOf(item.assignee); - const lead = doneBy === "assignee" ? `Marked done for ${holder}` : doneBy === "me" ? `Done, covering ${holder}` : "Done"; - const next = shortDateFormatter.format(dateFromIso(result.date)); - flash( - handed - ? doneBy ? `${lead} · next ${handed}, ${next}` : `Handed to ${handed} · ${next}` - : item.repeat !== "none" - ? `${lead} · next ${next}` - : `${lead} · ${item.title}`, - result.occurrenceId, - { id: item.id, action: "done" }, - ); + flash(taskDoneCopy(item, doneBy, result), result.occurrenceId, { id: item.id, action: "done" }); } catch (error) { fail(error instanceof Error ? error.message : "Could not update task"); + } finally { + setWorking(null); } } async function skipTask(item: OperationItem) { + setWorking({ id: item.id, action: "skip" }); try { const result = await skipOperation(item.id); - flash(`Moved to ${shortDateFormatter.format(dateFromIso(result.date))}`, result.occurrenceId, { id: item.id, action: "skip" }); + flash(`${item.title} moved to ${shortDay(result.date)}.`, result.occurrenceId, { id: item.id, action: "skip" }); } catch (error) { fail(error instanceof Error ? error.message : "Could not move task"); + } finally { + setWorking(null); } } @@ -296,6 +258,7 @@ export function OperationsScreen() { item={item} today={today} pending={mutationPending} + working={working?.id === item.id ? working.action : null} asking={asking === item.id} onOpen={() => go("operation", { operation: item.id })} onSkip={() => skipTask(item)} @@ -304,7 +267,7 @@ export function OperationsScreen() { /> ))} -
+

Yours

{yours.length ? ( yours.map((item) => ( @@ -501,23 +464,6 @@ export function OperationsScreen() {
) : null} - {/* Always present so screen readers announce each new message. */} -
holdToast(true)} - onMouseLeave={() => holdToast(false)} - onFocus={() => holdToast(true)} - onBlur={() => holdToast(false)} - > - {toast ? {toast.text} : null} - {toast?.undoId ? ( - - ) : null} -
); } @@ -526,6 +472,7 @@ function LateCard({ item, today, pending, + working = null, asking, onOpen, onSkip, @@ -536,6 +483,8 @@ function LateCard({ today: string; /** A change is saving: the actions wait so one click is not sent twice. */ pending: boolean; + /** This card's own action is being recorded. */ + working?: LateAction["action"] | null; /** Done was pressed on someone else's task: the card asks who did it. */ asking: boolean; onOpen: () => void; @@ -549,7 +498,7 @@ function LateCard({ : ""; const who = firstNameOf(item.assignee); return ( -
+

{lateLabel(item.date, today)} · {who || "Unassigned"}

@@ -588,12 +537,12 @@ function LateCard({ {/* As in the task window, only a repeating task can be skipped. */} {item.repeat !== "none" ? ( - ) : null} -
} diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx index 52781301..ebf3aca6 100644 --- a/apps/web/src/screens/Settings.tsx +++ b/apps/web/src/screens/Settings.tsx @@ -82,13 +82,13 @@ export function SettingsScreen() { - diff --git a/apps/web/src/shell/ProductShell.tsx b/apps/web/src/shell/ProductShell.tsx index c32bbf72..1079133a 100644 --- a/apps/web/src/shell/ProductShell.tsx +++ b/apps/web/src/shell/ProductShell.tsx @@ -2,7 +2,7 @@ import { Suspense, useEffect, useRef, useState, type MouseEvent, type ReactNode import { diagnosticsEnabled } from "../live/diagnostics"; import { useApp } from "../state"; import { Header, MoreSheet, PhoneTabs, Sidebar } from "./Shell"; -import { HomeEmptyScreen, HomeScreen, PlacesScreen } from "../screens/Home"; +import { HomeEmptyScreen, HomeScreen, HomeSkeleton, PlacesScreen } from "../screens/Home"; import { HubEmptyScreen, HubScreen } from "../screens/Hub"; import { ExpensesEmptyScreen, ExpensesScreen, SettleEvenScreen, SettleScreen } from "../screens/Money"; import { Windows } from "../windows/Windows"; @@ -13,6 +13,8 @@ import { RecoveryBoundary, ScreenRecovery } from "./Recovery"; import { isConnectionFailure } from "../live/connection"; import { lazyComponent, preloadWhenIdle } from "./lazyComponent"; import { usePageTitle } from "./pageTitle"; +import { ToastHost } from "../ui/Toast"; +import { useLayoutMotion } from "../ui/motion"; // The signed-in app. App draws it only for someone signed in, and starts // fetching it while the session is checked on one of its addresses (never on @@ -36,6 +38,9 @@ export function ProductShell() { const noteReader = screen === "notes" && win === "note" && Boolean(notes[noteKey]); const modalOpen = Boolean(win) && !noteReader; usePageFocus(screen, hub.id, Boolean(win) || Boolean(searchTarget)); + // Rows answer in place, fold away and make room, after something you did. + const mainRef = useRef(null); + useLayoutMotion(mainRef); // The screens and windows still to come load once the browser is idle, so // they open at once later. useEffect(() => preloadWhenIdle(), []); @@ -62,7 +67,7 @@ export function ProductShell() { {diagnosticsEnabled() ? : null} {apiError ?
{isConnectionFailure(apiError) ? <>Connection interrupted. Your last loaded view is still available. : apiError}
: null} {contentError && hub.activityState === "ready" ?
Could not refresh this commune. Showing its last loaded activity.
: null} -
+
}> @@ -74,6 +79,7 @@ export function ProductShell() {
+ ); } @@ -151,8 +157,8 @@ function Screen({ screen }: { screen: ScreenId }) { } // The page's own skeleton, while its records load or a screen's code arrives. -function ContentSkeleton({ label, children }: { label: string; children?: ReactNode }) { - return
{label}{children}