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} - - - {item.kind === "confirm" - ? - : null} +
+ {item.kind === "pay" && item.minor ? ( + onPay(entry)} onTell={() => onAct(entry)} onOpened={(link) => onOpened(entry, link)} + target={{ payerId: item.person, payerName: nameOf(hub, item.person, "the bill payer"), communeId: hub.id, + outstandingMinor: item.minor, currency: hub.currency, expenseName: item.title }} /> + ) : <> + + {item.kind === "confirm" + ? + : null} + } {note ? {note} : null}
); } +/** + * On a phone the sentence keeps to one line ("You owe £78.90 in Elm Road.") + * by easing its size down, never below 22px; a sentence too long even then + * wraps at the usual size. Wider screens leave it to the stylesheet. + */ +const PHONE = "(max-width: 560px)"; +const SMALLEST_LINE = 22; +function useOneLine(key: string) { + const ref = useRef(null); + useLayoutEffect(() => { + const element = ref.current; + if (!element || typeof window.matchMedia !== "function") return undefined; + const fit = () => { + element.style.fontSize = ""; + element.style.lineHeight = ""; + element.classList.remove("is-one-line"); + if (!window.matchMedia(PHONE).matches) return; + element.classList.add("is-one-line"); + const size = parseFloat(getComputedStyle(element).fontSize); + const room = element.clientWidth; + const needed = element.scrollWidth; + if (!size || !room || needed <= room) return; + const next = Math.floor(size * room / needed * 10) / 10; + if (next < SMALLEST_LINE) { element.classList.remove("is-one-line"); return; } + element.style.fontSize = `${next}px`; + element.style.lineHeight = "1.15"; + }; + fit(); + window.addEventListener("resize", fit); + void document.fonts?.ready.then(fit); + return () => window.removeEventListener("resize", fit); + }, [key]); + return ref; +} + /** Nothing needs you: say so, and point at anything others are late with. */ function Calm({ waiting, today }: { waiting: Entry[]; today: string }) { const open = useOpen(); @@ -288,6 +360,7 @@ function CommuneCard({ hub, current }: { hub: LiveHub; current: boolean }) { {state ? : null} {square ? : null} + {hub.activityState === "ready" && hub.pulse ? {hub.pulse.text} : null} @@ -295,14 +368,16 @@ function CommuneCard({ hub, current }: { hub: LiveHub; current: boolean }) { } /** A row in Needs you or Waiting on others. The title opens the record; the button acts on it. */ -function Row({ entry, today, waiting = false, onAct, busy, className }: { - entry: Entry; today: string; waiting?: boolean; onAct?: (entry: Entry) => void; busy?: boolean; className?: string; +function Row({ entry, today, waiting = false, onAct, onPay, busy, className }: { + entry: Entry; today: string; waiting?: boolean; onAct?: (entry: Entry) => void; onPay?: (entry: Entry) => void; busy?: boolean; className?: string; }) { const open = useOpen(); const { hub, item } = entry; const state = item.kind === "confirm" ? null : dueState(item.dueOn, today, waiting); const who = item.kind === "owed" ? item.people ?? [] : item.person ? [item.person] : []; - const verb = item.kind === "pay" ? "I’ve paid" : item.kind === "confirm" ? "Confirm" : "Open"; + const links = usePayeeLinks(item.kind === "pay" ? item.person : null, hub.id); + const payable = item.kind === "pay" && Boolean(links?.length) && Boolean(onPay); + const verb = item.kind === "pay" ? payable ? "Pay" : "I’ve paid" : item.kind === "confirm" ? "Confirm" : "Open"; const context = item.kind === "pay" ? `${nameOf(hub, item.person)} for ${item.title}` : item.kind === "confirm" ? `${item.amount} from ${nameOf(hub, item.person)}` : item.title; return (
  • @@ -322,7 +397,7 @@ function Row({ entry, today, waiting = false, onAct, busy, className }: { {onAct ? ( @@ -338,7 +413,9 @@ const COLLAPSE_OVER = 7; const PHONE_SHOWN = 2; const PHONE_COLLAPSE_OVER = 3; -function NeedsList({ needs, today, onAct, busy }: { needs: Entry[]; today: string; onAct: (entry: Entry) => void; busy: boolean }) { +function NeedsList({ needs, today, onAct, onPay, busy, title = "Needs you", id = "home-needs" }: { + needs: Entry[]; today: string; onAct: (entry: Entry) => void; onPay?: (entry: Entry) => void; busy: boolean; title?: string; id?: string; +}) { const { go } = useApp(); const [all, setAll] = useState(false); const collapsible = needs.length > COLLAPSE_OVER; @@ -355,16 +432,16 @@ function NeedsList({ needs, today, onAct, busy }: { needs: Entry[]; today: strin // Past the limit on every screen, past it on a phone, or never. const extra = (index: number) => cx(index >= limit && "is-extra", index >= phoneLimit && "is-extra-phone"); return ( -
    +
    -

    Needs you

    {needs.length} +

    {title}

    {needs.length}
    {groups.map((group) => (

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

      {group.rows.map((entry, index) => ( - ))}
    @@ -382,7 +459,60 @@ function NeedsList({ needs, today, onAct, busy }: { needs: Entry[]; today: strin ); } -function WaitingList({ waiting, today }: { waiting: Entry[]; today: string }) { +/** Your chores that are due, each ticked off here with Undo on the toast, in whichever commune it is. */ +function Chores({ chores, today }: { chores: Entry[]; today: string }) { + const { toggleOperation, undoOperation } = useApp(); + const go = useOpen(); + const [pending, setPending] = useState(null); + const [error, setError] = useState(""); + async function done({ hub, item }: Entry) { + if (!item.operationId || !item.dueOn) return; + setPending(item.operationId); setError(""); + try { + const result = await toggleOperation(item.operationId, undefined, { communeId: hub.id, date: item.dueOn }); + showToast({ text: `${item.title} is done.`, action: { label: "Undo", busyLabel: "Undoing…", undo: true, run: () => undo(result.occurrenceId, hub.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, communeId: string) { + try { + await undoOperation(occurrenceId, communeId); + 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."); + } + } + return ( +
    +

    Today

    {chores.length}
    +
      {chores.map((entry) => { + const { hub, item } = entry; + const state = dueState(item.dueOn, today); + return ( +
    • + {item.dueOn ? : No date} + + + {state ? <> : null} + + + + +
    • + ); + })}
    + {error ?

    {error}

    : null} +
    + ); +} + +function WaitingList({ waiting, today, onAct, busy }: { waiting: Entry[]; today: string; onAct?: (entry: Entry) => void; busy?: boolean }) { const total = perCurrency(waiting.filter(({ item }) => item.kind === "owed").map(({ hub, item }) => [hub.currency, item.minor ?? 0])); return (
    @@ -390,7 +520,8 @@ function WaitingList({ waiting, today }: { waiting: Entry[]; today: string }) {

    Waiting on others

    {waiting.length} {total.length ? {total.join(" · ")} : null} -
      {waiting.map((entry, index) => )}
    +
      {waiting.map((entry, index) => )}
    ); } @@ -416,7 +547,10 @@ function Activity({ places }: { places: LiveHub[] }) { {/* "Joined the commune · Maya Chen" under "Maya Chen" names her once. */} {[hub.name, activityWord(item.type), item.when.endsWith(` · ${item.title}`) ? item.when.slice(0, -` · ${item.title}`.length) : item.when].filter(Boolean).join(" · ")} - + + {item.amount ? {item.amount} : null} + +
  • ))} @@ -598,6 +732,10 @@ function HomeWithCommunes() { const waiting = places.flatMap((hub) => hub.waiting.map((item) => ({ hub, item }))).sort((a, b) => compareNeeds(a.item, b.item)); const coming = places.flatMap((hub) => hub.coming.map((item) => ({ hub, item }))).sort((a, b) => a.item.dueOn.localeCompare(b.item.dueOn)); const [next, ...rest] = needs; + // After the next step: chores to tick, money to pay, and payments to confirm sit with what others owe. + const chores = rest.filter(({ item }) => item.kind === "task"); + const toPay = rest.filter(({ item }) => item.kind !== "task" && item.kind !== "confirm"); + const waitingAll = [...rest.filter(({ item }) => item.kind === "confirm"), ...waiting]; const current = places.find((place) => place.id === hubId) ?? places[0]; const communes = places.map((hub, index) => ({ hub, index })).sort((a, b) => needRank(a.hub) - needRank(b.hub) || a.index - b.index).map(({ hub }) => hub); const ready = places.every((hub) => hub.activityState === "ready"); @@ -622,12 +760,26 @@ function HomeWithCommunes() { } } + const payTarget = ({ hub, item }: Entry) => item.shareId && item.minor + ? { shareId: item.shareId, payerName: nameOf(hub, item.person, "the bill payer"), expenseName: item.title, + outstandingMinor: item.minor, currency: hub.currency, communeId: hub.id, payerId: item.person } + : null; + // Pay opens their ways to be paid; opening one asks on the way back whether it went. + function pay(entry: Entry) { + const target = payTarget(entry); + setMessage(""); + if (target) receipts.pay(target); else open(entry); + } + function opened(entry: Entry, link: PayLink) { + const target = payTarget(entry); + if (target) receipts.opened(target, link); + } function act(entry: Entry, decline = false) { const { hub, item } = entry; setMessage(""); - if (item.kind === "pay" && item.shareId && item.minor) { - receipts.report({ shareId: item.shareId, payerName: nameOf(hub, item.person, "the bill payer"), expenseName: item.title, - outstandingMinor: item.minor, currency: hub.currency, communeId: hub.id, payerId: item.person }); + const target = item.kind === "pay" ? payTarget(entry) : null; + if (target) { + receipts.report(target); } else if (item.kind === "confirm" && item.report) { receipts.answer({ report: item.report, debtorName: nameOf(hub, item.person, "a former member"), expenseName: item.title, currency: hub.currency, decision: decline ? "decline" : "confirm", communeId: hub.id, expenseId: item.expenseId }); @@ -669,23 +821,26 @@ function HomeWithCommunes() {
    - {next ? : ready ? : null} + {next ? : ready ? : null} {status}
    {communeList}
    - {rest.length ? ( + {chores.length || toPay.length ? ( <> -
    - {waiting.length ? : null} + {chores.length ? : null} + {toPay.length ? : null} +
    +
    + {waitingAll.length ? : null}
    - ) : waiting.length || comingUp ? ( + ) : waitingAll.length || comingUp ? ( <> -
    {waiting.length ? : null}{comingUp}
    +
    {waitingAll.length ? : null}{comingUp}
    ) :
    } diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css index d3d2c899..624a84fa 100644 --- a/apps/web/src/screens/Hub.css +++ b/apps/web/src/screens/Hub.css @@ -14,8 +14,11 @@ --hub-action: #2d6a4f; --hub-action-text: #fff; --hub-action-hover: #245a42; + --hub-in: var(--sage); --hub-warning: #964417; --hub-heading-icon: #60706b; + --hub-late-soft: #fbeee6; + --hub-event: var(--lilac); color: var(--hub-ink); background: var(--hub-paper); } @@ -33,6 +36,9 @@ --hub-action-hover: #cfd8e1; --hub-warning: #f4ba94; --hub-heading-icon: #b5c6bb; + --hub-late-soft: #3a2a22; + --hub-event: #3a3448; + --hub-in: #7cc39f; } /* The phone app's layout (app.css) keeps its own frame background and page padding. */ .a-body.hub-editorial .a-app:where(:not(.is-native *)), @@ -40,7 +46,7 @@ /* Header geometry, typography and responsive behaviour belong to the shared shell. */ .hub-editorial .a-content:where(:not(.is-native *)) { padding: 22px 22px 32px; } -.c-hub { max-width: 1208px; margin-inline: auto; display: grid; gap: 22px; color: var(--hub-ink); font-family: var(--font); font-size: 14px; line-height: 1.5; } +.c-hub { max-width: 1208px; margin-inline: auto; display: grid; grid-template-columns: minmax(0, 1fr); gap: 22px; color: var(--hub-ink); font-family: var(--font); font-size: 14px; line-height: 1.5; } .c-hub :is(h1,h2,h3,p) { margin: 0; } .c-hub :is(button,summary,input):focus-visible { outline: 3px solid #649578; outline-offset: 4px; } .c-hub button { font: inherit; cursor: pointer; } @@ -67,67 +73,31 @@ .c-hub-hero-actions { position: absolute; right: 22px; top: 20px; display: flex; gap: 7px; } .c-hub-settings { display: flex; align-items: center; gap: 6px; padding: 9px 12px; min-height: 38px; border: 1px solid #ffffff55; border-radius: var(--radius-control); background: rgba(21, 34, 28, 0.65); color: #fff; font-size: 12px !important; } -.c-hub-overview { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 20px; } -/* The balance card keeps the prototype's pale sage so it reads first. */ -.c-hub-balance { padding: 23px; border: 1px solid var(--hub-sage-line); border-radius: var(--radius-surface); background: var(--hub-sage); } -.c-hub-amount { display: block; margin: 9px 0; font-size: clamp(32px,4vw,44px); font-weight: 500; letter-spacing: -1.8px; line-height: 1.2; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } -.c-hub-balance p { max-width: 42ch; margin-bottom: 22px; color: var(--hub-muted); } -.c-hub-balance-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 24px; } .c-hub-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; padding: 10px 17px; border: 0; border-radius: var(--radius-control); background: var(--hub-action); color: var(--hub-action-text); font-size: 13px !important; font-weight: 500 !important; } .c-hub-button:hover { background: var(--hub-action-hover); } .c-hub-lower { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); align-items: stretch; gap: 22px; } .c-hub-panel { min-width: 0; padding: 23px; border: 1px solid var(--hub-line); border-radius: var(--radius-surface); background: var(--hub-panel); } .c-hub-lower .c-hub-panel { min-height: 0; } -.c-hub-section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.c-hub-section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 16px; } .c-hub-section-title { display: flex; align-items: center; gap: 9px; font-size: 18px; font-weight: 550; letter-spacing: -.4px; } .c-hub-heading-icon { width: 17px; height: 17px; flex: none; color: var(--hub-heading-icon); } -.c-hub-context { margin: 7px 0 16px !important; color: var(--hub-muted); font-size: 12px; } -.c-hub-section-head:not(:has(+ .c-hub-context)) { margin-bottom: 16px; } -.c-hub-count { flex: none; color: var(--hub-muted); font-size: 11px; white-space: nowrap; } .c-hub-link { padding: 4px 0; border: 0; background: none; color: var(--hub-action); font-size: 12px !important; font-weight: 500 !important; text-decoration: none; } .c-hub-link:hover { text-decoration-color: currentColor; } -.c-hub-more { display: block; margin-top: 14px; } -.c-hub-more + .c-hub-more { margin-top: 10px; } -.c-hub-invite { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; border: 1px solid #6f9b87; border-radius: var(--radius-control); background: var(--hub-panel); color: var(--hub-ink); font-size: 12px !important; } -.c-hub-invite:hover { background: var(--hub-quiet); } .c-hub-empty { padding: 14px 0; border-top: 1px solid var(--hub-line); color: var(--hub-muted); font-size: 13px; } .c-hub-empty .c-hub-link { display: block; margin-top: 8px; } -.c-hub-attention-row, .c-hub-task, .c-hub-note-row, .c-hub-expense-row { border-top: 1px solid var(--hub-line); } -.c-hub-attention-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 0; } -.c-hub-attention-row > span:first-child { min-width: 0; } -.c-hub-attention-row :is(strong,small) { display: block; overflow-wrap: anywhere; } -.c-hub-attention-row strong { font-size: 13px; font-weight: 500; } -.c-hub-attention-row small { margin-top: 4px; color: var(--hub-muted); font-size: 12px; } -.c-hub-attention-end { flex: none; text-align: right; } -.c-hub-attention-end button { display: block; margin: 8px 0 0 auto; padding: 5px 16px; border: 1px solid #d7ddd9; border-radius: var(--radius-control); background: var(--hub-panel); color: var(--hub-ink); font-size: 12px !important; } -.c-hub-attention-end button:hover { background: var(--hub-quiet); } - -.c-hub-task { display: flex; align-items: center; gap: 12px; padding: 16px 0; } -.c-hub-task-copy { min-width: 0; flex: 1; padding: 0; border: 0; background: none; color: var(--hub-ink); text-align: left; } -.c-hub-task-copy strong { display: block; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } -.c-hub-task-copy > span { display: block; margin-top: 4px; color: var(--hub-muted); font-size: 12px; overflow-wrap: anywhere; } -.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; } +.c-hub-recent { padding-bottom: 8px; } .c-hub-recent .c-hub-expense-row > span:first-child { flex: 1; } -.c-hub-attention-copy { display: flex; align-items: center; gap: 12px; } -.c-hub-icon-chip { width: 37px; height: 37px; flex: none; border-radius: 10px; background: var(--hub-quiet); color: var(--hub-heading-icon); display: grid; place-items: center; } -.c-hub-empty .c-hub-button { margin-top: 18px; } -/* Needs you: what others owe, with nothing to do, comes after the actions and quieter. */ -.c-hub-waiting { margin-top: 18px; } -.c-hub .c-hub-waiting h3 { margin-bottom: 2px; color: var(--hub-heading-icon); font-size: 11px; font-weight: 550; letter-spacing: .6px; text-transform: uppercase; } +.c-hub-note-row, .c-hub-expense-row { border-top: 1px solid var(--hub-line); } .c-hub-note-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 16px 0; border-right: 0; border-bottom: 0; border-left: 0; background: none; color: var(--hub-ink); text-align: left; } .c-hub-note-row > span { min-width: 0; } .c-hub-note-row :is(strong,small) { display: block; overflow-wrap: anywhere; } .c-hub-note-row strong { font-size: 13px; font-weight: 500; } .c-hub-note-row small { margin-top: 4px; color: var(--hub-muted); font-size: 12px; } -.c-hub-lower .c-hub-note-row { padding-block: 10px; } .c-hub-note-row svg { flex: none; color: var(--hub-muted); } .c-hub-note-row:hover strong { color: var(--hub-action); } @@ -142,7 +112,7 @@ .c-hub-expense-status.is-settled { background: #e8efe8; color: var(--sage) !important; } .c-hub-expense-row:hover strong { color: var(--hub-action); } @media(max-width:800px) { - .c-hub-overview, .c-hub-lower { grid-template-columns: 1fr; } + .c-hub-lower, .c-hub-top { grid-template-columns: minmax(0, 1fr); } .c-hub-lower .c-hub-panel { min-height: 0; } } @media(max-width:560px) { @@ -152,17 +122,108 @@ .c-hub .c-hub-identity h2 { font-size: 29px; } .c-hub-settings { white-space: nowrap; } .c-hub-settings span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } - .c-hub-balance { padding: 24px; } .c-hub-panel { padding: 18px; } .c-hub-expense-row > time { display: none; } } -.c-hub-task-feedback { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0; padding: 12px 0 0; border-top: 1px solid var(--hub-line); color: var(--hub-ink); font-size: 13px; line-height: 1.6; } -.c-hub-task-feedback:focus { outline: none; } -.c-hub-task-feedback .c-hub-link { flex: none; display: inline-flex; align-items: center; min-height: 40px; padding: 0 4px; text-decoration: underline; } -@media (max-width: 560px) { .c-hub-task-feedback .c-hub-link { min-height: 44px; } } -/* Announcements: a quiet list above the balance, shown only when there are any. */ + +/* The week on the photo, in one sentence. */ +.c-hub-pulse { width: fit-content; max-width: 100%; margin: 16px 0 0 !important; padding: 7px 14px; border-radius: var(--radius-pill); background: var(--hub-panel); color: var(--hub-ink); text-shadow: none; font-size: 13px; font-weight: 500; } +.c-hub-over { margin: 0; color: var(--hub-muted); font-size: 12px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; } +.c-hub-pill { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 40px; padding: 8px 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-pill); background: var(--hub-panel); color: var(--hub-ink); font-size: 13px !important; font-weight: 500; text-decoration: none; } +.c-hub-pill:hover { background: var(--hub-quiet); } +.c-hub-pill.is-small { min-height: 32px; padding: 5px 12px; font-size: 12px !important; } + +/* Your part sits beside the pinned notice; alone, it takes the row. */ +.c-hub-top { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 20px; align-items: start; } +.c-hub-top > :only-child { grid-column: 1 / -1; } +.c-hub-part { display: grid; gap: 6px; padding: 23px; border: 1px solid var(--hub-sage-line); border-radius: var(--radius-surface); background: var(--hub-sage); } +.c-hub .c-hub-sentence { margin-top: 4px; font-family: var(--font-display); font-size: clamp(26px, 3vw, 34px); font-weight: 400; letter-spacing: -0.04em; line-height: 1.15; } +.c-hub-sentence em { padding: 0 .16em .04em; border-radius: 10px; background: var(--hub-panel); font-style: normal; font-variant-numeric: tabular-nums; } +.c-hub-part-copy { color: var(--hub-muted); } +.c-hub-next { margin-top: 10px; border-radius: 16px; background: var(--hub-panel); } +.c-hub-next-body { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 16px 12px; } +.c-hub-next-body > span { display: grid; min-width: 0; } +.c-hub-next-body strong { font-size: 14px; font-weight: 500; overflow-wrap: anywhere; } +.c-hub-next-body small { color: var(--hub-muted); font-size: 12.5px; } +.c-hub .c-hub-next-amount { flex: none; font-size: 22px; font-variant-numeric: tabular-nums; } +.c-hub-next-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; padding: 0 16px 16px; } +.c-hub .c-hub-button.is-large { min-height: 46px; padding: 12px 22px; font-size: 15px !important; } +.c-hub-pledge { padding-inline: 16px !important; margin-top: 6px; border: 0 !important; border-radius: 14px; background: var(--hub-panel) !important; } +.c-hub-part-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin-top: 10px; } +.c-hub-part-waiting { color: var(--hub-muted); font-size: 12px; } +.c-hub-top .c-hub-announcements { align-self: stretch; } + +/* This week: seven days you can scan, late first. */ +.c-hub-days { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 8px; } +.c-hub-day { display: flex; flex-direction: column; gap: 6px; min-width: 0; min-height: 150px; padding: 10px; border-radius: 16px; background: var(--hub-quiet); } +.c-hub-day.is-today { background: var(--hub-sage); box-shadow: inset 0 0 0 1.5px var(--hub-in); } +.c-hub-day.is-late { background: var(--hub-late-soft); } +.c-hub .c-hub-day-head { display: flex; align-items: baseline; justify-content: space-between; margin: 0 0 4px; color: var(--hub-muted); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; } +.c-hub-day-date { align-self: flex-start; margin-bottom: 4px; } +.c-hub-day-head b { color: var(--hub-ink); font-family: var(--font-display); font-size: 22px; font-weight: 400; letter-spacing: -0.03em; text-transform: none; } +.c-hub-day.is-late .c-hub-day-head :is(span, b) { color: var(--hub-warning); } +.c-hub-chip { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px 6px; width: 100%; min-width: 0; padding: 6px 8px; border: 0; border-radius: 10px; background: var(--hub-panel); color: var(--hub-ink); text-align: left; box-shadow: 0 1px 2px rgba(23, 27, 36, .05); } +.c-hub-chip .avatar { width: 22px; height: 22px; flex: none; font-size: 8px; } +.c-hub-chip-icon { flex: none; color: var(--hub-heading-icon); } +.c-hub-chip > .c-hub-chip-copy { grid-column: 1 / -1; grid-row: 2; } +.c-hub-chip:not(:has(> .avatar, > svg)) > .c-hub-chip-copy, .c-hub-chip.is-bill > .c-hub-chip-copy, .c-hub-chip.is-event > .c-hub-chip-copy { grid-column: 2 / 3; grid-row: 1; } +.c-hub-chip > .c-hub-check { grid-column: 3; grid-row: 1; justify-self: end; } +.c-hub-chip-copy { display: grid; min-width: 0; padding: 0; border: 0; background: none; color: inherit; text-align: left; } +.c-hub-chip-copy strong { font-size: 12.5px; font-weight: 500; line-height: 1.3; overflow-wrap: anywhere; } +.c-hub-chip-copy small { color: var(--hub-muted); font-size: 11.5px; } +.c-hub-chip.is-event { background: var(--hub-event); } +.c-hub-chip.is-bill { border: 1px dashed var(--border-strong); background: transparent; box-shadow: none; } +.c-hub-chip.is-late { box-shadow: inset 3px 0 0 var(--hub-warning); } +.c-hub-chip.is-late small { color: var(--hub-warning); } +.c-hub-chip input { width: 17px; height: 17px; flex: none; margin: 0; accent-color: var(--sage); cursor: pointer; } +.c-hub-chip-copy:hover strong { color: var(--hub-action); } +.c-hub-free { color: var(--hub-muted); font-size: 12px; opacity: .75; } + +/* People and essentials. */ +.c-hub-person { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; padding: 11px 0; border: 0; border-top: 1px solid var(--hub-line); background: none; color: var(--hub-ink); text-align: left; } +.c-hub-person .avatar { width: 38px; height: 38px; } +.c-hub-person > span:nth-child(2) { display: grid; min-width: 0; } +.c-hub-person strong { font-size: 14px; font-weight: 500; } +.c-hub-person small { color: var(--hub-muted); font-size: 12.5px; overflow-wrap: anywhere; } +.c-hub-person-balance { font-size: 14px; font-weight: 500; font-variant-numeric: tabular-nums; text-align: right; } +.c-hub-person-balance.is-in { color: var(--hub-in); } +.c-hub-person:hover strong { color: var(--hub-action); } +.c-hub-essential-grid { position: relative; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.c-hub-essential { position: relative; display: grid; align-content: start; gap: 4px; min-width: 0; padding: 14px; border-radius: 16px; background: var(--hub-quiet); } +.c-hub-essential-title { padding: 0; border: 0; background: none; color: var(--hub-ink); font-size: 15px !important; font-weight: 500 !important; text-align: left; overflow-wrap: anywhere; } +.c-hub-essential-title:hover { color: var(--hub-action); } +.c-hub-essential dl { display: grid; gap: 2px; margin: 2px 0 0; } +.c-hub-essential dl div { display: grid; grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 8px; font-size: 13px; } +.c-hub-essential dt { color: var(--hub-muted); } +.c-hub-essential dd { margin: 0; font-weight: 500; overflow-wrap: anywhere; } +.c-hub-essential-copy { color: var(--hub-muted); font-size: 13px; } +.c-hub-essential-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; } +.c-hub-copied { color: var(--hub-muted); font-size: 12px; } + +@media (max-width: 800px) { + .c-hub-top { grid-template-columns: minmax(0, 1fr); } + .c-hub-days { grid-auto-flow: row; grid-auto-columns: auto; } + .c-hub-day { flex-direction: row; flex-wrap: wrap; align-items: center; min-height: 0; padding: 8px 10px; } + .c-hub .c-hub-day-head { flex-direction: column; width: 46px; margin: 0; gap: 0; } + .c-hub-day-date { align-self: flex-start; margin-bottom: 4px; } +.c-hub-day-head b { font-size: 18px; } + .c-hub-chip { width: auto; flex: 1 1 200px; } + .c-hub-chip > .c-hub-chip-copy { grid-column: 2 / 3; grid-row: 1; } + .c-hub-day.is-free:not(.is-today) { display: none; } + .c-hub-free { display: none; } +} +@media (max-width: 560px) { + .c-hub-part { padding: 18px; } + .c-hub-next-foot { flex-direction: column; align-items: stretch; } + .c-hub-next-foot .c-hub-link { align-self: center; } + .c-hub-essential-grid { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; margin-inline: -18px; padding-inline: 18px; } + .c-hub-essential { flex: 0 0 76%; scroll-snap-align: start; } + .c-hub-chip input { width: 22px; height: 22px; } +} + +/* Announcements: the pinned notice beside your part, shown only when there are any. */ .c-hub-announcement { display: grid; gap: 6px; padding: 16px 0; border-top: 1px solid var(--hub-line); } .c-hub-announcement strong { font-size: 14px; font-weight: 500; overflow-wrap: anywhere; } .c-hub-announcement p { margin: 0; white-space: pre-line; overflow-wrap: anywhere; } @@ -176,10 +237,8 @@ /* Phones: links, View buttons and task boxes reach 44px through an invisible touch area, so the cards keep their look. */ @media (max-width: 560px) { - .c-hub-link, .c-hub-attention-end button { position: relative; } - .c-hub-link::before, .c-hub-attention-end button::before { content: ""; position: absolute; inset: -9px 0; } - .c-hub-attention-end button::before { inset: -8px 0; } - .c-hub-task-feedback .c-hub-link::before { content: none; } - .c-hub-check { align-items: center; width: 44px; height: 44px; margin: -13.5px -15px -13.5px -12px; padding-left: 12px; } + .c-hub-link { position: relative; } + .c-hub-link::before { content: ""; position: absolute; inset: -9px 0; } + .c-hub-check { align-items: center; justify-content: center; width: 44px; height: 44px; margin: -11px -8px -11px 0; } } } diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index e0008291..706e9204 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -1,38 +1,27 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; -import { Archive, CalendarDays, ChevronRight, CircleAlert, Megaphone, Plus, ReceiptText, UsersRound, Wallet, type LucideIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Archive, CalendarDays, ChevronRight, Copy, Eye, Megaphone, Phone, ReceiptText, UsersRound, type LucideIcon } from "lucide-react"; import { useApp } from "../state"; import { Avatar } from "../ui/widgets"; import { Icon } from "../ui/Icon"; import { CalendarDate } from "../ui/CalendarDate"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; -import { isNotePinned, noteCategory, notePreview } from "../data/notePresentation"; +import { isNotePinned, isSecretFact, noteCategory, notePreview } from "../data/notePresentation"; import { addDays, dateFromIso, needsDoneBy, taskDoneCopy, todayIso, type DoneBy, type OperationItem } from "../data/operations"; +import { useReceiptActions } from "../ui/Receipts"; +import { PayPrimary } from "../ui/PayLinks"; +import type { Need } from "../live/model"; import { hideToast, showToast } from "../ui/Toast"; import { DoneByChoice } from "../ui/DoneByChoice"; import { formatTimestampDay } from "../data/reminders"; -import type { Hub, RouteId } from "../data/household"; +import type { Hub, NoteCard } from "../data/household"; + +const weekday = new Intl.DateTimeFormat("en-GB", { weekday: "short" }); import "./Hub.css"; function HubHeading({ title, icon: HeadingIcon }: { title: string; icon: LucideIcon }) { return

    ; } -function HubSection({ title, icon, context, count, action, children }: { - title: string; icon: LucideIcon; context?: string; count?: string; - action?: { label: string; to: RouteId; prominent?: boolean }; children: ReactNode; -}) { - const { go } = useApp(); - return
    -
    - {action ? : count && {count}} -
    - {context &&

    {context}

    } - {children} -
    ; -} - function HubLocation({ hub }: { hub: Pick }) { if (hub.type === "Trip") { if (!hub.destination && !hub.stayName && !hub.stayAddress) return null; @@ -44,70 +33,6 @@ function HubLocation({ hub }: { hub: Pick

    {hub.address}

    : null; } -function Responsibilities() { - const { operations, toggleOperation, undoOperation, go, you } = useApp(); - const [pending, setPending] = useState(null); - // Someone else's task, ticked: it asks who did it before it is marked done. - const [asking, setAsking] = useState(null); - const [error, setError] = useState(""); - // 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 || 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 } = 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); - 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, taskId: string) { - setPending(occurrenceId); setError(""); - try { - 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); } - } - return -
    - {items.map(item => { - const choice = asking === item.id && void complete(item, doneBy)} - 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 [
    - - - {item.kind === "task" && } -
    , choice]; - })} -
    - {!items.length &&

    Nothing planned for this week.

    } - {error &&

    {error}

    } -
    ; -} - /** Notices from an admin. Important ones ask each member to confirm they have seen them. */ function Announcements() { const { announcements, hub, you, acknowledgeAnnouncement, archiveAnnouncement, go, personName } = useApp(); @@ -155,25 +80,220 @@ function Announcements() { ; } +/** One line on the photo: what this week holds for the house. */ +function weekSentence(late: number, today: number, bills: number): string { + const parts = [late ? `${late} late` : "", today ? `${today} due today` : "", bills ? `${bills} ${bills === 1 ? "bill" : "bills"} due this week` : ""].filter(Boolean); + if (!parts.length) return "A quiet week."; + const text = parts.length > 1 ? `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}` : parts[0]; + return `${text[0].toUpperCase()}${text.slice(1)}.`; +} + +const firstOf = (name = "") => name.split(" ")[0] || name; + +/** Where the viewer stands in this commune, and the one payment to record next. */ +function YourPart() { + const { hub, go, personName } = useApp(); + const [message, setMessage] = useState(""); + const receipts = useReceiptActions(setMessage); + const money = (hub.now as Need[]).filter(item => item.kind === "confirm" || item.kind === "pay"); + const pledges = (hub.now as Need[]).filter(item => item.kind === "pledge"); + const next = money[0]; + const who = next ? personName(next.person, next.kind === "pay" ? "the bill payer" : "a former member") : ""; + function act(decline = false) { + if (!next) return; + setMessage(""); + if (next.kind === "pay" && next.shareId && next.minor) { + receipts.report({ shareId: next.shareId, payerName: who, expenseName: next.title, outstandingMinor: next.minor, currency: hub.currency, communeId: hub.id, payerId: next.person }); + } else if (next.kind === "confirm" && next.report) { + receipts.answer({ report: next.report, debtorName: who, expenseName: next.title, currency: hub.currency, decision: decline ? "decline" : "confirm", communeId: hub.id, expenseId: next.expenseId }); + } else go("expense", { expense: next.expenseId }); + } + const square = hub.oweLabel === "You’re even"; + const payTarget = next?.kind === "pay" && next.shareId && next.minor + ? { shareId: next.shareId, payerName: who, expenseName: next.title, outstandingMinor: next.minor, currency: hub.currency, communeId: hub.id, payerId: next.person } + : null; + return
    +

    Your part in {hub.name}

    +

    {square ? <>You’re all square here. : <>{hub.oweLabel} {hub.oweAmount} here.}

    +

    {hub.oweCopy}

    + {next &&
    +
    + {next.title}{next.when} + {next.amount && {next.amount}} +
    +
    + {payTarget ? { setMessage(""); receipts.pay(payTarget); }} + onTell={() => act()} onOpened={(link) => receipts.opened(payTarget, link)} /> + : } + {next.kind === "confirm" && } + {money.length > 1 && } +
    +
    } + {pledges.slice(0, 2).map((item, index) => )} +
    + + + {hub.waiting.length > 0 && {hub.waiting.length} waiting on others} +
    + {message &&

    {message}

    } + {receipts.dialogs} +
    ; +} + +/** The next seven days as a strip, late jobs first, bills on the day they fall due. */ +function Week() { + const { operations, expenses, toggleOperation, undoOperation, go, you, hub } = useApp(); + const [pending, setPending] = useState(null); + // Someone else's task, ticked: it asks who did it before it is marked done. + const [asking, setAsking] = useState(null); + const [error, setError] = useState(""); + // 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 || focusTask === null) return; + listRef.current?.querySelector(`[data-task="${focusTask}"] input`)?.focus(); + setFocusTask(null); + }, [pending, focusTask]); + const today = todayIso(); + const days = Array.from({ length: 7 }, (_, index) => addDays(today, index)); + const end = days[6]; + const open = operations.filter(item => item.status === "open" && item.date <= end && (item.date >= today || item.kind === "task")); + const late = open.filter(item => item.date < today); + const bills = expenses.filter(row => row.dueOn && row.dueOn >= today && row.dueOn <= end && row.status !== "settled"); + async function complete(item: OperationItem, doneBy?: DoneBy) { + 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); + 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, taskId: string) { + setPending(occurrenceId); setError(""); + try { + 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); } + } + const face = (item: OperationItem) => { + const member = hub.members.find(entry => entry.userId === item.assigneeId); + return member ? : null; + }; + const chip = (item: OperationItem) =>
    + {item.kind === "event" ?
    ; + const asked = open.find(item => item.id === asking); + const empty = !open.length && !bills.length; + return
    +
    +
    + {asked && void complete(asked, doneBy)} + onCancel={() => { setAsking(null); setFocusTask(asked.id); }} />} + {empty ?

    Nothing planned for this week.

    + :
    + {late.length > 0 &&

    Late{late.length}

    {late.map(chip)}
    } + {days.map(day => { + const items = open.filter(item => item.date === day); + const due = bills.filter(row => row.dueOn === day); + return
    + + {items.map(chip)} + {due.map(row => )} + {!items.length && !due.length && Free} +
    ; + })} +
    } + {error &&

    {error}

    } +
    ; +} + +/** Everyone in the commune: what you and they owe each other, and their next job. */ +function People() { + const { hub, operations, you, go } = useApp(); + const today = todayIso(); + const jobs = operations.filter(item => item.status === "open" && item.kind === "task" && item.date <= addDays(today, 6)).sort((a, b) => a.date.localeCompare(b.date)); + return
    +
    +
    + {hub.members.map(member => { + const mine = member.userId === you.id; + const job = jobs.find(item => item.assigneeId === member.userId); + const when = job ? job.date < today ? "late" : job.date === today ? "today" : weekday.format(dateFromIso(job.date)) : ""; + return ; + })} + {hub.members.length < 2 && hub.role === "admin" &&

    Bring your people in to share costs, jobs and the useful details.

    } +
    ; +} + +const PHONE = /^\+?[\d ()-]{7,}$/; + +/** A shared detail as a card: safe facts shown, passwords and codes behind Show and Copy. */ +function Essential({ id, note }: { id: string; note: NoteCard }) { + const { go } = useApp(); + const [shown, setShown] = useState(false); + const [copied, setCopied] = useState(""); + const category = noteCategory(note); + const facts = note.facts.filter(([key]) => key !== "This Hub").slice(0, 3); + const secret = (key: string) => isSecretFact(key) || /\bcode\b/i.test(key); + const secrets = facts.filter(([key]) => secret(key)); + const phone = facts.find(([key, value]) => !secret(key) && PHONE.test(value.trim())); + async function copy() { + const text = secrets.map(([, value]) => value).join(" "); + try { await navigator.clipboard.writeText(text); setCopied("Copied"); } + catch { setShown(true); setCopied("Select it to copy"); } + } + return
    + {category.toLowerCase() !== note.title.toLowerCase() && {category}} + + {facts.length ?
    {facts.map(([key, value]) =>
    {key}
    {secret(key) && !shown ? •••••• : value}
    )}
    + :

    {notePreview(note)}

    } + {(secrets.length > 0 || phone) &&
    + {secrets.length > 0 && } + {secrets.length > 0 && } + {phone && } + {copied && {copied}} +
    } +
    ; +} + +const PRIORITY = ["Emergency", "Wi-Fi", "Keys / door", "Bins", "Contacts", "House rules", "Other"]; + export function HubScreen() { - const { hub, go, notes, expenses, you, announcements } = useApp(); - const entries = Object.entries(notes); - const pinned = entries.filter(([, note]) => isNotePinned(note)); - const priority = ["Emergency", "Keys / door", "Wi-Fi", "Contacts", "Bins", "House rules", "Other"]; - const essentials = entries.filter(([, note]) => !isNotePinned(note)).sort((a, b) => { - const rank = (note: typeof a[1]) => { const index = priority.indexOf(noteCategory(note)); return index < 0 ? priority.length : index; }; - return rank(a[1]) - rank(b[1]); - }); - // Needs you follows the same rule as Home: payments to confirm and shares - // to pay, then tasks, then pledges. What others owe waits separately. - const money = hub.now.filter(item => item.kind === "confirm" || item.kind === "pay"); - const yours = hub.now.filter(item => item.kind === "task"); - const yourPledges = hub.now.filter(item => item.kind === "pledge"); - // Important notices this member has not yet confirmed. - const toConfirm = announcements.filter(item => item.needsAcknowledgement && item.authorId !== you.id - && !item.acknowledgements.some(entry => entry.userId === you.id)); - const attentionCount = hub.now.length + toConfirm.length; + const { hub, go, notes, expenses, operations } = useApp(); + // Pinned notes lead, then the details people look up most. + const rank = (note: NoteCard) => (isNotePinned(note) ? -1 : 0) * 100 + (PRIORITY.indexOf(noteCategory(note)) + 1 || PRIORITY.length + 1); + const essentials = Object.entries(notes).sort((a, b) => rank(a[1]) - rank(b[1])); const canInvite = hub.role === "admin"; + const today = todayIso(); + const end = addDays(today, 6); + const late = operations.filter(item => item.status === "open" && item.kind === "task" && item.date < today).length; + const dueToday = operations.filter(item => item.status === "open" && item.date === today).length; + const bills = expenses.filter(row => row.dueOn && row.dueOn >= today && row.dueOn <= end && row.status !== "settled").length; return
    {hub.photo && } @@ -184,6 +304,7 @@ export function HubScreen() { {hub.members.length} {hub.members.length === 1 ? "person" : "people"}{" "}in this commune, view members +

    {weekSentence(late, dueToday, bills)}

    {canInvite && } @@ -191,51 +312,24 @@ export function HubScreen() {
    - -
    -
    {hub.oweAmount} -

    {hub.oweCopy}

    -
    -
    - - {money.slice(0, 3).map((item, index) =>
    - {item.title}{item.when} - {item.amount} -
    )} - {toConfirm.slice(0, 2).map(item =>
    - {item.title}Announcement · please confirm you have seen it -
    )} - {yours.slice(0, 2).map(item => )} - {yourPledges.slice(0, 2).map((item, index) => )} - {!attentionCount &&

    {hub.members.length<2 && canInvite ? "Bring your people in to share costs, jobs and the useful details." : "Nothing needs your attention right now."}

    } - {money.length > 3 && } - {yours.length > 2 && } - {yourPledges.length > 2 && } - {hub.waiting.length > 0 &&
    -

    Waiting on others

    - {hub.waiting.slice(0, 3).map((item, index) => )} - {hub.waiting.length > 3 && } -
    } -
    +
    + +
    +
    - - - {essentials.slice(0, 3).map(([id, note]) => )} - {!essentials.length &&

    Keep Wi-Fi, contacts and other useful details here.

    } - {pinned.length > 0 &&
    {pinned.slice(0, 2).map(([id, note]) => )}
    } -
    + +
    +
    +
    + {essentials.length ?
    {essentials.slice(0, 4).map(([id, note]) => )}
    + :

    Keep Wi-Fi, contacts and other useful details here.

    } +
    - {expenses.length > 0 &&
    + {expenses.length > 0 &&
    {expenses.slice(0, 3).map(row => )}
    } diff --git a/apps/web/src/screens/Money.css b/apps/web/src/screens/Money.css index 6829dc82..bc88d113 100644 --- a/apps/web/src/screens/Money.css +++ b/apps/web/src/screens/Money.css @@ -229,4 +229,25 @@ .c-pay-copy { flex: 1 1 calc(100% - 40px); } .c-pay-link .a-btn:first-of-type { margin-left: 40px; } } + +/* Ways to pay someone, beside a payment you owe them. */ +/* Paying: the main button (their favourite way, or "Pay Tom"), what else + there is, and the way to say it was already paid. */ +.c-pay-primary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 46px; padding: 0 20px; border: 0; border-radius: var(--radius-control); background: var(--action); color: var(--on-action); font: inherit; font-size: 15px; font-weight: 600; text-decoration: none; cursor: pointer; } +.c-pay-primary:hover { background: var(--action-hover); } +.c-pay-primary:focus-visible, .c-pay-secondary:focus-visible, .c-pay-tell:focus-visible { outline: 2px solid var(--action); outline-offset: 2px; } +.c-pay-primary:disabled, .c-pay-primary[aria-disabled="true"], .c-pay-secondary:disabled, .c-pay-tell:disabled { opacity: .45; cursor: not-allowed; } +.c-pay-more { display: inline-flex; flex-wrap: wrap; gap: 8px; } +.c-pay-secondary { min-height: 46px; padding: 0 18px; border: 1px solid var(--border); border-radius: var(--radius-control); background: transparent; color: var(--ink); font: inherit; font-size: 14.5px; font-weight: 600; cursor: pointer; } +.c-pay-hint { flex-basis: 100%; margin: 0; font-size: 12.5px; line-height: 18px; color: var(--ink-soft); } +.c-pay-tell { min-height: 44px; padding: 0 4px; border: 0; background: none; color: var(--action); font: inherit; font-size: 14.5px; font-weight: 600; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; } +.c-pay-intro { margin: 0 0 12px; font-size: 14px; line-height: 1.5; color: var(--ink-soft); } +@media (max-width: 560px) { + .c-pay-primary { flex: 1 1 100%; height: 50px; font-size: 16px; } + .c-pay-more { flex: 1 1 100%; } + .c-pay-more .c-pay-secondary { flex: 1 1 0; } + .c-pay-hint { text-align: center; } + .c-pay-tell { flex: 1 1 100%; } +} +.c-pay-favourite { color: var(--action); font-weight: 600; } } diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx index 347ae72b..cbd80726 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -146,7 +146,7 @@ export function ExpensesEmptyScreen() { } export function SettleScreen() { - const { go, hub, expenses, session, reminderTargets, reminderUsage, sentReminders, sendReminder, personName } = useApp(); + const { go, hub, expenses, session, reminderTargets, reminderUsage, sentReminders, sendReminder, personName, payLinksFor } = useApp(); const reminder = useReminderFlow(sendReminder); const [message, setMessage] = useState(""); const receipts = useReceiptActions(setMessage); @@ -244,9 +244,16 @@ export function SettleScreen() {
    ) : (
    - + + : + }
    )} diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 54b2a304..b61be2ee 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -293,6 +293,8 @@ export type AppState = { loadMyPayLinks: () => Promise; savePayLink: (provider: PayProvider, input: PayLinkInput) => Promise; removePayLink: (provider: PayProvider) => Promise; + /** Mark one of your pay links as your favourite, or none (null). */ + setPreferredPayLink: (provider: PayProvider | null) => Promise; native: boolean; you: { id: string; name: string; email: string; initials: string; photo?: string }; lastExpense: { name: string; amount: string; paidBy: string } | null; @@ -398,7 +400,8 @@ export type AppState = { updateOperation: (id: string, item: NewOperation, expectedVersion?: number) => Promise; /** Completes an open task, or reopens a done one. The server advances repeats and rotation. */ /** Marking someone else's task done says who did it (needsDoneBy). */ - toggleOperation: (id: string, doneBy?: DoneBy) => Promise; + /** From another commune than the one in view (Home), name it and the date the task was seen on. */ + toggleOperation: (id: string, doneBy?: DoneBy, from?: { communeId: string; date: string }) => Promise; skipOperation: (id: string, reason?: string) => Promise; /** * from: the task as another screen saw it, in a commune that may not be the @@ -407,7 +410,7 @@ export type AppState = { handOverOperation: (id: string, toMemberId: string, reason?: string, from?: { communeId: string; date: string; assigneeId: string | null }) => Promise; /** Restores the task to exactly how it was before that action. */ - undoOperation: (occurrenceId: string) => Promise; + undoOperation: (occurrenceId: string, communeId?: string) => Promise; removeOperation: (id: string) => Promise; createHub: (input: CreatedHub, opts?: { openExpense?: boolean }) => Promise; updateCommune: (input: { @@ -1655,6 +1658,13 @@ export function AppProvider({ children }: { children: ReactNode }) { await refreshRecords(); return links; }), [mutate, refreshRecords]); + const setPreferredPayLink = useCallback((provider: PayProvider | null) => mutate(async () => { + const result = await api<{ links: unknown }>("/pay-links-preferred", { method: "PUT", body: JSON.stringify({ provider }) }); + const links = parsePayLinks(result.links); + setMyPayLinks(links); + await refreshRecords(); + return links; + }), [mutate, refreshRecords]); const heldSnapshots = data?.snapshots; const payLinksFor = useCallback((userId: string | null | undefined, communeId?: string) => { const record = heldSnapshots?.[communeId ?? activeId]; @@ -1901,9 +1911,15 @@ export function AppProvider({ children }: { children: ReactNode }) { reason: reason?.trim() || null, requestId: newRequestId(), }, from?.communeId); }, [occurrenceCommand]); - const undoOperation = useCallback((occurrenceId: string) => - occurrenceCommand(`/occurrences/${encodeURIComponent(occurrenceId)}/undo`, {}), [occurrenceCommand]); - const toggleOperation = useCallback((id: string, doneBy?: DoneBy) => { + const undoOperation = useCallback((occurrenceId: string, communeId?: string) => + occurrenceCommand(`/occurrences/${encodeURIComponent(occurrenceId)}/undo`, {}, communeId), [occurrenceCommand]); + const toggleOperation = useCallback((id: string, doneBy?: DoneBy, from?: { communeId: string; date: string }) => { + // Home marks tasks done in any commune, checked against the date it showed. + if (from) { + return occurrenceCommand(`/operations/${encodeURIComponent(id)}/occurrences`, { + action: "completed", expectedDate: from.date, today: todayIso(), requestId: newRequestId(), ...(doneBy ? { doneBy } : {}), + }, from.communeId); + } const item = operationsRef.current.find((row) => row.id === id); if (item?.status !== "done") return taskAction(id, "completed", undefined, doneBy); // Undo the completion when it is still the latest state; a task finished @@ -2023,7 +2039,7 @@ export function AppProvider({ children }: { children: ReactNode }) { operationTime, operationsByHub, operations, selectedOperation, fundKey, dark: device.theme === "dark" || (device.theme === "system" && prefersDark), theme: device.theme, noticesOn: device.noticesOn, searchTarget, searchOpened, invites: data?.invites[activeId] ?? NONE, payMethods: prefs.payMethods, - payLinksFor, loadPayLinksFor, myPayLinks, loadMyPayLinks, savePayLink, removePayLink, native, you, + payLinksFor, loadPayLinksFor, myPayLinks, loadMyPayLinks, savePayLink, removePayLink, setPreferredPayLink, native, you, lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses, expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory, funds, recurringBills, splitTemplates, expenseTemplateId, reminderTargets, reminderUsage, sentReminders, notifications, diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 2bdb2cc9..2bc6f5c3 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -3236,3 +3236,4 @@ button.a-item { /* Members: accepted and revoked invitations, kept for the record. */ .c-invite-history ul { margin: 8px 0 0; padding-left: 18px; } .c-invite-history li { margin: 2px 0; overflow-wrap: anywhere; } + diff --git a/apps/web/src/ui/PayLinks.tsx b/apps/web/src/ui/PayLinks.tsx index 4d045f61..a954e1ed 100644 --- a/apps/web/src/ui/PayLinks.tsx +++ b/apps/web/src/ui/PayLinks.tsx @@ -6,7 +6,7 @@ import { bankDetailsText, formatSortCode, payLinkHref, payLinkLabel, PAY_PROVIDER_NAME, PAY_PROVIDERS, type PayLink, type PayLinkInput, type PayProvider, } from "../data/payLinks"; -import { Button, Feedback, Field } from "./components"; +import { Button, Dialog, Feedback, Field } from "./components"; // Styles sit with the other money surfaces. import "../screens/Money.css"; @@ -15,8 +15,10 @@ import "../screens/Money.css"; * Each link opens the payer's own app; Commune records nothing until the * person reports the payment. */ -export function PayeeLinks({ userId, communeId, name, amountMinor, currency, note }: { +export function PayeeLinks({ userId, communeId, name, amountMinor, currency, note, onOpen }: { userId: string | null | undefined; communeId?: string; name: string; amountMinor: number; currency: CurrencyCode; note?: string; + /** Runs when one of their apps is opened, so Commune can ask afterwards whether it went. */ + onOpen?: (link: PayLink) => void; }) { const { payLinksFor, loadPayLinksFor } = useApp(); const links = payLinksFor(userId, communeId); @@ -50,13 +52,109 @@ export function PayeeLinks({ userId, communeId, name, amountMinor, currency, not {target ? {target.amountFilled ? `${amount} filled in` : `Enter ${amount} when it opens`} : null} {target - ? Open {PAY_PROVIDER_NAME[link.provider]} + ? onOpen?.(link)}>Open {PAY_PROVIDER_NAME[link.provider]} : } ; })} ; } +/** Someone you owe, as the pay controls need them. */ +export type PayeeTarget = { + payerId?: string; payerName: string; communeId?: string; + outstandingMinor: number; currency: CurrencyCode; expenseName: string; +}; + +/** Their pay links, reading the commune's full record when only a summary is held. */ +export function usePayeeLinks(payerId: string | null | undefined, communeId?: string): PayLink[] | null { + const { payLinksFor, loadPayLinksFor } = useApp(); + const links = payLinksFor(payerId, communeId); + const unread = links === null && Boolean(payerId); + useEffect(() => { + if (unread) void loadPayLinksFor(communeId).catch(() => undefined); + }, [unread, communeId, loadPayLinksFor]); + return payerId ? links : []; +} + +const firstOf = (name: string) => name.split(" ")[0] || name; + +/** + * The main payment action, by what the person you owe has saved: + * - a favourite way: one button that opens it ("Pay £5.90 with Monzo"), + * with their other ways and "Already paid" beside it; + * - ways but no favourite: "Pay Tom £5.90", which lists them; + * - none: "I’ve paid Tom £5.90", which records a payment made however you + * usually pay them. + * Commune moves no money: a link opens their own app, and coming back asks + * whether it went. + */ +export function PayPrimary({ target, onPay, onTell, onOpened, busy = false, size = "large" }: { + target: PayeeTarget; onPay: () => void; onTell: () => void; onOpened: (link: PayLink) => void; + busy?: boolean; size?: "large" | "normal"; +}) { + const links = usePayeeLinks(target.payerId, target.communeId); + const amount = money(target.outstandingMinor, target.currency); + const first = firstOf(target.payerName); + const big = size === "large" ? " is-large" : ""; + if (!links?.length) { + return <> + +

    {links ? `${first} hasn’t said how they like to be paid. Pay them however you usually do, then record it here.` : `Pay ${first} however you usually do, then record it here.`}

    + ; + } + const favourite = links.find((link) => link.preferred); + if (favourite) { + const provider = PAY_PROVIDER_NAME[favourite.provider]; + const href = payLinkHref(favourite, target.outstandingMinor, target.currency, target.expenseName); + const others = links.length - 1; + return <> + {href + ? { if (busy) { event.preventDefault(); return; } onOpened(favourite); }}> + Pay {amount} with {provider} + + , opens {provider} + + : } + + {others > 0 ? : null} + + +

    {href?.amountFilled ? `${first}’s favourite. It opens with ${amount} filled in.` : href ? `${first}’s favourite. Enter ${amount} when it opens.` : `${first}’s favourite. Their details are a tap away.`}

    + ; + } + return <> + +

    {links.map((link) => PAY_PROVIDER_NAME[link.provider]).join(" · ")}

    + + ; +} + +/** + * "Pay Tom £5.90": every way they take money, each opening their own app + * with the amount filled in where it can, and a way to say you already paid. + */ +export function PaySheet({ target, onClose, onTell, onOpened }: { + target: PayeeTarget; onClose: () => void; onTell: () => void; onOpened: (link: PayLink) => void; +}) { + const links = usePayeeLinks(target.payerId, target.communeId); + const amount = money(target.outstandingMinor, target.currency); + const first = firstOf(target.payerName); + return +

    {target.expenseName}

    + {links?.length ?

    {first} takes these. Pick one and it opens in your own app.

    : null} + +

    The money moves in your own app. Commune never sees it.

    +
    + +
    +
    ; +} + const EMPTY: Record = { monzo: { handle: "" }, paypal: { handle: "" }, revolut: { handle: "" }, bank: { accountName: "", sortCode: "", accountNumber: "" }, @@ -71,7 +169,7 @@ function inputFrom(link: PayLink | undefined, provider: PayProvider): PayLinkInp /** Your own pay links: saved in Commune and shown to people in your communes. */ export function PayLinksEditor() { - const { myPayLinks, loadMyPayLinks, savePayLink, removePayLink } = useApp(); + const { myPayLinks, loadMyPayLinks, savePayLink, removePayLink, setPreferredPayLink } = useApp(); const [loadError, setLoadError] = useState(""); const [editing, setEditing] = useState(null); const [draft, setDraft] = useState({}); @@ -118,6 +216,22 @@ export function PayLinksEditor() { } } + // One favourite: people who owe you get a single button that opens it. + async function favourite(provider: PayProvider, on: boolean) { + if (pending) return; + setPending(true); setError(""); setMessage(""); + try { + await setPreferredPayLink(on ? provider : null); + setMessage(on + ? `${PAY_PROVIDER_NAME[provider]} is your favourite. People who owe you see it first.` + : "No favourite. People who owe you see every way you take money."); + } catch (cause) { + setError(commandFailure(cause, "Could not change your favourite.").message); + } finally { + setPending(false); + } + } + if (loadError) return ; if (!myPayLinks) return

    Loading your pay links…

    ; return
    @@ -155,7 +269,12 @@ export function PayLinksEditor() { {PAY_PROVIDER_NAME[provider]} {link ? payLinkLabel(link) : "Not added"} + {link?.preferred ? Your favourite : null} + {link ? : null} {link ? : null}
    ; diff --git a/apps/web/src/ui/Receipts.tsx b/apps/web/src/ui/Receipts.tsx index 9c61aacf..afb90491 100644 --- a/apps/web/src/ui/Receipts.tsx +++ b/apps/web/src/ui/Receipts.tsx @@ -1,4 +1,4 @@ -import { useId, useRef, useState, type FormEvent } from "react"; +import { useEffect, useId, useRef, useState, type FormEvent } from "react"; import { useApp } from "../state"; import { money, type CurrencyCode, type PaymentReport, type Receipt, type Share } from "../live/model"; import { moneyInputError, parseMoneyInput } from "../data/moneyInput"; @@ -9,7 +9,8 @@ import { formatReminderDay, formatTimestampDay } from "../data/reminders"; import { LIMITS } from "../data/limits"; import { Button, Dialog, Feedback, Field, useFormError } from "./components"; import { firstWord, showToast, type Toast } from "./Toast"; -import { PayeeLinks } from "./PayLinks"; +import { PayeeLinks, PaySheet } from "./PayLinks"; +import { PAY_PROVIDER_NAME, type PayLink } from "../data/payLinks"; export const RECEIPT_REFERENCE_MAX = 80; // communeId names the commune the share is in, when that may not be the open @@ -376,6 +377,51 @@ export function LatestReportAnswer({ share, currency, viewerId, names }: { return

    {names(latest.resolvedBy ?? "")} has not received the {money(latest.amountMinor, currency)} you reported: {latest.note}

    ; } +/** + * Back from paying in their app: did it go? Yes records the whole amount as + * sent today with the app's name as the reference; a different amount opens + * the full form; not yet changes nothing. + */ +export function DidItGoDialog({ target, provider, onClose, onDifferent, onReported }: { + target: PaymentReportTarget; provider: string; onClose: () => void; onDifferent: () => void; onReported: (toast: Toast) => void; +}) { + const { reportSharePayment } = useApp(); + const [pending, setPending] = useState(false); + const [uncertain, setUncertain] = useState(false); + const [error, setError] = useState(""); + const requestId = useRef(newRequestId()); + const amount = money(target.outstandingMinor, target.currency); + const first = firstWord(target.payerName); + async function yes() { + if (pending) return; + setPending(true); setError(""); + try { + const result = await reportSharePayment(target.shareId, + { amountMinor: target.outstandingMinor, sentOn: todayIso(), reference: provider, requestId: requestId.current }, target.communeId); + onReported({ text: result.duplicate ? `Already recorded. ${first} will confirm your ${amount}.` : `Recorded. ${first} will confirm your ${amount}.` }); + } catch (cause) { + const failure = commandFailure(cause, "Could not report the payment."); + if (!failure.rejected) setUncertain(true); + setError(!failure.rejected ? UNCERTAIN_RETRY_SAFE + : uncertain ? "An earlier attempt may already have been reported. Close this and check the expense before trying again." + : `${failure.message.replace(/\.$/, "")}. Nothing was reported.`); + } finally { + setPending(false); + } + } + return +

    {target.expenseName} · paid with {provider}, today

    +

    You opened {provider} a moment ago. If it went through, tell {first} so they can confirm it.

    + +
    + + + +
    +

    Your share stays open until {first} confirms they received it.

    +
    ; +} + /** * One place to open the receipt and report dialogs from any screen. What was * recorded is said in the toast; onError gets what could not be done, to show @@ -396,6 +442,34 @@ export function useReceiptActions(onError: (message: string) => void, options: { const [reporting, setReporting] = useState(null); const [answering, setAnswering] = useState(null); const [withdrawing, setWithdrawing] = useState(false); + // Paying: the sheet of their ways to be paid, then, once the person has + // left for their app and come back, a question whether it went. + const [paying, setPaying] = useState(null); + const [away, setAway] = useState<{ target: PaymentReportTarget; provider: string } | null>(null); + const [asking, setAsking] = useState<{ target: PaymentReportTarget; provider: string } | null>(null); + useEffect(() => { + if (!away) return undefined; + let left = false; + const leave = () => { left = true; }; + const back = () => { + if (!left || document.visibilityState === "hidden") return; + setAsking(away); + setAway(null); + }; + const visibility = () => (document.visibilityState === "hidden" ? leave() : back()); + window.addEventListener("blur", leave); + window.addEventListener("focus", back); + document.addEventListener("visibilitychange", visibility); + return () => { + window.removeEventListener("blur", leave); + window.removeEventListener("focus", back); + document.removeEventListener("visibilitychange", visibility); + }; + }, [away]); + function opened(target: PaymentReportTarget, link: PayLink) { + setPaying(null); + setAway({ target, provider: PAY_PROVIDER_NAME[link.provider] }); + } async function withdraw(report: PaymentReport) { if (withdrawing) return; setWithdrawing(true); @@ -411,6 +485,11 @@ export function useReceiptActions(onError: (message: string) => void, options: { } } const dialogs = <> + {paying && setPaying(null)} + onTell={() => { const target = paying; setPaying(null); setReporting(target); }} onOpened={(link) => opened(paying, link)} />} + {asking && setAsking(null)} + onDifferent={() => { const target = asking.target; setAsking(null); setReporting(target); }} + onReported={toast => { setAsking(null); done(toast); }} />} {reporting && setReporting(null)} onReported={toast => { setReporting(null); done(toast); }} />} {answering && setAnswering(null)} @@ -422,6 +501,10 @@ export function useReceiptActions(onError: (message: string) => void, options: { ; return { record: setRecording, reverse: setReversing, report: setReporting, answer: setAnswering, withdraw, dialogs, - busy: recording !== null || reversing !== null || reporting !== null || answering !== null || withdrawing, + /** Opens "Pay Tom £5.90": every way they take money. */ + pay: setPaying, + /** One of their apps was opened from the page itself: ask on the way back. */ + opened, + busy: recording !== null || reversing !== null || reporting !== null || answering !== null || withdrawing || paying !== null || asking !== null, }; } diff --git a/apps/web/src/ui/components.tsx b/apps/web/src/ui/components.tsx index 0c717685..a8203924 100644 --- a/apps/web/src/ui/components.tsx +++ b/apps/web/src/ui/components.tsx @@ -179,7 +179,8 @@ export function focusAfterClose(opener: HTMLElement | null): () => void { return () => { settle(); if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => { if (focusLost()) settle(); }); - window.setTimeout(() => { if (focusLost()) settle(); }, 400); + // The page may be gone by then (a test that has finished, say). + window.setTimeout(() => { if (typeof document !== "undefined" && focusLost()) settle(); }, 400); }; } diff --git a/apps/web/tests/announcements.test.tsx b/apps/web/tests/announcements.test.tsx index d0e0cfaa..40453c77 100644 --- a/apps/web/tests/announcements.test.tsx +++ b/apps/web/tests/announcements.test.tsx @@ -75,9 +75,6 @@ describe("announcements on This Hub", () => { expect(within(section).getByText("Boiler service Thursday")).toBeTruthy(); expect(within(section).getByText("Please be in from 8.")).toBeTruthy(); expect(within(section).getByText("Seen by 0 of 2")).toBeTruthy(); - // It also counts as something that needs this member. - const needs = screen.getByRole("region", { name: "Needs you" }); - expect(within(needs).getByText("1 item")).toBeTruthy(); // A member cannot take down an admin's notice or post one. expect(within(section).queryByRole("button", { name: /^Take down/ })).toBeNull(); expect(screen.queryByRole("button", { name: "Announce" })).toBeNull(); @@ -85,7 +82,6 @@ describe("announcements on This Hub", () => { expect(vi.mocked(post)).toHaveBeenCalledWith("/announcements/notice-1/acknowledge", {}); expect(within(section).getByText("You have seen this")).toBeTruthy(); expect(within(section).getByText("Seen by 1 of 2")).toBeTruthy(); - expect(within(screen.getByRole("region", { name: "Needs you" })).getByText("0 items")).toBeTruthy(); }); it("shows an admin who is still to confirm, and takes a notice down after asking", async () => { diff --git a/apps/web/tests/loading.test.tsx b/apps/web/tests/loading.test.tsx index bf5c3320..47361c6a 100644 --- a/apps/web/tests/loading.test.tsx +++ b/apps/web/tests/loading.test.tsx @@ -131,15 +131,15 @@ describe("commune loading", () => { vi.mocked(loadSnapshot).mockResolvedValue({ ...snapshot, notes: [note], operations: [hubTask] }); mount(); await screen.findByRole("heading", { name: commune.name }); - expect(screen.getByRole("region", { name: "Pinned notes" }).textContent).toContain(note.title); + expect(screen.getByRole("region", { name: "Shared essentials" }).textContent).toContain(note.title); expect(screen.getByRole("checkbox", { name: "Mark Take recycling out done" })).toBeTruthy(); expect(document.querySelector(".c-hub-cover")?.getAttribute("src")).toBe(coverSource({ type: "Household", photo: HUBS.park.photo }, "hero").src); expect(document.querySelector(".c-hub")?.textContent).not.toContain("↗"); - expect(within(screen.getByRole("region", { name: "Your balance" })).getByRole("button", { name: "Add expense" })).toBeTruthy(); - expect(within(screen.getByRole("region", { name: "Your balance" })).getByRole("button", { name: "View settlement" })).toBeTruthy(); + expect(within(screen.getByRole("region", { name: "Your part" })).getByRole("button", { name: "Add expense" })).toBeTruthy(); + expect(within(screen.getByRole("region", { name: "Your part" })).getByRole("button", { name: "View settlement" })).toBeTruthy(); expect(within(screen.getByRole("region", { name: "Shared essentials" })).getByText(note.title)).toBeTruthy(); expect(within(screen.getByRole("region", { name: "Commune identity" })).getByRole("button", { name: /^\d+ (people|person) in this commune, view members$/ })).toBeTruthy(); - fireEvent.click(within(screen.getByRole("region", { name: "Pinned notes" })).getByRole("button", { name: /House essentials/ })); + fireEvent.click(within(screen.getByRole("region", { name: "Shared essentials" })).getByRole("button", { name: "House essentials" })); await screen.findByRole("complementary", { name: "House essentials" }); }); @@ -273,14 +273,18 @@ describe("commune loading", () => { }); const wifiNote = { id: "note-1", title: "Wi-Fi & internet", pill: "Wi-Fi", kicker: "Pinned · everyone", lede: "private-secret", body: "Connect upstairs. Password: private-secret", facts: [["Network", "Test network"], ["Password", "private-secret"], ["This Hub", "Pinned"]] as Array<[string, string]> }; - it("keeps credentials out of hub pinned cards and essentials previews", async () => { + it("keeps passwords on hub essentials hidden until someone asks to see them", async () => { const unpinnedWifi = { ...wifiNote, id: "note-2", title: "Guest network", kicker: "Everyone", facts: wifiNote.facts.filter(([key]) => key !== "This Hub") }; vi.mocked(loadSnapshot).mockResolvedValue({ ...snapshot, notes: [wifiNote, unpinnedWifi] }); mount(); await screen.findByRole("heading", { name: commune.name }); - expect(screen.getByRole("region", { name: "Pinned notes" }).textContent).toContain("Network and password. Open to view connection details."); - expect(screen.getByRole("region", { name: "Shared essentials" }).textContent).toContain("Guest network"); + const essentials = screen.getByRole("region", { name: "Shared essentials" }); + // The network name is shown; the password waits behind Show. + expect(essentials.textContent).toContain("Guest network"); + expect(essentials.textContent).toContain("Test network"); expect(document.body.textContent).not.toContain("private-secret"); + fireEvent.click(within(essentials).getAllByText("Show")[0].closest("button")!); + expect(essentials.textContent).toContain("private-secret"); }); it("restores default covers while preserving uploads and the activity owner's identity", () => { diff --git a/apps/web/tests/needs-you.test.tsx b/apps/web/tests/needs-you.test.tsx index 8732361f..f0996683 100644 --- a/apps/web/tests/needs-you.test.tsx +++ b/apps/web/tests/needs-you.test.tsx @@ -1,5 +1,5 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; import { App } from "../src/App"; @@ -194,45 +194,44 @@ describe("Needs you on Home", () => { expect(within(next).getByRole("button", { name: "Confirm £20.00 received" })).toBeTruthy(); expect(within(next).getByRole("button", { name: "Not received" })).toBeTruthy(); - const needs = screen.getByRole("region", { name: "Needs you" }); - await within(needs).findByText("Ferry"); + // Chores due are ticked off from Home, in whichever commune they are. + const chores = screen.getByRole("region", { name: "Today" }); + expect([...chores.querySelectorAll(".c-home-row-title")].map((title) => title.textContent)).toEqual(["Bins", "Hoover"]); + expect(within(chores).getByRole("checkbox", { name: "Mark Bins done" })).toBeTruthy(); + expect(within(chores).getByRole("checkbox", { name: "Mark Hoover done" })).toBeTruthy(); + expect(within(chores).getByText("2 days overdue")).toBeTruthy(); + + const needs = screen.getByRole("region", { name: "Still to pay" }); const groups = [...needs.querySelectorAll(".c-home-group")].map((group) => [group.querySelector("h3")?.firstChild?.textContent?.trim(), [...group.querySelectorAll(".c-home-row")].map(row)]); expect(groups).toEqual([ - // The trip's report waits on Maya now, whenever its bill is due. - ["To confirm", [["Ferry", "Lisbon trip", "Ben Baker says they sent it", "€25.00", null]]], ["Overdue", [ ["Internet", "Our home", "You owe Ben Baker", "£15.00", "8 days overdue"], - ["Bins", "Our home", "Your turn", null, "2 days overdue"], ["Boiler", "Our home", "Remaining on your pledge", "£60.00", "4 days overdue"], ]], - ["Today", [["Hoover", "Our home", "Your turn today", null, "Due today"]]], ["No due date", [["Water", "Our home", "You owe Cat Cole", "£8.00", null]]], ]); - // Payments act where they are; tasks and pledges open their record. + // Payments act where they are; a pledge opens its record. expect(within(needs).getAllByRole("button").filter((button) => button.classList.contains("c-home-btn")).map((button) => button.textContent)).toEqual([ - "Confirm €25.00 from Ben Baker", "I’ve paid Ben Baker for Internet", "Open Bins", "Open Boiler", "Open Hoover", "I’ve paid Cat Cole for Water", + "I’ve paid Ben Baker for Internet", "Open Boiler", "I’ve paid Cat Cole for Water", ]); - // A phone shows the first two in the order drawn and offers the rest; a wider screen shows all six. - const extra = [...needs.querySelectorAll(".c-home-row")].map((item) => item.classList.contains("is-extra-phone")); - expect(extra).toEqual([false, false, true, true, true, true]); - expect(needs.querySelectorAll(".is-extra")).toHaveLength(0); - expect([...needs.querySelectorAll(".c-home-group.is-extra-phone h3")].map((h) => h.firstChild?.textContent?.trim())).toEqual(["Today", "No due date"]); - const more = within(needs).getByRole("button", { name: "Show 4 more" }); - expect(more.classList.contains("is-phone")).toBe(true); - expect(more.getAttribute("aria-expanded")).toBe("false"); - fireEvent.click(more); - expect(within(needs).getByRole("button", { name: "Show fewer" }).getAttribute("aria-expanded")).toBe("true"); - expect(needs.classList.contains("is-all")).toBe(true); + // Three rows fit a phone, so nothing is folded away. + expect(needs.querySelectorAll(".is-extra-phone")).toHaveLength(0); + expect(within(needs).queryByRole("button", { name: /Show \d+ more/ })).toBeNull(); + // A payment someone says they sent sits with what others owe, with Confirm on the row. const waiting = screen.getByRole("region", { name: "Waiting on others" }); + await within(waiting).findByText("Ferry"); expect(within(waiting).getByText("£50.00")).toBeTruthy(); expect([...waiting.querySelectorAll(".c-home-row")].map(row)).toEqual([ + // The trip's report waits on Maya now, whenever its bill is due. + ["Ferry", "Lisbon trip", "Ben Baker says they sent it", "€25.00", null], ["Electricity", "Our home", "Cat Cole owes you", "£20.00", "Due 20 Oct"], ["Takeaway", "Our home", "3 people owe you", "£30.00", null], ["Rent", "Our home", "You said you sent it · waiting for Cat Cole", "£500.00", "Due Thu"], ]); - expect(within(waiting).queryByRole("button", { name: /I’ve paid|Confirm/ })).toBeNull(); + expect(within(waiting).getAllByRole("button").filter((button) => button.classList.contains("c-home-btn")).map((button) => button.textContent)) + .toEqual(["Confirm €25.00 from Ben Baker"]); }); it("says where Maya stands per currency, never adding pounds to euros", async () => { @@ -242,7 +241,7 @@ describe("Needs you on Home", () => { mount("/"); const standing = await screen.findByRole("heading", { name: "You owe £523.00 and €12.00 across two communes." }); expect(standing.parentElement?.querySelector(".c-home-sub")?.textContent) - .toBe("You’re owed £70.00 and €25.00. Three things are overdue, the oldest eight days."); + .toBe("You’re owed £70.00 and €25.00, so in pounds you owe £453.00 and in euros you’re €13.00 ahead. Three things are overdue, the oldest eight days."); // The bar names the page; the sentence is Home's own heading. expect(screen.getByRole("heading", { level: 1, name: "Home" })).toBeTruthy(); // A phone's bar shows the wordmark beside the menu on Home; the h1 names the page. @@ -255,8 +254,8 @@ describe("Needs you on Home", () => { it("tells the bill payer from Home, then reads the share's own commune again", async () => { vi.mocked(post).mockResolvedValue({ duplicate: false }); mount("/"); - const needs = await screen.findByRole("region", { name: "Needs you" }); - await within(needs).findByText("Ferry"); + await screen.findByText("Ferry"); + const needs = screen.getByRole("region", { name: "Still to pay" }); vi.mocked(loadSnapshot).mockClear(); fireEvent.click(within(needs).getByRole("button", { name: "I’ve paid Ben Baker for Internet" })); const dialog = await screen.findByRole("dialog", { name: "Tell Ben Baker you’ve paid" }); @@ -269,10 +268,10 @@ describe("Needs you on Home", () => { it("confirms a payment in another commune where it sits, and reads that commune again", async () => { vi.mocked(post).mockResolvedValue({ outstandingMinor: 0 }); mount("/"); - const needs = await screen.findByRole("region", { name: "Needs you" }); - await within(needs).findByText("Ferry"); + const waiting = await screen.findByRole("region", { name: "Waiting on others" }); + await within(waiting).findByText("Ferry"); vi.mocked(loadSnapshot).mockClear(); - fireEvent.click(within(needs).getByRole("button", { name: "Confirm €25.00 from Ben Baker" })); + fireEvent.click(within(waiting).getByRole("button", { name: "Confirm €25.00 from Ben Baker" })); const dialog = await screen.findByRole("dialog", { name: "Confirm €25.00 received?" }); fireEvent.click(within(dialog).getByRole("button", { name: "Confirm Ben’s €25.00" })); await waitFor(() => expect(toastText()).toBe("Confirmed. Ben’s €25.00 is recorded as received.")); @@ -281,6 +280,68 @@ describe("Needs you on Home", () => { }); }); +describe("Today on Home", () => { + it("ticks off a chore in its own commune, checked against the date Home showed, with Undo", async () => { + vi.mocked(post).mockResolvedValue({ id: "occurrence-bins", date: "2026-10-14", assigneeId: "me", turnOwnerId: "me", status: "open" }); + mount("/"); + const chores = await screen.findByRole("region", { name: "Today" }); + vi.mocked(loadSnapshot).mockClear(); + fireEvent.click(within(chores).getByRole("checkbox", { name: "Mark Bins done" })); + await waitFor(() => expect(toastText()).toBe("Bins is done.")); + expect(post).toHaveBeenCalledWith("/operations/bins/occurrences", expect.objectContaining({ action: "completed", expectedDate: "2026-10-07", today })); + expect(readAgain()).toEqual(["home-1"]); + }); + + const withLinks = (links: unknown[]) => vi.mocked(loadSnapshot).mockImplementation(async (commune) => commune.id === home.id + ? { ...homeSnapshot(), reports: undefined, expenses: homeSnapshot().expenses.filter((item) => item.title !== "Electricity"), + payLinks: { ben: links } } + : { ...tripSummary(), expenses: [] }); + const monzo = { provider: "monzo", handle: "benbaker" }; + const bank = { provider: "bank", accountName: "Ben Baker", sortCode: "204577", accountNumber: "41029388" }; + + it("with no favourite, Pay lists every way the bill payer takes money, and Already paid tells them", async () => { + withLinks([monzo, bank]); + mount("/"); + const next = await screen.findByRole("article", { name: "Internet" }); + expect(within(next).getByText("Monzo · Bank transfer")).toBeTruthy(); + fireEvent.click(within(next).getByRole("button", { name: "Pay Ben £15.00" })); + const sheet = await screen.findByRole("dialog", { name: "Pay Ben £15.00" }); + expect(within(sheet).getByRole("link", { name: "Open Monzo" }).getAttribute("href")).toBe("https://monzo.me/benbaker/15.00?d=Internet"); + expect(within(sheet).getByRole("button", { name: "Copy details" })).toBeTruthy(); + fireEvent.click(within(sheet).getByRole("button", { name: "I’ve already paid, tell Ben" })); + expect(await screen.findByRole("dialog", { name: "Tell Ben Baker you’ve paid" })).toBeTruthy(); + }); + + it("with a favourite, one button opens it, and coming back asks whether it went", async () => { + withLinks([bank, { ...monzo, preferred: true }]); + vi.mocked(post).mockResolvedValue({ duplicate: false }); + mount("/"); + const next = await screen.findByRole("article", { name: "Internet" }); + const open = within(next).getByRole("link", { name: /^Pay £15\.00 with Monzo/ }); + expect(open.getAttribute("href")).toBe("https://monzo.me/benbaker/15.00?d=Internet"); + expect(open.getAttribute("target")).toBe("_blank"); + expect(within(next).getByRole("button", { name: "Other ways (1)" })).toBeTruthy(); + expect(within(next).getByRole("button", { name: "Already paid" })).toBeTruthy(); + fireEvent.click(open); + // Nothing is asked until the person has been away and come back. + expect(screen.queryByRole("dialog")).toBeNull(); + act(() => { window.dispatchEvent(new Event("blur")); window.dispatchEvent(new Event("focus")); }); + const back = await screen.findByRole("dialog", { name: "Did £15.00 go to Ben?" }); + fireEvent.click(within(back).getByRole("button", { name: "Yes, tell Ben" })); + await waitFor(() => expect(toastText()).toBe("Recorded. Ben will confirm your £15.00.")); + expect(post).toHaveBeenCalledWith("/shares/share-me-1500/payment-reports", + expect.objectContaining({ amountMinor: 1500, sentOn: today, reference: "Monzo" })); + }); + + it("with no pay links saved, keeps I’ve paid and says why", async () => { + withLinks([]); + mount("/"); + const next = await screen.findByRole("article", { name: "Internet" }); + expect(within(next).getByRole("button", { name: "I’ve paid Ben £15.00" })).toBeTruthy(); + expect(within(next).getByText(/Ben hasn’t said how they like to be paid/)).toBeTruthy(); + }); +}); + describe("Coming up on Home", () => { // Nothing needs Maya: Ben owes her for the bins bags, and the trip's gutters need someone. const calm = () => snapshotOf(home, { @@ -315,29 +376,23 @@ describe("Coming up on Home", () => { }); }); -describe("Needs you on This Hub", () => { - it("counts only what needs the viewer, in the same order, and keeps what others owe apart", async () => { +describe("Your part on This Hub", () => { + it("leads with the first payment to act on, in Home's order, and keeps what others owe apart", async () => { mount("/hub"); - const needs = await screen.findByRole("region", { name: "Needs you" }); - await within(needs).findAllByText("Electricity"); - expect(within(needs).getByText("6 items")).toBeTruthy(); - const waiting = within(needs).getByRole("region", { name: "Waiting on others" }); - const money = [...needs.querySelectorAll(".c-hub-attention-row")].map((row) => - [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent, row.querySelector(".c-hub-attention-end strong")?.textContent]); - expect(money).toEqual([ - ["Electricity", "Ben Baker says they sent it", "£20.00"], - ["Internet", "You owe Ben Baker · overdue", "£15.00"], - ["Water", "You owe Cat Cole", "£8.00"], - ]); - const others = [...needs.querySelectorAll(".c-hub-note-row")].filter((row) => !waiting.contains(row)) - .map((row) => [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent]); - expect(others).toEqual([ - ["Bins", "Overdue · your turn"], - ["Hoover", "Your turn today"], - ["Boiler", "Remaining on your pledge · overdue · £60.00"], - ]); - expect([...waiting.querySelectorAll(".c-hub-note-row")].map((row) => row.querySelector("small")?.textContent)).toEqual([ - "Cat Cole owes you · £20.00", "3 people owe you · £30.00", "You said you sent it · waiting for Cat Cole · £500.00", - ]); + const part = await screen.findByRole("region", { name: "Your part" }); + await within(part).findByText("Electricity"); + // A payment someone says they sent comes first, with the answer on the button. + expect(within(part).getByText("Ben Baker says they sent it")).toBeTruthy(); + expect(within(part).getByRole("button", { name: "Confirm £20.00 received" })).toBeTruthy(); + expect(within(part).getByRole("button", { name: "Not received" })).toBeTruthy(); + expect(within(part).getByRole("button", { name: "2 more to settle" })).toBeTruthy(); + // Pledges stay with the viewer's part; tasks live in the week. + const pledge = within(part).getByRole("button", { name: /Boiler/ }); + expect(pledge.querySelector("small")?.textContent).toBe("Remaining on your pledge · overdue · £60.00"); + expect(within(part).getByText("3 waiting on others")).toBeTruthy(); + const week = screen.getByRole("region", { name: "This week" }); + expect(within(week).getByRole("checkbox", { name: "Mark Bins done" })).toBeTruthy(); + expect(within(week).getByRole("checkbox", { name: "Mark Hoover done" })).toBeTruthy(); }); }); + diff --git a/apps/web/tests/pay-links.test.tsx b/apps/web/tests/pay-links.test.tsx index 079e5e0f..9497b2d9 100644 --- a/apps/web/tests/pay-links.test.tsx +++ b/apps/web/tests/pay-links.test.tsx @@ -39,6 +39,9 @@ describe("pay link addresses", () => { "nonsense", ]); expect(links.map((link) => link.provider)).toEqual(["monzo", "bank"]); + // A favourite comes first. + expect(parsePayLinks([{ provider: "monzo", handle: "sam" }, { provider: "revolut", handle: "sam", preferred: true }]) + .map((link) => [link.provider, link.preferred])).toEqual([["revolut", true], ["monzo", false]]); expect(parsePayLinks(undefined)).toEqual([]); }); @@ -117,7 +120,7 @@ afterEach(cleanup); describe("paying someone who has shared their pay links", () => { it("offers their Monzo with the amount filled in, and their bank details, before you report it", async () => { mount("/settle"); - fireEvent.click(await screen.findByRole("button", { name: /^I’ve sent it/ })); + fireEvent.click(await screen.findByRole("button", { name: "Already paid Sam for Printer ink" })); const dialog = screen.getByRole("dialog", { name: "Tell Sam you’ve paid" }); const open = within(dialog).getByRole("link", { name: "Open Monzo" }); expect(open.getAttribute("href")).toBe("https://monzo.me/samokafor/16.00?d=Printer%20ink"); @@ -127,11 +130,24 @@ describe("paying someone who has shared their pay links", () => { expect(within(dialog).getByRole("button", { name: "Copy details" })).toBeTruthy(); }); + it("Pay on the Settle row lists their ways to be paid; opening one asks on the way back", async () => { + mount("/settle"); + fireEvent.click(await screen.findByRole("button", { name: "Pay Sam for Printer ink" })); + const sheet = screen.getByRole("dialog", { name: "Pay Sam £16.00" }); + fireEvent.click(within(sheet).getByRole("link", { name: "Open Monzo" })); + expect(screen.queryByRole("dialog", { name: "Pay Sam £16.00" })).toBeNull(); + act(() => { document.dispatchEvent(new Event("visibilitychange")); window.dispatchEvent(new Event("blur")); window.dispatchEvent(new Event("focus")); }); + const back = await screen.findByRole("dialog", { name: "Did £16.00 go to Sam?" }); + fireEvent.click(within(back).getByRole("button", { name: "Paid a different amount" })); + expect(screen.getByRole("dialog", { name: "Tell Sam you’ve paid" })).toBeTruthy(); + }); + it("says plainly when the bill payer has not added any", async () => { current.payLinks = {}; mount("/settle"); fireEvent.click(await screen.findByRole("button", { name: /^I’ve sent it/ })); const dialog = screen.getByRole("dialog", { name: "Tell Sam you’ve paid" }); + expect(screen.queryByRole("button", { name: /^Pay Sam/ })).toBeNull(); expect(within(dialog).queryByRole("link")).toBeNull(); expect(within(dialog).getByText(/Sam hasn’t added how they like to be paid/)).toBeTruthy(); }); @@ -153,6 +169,32 @@ describe("your own pay links", () => { expect(vi.mocked(api).mock.calls.some(([path, init]) => path === "/pay-links/monzo" && init?.method === "DELETE")).toBe(true); expect(statusLines()).toContain("Monzo removed."); }); + + it("marks one way as the favourite, and can take it off again", async () => { + saved = [monzo]; + vi.mocked(api).mockImplementation(async (path, init) => { + if (path === "/auth/me") return { user }; + if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null }; + if (path.endsWith("/invites")) return { invites: [] }; + if (path === "/pay-links") return { links: saved }; + if (path === "/pay-links-preferred" && init?.method === "PUT") { + const provider = JSON.parse(String(init.body)).provider; + saved = [{ ...monzo, preferred: provider === "monzo" }]; + return { links: saved }; + } + return { ok: true }; + }); + mount("/settings"); + fireEvent.click(await screen.findByRole("button", { name: /How people pay you/ })); + const make = await screen.findByRole("button", { name: "Make favourite Monzo" }); + await act(async () => { fireEvent.click(make); }); + const put = vi.mocked(api).mock.calls.find(([path]) => path === "/pay-links-preferred"); + expect(JSON.parse(String(put?.[1]?.body))).toEqual({ provider: "monzo" }); + expect(await screen.findByText("Your favourite")).toBeTruthy(); + expect(statusLines().some((line) => line.includes("Monzo is your favourite"))).toBe(true); + await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Unfavourite Monzo" })); }); + expect(screen.queryByText("Your favourite")).toBeNull(); + }); }); /** Every live status line's words; the toast region is one of them, and empty here. */ diff --git a/apps/web/tests/workflow-screens.test.tsx b/apps/web/tests/workflow-screens.test.tsx index 63d2c791..8886599b 100644 --- a/apps/web/tests/workflow-screens.test.tsx +++ b/apps/web/tests/workflow-screens.test.tsx @@ -275,9 +275,8 @@ describe("complete shared workflows", () => { mount("/hub"); await screen.findByRole("heading", { name: "Shared essentials" }); const headings = [ - within(screen.getByRole("region", { name: "Your balance" })).getByRole("heading", { level: 2 }), - screen.getByRole("heading", { name: "Needs you" }), screen.getByRole("heading", { name: "This week" }), + screen.getByRole("heading", { name: "People" }), screen.getByRole("heading", { name: "Shared essentials" }), ]; if (state === "populated") headings.push(screen.getByRole("heading", { name: "Recently in your commune" })); diff --git a/infra/neon/README.md b/infra/neon/README.md index 168bfdd2..5201aa02 100644 --- a/infra/neon/README.md +++ b/infra/neon/README.md @@ -19,6 +19,11 @@ Pay links, **0042** (local only), add `my_pay_links`, `set_pay_link`, Apply it after 0041, then deploy the client that calls them. The previous client ignores the new snapshot field. Test: `tests/pay_links.sql`. +A favourite pay link, **0043** (local only), adds `preferred` to each pay +link (the favourite listed first, at most one per person) and +`set_preferred_pay_link`. Apply it after 0042, then deploy the client that +offers it. The previous client ignores the new field. Test: `tests/pay_links.sql`. + Migrations **0015 to 0041 are local only**. They have not been applied to any Neon branch. Release order when they are: apply 0013 to 0041 in numeric order to the preview branch, refresh that branch's Data API schema cache, then diff --git a/infra/neon/migrations/0043_preferred_pay_link.sql b/infra/neon/migrations/0043_preferred_pay_link.sql new file mode 100644 index 00000000..c5fcde0f --- /dev/null +++ b/infra/neon/migrations/0043_preferred_pay_link.sql @@ -0,0 +1,51 @@ +-- A favourite way to be paid. A person may mark one of their pay links as the +-- one they prefer; people who owe them then get a single button that opens +-- it, with the others a tap away. Marking none is fine: they see all of them. +-- Depends on 0042. +BEGIN; + +ALTER TABLE public.member_pay_links ADD COLUMN preferred boolean NOT NULL DEFAULT false; +CREATE UNIQUE INDEX member_pay_links_one_preferred ON public.member_pay_links (user_id) WHERE preferred; + +-- The favourite first, then the usual order. +CREATE OR REPLACE FUNCTION commune_private.pay_links_for(p_user_id text) +RETURNS jsonb LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT coalesce(jsonb_agg(jsonb_build_object('provider', l.provider, 'handle', l.handle, + 'accountName', l.account_name, 'sortCode', l.sort_code, 'accountNumber', l.account_number, + 'preferred', l.preferred, 'updatedAt', l.updated_at) + ORDER BY l.preferred DESC, array_position(ARRAY['monzo', 'paypal', 'revolut', 'bank'], l.provider)), '[]'::jsonb) + FROM public.member_pay_links l WHERE l.user_id = p_user_id; +$$; +REVOKE ALL ON FUNCTION commune_private.pay_links_for(text) FROM PUBLIC; + +-- Mark one of your own links as the favourite, or pass nothing to have none. +-- Marking the one already marked is harmless. +CREATE FUNCTION public.set_preferred_pay_link(p_provider text) +RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE + v_actor text := commune_private.data_api_user_id(); + v_provider text := nullif(lower(btrim(coalesce(p_provider, ''))), ''); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'Sign in to change how people pay you' USING ERRCODE = '42501'; + END IF; + IF v_provider IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM public.member_pay_links WHERE user_id = v_actor AND provider = v_provider + ) THEN + RAISE EXCEPTION 'Add that way to be paid before making it your favourite' USING ERRCODE = '22023'; + END IF; + UPDATE public.member_pay_links SET preferred = false + WHERE user_id = v_actor AND preferred AND provider IS DISTINCT FROM v_provider; + IF v_provider IS NOT NULL THEN + UPDATE public.member_pay_links SET preferred = true + WHERE user_id = v_actor AND provider = v_provider AND NOT preferred; + END IF; + RETURN commune_private.pay_links_for(v_actor); +END; +$$; + +REVOKE ALL ON FUNCTION public.set_preferred_pay_link(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.set_preferred_pay_link(text) TO authenticated; + +NOTIFY pgrst, 'reload schema'; +COMMIT; diff --git a/infra/neon/schema.sql b/infra/neon/schema.sql index 77271ab8..cc02f9e0 100644 --- a/infra/neon/schema.sql +++ b/infra/neon/schema.sql @@ -708,8 +708,8 @@ CREATE FUNCTION commune_private.pay_links_for(p_user_id text) RETURNS jsonb AS $$ SELECT coalesce(jsonb_agg(jsonb_build_object('provider', l.provider, 'handle', l.handle, 'accountName', l.account_name, 'sortCode', l.sort_code, 'accountNumber', l.account_number, - 'updatedAt', l.updated_at) - ORDER BY array_position(ARRAY['monzo', 'paypal', 'revolut', 'bank'], l.provider)), '[]'::jsonb) + 'preferred', l.preferred, 'updatedAt', l.updated_at) + ORDER BY l.preferred DESC, array_position(ARRAY['monzo', 'paypal', 'revolut', 'bank'], l.provider)), '[]'::jsonb) FROM public.member_pay_links l WHERE l.user_id = p_user_id; $$; @@ -3922,6 +3922,37 @@ END; $_$; +-- +-- Name: set_preferred_pay_link(text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.set_preferred_pay_link(p_provider text) RETURNS jsonb + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' + AS $$ +DECLARE + v_actor text := commune_private.data_api_user_id(); + v_provider text := nullif(lower(btrim(coalesce(p_provider, ''))), ''); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'Sign in to change how people pay you' USING ERRCODE = '42501'; + END IF; + IF v_provider IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM public.member_pay_links WHERE user_id = v_actor AND provider = v_provider + ) THEN + RAISE EXCEPTION 'Add that way to be paid before making it your favourite' USING ERRCODE = '22023'; + END IF; + UPDATE public.member_pay_links SET preferred = false + WHERE user_id = v_actor AND preferred AND provider IS DISTINCT FROM v_provider; + IF v_provider IS NOT NULL THEN + UPDATE public.member_pay_links SET preferred = true + WHERE user_id = v_actor AND provider = v_provider AND NOT preferred; + END IF; + RETURN commune_private.pay_links_for(v_actor); +END; +$$; + + -- -- Name: set_recurring_bill_archived(uuid, boolean); Type: FUNCTION; Schema: public; Owner: - -- @@ -5228,6 +5259,7 @@ CREATE TABLE public.member_pay_links ( sort_code text, account_number text, updated_at timestamp with time zone DEFAULT now() NOT NULL, + preferred boolean DEFAULT false NOT NULL, CONSTRAINT member_pay_links_account_name_check CHECK (((length(btrim(account_name)) >= 1) AND (length(btrim(account_name)) <= 60))), CONSTRAINT member_pay_links_account_number_check CHECK ((account_number ~ '^[0-9]{8}$'::text)), CONSTRAINT member_pay_links_check CHECK ( @@ -6013,6 +6045,13 @@ CREATE INDEX invitation_email_recipient_idx ON public.invitation_email_deliverie CREATE INDEX invitation_email_sender_idx ON public.invitation_email_deliveries USING btree (sender_id, created_at); +-- +-- Name: member_pay_links_one_preferred; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX member_pay_links_one_preferred ON public.member_pay_links USING btree (user_id) WHERE preferred; + + -- -- Name: note_revisions_note_idx; Type: INDEX; Schema: public; Owner: - -- @@ -8434,6 +8473,14 @@ REVOKE ALL ON FUNCTION public.set_pay_link(p_provider text, p_handle text, p_acc GRANT ALL ON FUNCTION public.set_pay_link(p_provider text, p_handle text, p_account_name text, p_sort_code text, p_account_number text) TO authenticated; +-- +-- Name: FUNCTION set_preferred_pay_link(p_provider text); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.set_preferred_pay_link(p_provider text) FROM PUBLIC; +GRANT ALL ON FUNCTION public.set_preferred_pay_link(p_provider text) TO authenticated; + + -- -- Name: FUNCTION set_recurring_bill_archived(p_id uuid, p_archived boolean); Type: ACL; Schema: public; Owner: - -- diff --git a/infra/neon/tests/grants.sql b/infra/neon/tests/grants.sql index 9fdafac9..e0aafc63 100644 --- a/infra/neon/tests/grants.sql +++ b/infra/neon/tests/grants.sql @@ -36,7 +36,8 @@ BEGIN END IF; IF has_function_privilege('anonymous', 'public.set_pay_link(text,text,text,text,text)', 'EXECUTE') OR has_function_privilege('anonymous', 'public.my_pay_links()', 'EXECUTE') - OR has_function_privilege('anonymous', 'public.remove_pay_link(text)', 'EXECUTE') THEN + OR has_function_privilege('anonymous', 'public.remove_pay_link(text)', 'EXECUTE') + OR has_function_privilege('anonymous', 'public.set_preferred_pay_link(text)', 'EXECUTE') THEN RAISE EXCEPTION 'Anonymous pay link execution grant'; END IF; IF has_function_privilege('anonymous', 'public.commune_snapshot(uuid)', 'EXECUTE') diff --git a/infra/neon/tests/pay_links.sql b/infra/neon/tests/pay_links.sql index 2e964cc7..4bde14e9 100644 --- a/infra/neon/tests/pay_links.sql +++ b/infra/neon/tests/pay_links.sql @@ -1,5 +1,5 @@ -- Run only in an empty disposable local DB after local_auth_stub.sql and --- migrations 0001 through 0025 and 0042. Real Neon verification must use signed JWTs. +-- migrations 0001 through 0025, 0042 and 0043. Real Neon verification must use signed JWTs. BEGIN; DO $$ @@ -124,8 +124,28 @@ BEGIN IF jsonb_array_length(v_links) <> 3 OR v_links->0->>'handle' <> 'alicehale' THEN RAISE EXCEPTION 'Bob changed Alice''s links: %', v_links; END IF; + -- A favourite comes first; marking another moves it, and nothing clears it. + v_links := public.set_preferred_pay_link('bank'); + IF v_links->0->>'provider' <> 'bank' OR (v_links->0->>'preferred')::boolean IS NOT TRUE + OR (SELECT count(*) FROM jsonb_array_elements(v_links) e WHERE (e->>'preferred')::boolean) <> 1 THEN + RAISE EXCEPTION 'Bank was not made the favourite: %', v_links; + END IF; + v_links := public.set_preferred_pay_link('PayPal'); + IF v_links->0->>'provider' <> 'paypal' + OR (SELECT count(*) FROM jsonb_array_elements(v_links) e WHERE (e->>'preferred')::boolean) <> 1 THEN + RAISE EXCEPTION 'The favourite did not move to PayPal: %', v_links; + END IF; + BEGIN PERFORM public.set_preferred_pay_link('revolut'); + RAISE EXCEPTION 'A link Alice has not added became her favourite'; + EXCEPTION WHEN SQLSTATE '22023' THEN NULL; END; + v_links := public.set_preferred_pay_link(NULL); + IF v_links->0->>'provider' <> 'monzo' OR v_links @> '[{"preferred":true}]' THEN + RAISE EXCEPTION 'Clearing the favourite did not restore the usual order: %', v_links; + END IF; + PERFORM public.set_preferred_pay_link('paypal'); + -- Removing the favourite leaves none. v_links := public.remove_pay_link('paypal'); - IF jsonb_array_length(v_links) <> 2 OR v_links @> '[{"provider":"paypal"}]' THEN + IF jsonb_array_length(v_links) <> 2 OR v_links @> '[{"provider":"paypal"}]' OR v_links @> '[{"preferred":true}]' THEN RAISE EXCEPTION 'PayPal was not removed: %', v_links; END IF; v_links := public.remove_pay_link('paypal');