From a2f08bb95ba01342968a550b303259944b5df420 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:59:52 +0000 Subject: [PATCH 1/4] Open Home on where you stand and the next step (Direction B) Home now leads with one sentence of what you owe and are owed, an amount per currency and never added across currencies, then the one next step with the page's only filled button. Your communes follow in order of need, the current one tagged; then Needs you grouped by when, Waiting on others kept calm, and recent activity. With nothing owed or waiting anywhere, the current commune's photo fills the top instead. Paying and confirming act in place through the existing report and confirm dialogs, and read again the commune the share is in, which may not be the open one. Tasks and pledges open their record. On a phone the sentence sits on the navy band with the next step lifted over it, and communes show as tiles. The bar names the page Home once there is a commune; the greeting stays for a member with none. The old featured card, week grid and #home rules are gone from the shared sheets. The all-square photo's shade is measured in tests/cover-contrast.test.ts like the other covers. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PxTvnaAvCxprncxCVPRh7u --- apps/web/src/data/covers.ts | 13 +- apps/web/src/live/model.ts | 36 +- apps/web/src/screens/Home.css | 522 +++++++++++++++ apps/web/src/screens/Home.tsx | 596 +++++++++++++++--- apps/web/src/shell/Shell.tsx | 3 +- apps/web/src/state.tsx | 37 +- apps/web/src/styles/app.css | 113 +--- apps/web/src/styles/fonts.css | 17 +- apps/web/src/styles/fonts/dm-sans-latin.woff2 | Bin 0 -> 36932 bytes apps/web/src/styles/overrides.css | 16 - apps/web/src/ui/Receipts.tsx | 14 +- apps/web/tests/activity-log.test.tsx | 6 +- apps/web/tests/addresses.test.tsx | 4 +- apps/web/tests/cover-contrast.test.ts | 65 +- apps/web/tests/covers.test.tsx | 19 +- apps/web/tests/error-recovery.test.tsx | 2 +- apps/web/tests/fonts.test.ts | 4 +- apps/web/tests/invitation-hint.test.tsx | 2 +- apps/web/tests/needs-you.test.tsx | 107 +++- apps/web/tests/placeholder-screens.test.tsx | 2 +- apps/web/tests/workflow-screens.test.tsx | 33 +- docs/DESIGN-LANGUAGE.md | 2 +- docs/HANDOFF.md | 5 +- 23 files changed, 1286 insertions(+), 332 deletions(-) create mode 100644 apps/web/src/screens/Home.css create mode 100644 apps/web/src/styles/fonts/dm-sans-latin.woff2 diff --git a/apps/web/src/data/covers.ts b/apps/web/src/data/covers.ts index 50dcaed2..fa6d66ef 100644 --- a/apps/web/src/data/covers.ts +++ b/apps/web/src/data/covers.ts @@ -9,7 +9,7 @@ import { HUBS, type Hub } from "./household"; * --cover-* tokens in shared.css), which shows while the photo loads and * stays if it cannot load. */ -export type CoverPlacement = "featured" | "card" | "hero" | "preview"; +export type CoverPlacement = "card" | "hero" | "preview" | "commune" | "thumb"; export type CoverTone = "household" | "workspace" | "trip"; export type CoverHub = Pick; export type CoverSource = { src: string; srcSet?: string; sizes?: string }; @@ -17,15 +17,18 @@ export type CoverSource = { src: string; srcSet?: string; sizes?: string }; const WIDTHS = [480, 800, 1200, 1600]; // How wide each placement is drawn, in CSS pixels. The sidebar (248px) goes -// away at 560px. featured: the first card on Home. card: the smaller cards on -// Home and Places (three, then two, then one column). hero: the photo at the -// top of This Hub (at most 1208px). preview: the commune settings preview +// away at 560px. card: the cards on Places (three, then two, then one +// column). hero: the photo at the top of This Hub (at most 1208px), and Home's +// photo when everything is square. preview: the commune settings preview // (240px beside the form, full width once the form drops below it at 1000px). +// commune: Home's commune cards (132px, or a third of a phone as tiles). +// thumb: the small marks beside a record's name on Home (44px at most). const PLACEMENTS: Record = { - featured: { sizes: "(max-width: 560px) 100vw, calc(100vw - 248px)", fallback: 1200 }, card: { sizes: "(max-width: 560px) 100vw, (max-width: 850px) 50vw, 33vw", fallback: 800 }, hero: { sizes: "(max-width: 560px) 100vw, (max-width: 1500px) calc(100vw - 292px), 1208px", fallback: 1200 }, preview: { sizes: "(max-width: 560px) 100vw, (max-width: 1000px) 560px, 240px", fallback: 480 }, + commune: { sizes: "(max-width: 560px) 33vw, 132px", fallback: 480 }, + thumb: { sizes: "44px", fallback: 480 }, }; export function coverTone(hub: Pick): CoverTone { diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 68d2fe91..ea09e8ea 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -330,6 +330,11 @@ export type LiveHub = Omit & { now: Need[]; /** What the viewer is waiting on others for, with nothing to do. */ waiting: Need[]; + /** The viewer's outstanding shares here, and everyone's outstanding shares of bills the viewer paid, in minor units. */ + youOweMinor: number; + owedToYouMinor: number; + /** Who has an outstanding share of a bill the viewer paid. */ + owingYou: string[]; }; /** @@ -346,12 +351,22 @@ export type Need = { /** Who and what, without the amount. */ when: string; amount?: string; + /** The amount in minor units, where there is one. */ + minor?: number; dueOn?: string; /** Past its date. */ late: boolean; expenseId?: string; operationId?: string; fundId?: string; + /** The share it is about: the viewer's own (pay, reported) or one the viewer can confirm. */ + shareId?: string; + /** The other person: who paid the bill (pay, reported) or who says they sent it (confirm). */ + person?: string; + /** Everyone who still owes the viewer for this bill (owed). */ + people?: string[]; + /** The payment to confirm. */ + report?: PaymentReport; }; /** * The name of anyone a snapshot knows, former members included (0031). With @@ -401,23 +416,28 @@ export function needsYou(snapshot: Snapshot, viewerId: string, today = todayIso( const report = share.paymentReports?.find((item) => item.status === "pending"); if (expense.paidByUserId === viewerId) { if (report) { - actions.push({ ...base, kind: "confirm", when: `${nameOf(share.userId)} says they sent it`, amount: money(report.amountMinor, currency) }); + actions.push({ ...base, kind: "confirm", when: `${nameOf(share.userId)} says they sent it`, amount: money(report.amountMinor, currency), + minor: report.amountMinor, shareId: share.id, person: share.userId, report }); } else { owing.push({ userId: share.userId, minor: outstanding }); } } else if (share.userId === viewerId) { const payer = nameOf(expense.paidByUserId); + const yours = { shareId: share.id, person: expense.paidByUserId }; if (report) { - waiting.push({ ...base, kind: "reported", when: `You said you sent it · waiting for ${payer}`, amount: money(report.amountMinor, currency) }); + waiting.push({ ...base, ...yours, kind: "reported", when: `You said you sent it · waiting for ${payer}`, amount: money(report.amountMinor, currency), + minor: report.amountMinor }); } else { - actions.push({ ...base, kind: "pay", when: `You owe ${payer}${late ? " · overdue" : ""}`, amount: money(outstanding, currency) }); + actions.push({ ...base, ...yours, kind: "pay", when: `You owe ${payer}${late ? " · overdue" : ""}`, amount: money(outstanding, currency), + minor: outstanding }); } } } if (owing.length) { const who = namesList(owing.map((item) => nameOf(item.userId))); + const minor = owing.reduce((total, item) => total + item.minor, 0); waiting.push({ ...base, kind: "owed", when: `${who.charAt(0).toUpperCase()}${who.slice(1)} ${owing.length === 1 ? "owes" : "owe"} you`, - amount: money(owing.reduce((total, item) => total + item.minor, 0), currency) }); + amount: money(minor, currency), minor, people: owing.map((item) => item.userId) }); } } for (const item of snapshot.operations ?? []) { @@ -431,8 +451,9 @@ export function needsYou(snapshot: Snapshot, viewerId: string, today = todayIso( for (const pledge of fund.commitments) { if (pledge.cancelledAt || pledge.userId !== viewerId || pledge.amountMinor <= pledge.contributedMinor || pledge.dueOn > today) continue; const late = pledge.dueOn < today; + const remaining = pledge.amountMinor - pledge.contributedMinor; actions.push({ kind: "pledge", go: "funds", title: fund.name, when: `Remaining on your pledge${late ? " · overdue" : ""}`, - amount: money(pledge.amountMinor - pledge.contributedMinor, currency), fundId: fund.id, dueOn: pledge.dueOn, late }); + amount: money(remaining, currency), minor: remaining, fundId: fund.id, dueOn: pledge.dueOn, late }); } } return { actions: actions.sort(compareNeeds), waiting: waiting.sort(compareNeeds) }; @@ -517,6 +538,7 @@ export function hubFromSnapshot( const moves: Hub["moves"] = []; let youOwe = 0; let youAreOwed = 0; + const owingYou = new Set(); for (const expense of snapshot.expenses) { for (const share of expense.shares) { if (share.userId === expense.paidByUserId) continue; @@ -534,6 +556,7 @@ export function hubFromSnapshot( }); } else if (expense.paidByUserId === viewer.id) { youAreOwed += due; + owingYou.add(share.userId); moves.push({ go: "settle", arrow: "→", @@ -631,6 +654,9 @@ export function hubFromSnapshot( ], now: needs.actions, waiting: needs.waiting, + youOweMinor: youOwe, + owedToYouMinor: youAreOwed, + owingYou: [...owingYou], moves, members, activity: [...snapshot.expenses.map((expense) => ({ diff --git a/apps/web/src/screens/Home.css b/apps/web/src/screens/Home.css new file mode 100644 index 00000000..f85bfc98 --- /dev/null +++ b/apps/web/src/screens/Home.css @@ -0,0 +1,522 @@ +/* In the screens layer, above the shared base styles and below the dark + theme and the design-language primitives (see styles/index.css). + Home, direction B of the design panel (11 October 2026): the sentence, the + next step, the communes by need, then what needs you by when. */ +@layer screens { +.c-home { + --home-surface: var(--white); + --home-ink: var(--ink); + --home-ink-2: var(--ink-soft); + --home-line: var(--border); + --home-line-strong: var(--border-strong); + --home-recessed: #eee8de; + --home-hover: rgba(26, 30, 43, 0.045); + --home-late: #a12f2a; + --home-attention: #8a4b1f; + --home-settled: var(--sage-soft); + --home-on-settled: var(--sage-strong); + --home-action: var(--sage); + --home-action-hover: #24583f; + --home-on-action: #fff; + --home-band: var(--navy); + --home-on-band: rgb(245 241 234); + --home-on-band-2: rgba(245, 241, 234, 0.68); + --home-overlay: 0 18px 40px -18px rgba(18, 22, 34, 0.35), 0 2px 6px rgba(18, 22, 34, 0.06); + --home-display: "DM Sans", var(--font); + display: grid; + gap: 40px; + max-width: 1192px; + padding-top: 12px; + color: var(--home-ink); + font-variant-numeric: tabular-nums; +} +.a-body.is-dark .c-home { + --home-recessed: rgba(245, 241, 234, 0.07); + --home-hover: rgba(245, 241, 234, 0.05); + --home-late: #f2a89b; + --home-attention: #ebb886; + --home-settled: #1d3a2d; + --home-on-settled: #bfe3cf; + --home-action: #7cc39f; + --home-action-hover: #95d2b2; + --home-on-action: #0c1912; + --home-band: #1a1f2b; + --home-overlay: 0 18px 40px -18px rgba(0, 0, 0, 0.6), 0 2px 6px rgba(0, 0, 0, 0.2); +} +.c-home :is(h2, h3, p, ul) { margin: 0; padding: 0; } +.c-home ul { list-style: none; } + +/* ---------- Layout ---------- */ +.c-home-split { + display: grid; + grid-template-columns: minmax(0, 7fr) minmax(0, 5fr); + gap: 40px; + align-items: start; +} +.c-home-col { display: grid; gap: 24px; align-content: start; min-width: 0; } +.c-home-top { gap: 28px; } + +/* ---------- The sentence ---------- */ +.c-home-standing { display: grid; gap: 10px; padding-top: 6px; } +.c-home-over { font-size: 13px; line-height: 18px; font-weight: 500; color: var(--home-ink-2); } +.c-home-display { + font: 500 46px/50px var(--home-display); + letter-spacing: -0.028em; + text-wrap: balance; +} +.c-home-display em { font-style: normal; } +.c-home-sub { max-width: 560px; font-size: 16px; line-height: 24px; color: var(--home-ink-2); } + +/* ---------- Status ---------- */ +.c-home-state { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12.5px; + line-height: 16px; + font-weight: 500; + color: var(--home-ink-2); + white-space: nowrap; +} +.c-home-state::before { content: ""; width: 6px; height: 6px; flex: none; border-radius: 50%; background: currentColor; } +.c-home-state.is-late { color: var(--home-late); } +.c-home-state.is-attention { color: var(--home-attention); } +.c-home-state.is-plain::before { background: var(--home-line-strong); } +.c-home-badge { + display: inline-flex; + align-items: center; + gap: 5px; + height: 22px; + padding: 0 9px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + background: var(--home-settled); + color: var(--home-on-settled); +} +.c-home-tag { + position: absolute; + left: 8px; + top: 8px; + z-index: 1; + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border-radius: var(--radius-pill); + font-size: 11px; + font-weight: 500; + background: rgba(14, 16, 24, 0.62); + color: #fff; + backdrop-filter: blur(6px); +} +.c-home-message { + padding: 10px 14px; + border-radius: var(--radius-control); + background: var(--home-settled); + color: var(--home-on-settled); + font-size: 13.5px; + line-height: 19px; +} + +/* ---------- Buttons ---------- */ +.c-home-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 40px; + padding: 0 16px; + border: 0; + border-radius: var(--radius-control); + font: inherit; + font-size: 14px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; + transition: background 150ms cubic-bezier(0.2, 0, 0, 1); +} +.c-home-btn.is-primary { background: var(--home-action); color: var(--home-on-action); } +.c-home-btn.is-primary:hover { background: var(--home-action-hover); } +.c-home-btn.is-secondary { background: var(--home-surface); color: var(--home-ink); box-shadow: inset 0 0 0 1px var(--home-line-strong); } +.c-home-btn.is-secondary:hover { background: color-mix(in srgb, var(--home-surface), var(--home-ink) 4%); } +.c-home-btn.is-large { height: 46px; padding: 0 20px; font-size: 15px; } +.c-home-btn.is-small { height: 32px; padding: 0 12px; font-size: 13px; } +.c-home-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.c-home-note { max-width: 300px; font-size: 12px; line-height: 16px; color: var(--home-ink-2); } +.c-home-link { + margin-left: auto; + padding: 0; + border: 0; + background: none; + font: inherit; + font-size: 14px; + font-weight: 500; + color: var(--home-ink); + text-decoration: underline; + text-decoration-color: var(--home-line-strong); + text-underline-offset: 3px; + cursor: pointer; +} +.c-home-link:hover { text-decoration-color: currentColor; } + +/* ---------- Next step ---------- */ +.c-home-next { + display: grid; + gap: 16px; + padding: 18px 20px 20px; + border-radius: var(--radius-surface); + background: var(--home-surface); + box-shadow: inset 0 0 0 1px var(--home-line); +} +.c-home-next-top { display: flex; justify-content: space-between; font-size: 12px; font-weight: 500; color: var(--home-ink-2); } +.c-home-next-body { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 14px; align-items: center; } +.c-home-next.is-calm .c-home-next-body { grid-template-columns: auto minmax(0, 1fr); } +.c-home-next-copy h3 { font-size: 17px; line-height: 23px; font-weight: 600; letter-spacing: -0.01em; } +.c-home-next-copy p { margin-top: 2px; font-size: 13px; line-height: 18px; color: var(--home-ink-2); } +.c-home-next-copy .c-home-state { margin-top: 6px; } +.c-home-next-amount { font-size: 22px; line-height: 26px; font-weight: 600; letter-spacing: -0.01em; } +.c-home-next-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 14px; } +.c-home-who { position: relative; flex: none; } +.c-home-who .avatar { width: 44px; height: 44px; font-size: 15px; border: 0; } +.c-home-who-place, +.c-home-who-badge { position: relative; display: block; overflow: hidden; background: var(--cover-tone); } +.c-home-who-place { width: 44px; height: 44px; border-radius: 10px; } +.c-home-who-badge { + position: absolute; + right: -4px; + bottom: -4px; + width: 20px; + height: 20px; + border-radius: 6px; + box-shadow: 0 0 0 2px var(--home-surface); +} +.c-home-calm { + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--home-settled); + color: var(--home-on-settled); +} + +/* ---------- Communes ---------- */ +.c-home-communes, +.c-home-commune-list { display: grid; gap: 12px; } +.c-home-commune { + position: relative; + display: grid; + grid-template-columns: 132px minmax(0, 1fr) auto; + align-items: stretch; + min-height: 108px; + overflow: hidden; + border-radius: var(--radius-surface); + background: var(--home-surface); + box-shadow: inset 0 0 0 1px var(--home-line); + transition: background 150ms cubic-bezier(0.2, 0, 0, 1); +} +.c-home-commune:hover { background: color-mix(in srgb, var(--home-surface), var(--home-ink) 2.5%); } +.c-home-commune:has(button:focus-visible) { outline: 2px solid var(--focus-ring); outline-offset: 3px; } +.c-home-commune-name button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +/* The name's button covers the card; the ring goes round the whole card. */ +.c-home-commune-name button::after { content: ""; position: absolute; inset: 0; z-index: 2; } +.c-home-commune-name button:focus-visible { outline: none; } +.c-home-commune-photo { position: relative; overflow: hidden; background: var(--cover-tone); } +.c-home-commune-copy { display: grid; gap: 6px; align-content: center; min-width: 0; padding: 14px 16px; } +.c-home-commune-name { margin: 0; font-size: 15px; line-height: 20px; font-weight: 600; } +.c-home-commune-line { font-size: 13px; line-height: 18px; color: var(--home-ink-2); } +.c-home-commune-line b { font-weight: 500; color: var(--home-ink); } +.c-home-commune-state { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; } +.c-home-commune-go { display: grid; place-items: center; padding-right: 14px; color: var(--home-ink-2); } + +/* ---------- People and places ---------- */ +.c-home-faces { display: inline-flex; align-items: center; } +.c-home-faces .avatar { width: 28px; height: 28px; font-size: 10px; border: 0; box-shadow: 0 0 0 2px var(--home-surface); } +.c-home-faces .avatar + .avatar { margin-left: -4px; } +.c-home-faces.is-sm .avatar { width: 22px; height: 22px; font-size: 8.5px; letter-spacing: 0; } +.c-home-place { display: inline-flex; align-items: center; gap: 6px; min-width: 0; } +.c-home-place i { position: relative; flex: none; width: 14px; height: 14px; overflow: hidden; border-radius: 4px; background: var(--cover-tone); } + +/* ---------- Panels and rows ---------- */ +.c-home-panel { + container: home-panel / inline-size; + overflow: hidden; + border-radius: var(--radius-surface); + background: var(--home-surface); + box-shadow: inset 0 0 0 1px var(--home-line); +} +.c-home-head { display: flex; align-items: center; gap: 10px; } +.c-home-head h2 { font-size: 17px; line-height: 24px; font-weight: 600; letter-spacing: -0.008em; } +.c-home-panel > .c-home-head { padding: 18px 24px 14px; } +.c-home-count { + display: inline-grid; + place-items: center; + min-width: 22px; + height: 22px; + padding: 0 7px; + border-radius: var(--radius-pill); + background: var(--home-recessed); + color: var(--home-ink-2); + font-size: 12px; + font-weight: 600; +} +.c-home-head-note { margin-left: auto; font-size: 12px; line-height: 16px; font-weight: 500; color: var(--home-ink-2); } +.c-home-group h3 { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 24px 6px; + border-top: 1px solid var(--home-line); + font-size: 12px; + line-height: 16px; + font-weight: 600; +} +.c-home-group h3 span { font-weight: 500; color: var(--home-ink-2); } +.c-home-group h3.is-late { color: var(--home-late); } +.c-home-row { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 16px; + padding: 12px 24px; + transition: background 150ms cubic-bezier(0.2, 0, 0, 1); +} +.c-home-row.is-waiting { grid-template-columns: auto minmax(0, 1fr) auto; } +.c-home-row:hover { background: var(--home-hover); } +.c-home-row + .c-home-row { box-shadow: inset 0 1px 0 var(--home-line); } +.c-home-row-lead { display: grid; justify-items: center; min-width: 52px; } +/* Up to three faces, so every title in the list starts at the same place. */ +.c-home-row.is-waiting .c-home-row-lead { width: 76px; justify-items: start; } +.c-home-row .c-calendar-date { width: 52px; } +.c-home-row .c-calendar-date .c-calendar-number { font-size: 21px; } +.c-home-nodate { + display: grid; + place-items: center; + width: 52px; + min-height: 52px; + border: 1px dashed var(--home-line-strong); + border-radius: 10px; + font-size: 9.5px; + font-weight: 500; + color: var(--home-ink-2); +} +.c-home-row-main { display: grid; min-width: 0; } +.c-home-row-title { + justify-self: start; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-size: 15px; + line-height: 21px; + font-weight: 500; + text-align: left; + cursor: pointer; +} +/* The whole row opens the record; its button sits above. */ +.c-home-row-title::after { content: ""; position: absolute; inset: 0; } +.c-home-row-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 6px; + margin-top: 2px; + font-size: 13px; + line-height: 18px; + color: var(--home-ink-2); +} +.c-home-row-fig { display: grid; gap: 3px; justify-items: end; text-align: right; } +.c-home-row-fig strong { font-size: 15px; line-height: 20px; font-weight: 600; letter-spacing: -0.01em; } +.c-home-row-act { position: relative; z-index: 1; display: flex; justify-content: flex-end; min-width: 96px; } +.c-home-more { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + height: 46px; + padding: 0; + border: 0; + background: none; + box-shadow: inset 0 1px 0 var(--home-line); + color: var(--home-ink); + font: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} +.c-home-more svg.is-open { transform: rotate(180deg); } + +/* Figures under the title: waiting rows in the narrow column, and every row on a phone. */ +/* ---------- Recent activity ---------- */ +.c-home-feed li { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 12px; + padding: 11px 24px; +} +.c-home-feed li + li { box-shadow: inset 0 1px 0 var(--home-line); } +.c-home-feed .avatar { width: 28px; height: 28px; font-size: 10px; border: 0; } +.c-home-feed button { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + font-size: 13.5px; + line-height: 19px; + text-align: left; + cursor: pointer; +} +.c-home-feed b { font-weight: 500; } +.c-home-feed button { display: grid; } +.c-home-feed button span { color: var(--home-ink-2); } +.c-home-feed time { font-size: 12px; line-height: 19px; color: var(--home-ink-2); white-space: nowrap; } +.c-home-empty { padding: 0 24px 20px; font-size: 14px; line-height: 20px; color: var(--home-ink-2); } + +/* ---------- All square: the commune's photo as the reward ---------- */ +.c-home-square { + position: relative; + display: flex; + align-items: flex-end; + min-height: 420px; + overflow: hidden; + border-radius: var(--radius-surface); + background: var(--cover-tone); + color: #fff; +} +.c-home-square > .c-cover-photo { object-position: center 40%; } +/* Darkest behind the text at the bottom, so it reads over a bright photo + (tests/cover-contrast.test.ts); the side shade only deepens it. */ +.c-home-square::before, +.c-home-square::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; +} +.c-home-square::before { + background: linear-gradient( + 0deg, + rgba(14, 16, 24, 0.76) 0, + rgba(14, 16, 24, 0.66) 270px, + rgba(14, 16, 24, 0) 100% + ); +} +.c-home-square::after { + background: linear-gradient(90deg, rgba(14, 16, 24, 0.4) 0%, rgba(14, 16, 24, 0) 60%); +} +.c-home-square-copy { position: relative; z-index: 2; display: grid; gap: 12px; padding: 40px 44px; } +.c-home-square .c-home-display { font-size: 56px; line-height: 58px; letter-spacing: -0.032em; } +.c-home-square .c-home-over, +.c-home-square .c-home-sub { color: rgba(255, 255, 255, 0.9); } +.c-home-square .c-home-sub { font-size: 17px; } +.c-home-square-people { display: flex; align-items: center; gap: 12px; font-size: 14px; color: rgba(255, 255, 255, 0.9); } +.c-home-square-people .avatar { width: 32px; height: 32px; font-size: 11px; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9); } + +/* ---------- Narrow lists ---------- */ +/* A narrow list (the right-hand column, a tablet beside the sidebar, a phone): + each row's amount and state sit under its title, beside its button. */ +@container home-panel (max-width: 460px) { + .c-home-row { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "lead main act" "lead fig act"; gap: 2px 14px; } + .c-home-row.is-waiting { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "lead main" "lead fig"; } + .c-home-row-lead { grid-area: lead; } + .c-home-row-main { grid-area: main; } + .c-home-row-fig { grid-area: fig; display: flex; flex-wrap: wrap; align-items: center; gap: 2px 10px; text-align: left; } + .c-home-row-fig strong { font-size: 13px; line-height: 18px; font-weight: 500; } + .c-home-row-act { grid-area: act; min-width: 0; } + .c-home-row-meta > :not(.c-home-place) { display: none; } +} +/* Narrower still (a phone, or a tablet beside the sidebar): tighter edges. */ +@container home-panel (max-width: 400px) { + .c-home-panel > .c-home-head { padding: 14px 16px 10px; } + .c-home-group h3 { padding: 9px 14px 5px; } + .c-home-row { column-gap: 12px; padding: 12px 14px; } + .c-home-row-lead { min-width: 44px; } + .c-home-row-title { font-size: 14.5px; line-height: 20px; } + .c-home-row .c-calendar-date { width: 44px; } + .c-home-row .c-calendar-date .c-calendar-number { font-size: 18px; } + .c-home-nodate { width: 44px; min-height: 46px; } + .c-home-feed li { padding: 11px 14px; } + .c-home-empty { padding: 0 14px 16px; } +} + +/* ---------- Narrower: one column ---------- */ +@media (max-width: 1000px) { + .c-home-split { grid-template-columns: minmax(0, 1fr); gap: 28px; } + .c-home-square-copy { padding: 32px 36px; } + .c-home-square .c-home-display { font-size: 44px; line-height: 48px; } +} + +/* Beside the sidebar on a tablet, the page is about as narrow as a phone. */ +@media (max-width: 760px) { + .c-home-display, + .c-home-square .c-home-display { font-size: 36px; line-height: 40px; } + .c-home-commune { grid-template-columns: 96px minmax(0, 1fr) auto; } +} + +/* ---------- Phone: the sentence on the navy band, the next step lifted over it ---------- */ +@media (max-width: 560px) { + .c-home { gap: 28px; padding-top: 0; } + .c-home-top { gap: 0; } + .c-home-standing { + gap: 10px; + margin: -16px -16px 0; + padding: 14px 20px 66px; + background: var(--home-band); + color: var(--home-on-band); + } + .c-home-standing .c-home-over, + .c-home-standing .c-home-sub { color: var(--home-on-band-2); } + .c-home-standing .c-home-sub { font-size: 14.5px; line-height: 21px; } + .c-home-display { font-size: 31px; line-height: 35px; letter-spacing: -0.024em; } + .c-home-top > .c-home-next { position: relative; margin-top: -46px; box-shadow: var(--home-overlay); } + .c-home-top > .c-home-message { margin-top: 12px; } + .c-home-next-foot .c-home-btn { flex: 1 1 100%; } + .c-home-next-foot .c-home-note { max-width: none; } + + .c-home-commune-list.is-tiles { grid-template-columns: repeat(var(--home-tiles), minmax(0, 1fr)); gap: 10px; } + .is-tiles .c-home-commune { + grid-template-columns: minmax(0, 1fr); + align-content: start; + gap: 8px; + min-height: 0; + overflow: visible; + border-radius: 0; + background: none; + box-shadow: none; + } + .is-tiles .c-home-commune-photo { aspect-ratio: 1 / 0.9; border-radius: 12px; } + .is-tiles .c-home-commune-photo .c-home-tag { left: 6px; top: 6px; height: 18px; padding: 0 7px; font-size: 10.5px; } + .is-tiles .c-home-commune-copy { gap: 2px; padding: 0; align-content: start; } + .is-tiles .c-home-commune-name { font-size: 13px; line-height: 17px; } + .is-tiles .c-home-commune-line { font-size: 12px; line-height: 16px; } + .is-tiles .c-home-commune-line b { font-weight: 400; color: inherit; } + .is-tiles .c-home-commune-state .c-home-state { font-size: 11.5px; margin-top: 1px; } + .is-tiles :is(.c-home-commune-extra, .c-home-faces, .c-home-commune-go) { display: none; } + .is-tiles .c-home-badge { height: 20px; padding: 0 7px; font-size: 11px; } + + .c-home-square { min-height: 470px; margin: -16px -16px 0; border-radius: 0; } + .c-home-square-copy { gap: 10px; padding: 0 20px 26px; } + .c-home-square .c-home-display { font-size: 36px; line-height: 39px; } + .c-home-square .c-home-sub { font-size: 14.5px; line-height: 21px; } + .c-home-square-people > span:last-child { display: none; } +} +@media (prefers-reduced-motion: reduce) { + .c-home :is(.c-home-btn, .c-home-commune, .c-home-row) { transition: none; } +} +} diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index fc77324e..46e1a2f6 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -1,120 +1,539 @@ -import type { ReactNode } from "react"; +import { useState, type CSSProperties, type ReactNode } from "react"; +import { Check, ChevronDown, ChevronRight } from "lucide-react"; +import { formatDay } from "@commune/core"; import { useApp } from "../state"; -import { Avatar, Pill } from "../ui/widgets"; +import { Avatar } from "../ui/widgets"; import { CalendarDate } from "../ui/CalendarDate"; -import { compareNeeds, type LiveHub, type Need } from "../live/model"; +import { compareNeeds, money, type CurrencyCode, type LiveHub, type Need } from "../live/model"; import { cx } from "../ui/cx"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; -import { ActivityTypeChip, activityStamp, activityWord } from "../ui/ActivityLog"; +import { activityStamp, activityWord } from "../ui/ActivityLog"; +import { useReceiptActions } from "../ui/Receipts"; +import { addDays, dateFromIso, todayIso } from "../data/operations"; +import "./Home.css"; -function PhotoFace({ hub, focus, children }: { hub: LiveHub; focus?: boolean; children: ReactNode }) { +// Home opens on where you stand, then the one thing to do next, then your +// communes in order of need, then everything that needs you by when. The +// approved direction is "B" in the design panel of 11 October 2026. + +type Entry = { hub: LiveHub; item: Need }; +type Tone = "late" | "attention" | "plain"; + +const WORDS = ["no", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"]; +/** "two communes", "one person", "12 days": small counts in words, as a sentence says them. */ +function count(n: number, one: string, many = `${one}s`): string { + return `${n < WORDS.length ? WORDS[n] : n} ${n === 1 ? one : many}`; +} +const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1); +const firstName = (name: string) => name.trim().split(/\s+/)[0] ?? name; +/** "A", "A and B", "A, B and C". */ +function joined(parts: T[]): ReactNode[] { + return parts.flatMap((part, index) => index === 0 ? [part] : [index === parts.length - 1 ? " and " : ", ", part]); +} +/** Whole days from one calendar date to another, whatever the clocks did in between. */ +function daysBetween(from: string, to: string): number { + const [a, b] = [dateFromIso(from), dateFromIso(to)]; + return Math.round((Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()) - Date.UTC(a.getFullYear(), a.getMonth(), a.getDate())) / 86400000); +} +/** One amount per currency, never added across currencies, in the order the communes come. */ +function perCurrency(amounts: Array<[CurrencyCode, number]>): string[] { + const totals = new Map(); + for (const [currency, minor] of amounts) if (minor > 0) totals.set(currency, (totals.get(currency) ?? 0) + minor); + return [...totals].map(([currency, minor]) => money(minor, currency)); +} + +const weekday = new Intl.DateTimeFormat("en-GB", { weekday: "short" }); +const longDate = new Intl.DateTimeFormat("en-GB", { weekday: "long", day: "numeric", month: "long" }); + +/** When something is due, in words, and how much it matters. Waiting on others is never red. */ +function dueState(dueOn: string | undefined, today: string, waiting = false): { text: string; tone: Tone } | null { + if (!dueOn) return null; + const days = daysBetween(dueOn, today); + if (days > 0) return { text: `${days} ${days === 1 ? "day" : "days"} ${waiting ? "past due" : "overdue"}`, tone: waiting ? "attention" : "late" }; + if (days === 0) return { text: "Due today", tone: "attention" }; + if (days === -1) return { text: "Due tomorrow", tone: "plain" }; + if (days > -7) return { text: `Due ${weekday.format(dateFromIso(dueOn))}`, tone: "plain" }; + return { text: `Due ${formatDay(dueOn, today)}`, tone: "plain" }; +} + +function State({ text, tone }: { text: string; tone: Tone }) { + return {text}; +} + +// A payment someone says they sent waits on the viewer now, whenever the bill was due. +const GROUPS = ["To confirm", "Overdue", "Today", "This week", "Later", "No due date"] as const; +function groupOf({ kind, dueOn }: Need, today: string): (typeof GROUPS)[number] { + if (kind === "confirm") return "To confirm"; + if (!dueOn) return "No due date"; + if (dueOn < today) return "Overdue"; + if (dueOn === today) return "Today"; + return dueOn <= addDays(today, 6) ? "This week" : "Later"; +} + +/** Who and what, without the date, which the row shows on its own. */ +function detail(item: Need): string { + return capital(item.when.replace(/ · overdue$/, "").replace(/^Overdue · /, "")); +} +/** Late on the viewer's side: a payment to confirm is waiting on the viewer, not overdue from them. */ +const lateForYou = (item: Need) => item.late && item.kind !== "confirm"; + +function memberOf(hub: LiveHub, userId: string | undefined) { + return hub.members.find((member) => member.userId === userId); +} +function nameOf(hub: LiveHub, userId: string | undefined, fallback = "a former member"): string { + return memberOf(hub, userId)?.name ?? fallback; +} + +function Faces({ hub, ids, size, max = 4 }: { hub: LiveHub; ids: string[]; size?: "sm"; max?: number }) { + const people = ids.map((id) => memberOf(hub, id)).filter((member) => member !== undefined).slice(0, max); + if (!people.length) return null; return ( -
- - {children} + + ); +} + +function Place({ hub }: { hub: LiveHub }) { + return ( + + + {hub.name} + + ); +} + +function useOpen() { + const { go } = useApp(); + return ({ hub, item }: Entry) => item.go === "funds" + ? go("funds", { hub: hub.id, record: item.fundId ? { kind: "fund", id: item.fundId } : undefined }) + : go(item.go, { hub: hub.id, expense: item.expenseId, operation: item.operationId }); +} + +/** The opening sentence: what you owe and are owed, per currency, never summed. */ +function Standing({ places, needs, waiting, today, name }: { + places: LiveHub[]; needs: Entry[]; waiting: Entry[]; today: string; name: string; +}) { + const ready = places.filter((hub) => hub.activityState === "ready").sort((a, b) => needRank(a) - needRank(b)); + const owe = perCurrency(ready.map((hub) => [hub.currency, hub.youOweMinor])); + const owed = perCurrency(ready.map((hub) => [hub.currency, hub.owedToYouMinor])); + const owing = ready.filter((hub) => hub.youOweMinor > 0); + const owedIn = ready.filter((hub) => hub.owedToYouMinor > 0); + const people = new Set(ready.flatMap((hub) => hub.owingYou)).size; + const late = needs.filter(({ item }) => lateForYou(item) && item.dueOn); + const oldest = late.reduce((most, { item }) => Math.max(most, daysBetween(item.dueOn!, today)), 0); + const lateLine = late.length === 1 + ? `${late[0].item.title} is ${count(oldest, "day")} overdue.` + : late.length > 1 ? `${capital(count(late.length, "thing"))} are overdue, the oldest ${count(oldest, "day")}.` : ""; + const where = (list: LiveHub[]) => list.length === 1 ? `in ${list[0].name}` : `across ${count(list.length, "commune")}`; + const amounts = (list: string[]) => joined(list.map((amount) => {amount})); + const loading = places.length - ready.length; + const unavailable = places.some((hub) => hub.activityState === "error"); + const partial = !loading ? "" : unavailable + ? "Some communes could not be loaded. Open one to try again." + : `Still loading ${count(loading, "commune")}.`; + + let sentence: ReactNode; + let lines: string[]; + if (!ready.length) { + sentence = null; + lines = []; + } else if (owe.length) { + sentence = <>You owe {amounts(owe)} {where(owing)}.; + lines = [owed.length ? `You’re owed ${owed.join(" and ")}.` : "", lateLine]; + } else if (owed.length) { + sentence = <>You’re owed {amounts(owed)}.; + lines = [`You owe nothing. ${capital(count(people, "person", "people"))} still ${people === 1 ? "owes" : "owe"} you ${where(owedIn)}.`, lateLine]; + } else { + sentence = <>You’re all square{name ? `, ${name}` : ""}.; + const communes = places.length === 1 ? places[0].name : `your ${count(places.length, "commune")}`; + lines = [needs.length || waiting.length ? `Nothing owed either way in ${communes}.` : `Nothing owed and nothing waiting in ${communes}.`, lateLine]; + } + const sub = [...lines, partial].filter(Boolean).join(" "); + return ( +
+

{longDate.format(dateFromIso(today))}

+ {sentence ?

{sentence}

:

Where you stand

} + {sub || !ready.length ?

{sub || "Loading your communes…"}

: null}
); } -function PlaceCard({ hub, focus = false, level = 3 }: { hub: LiveHub; focus?: boolean; level?: 2 | 3 }) { +/** The first thing that needs you, with the only filled button on the page. */ +function NextStep({ entry, total, onAct, busy }: { entry: Entry; total: number; onAct: (entry: Entry, decline?: boolean) => void; busy: boolean }) { + const { hub, item } = entry; + const today = todayIso(); + // The bill's due date says nothing about a payment waiting to be confirmed. + const state = item.kind === "confirm" ? null : dueState(item.dueOn, today); + const person = memberOf(hub, item.person); + const first = firstName(nameOf(hub, item.person, "them")); + const action = item.kind === "pay" ? `I’ve paid ${first} ${item.amount}` + : item.kind === "confirm" ? `Confirm ${item.amount} received` + : item.kind === "task" ? "Open the task" : "Open the fund"; + const note = item.kind === "pay" ? `Pay ${first} however you usually do, then record it here. ${first} confirms it.` + : item.kind === "confirm" ? `Only once it has reached you. ${first} is told either way.` : ""; + return ( +
+
Your next step{total > 1 ? 1 of {total} : null}
+
+ + {person + ? + : } + {person ? : null} + +
+

{item.title}

+

{hub.name} · {detail(item)}

+ {state ? : null} +
+ {item.amount ? {item.amount} : null} +
+
+ + {item.kind === "confirm" + ? + : null} + {note ? {note} : null} +
+
+ ); +} + +/** Nothing needs you: say so, and point at anything others are late with. */ +function Calm({ waiting, today }: { waiting: Entry[]; today: string }) { + const open = useOpen(); + const late = waiting.find(({ item }) => item.kind === "owed" && item.late); + const people = late ? joined((late.item.people ?? []).map((id) => firstName(nameOf(late.hub, id, "a former member")))) : []; + const days = late?.item.dueOn ? daysBetween(late.item.dueOn, today) : 0; + return ( +
+
+ +
+

Nothing needs you

+

{late + ? <>{late.item.title} is {count(days, "day")} past due. {people} can see it, and you can nudge them from the expense. + : "You’re up to date in every commune."}

+
+
+ {late ? ( +
+ +
+ ) : null} +
+ ); +} + +/** How a commune stands for the viewer, in a line and a state. */ +function communeLine(hub: LiveHub): { line: ReactNode; extra?: ReactNode; state?: { text: string; tone: Tone }; square?: boolean } { + if (hub.activityState !== "ready") return { line: hub.status }; + const late = hub.now.filter(lateForYou).length; + const earliest = (items: Need[]) => items.map((item) => item.dueOn).filter((day): day is string => Boolean(day)).sort()[0]; + if (hub.youOweMinor > 0) { + const due = earliest(hub.now.filter((item) => item.kind === "pay")); + return { + line: You owe {money(hub.youOweMinor, hub.currency)}, + extra: hub.owedToYouMinor > 0 ? <> · owed {money(hub.owedToYouMinor, hub.currency)} : null, + state: late ? { text: late === 1 ? "Overdue" : `${late} overdue`, tone: "late" } : due ? dueState(due, todayIso()) ?? undefined : undefined, + }; + } + const pastDue = hub.waiting.filter((item) => item.kind === "owed" && item.late).length; + if (hub.owedToYouMinor > 0) { + const due = earliest(hub.waiting.filter((item) => item.kind === "owed")); + return { + line: Owed {money(hub.owedToYouMinor, hub.currency)}, + extra: <> by {count(hub.owingYou.length, "person", "people")}, + state: late ? { text: late === 1 ? "Overdue" : `${late} overdue`, tone: "late" } + : pastDue ? { text: pastDue === 1 ? "Past due" : `${pastDue} past due`, tone: "attention" } + : due ? dueState(due, todayIso(), true) ?? undefined : undefined, + }; + } + if (hub.now.length) { + return { line: "Nothing owed either way", state: late ? { text: late === 1 ? "Overdue" : `${late} overdue`, tone: "late" } : { text: `${hub.now.length} to do`, tone: "attention" } }; + } + return { line: "Nothing owed either way", square: true }; +} + +/** Communes that need you most come first; the order is otherwise yours. */ +function needRank(hub: LiveHub): number { + if (hub.activityState !== "ready") return 5; + if (hub.now.some(lateForYou)) return 0; + if (hub.now.length) return 1; + if (hub.waiting.some((item) => item.kind === "owed" && item.late)) return 2; + return hub.owedToYouMinor > 0 ? 3 : 4; +} + +/** A commune's card. Its name is a heading holding the button, which covers the whole card. */ +function CommuneCard({ hub, current }: { hub: LiveHub; current: boolean }) { const { go } = useApp(); - const balance = hub.oweAmount.replace(/\.00\b/, ""); - const hasBalance = hub.moves.some((move) => !move.even); - const ready = hub.activityState === "ready"; - const memberCount = ready ? hub.members.length : hub.memberCount; + const { line, extra, state, square } = communeLine(hub); + const ids = hub.members.map((member) => member.userId); return ( - + {line}{extra ? {extra} : null} + + + {state ? : null} + {square ? : null} + + + + + ); +} + +/** 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 }: { + entry: Entry; today: string; waiting?: boolean; onAct?: (entry: Entry) => void; busy?: boolean; +}) { + 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 context = item.kind === "pay" ? `${nameOf(hub, item.person)} for ${item.title}` : item.kind === "confirm" ? `${item.amount} from ${nameOf(hub, item.person)}` : item.title; + return ( +
  • + + {(waiting || item.kind === "confirm") && who.length + ? + : item.dueOn ? : No date} + + + + {detail(item)} + + + {item.amount ? {item.amount} : null} + {state ? : null} + + {onAct ? ( + + + + ) : null} +
  • + ); +} + +// A long list shows its first five; a short one shows everything. +const SHOWN = 5; +const COLLAPSE_OVER = 7; + +function NeedsList({ needs, today, onAct, busy }: { needs: Entry[]; today: string; onAct: (entry: Entry) => void; busy: boolean }) { + const [all, setAll] = useState(false); + const collapsible = needs.length > COLLAPSE_OVER; + const shown = all || !collapsible ? needs : needs.slice(0, SHOWN); + const groups = GROUPS.map((name) => ({ name, rows: shown.filter(({ item }) => groupOf(item, today) === name) })).filter((group) => group.rows.length); + const totals = new Map(GROUPS.map((name) => [name, needs.filter(({ item }) => groupOf(item, today) === name).length])); + return ( +
    +

    Needs you

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

    {group.name} {totals.get(group.name)}

    +
      {group.rows.map((entry, index) => )}
    +
    + ))} + {collapsible ? ( + + ) : null} +
    + ); +} + +function WaitingList({ waiting, today }: { waiting: Entry[]; today: string }) { + const total = perCurrency(waiting.filter(({ item }) => item.kind === "owed").map(({ hub, item }) => [hub.currency, item.minor ?? 0])); + return ( +
    +
    +

    Waiting on others

    {waiting.length} + {total.length ? {total.join(" · ")} : null}
    - +
      {waiting.map((entry, index) => )}
    +
    ); } -/** One row of Needs you or Waiting on others, opening the record it is about in its commune. */ -function NeedRow({ hub, item, quiet = false }: { hub: LiveHub; item: Need; quiet?: boolean }) { +function Activity({ places }: { places: LiveHub[] }) { const { go } = useApp(); + const activity = places.flatMap((hub) => hub.activity.map((item) => ({ hub, item }))) + .sort((a, b) => (Date.parse(b.item.createdAt ?? "") || 0) - (Date.parse(a.item.createdAt ?? "") || 0)).slice(0, 5); + const incomplete = places.some((hub) => hub.activityState !== "ready"); return ( - +
    +
    +

    Recent activity

    + +
    + {activity.length ? ( +
      + {activity.map(({ hub, item }, index) => ( +
    • + + + +
    • + ))} +
    + ) :

    {incomplete ? "Still loading activity from your communes…" : "Activity will appear after something is shared."}

    } +
    + ); +} + +/** All square and nothing to do: the current commune's photo, as a reward. */ +function SquareBand({ hub, places, name, today }: { hub: LiveHub; places: LiveHub[]; name: string; today: string }) { + const { you } = useApp(); + const people = new Map(); + for (const place of places) for (const member of place.members) if (!people.has(member.userId)) people.set(member.userId, member); + const others = [...people.values()].filter((member) => member.userId !== you.id); + const names = joined(["You", ...others.slice(0, 3).map((member) => firstName(member.name))]); + const communes = places.length === 1 ? places[0].name : `your ${count(places.length, "commune")}`; + return ( +
    + +
    +

    {longDate.format(dateFromIso(today))}

    +

    You’re all square{name ? `, ${name}` : ""}.

    +

    Nothing owed and nothing waiting in {communes}.

    +

    + + {names}{others.length > 3 ? ` and ${count(others.length - 3, "other")}` : ""} +

    +
    +
    ); } export function HomeScreen() { - const { go, places, hubId } = useApp(); + const { places, hubId, you } = useApp(); + const [message, setMessage] = useState(""); + const receipts = useReceiptActions(setMessage); + const open = useOpen(); if (!places.length) return ; - const first = places.find(place => place.id === hubId) ?? places[0]; - const others = places.filter(place => place.id !== first.id); + const today = todayIso(); + const name = firstName(you.name); // The same rule as This Hub, across every commune: actions in their order, then what others owe. const needs = places.flatMap((hub) => hub.now.map((item) => ({ hub, item }))).sort((a, b) => compareNeeds(a.item, b.item)); const waiting = places.flatMap((hub) => hub.waiting.map((item) => ({ hub, item }))).sort((a, b) => compareNeeds(a.item, b.item)); - const activity = places.flatMap((hub) => hub.activity.map((item) => ({ hub, item }))) - .sort((a, b) => (Date.parse(b.item.createdAt ?? "") || 0) - (Date.parse(a.item.createdAt ?? "") || 0)).slice(0, 6); - const incomplete = places.some((hub) => hub.activityState !== "ready"); - const unavailable = places.some((hub) => hub.activityState === "error"); - const partialMessage = unavailable ? "Some commune activity could not be loaded. Open that commune to retry." : "Still loading activity from your communes…"; + const [next, ...rest] = needs; + 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"); + const square = ready && !needs.length && !waiting.length && places.every((hub) => !hub.youOweMinor && !hub.owedToYouMinor); + + 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 }); + } 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 }); + } else { + open(entry); + } + } + + const communeList = ( +
    +
    +

    Your communes

    {places.length} +
    +
      1 && "is-tiles")} style={{ "--home-tiles": Math.min(places.length, 3) } as CSSProperties}> + {communes.map((hub) => 1 && hub.id === current.id} />)} +
    +
    + ); + const status = message ?

    {message}

    : null; + + if (square) { + return ( +
    + + {status} +
    +
    +
    {communeList}
    +
    +
    + ); + } return ( - <> -
    - {others.length ?
    {others.map((hub) => )}
    : null} -
    -
    -

    Needs you

    -

    What you need to do, across your communes.

    - {needs.length ? ( -
    - {needs.slice(0, 6).map(({ hub, item }, index) => )} -
    - ) : !incomplete ?

    Nothing for you to do right now.

    : null} - {needs.length > 6 ?

    And {needs.length - 6} more.

    : null} - {waiting.length ? ( -
    -

    Waiting on others

    -
    - {waiting.slice(0, 4).map(({ hub, item }, index) => )} -
    - {waiting.length > 4 ?

    And {waiting.length - 4} more.

    : null} -
    - ) : null} - {incomplete ?

    {partialMessage}

    : null} +
    +
    +
    + + {next ? : ready ? : null} + {status} +
    +
    {communeList}
    +
    +
    +
    + {rest.length ? : null} + {!rest.length && waiting.length ? : null} + {!rest.length && !waiting.length ? : null}
    -
    -
    -

    Recent activity

    What has been recorded.

    - -
    - {activity.length ? activity.map(({ hub, item }, index) => ( - - )) : !incomplete ?

    Activity will appear after something is shared.

    : null} - {incomplete ?

    {partialMessage}

    : null} +
    + {rest.length && waiting.length ? : null} + {rest.length || waiting.length ? : null}
    - + {receipts.dialogs} +
    + ); +} + +function PhotoFace({ hub, children }: { hub: LiveHub; children: ReactNode }) { + return ( +
    + + {children} +
    + ); +} + +function PlaceCard({ hub }: { hub: LiveHub }) { + const { go } = useApp(); + const balance = hub.oweAmount.replace(/\.00\b/, ""); + const hasBalance = hub.moves.some((move) => !move.even); + const ready = hub.activityState === "ready"; + const memberCount = ready ? hub.members.length : hub.memberCount; + return ( + ); } @@ -124,8 +543,7 @@ export function PlacesScreen() { return ( <>
    - {/* The cards sit straight under the page title, so each name is an h2. */} -
    {places.map((hub) => )}
    +
    {places.map((hub) => )}
    ); } diff --git a/apps/web/src/shell/Shell.tsx b/apps/web/src/shell/Shell.tsx index 8bb7aecc..8a2529a3 100644 --- a/apps/web/src/shell/Shell.tsx +++ b/apps/web/src/shell/Shell.tsx @@ -245,6 +245,7 @@ export function Header({ screen }: { screen: ScreenId }) { noticeCounts, hubId, session, + places, window: currentWindow, } = useApp(); const [searchOpen, setSearchOpen] = useState(false); @@ -267,7 +268,7 @@ export function Header({ screen }: { screen: ScreenId }) { return () => document.removeEventListener("keydown", shortcut); }, [searchOpen, currentWindow]); return ( -
    +
    diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 8ef74d24..8fc4e2a0 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -401,14 +401,15 @@ export type AppState = { /** Payer or admin voids an expense, keeping it and its history. */ voidExpense: (expenseId: string, input: { expectedVersion: number; reason: string; requestId: string }) => Promise; /** Payer records money received against one share. Retry-safe on requestId. */ - recordShareReceipt: (shareId: string, input: { amountMinor: number; receivedOn: string; reference?: string; requestId: string }) => Promise; + // Payment commands refresh the open commune, or the one named: Home acts on any commune's payments. + recordShareReceipt: (shareId: string, input: { amountMinor: number; receivedOn: string; reference?: string; requestId: string }, communeId?: string) => Promise; /** Payer reverses one receipt with a reason. The original entry is kept. */ reverseShareReceipt: (receiptId: string, reason: string) => Promise; /** The member who owes says they have paid. A claim, not a receipt. */ - reportSharePayment: (shareId: string, input: { amountMinor: number; sentOn: string; reference?: string; requestId: string }) => Promise; + reportSharePayment: (shareId: string, input: { amountMinor: number; sentOn: string; reference?: string; requestId: string }, communeId?: string) => Promise; withdrawPaymentReport: (reportId: string) => Promise; /** The bill payer confirms (records a receipt) or declines with a reason. */ - resolvePaymentReport: (reportId: string, input: { decision: "confirm"; requestId: string } | { decision: "decline"; note: string }) => Promise; + resolvePaymentReport: (reportId: string, input: { decision: "confirm"; requestId: string } | { decision: "decline"; note: string }, communeId?: string) => Promise; markExpensePaid: (expenseId: string) => Promise; createInvite: (email: string, role: "admin" | "member") => Promise<{ token: string; url: string }>; sendInviteEmail: (token: string) => Promise; @@ -594,6 +595,9 @@ function emptyHub(): LiveHub { kpis: [], now: [], waiting: [], + youOweMinor: 0, + owedToYouMinor: 0, + owingYou: [], moves: [], members: [], activity: [], @@ -1569,27 +1573,27 @@ export function AppProvider({ children }: { children: ReactNode }) { }, [expenseChange]); const voidExpense = useCallback((expenseId: string, input: { expectedVersion: number; reason: string; requestId: string }) => expenseChange(`/expenses/${encodeURIComponent(expenseId)}/void`, { ...input, reason: input.reason.trim() }, "POST"), [expenseChange]); - const recordShareReceipt = useCallback((shareId: string, input: { amountMinor: number; receivedOn: string; reference?: string; requestId: string }) => mutate(async () => { + const recordShareReceipt = useCallback((shareId: string, input: { amountMinor: number; receivedOn: string; reference?: string; requestId: string }, communeId?: string) => mutate(async () => { const result = await post(`/shares/${encodeURIComponent(shareId)}/receipts`, { amountMinor: input.amountMinor, receivedOn: input.receivedOn, reference: input.reference?.trim() || null, requestId: input.requestId, }); - await refreshRecords(); + await (communeId ? refreshCommune(communeId) : refreshRecords()); return result; - }), [mutate, refreshRecords]); + }), [mutate, refreshCommune, refreshRecords]); const reverseShareReceipt = useCallback((receiptId: string, reason: string) => mutate(async () => { const result = await post(`/receipts/${encodeURIComponent(receiptId)}/reverse`, { reason }); await refreshRecords(); return result; }), [mutate, refreshRecords]); - const reportSharePayment = useCallback((shareId: string, input: { amountMinor: number; sentOn: string; reference?: string; requestId: string }) => mutate(async () => { + const reportSharePayment = useCallback((shareId: string, input: { amountMinor: number; sentOn: string; reference?: string; requestId: string }, communeId?: string) => mutate(async () => { const result = await post(`/shares/${encodeURIComponent(shareId)}/payment-reports`, { amountMinor: input.amountMinor, sentOn: input.sentOn, reference: input.reference?.trim() || null, requestId: input.requestId, }); - await refreshRecords(); + await (communeId ? refreshCommune(communeId) : refreshRecords()); return result; - }), [mutate, refreshRecords]); + }), [mutate, refreshCommune, refreshRecords]); const postAnnouncement = useCallback((input: { title: string; body: string; needsAcknowledgement: boolean; requestId: string }) => mutate(async () => { const result = await post(`/communes/${encodeURIComponent(activeId)}/announcements`, { title: input.title.trim(), body: input.body.trim(), needsAcknowledgement: input.needsAcknowledgement, requestId: input.requestId, @@ -1612,12 +1616,12 @@ export function AppProvider({ children }: { children: ReactNode }) { await refreshRecords(); return result; }), [mutate, refreshRecords]); - const resolvePaymentReport = useCallback((reportId: string, input: { decision: "confirm"; requestId: string } | { decision: "decline"; note: string }) => mutate(async () => { + const resolvePaymentReport = useCallback((reportId: string, input: { decision: "confirm"; requestId: string } | { decision: "decline"; note: string }, communeId?: string) => mutate(async () => { const result = await post(`/payment-reports/${encodeURIComponent(reportId)}/resolve`, input.decision === "confirm" ? input : { decision: "decline", note: input.note.trim() }); - await refreshRecords(); + await (communeId ? refreshCommune(communeId) : refreshRecords()); return result; - }), [mutate, refreshRecords]); + }), [mutate, refreshCommune, refreshRecords]); const markExpensePaid = useCallback((expenseId: string) => mutate(async () => { const expense = expensesRef.current.find((row) => row.id === expenseId); const viewer = dataRef.current?.user.id; @@ -1927,14 +1931,17 @@ export function AppProvider({ children }: { children: ReactNode }) { personName(members, userId, fallback, options), [members]); // Dialogs have their own heading; the page behind them remains the page. + // Home with communes opens on its own sentence, so the bar only names the + // page; with none yet, the bar greets the member. const isHome = screen === "home" || screen === "home-empty"; + const greeting = isHome && !places.length; const firstName = you.name.trim().split(/\s+/)[0]; - const pageTitle = unknownCommune ? "Commune not found" : isHome + const pageTitle = unknownCommune ? "Commune not found" : greeting ? firstName ? `Your communes, ${firstName}.` : "Your communes." : PAGE_TITLES[screen] ?? "Home"; const outstandingShareCount = places.reduce((sum, place) => sum + place.moves.length, 0); - const pageLede = unknownCommune ? "" : isHome - ? `${places.length} ${places.length === 1 ? "space" : "spaces"} · shared details that stay in sync for everyone.` + const pageLede = unknownCommune || (isHome && !greeting) ? "" : greeting + ? "0 spaces · shared details that stay in sync for everyone." : screen === "communes" ? session?.hasHubs ? places.some((place) => place.activityState !== "ready") diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index e7a3a200..95239329 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -818,7 +818,7 @@ line-height: 1.5; } -:is(.a-place-hero :is(h2, h3), .a-focus-hero h2, +:is(.a-place-hero :is(h2, h3), .a-set-row-copy strong, .a-commune-set h2, .a-item strong, .a-member-row .who strong) { overflow-wrap: anywhere; @@ -1274,18 +1274,6 @@ div.a-set-row { flex-shrink: 0; } -/* This Hub — D Home photo treatment, A chrome */ - -.a-cover-people { - display: flex; - align-items: center; - gap: 10px; - margin-top: 16px; - font-size: 13px; - font-weight: 600; - color: rgba(248, 245, 240, 0.92); -} - .a-toolbar { display: flex; align-items: center; @@ -1850,18 +1838,6 @@ div.a-set-row { flex-shrink: 0; } -.a-week { - display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr); - gap: 16px; - margin-top: 16px; - align-items: stretch; -} - -#home .a-week { - margin-top: 0; -} - .a-item { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; @@ -1948,8 +1924,7 @@ button.a-item { } /* The cover photo (ui/CoverImage.tsx) sits under the shade and the text. */ -.a-place-hero > .c-cover-photo, -.a-focus-hero > .c-cover-photo { +.a-place-hero > .c-cover-photo { position: absolute; z-index: 0; } @@ -2004,8 +1979,7 @@ button.a-item { color: rgba(248, 245, 240, 0.9); } -.a-place-hero .a-place-location, -.a-focus-hero .a-place-location { +.a-place-hero .a-place-location { overflow: hidden; max-width: 100%; margin-bottom: 4px; @@ -2041,85 +2015,6 @@ button.a-item { color: var(--sage-soft); } -.a-focus-lead .a-focus-hero { - border-radius: 0; - box-shadow: none; - cursor: inherit; -} - -.a-focus-hero { - position: relative; - isolation: isolate; - width: 100%; - min-height: 292px; - padding: 28px 28px 26px; - border: 0; - overflow: hidden; - display: flex; - flex-direction: column; - justify-content: flex-end; - text-align: left; - font: inherit; - color: #f8f5f0; - background: var(--cover-tone, var(--navy)); -} - -.a-focus-hero > .c-cover-photo { - transform: scale(1.03); -} - -/* As the smaller cards: darkest behind the text (tests/cover-contrast.test.ts). */ -.a-focus-hero::after { - content: ""; - position: absolute; - inset: 0; - z-index: 1; - pointer-events: none; - background: linear-gradient( - 0deg, - rgba(26, 30, 46, 0.72) 0, - rgba(26, 30, 46, 0.68) 112px, - rgba(26, 30, 46, 0.64) 130px, - rgba(26, 30, 46, 0.52) 178px, - rgba(26, 30, 46, 0.26) 214px, - rgba(26, 30, 46, 0.08) 100% - ); -} - -.a-focus-hero > * { - position: relative; - z-index: 2; -} - -.a-focus-hero .type { - width: fit-content; - height: 26px; - padding: 0 10px; - border-radius: 999px; - display: inline-flex; - align-items: center; - background: rgba(26, 30, 46, 0.64); - border: 1px solid rgba(245, 241, 234, 0.2); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.12em; - text-transform: uppercase; -} - -.a-focus-hero h2 { - margin: 12px 0 8px; - font-size: 2.1rem; - font-weight: 800; - letter-spacing: -0.035em; -} - -.a-focus-hero p { - margin: 0; - max-width: 34rem; - font-size: 15px; - color: rgba(248, 245, 240, 0.9); -} - .a-pay-hero { display: grid; gap: 4px; @@ -2356,7 +2251,6 @@ button.a-item { } @media (max-width: 1000px) { - .a-week, .a-set { grid-template-columns: 1fr; } @@ -2735,7 +2629,6 @@ button.a-item { padding: 16px 16px 28px; } -.a-body.is-native .a-week, .a-body.is-native .a-place-grid { grid-template-columns: 1fr; } diff --git a/apps/web/src/styles/fonts.css b/apps/web/src/styles/fonts.css index 5bef5737..f1a50968 100644 --- a/apps/web/src/styles/fonts.css +++ b/apps/web/src/styles/fonts.css @@ -8,12 +8,16 @@ * in its unicode-range: the interface needs only Latin, and the others are * fetched just for names such as Łukasz, Дмитрий, Νίκος or Nguyễn. * - * DM Sans: only the glyphs of the "commune." wordmark (.a-wordmark), with both - * variation axes kept so it renders identically at every size it is drawn. + * DM Sans Wordmark: only the glyphs of the "commune." wordmark (.a-wordmark), + * with both variation axes kept so it renders identically at every size it is drawn. * Made from @fontsource-variable/dm-sans 5.3.0 dm-sans-latin-standard-normal.woff2: * pyftsubset dm-sans-latin-standard-normal.woff2 --unicodes=U+002E,U+0063,U+0065,U+006D,U+006E,U+006F,U+0075 --layout-features='*' --flavor=woff2 * Make it again if the wordmark's letters change. * + * DM Sans: the display face, for the sentence Home opens on. It is + * @fontsource-variable/dm-sans 5.3.0 dm-sans-latin-wght-normal.woff2 as it + * comes (Latin, the weight axis), downloaded only by a page that uses it. + * * Both are under the SIL Open Font License; the licences sit beside the files. */ @@ -87,3 +91,12 @@ font-display: swap; src: url("./fonts/dm-sans-wordmark.woff2") format("woff2"); } + +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 100 1000; + font-display: swap; + src: url("./fonts/dm-sans-latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/apps/web/src/styles/fonts/dm-sans-latin.woff2 b/apps/web/src/styles/fonts/dm-sans-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..01383d7e5c4340847b8fe4570ad0458e6582989d GIT binary patch literal 36932 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/styles/overrides.css b/apps/web/src/styles/overrides.css index a0e8ddbf..6610965c 100644 --- a/apps/web/src/styles/overrides.css +++ b/apps/web/src/styles/overrides.css @@ -162,27 +162,11 @@ body, .a-pending-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 20px; } .a-pending-card { min-height: 160px; border-radius: var(--radius-surface); padding: 24px; background: rgba(255, 255, 255, 0.45); } .a-pending-card .a-pending-line + .a-pending-line { margin-top: 20px; width: 210px; } -/* Home keeps one featured commune with smaller places beneath it. */ -.a-home-featured { margin-bottom: 16px; } -.a-home-places { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin-bottom: 22px; } -#home .a-place { box-shadow: none; border-radius: var(--radius-surface); } -#home .a-place-hero { min-height: 208px; padding: 18px; } -#home .a-place-hero h3 { font-size: 21px; font-weight: 500; letter-spacing: -.6px; } -#home .a-week { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 22px; } -#home .a-card { background: var(--white); box-shadow: none; border: 1px solid var(--border); border-radius: var(--radius-surface); padding: 23px; min-width: 0; } -#home .a-card h2 { font-size: 18px; font-weight: 550; } -#home .a-card strong:not(.c-calendar-number) { font-weight: 500; } -#home .hub-feed-row { grid-template-columns: minmax(0,1fr) auto; } -#home .hub-feed-row > .pill { grid-column: 1 / -1; justify-self: start; } -#home .hub-feed-row:has(> .c-calendar-date) { grid-template-columns: auto minmax(0,1fr) auto; } .a-search:focus-within { outline: 2px solid var(--sage); outline-offset: 2px; } @media (max-width: 850px) { - .a-home-places { grid-template-columns: repeat(2, minmax(0, 1fr)); } - #home .a-week { grid-template-columns: 1fr; } .a-body:not(.is-native) .a-header { flex-wrap: wrap; height: auto; min-height: 60px; } .a-body:not(.is-native) .a-search { display: flex; order: 3; flex: 1 0 100%; width: 100%; max-width: none; margin-top: 8px; background: var(--white); color: var(--ink-soft); } } -@media (max-width: 560px) { .a-home-places { grid-template-columns: 1fr; } } /* Operations was drawn for the light theme only: cream cards with ink text, so in dark mode the titles turned light-on-cream and disappeared. Give its diff --git a/apps/web/src/ui/Receipts.tsx b/apps/web/src/ui/Receipts.tsx index 708ec9c2..a56a775e 100644 --- a/apps/web/src/ui/Receipts.tsx +++ b/apps/web/src/ui/Receipts.tsx @@ -10,20 +10,22 @@ import { LIMITS } from "../data/limits"; import { Button, Dialog, Feedback, Field } from "./components"; export const RECEIPT_REFERENCE_MAX = 80; +// communeId names the commune the share is in, when that may not be the open +// one (Home acts on every commune's payments); it is read again afterwards. export type ReceiptTarget = { shareId: string; debtorName: string; expenseName: string; - shareMinor: number; outstandingMinor: number; currency: CurrencyCode; + shareMinor: number; outstandingMinor: number; currency: CurrencyCode; communeId?: string; }; export type ReversalTarget = { receipt: Receipt; debtorName: string; expenseName: string; currency: CurrencyCode; }; export type PaymentReportTarget = { shareId: string; payerName: string; expenseName: string; - outstandingMinor: number; currency: CurrencyCode; + outstandingMinor: number; currency: CurrencyCode; communeId?: string; }; export type ReportAnswerTarget = { report: PaymentReport; debtorName: string; expenseName: string; currency: CurrencyCode; - decision: "confirm" | "decline"; + decision: "confirm" | "decline"; communeId?: string; }; /** The share's open "I've sent it" report, if any. */ @@ -72,7 +74,7 @@ export function RecordReceiptDialog({ target, onClose, onRecorded }: { if (unsafeReference(reference)) { setError("Leave out card or account numbers. A short note such as “bank transfer” is enough."); return; } setPending(true); setError(""); try { - const result = await recordShareReceipt(target.shareId, { amountMinor: minor, receivedOn, reference, requestId: requestId.current }); + const result = await recordShareReceipt(target.shareId, { amountMinor: minor, receivedOn, reference, requestId: requestId.current }, target.communeId); onRecorded(result.duplicate ? `This repayment was already recorded. ${money(result.outstandingMinor, target.currency)} is outstanding from ${target.debtorName}.` : result.outstandingMinor > 0 @@ -147,7 +149,7 @@ export function ReportPaymentDialog({ target, onClose, onReported }: { if (unsafeReference(reference)) { setError("Leave out card or account numbers. A short note such as “bank transfer” is enough."); return; } setPending(true); setError(""); try { - const result = await reportSharePayment(target.shareId, { amountMinor: minor, sentOn, reference, requestId: requestId.current }); + const result = await reportSharePayment(target.shareId, { amountMinor: minor, sentOn, reference, requestId: requestId.current }, target.communeId); onReported(result.duplicate ? `This payment was already reported. ${target.payerName} has been told.` : `${target.payerName} has been told you sent ${money(minor, target.currency)}. Your balance changes when they confirm it.`); @@ -208,7 +210,7 @@ export function AnswerReportDialog({ target, onClose, onAnswered }: { setPending(true); setError(""); try { const result = await resolvePaymentReport(target.report.id, confirming - ? { decision: "confirm", requestId: requestId.current } : { decision: "decline", note }); + ? { decision: "confirm", requestId: requestId.current } : { decision: "decline", note }, target.communeId); onAnswered(confirming ? result.outstandingMinor > 0 ? `${amount} recorded as received from ${target.debtorName}. ${money(result.outstandingMinor, target.currency)} still outstanding.` diff --git a/apps/web/tests/activity-log.test.tsx b/apps/web/tests/activity-log.test.tsx index f6caddda..fb825c2b 100644 --- a/apps/web/tests/activity-log.test.tsx +++ b/apps/web/tests/activity-log.test.tsx @@ -148,10 +148,10 @@ describe("the activity log", () => { it("gives Home's recent records their type, day and time", async () => { mount("/"); - const recent = (await screen.findByRole("heading", { name: "Recent activity" })).closest(".a-card") as HTMLElement; + const recent = (await screen.findByRole("heading", { name: "Recent activity" })).closest("section") as HTMLElement; await within(recent).findByText("Ferry"); - const rows = within(recent).getAllByRole("button").filter((button) => button.classList.contains("a-item")); - expect(rows.map((row) => [row.querySelector("strong")?.textContent, ...[...row.querySelectorAll(".when")].map((node) => node.textContent)])) + const rows = within(recent).getAllByRole("listitem"); + expect(rows.map((row) => [row.querySelector("b")?.textContent, row.querySelector("b + span")?.textContent, row.querySelector("time")?.textContent])) .toEqual([ ["Inspection", "Our home · Event · Created · Housemate Two", "Today, 14:05"], ["Ferry", "Lisbon trip · Repayment · Repayment of €20.00 recorded · Housemate Two", "Today, 10:10"], diff --git a/apps/web/tests/addresses.test.tsx b/apps/web/tests/addresses.test.tsx index 066f0c0b..0b80990c 100644 --- a/apps/web/tests/addresses.test.tsx +++ b/apps/web/tests/addresses.test.tsx @@ -167,7 +167,7 @@ describe("an address for every window", () => { expect(app.window).toBeNull(); await waitFor(() => expect(document.title).toBe("Commune not found · Commune")); fireEvent.click(screen.getByRole("button", { name: "Go to Home" })); - await screen.findByRole("heading", { name: "Your communes, Test." }); + await screen.findByRole("heading", { name: "Home", level: 1 }); }); it("shows the window's own not-found state for a record outside the commune", async () => { @@ -276,7 +276,7 @@ describe("the page asked for before signing in", () => { sessionStorage.setItem("commune.return-to", "https://evil.example/steal"); const location = mount("/login"); await signIn(); - await screen.findByRole("heading", { name: "Your communes, Test." }); + await screen.findByRole("heading", { name: "Home", level: 1 }); expect(location.history?.at(-1)).toBe("/"); }); diff --git a/apps/web/tests/cover-contrast.test.ts b/apps/web/tests/cover-contrast.test.ts index 7dccedf3..836890db 100644 --- a/apps/web/tests/cover-contrast.test.ts +++ b/apps/web/tests/cover-contrast.test.ts @@ -3,11 +3,11 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -// Text over commune photos, on Home's cards and the Hub hero, measured over a -// bright stand-in photo: pure white, the worst a photo can be behind light -// text. Small text needs 4.5:1; large text (24px, or 19px bold) and buttons 3:1. +// Text over commune photos, on the communes' cards, Home's all-square photo and +// the Hub hero, measured over a +// bright stand-in photo: pure white, the worst a photo can be behind light text. Small text needs 4.5:1; large text (24px, or 19px bold) and buttons 3:1. const readStyle = (path: string) => readFileSync(resolve(process.cwd(), "src", path), "utf8"); -const styles = { app: readStyle("styles/app.css"), hub: readStyle("screens/Hub.css") }; +const styles = { app: readStyle("styles/app.css"), home: readStyle("screens/Home.css"), hub: readStyle("screens/Hub.css") }; function declarations(sheet: keyof typeof styles, selector: string) { const source = `\n${styles[sheet]}`; @@ -112,52 +112,57 @@ function stack(padding: number, items: Array<{ name: string; line: number; gap: const light = colour("#f8f5f0"); const withOpacity = (value: Colour, opacity: number) => ({ ...value, alpha: value.alpha * opacity }); -describe("text over Home's commune photos", () => { +describe("text over the commune cards' photos", () => { const status = () => colour(property("app", ".a-place-hero p", "color")); - const location = () => colour(property("app", ".a-place-hero .a-place-location,\n.a-focus-hero .a-place-location", "color")); + const location = () => colour(property("app", ".a-place-hero .a-place-location", "color")); it.each([ ["with an address", true, 1], ["with a status that wraps", true, 2], ["without an address", false, 1], ] as const)("keeps a card's title, address and status readable %s", (_case, address, statusLines) => { - // A 208px card with 18px padding (#home .a-place-hero): status 14px, address - // 12px 4px above it, title 21px 4px above that. The title is not large text. - const lines = stack(18, [ + // A 208px card with 16px bottom padding (.a-place-hero): status 14px, + // address 12px 4px above it, title 20px 4px above that, held to 4.5:1. + const lines = stack(16, [ { name: "status", line: 14 * 1.2, gap: 0, text: status(), target: 4.5, lines: statusLines }, ...(address ? [{ name: "address", line: 12 * 1.2, gap: 4, text: location(), target: 4.5 }] : []), - { name: "title", line: 21 * 1.2, gap: 4, text: light, target: 4.5 }, + { name: "title", line: 20 * 1.2, gap: 4, text: light, target: 4.5 }, ]); const result = weakest(lines, 208, shade(property("app", ".a-place-hero::after", "background"))); expect(failures(result), JSON.stringify(result)).toEqual([]); }); + it("gives the type label its own backdrop, wherever it sits", () => { + // The label can sit high on a card with a long name, so it does not rely on the shade. + const backdrop = over(colour(property("app", ".a-place-hero .type", "background")), white); + expect(ratio(light.rgb, backdrop)).toBeGreaterThanOrEqual(4.5); + }); +}); + +describe("text over Home's all-square photo", () => { + const soft = () => colour(property("home", ".c-home-square .c-home-over,\n.c-home-square .c-home-sub", "color")); + const people = () => colour(property("home", ".c-home-square-people", "color")); + it.each([ - ["with an address", true, 1], - ["with a status that wraps", true, 2], - ] as const)("keeps the featured card's text readable %s", (_case, address, statusLines) => { - // A 292px card with 26px bottom padding (.a-focus-hero): "Open this commune" - // (13px) centred on 32px avatars, status 15px 16px above, address 12px, - // then the title, 2.1rem and bold, which is large text. - const people = colour(property("app", ".a-cover-people", "color")); - const featured = colour(property("app", ".a-focus-hero p", "color")); + ["on a computer", 420, 40, 58, 1, 24, 1], + ["on a tablet, with the sentence on two lines", 420, 32, 48, 2, 24, 2], + ["on a phone", 470, 26, 39, 2, 21, 2], + ] as const)("keeps the date, sentence and people readable %s", (_case, cover, padding, display, displayLines, sub, subLines) => { + // .c-home-square-copy, from the bottom: the names (14px) centred on 32px + // avatars, then the line under the sentence, the sentence (large text) and + // the date, each the grid's gap apart (12px, 10px on a phone). + const gap = cover === 470 ? 10 : 12; const lines = [ - { name: "people", from: 26 + (32 - 13 * 1.2) / 2, to: 26 + (32 + 13 * 1.2) / 2, text: people, target: 4.5 }, - ...stack(26 + 32, [ - { name: "status", line: 15 * 1.2, gap: 16, text: featured, target: 4.5, lines: statusLines }, - ...(address ? [{ name: "address", line: 12 * 1.2, gap: 4, text: location(), target: 4.5 }] : []), - { name: "title", line: 33.6 * 1.2, gap: 8, text: light, target: 3 }, + { name: "people", from: padding + (32 - 14 * 1.2) / 2, to: padding + (32 + 14 * 1.2) / 2, text: people(), target: 4.5 }, + ...stack(padding + 32, [ + { name: "sub", line: sub, gap, text: soft(), target: 4.5, lines: subLines }, + { name: "sentence", line: display, gap, text: colour("#ffffff"), target: 3, lines: displayLines }, + { name: "date", line: 18, gap, text: soft(), target: 4.5 }, ]), ]; - const result = weakest(lines, 292, shade(property("app", ".a-focus-hero::after", "background"))); + const result = weakest(lines, cover, shade(property("home", ".c-home-square::before", "background"))); expect(failures(result), JSON.stringify(result)).toEqual([]); }); - - it.each([".a-place-hero .type", ".a-focus-hero .type"])("gives the %s label its own backdrop, wherever it sits", (selector) => { - // The label can sit high on a card with a long name, so it does not rely on the shade. - const backdrop = over(colour(property("app", selector, "background")), white); - expect(ratio(light.rgb, backdrop)).toBeGreaterThanOrEqual(4.5); - }); }); describe("text over the Hub's commune photo", () => { diff --git a/apps/web/tests/covers.test.tsx b/apps/web/tests/covers.test.tsx index f60aab0f..d18e1d1b 100644 --- a/apps/web/tests/covers.test.tsx +++ b/apps/web/tests/covers.test.tsx @@ -20,7 +20,7 @@ const widths = (srcSet = "") => srcSet.split(", ").map((candidate) => { const [url, width] = candidate.split(" "); return { photo: new URL(url).pathname, w: new URL(url).searchParams.get("w"), width }; }); -const PLACEMENTS: CoverPlacement[] = ["featured", "card", "hero", "preview"]; +const PLACEMENTS: CoverPlacement[] = ["card", "hero", "preview", "commune", "thumb"]; const CUSTOM = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ"; // The screens and windows a browser loads on first use are loaded up front, @@ -109,16 +109,17 @@ describe("covers across the app", () => { }); afterEach(cleanup); - it("sizes Home's covers for their cards and falls back to each type's tone", async () => { + it("sizes Home's covers for where they sit and falls back to each type's tone", async () => { mount("/"); await screen.findByRole("heading", { name: "Our home" }); - const featured = document.querySelector(".a-focus-hero")!; - const cards = [...document.querySelectorAll(".a-home-places .a-place-hero")]; - expect(featured.classList.contains("c-cover-household")).toBe(true); - expect(cards.map((card) => [...card.classList].find((name) => name.startsWith("c-cover-")))).toEqual(["c-cover-workspace", "c-cover-trip"]); - expect(featured.querySelector("img")?.getAttribute("sizes")).toBe(coverSource({ type: "Household", photo: "" }, "featured").sizes); - for (const card of cards) expect(card.querySelector("img")?.getAttribute("sizes")).toBe(coverSource({ type: "Trip", photo: "" }, "card").sizes); - expect(cards[1].querySelector("img")?.getAttribute("src")).toBe(coverSource({ type: "Trip", photo: "" }, "card").src); + // Nothing is owed in any of them, so the current commune's photo leads. + const band = document.querySelector(".c-home-square")!; + const photos = [...document.querySelectorAll(".c-home-commune-photo")]; + expect(band.classList.contains("c-cover-household")).toBe(true); + expect(photos.map((photo) => [...photo.classList].find((name) => name.startsWith("c-cover-")))).toEqual(["c-cover-household", "c-cover-workspace", "c-cover-trip"]); + expect(band.querySelector("img")?.getAttribute("sizes")).toBe(coverSource({ type: "Household", photo: "" }, "hero").sizes); + for (const photo of photos) expect(photo.querySelector("img")?.getAttribute("sizes")).toBe(coverSource({ type: "Trip", photo: "" }, "commune").sizes); + expect(photos[2].querySelector("img")?.getAttribute("src")).toBe(coverSource({ type: "Trip", photo: "" }, "commune").src); failAll(); expect(document.querySelector("img.c-cover-photo")).toBeNull(); expect(screen.getByRole("heading", { name: "Our home" })).toBeTruthy(); diff --git a/apps/web/tests/error-recovery.test.tsx b/apps/web/tests/error-recovery.test.tsx index 35b94c56..b492f253 100644 --- a/apps/web/tests/error-recovery.test.tsx +++ b/apps/web/tests/error-recovery.test.tsx @@ -102,7 +102,7 @@ describe("a part of the app that fails to draw", () => { broken.screen = true; start("/funds?commune=home-1"); fireEvent.click(within(await screen.findByRole("alert")).getByRole("button", { name: "Go to Home" })); - await screen.findByRole("heading", { name: "Your communes, Test." }); + await screen.findByRole("heading", { name: "Home", level: 1 }); expect(window.location.pathname).toBe("/"); }); diff --git a/apps/web/tests/fonts.test.ts b/apps/web/tests/fonts.test.ts index 8f1ae873..3f23a3f1 100644 --- a/apps/web/tests/fonts.test.ts +++ b/apps/web/tests/fonts.test.ts @@ -8,8 +8,8 @@ const faces = css.match(/@font-face\s*{[^}]*}/g) ?? []; const family = (face: string) => /font-family:\s*"([^"]+)"/.exec(face)?.[1]; describe("self-hosted fonts", () => { - it("serves Inter and the wordmark from files kept with the app", () => { - expect(faces.map(family)).toEqual([...Array(7).fill("Inter"), "DM Sans Wordmark"]); + it("serves Inter, the wordmark and the display face from files kept with the app", () => { + expect(faces.map(family)).toEqual([...Array(7).fill("Inter"), "DM Sans Wordmark", "DM Sans"]); for (const face of faces) { const file = /src:\s*url\("(\.\/fonts\/[\w-]+\.woff2)"\)\s*format\("woff2"\);/.exec(face)?.[1]; expect(file).toBeTruthy(); diff --git a/apps/web/tests/invitation-hint.test.tsx b/apps/web/tests/invitation-hint.test.tsx index be643fde..dc47d178 100644 --- a/apps/web/tests/invitation-hint.test.tsx +++ b/apps/web/tests/invitation-hint.test.tsx @@ -90,7 +90,7 @@ describe("an invitation sent to another address", () => { it("carries on to Commune", async () => { const location = mount(); fireEvent.click(await screen.findByRole("button", { name: "Continue to Commune" })); - await screen.findByRole("heading", { name: "Your communes, Test." }); + await screen.findByRole("heading", { name: "Home", level: 1 }); expect(location.history?.at(-1)).toBe("/"); }); diff --git a/apps/web/tests/needs-you.test.tsx b/apps/web/tests/needs-you.test.tsx index 0ef98527..1a6b57f6 100644 --- a/apps/web/tests/needs-you.test.tsx +++ b/apps/web/tests/needs-you.test.tsx @@ -1,11 +1,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; import { App } from "../src/App"; import { preloadAll } from "../src/shell/lazyComponent"; import { AppProvider } from "../src/state"; -import { api, loadCommunes, loadSnapshot } from "../src/live/api"; +import { api, loadCommunes, loadSnapshot, post } from "../src/live/api"; import { needsYou, type Commune, type PaymentReport, type Snapshot } from "../src/live/model"; vi.mock("../src/live/api", async (importOriginal) => ({ @@ -112,12 +112,16 @@ function mount(path: string) { return location; } const modalDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "showModal"); +const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close"); beforeAll(() => { Object.defineProperty(HTMLDialogElement.prototype, "showModal", { configurable: true, value: function (this: HTMLDialogElement) { this.setAttribute("open", ""); } }); + Object.defineProperty(HTMLDialogElement.prototype, "close", { configurable: true, value: function (this: HTMLDialogElement) { this.removeAttribute("open"); } }); }); afterAll(() => { if (modalDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "showModal", modalDescriptor); else delete (HTMLDialogElement.prototype as unknown as Record).showModal; + if (closeDescriptor) Object.defineProperty(HTMLDialogElement.prototype, "close", closeDescriptor); + else delete (HTMLDialogElement.prototype as unknown as Record).close; }); beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }); @@ -139,31 +143,90 @@ afterEach(() => { vi.useRealTimers(); }); -const feed = (row: HTMLElement) => [row.querySelector(".pill")?.className, row.querySelector(":scope > div > strong")?.textContent, - row.querySelector(".when")?.textContent]; +// A Home row: its title, commune, who and what, amount and when. +const row = (item: HTMLElement) => [item.querySelector(".c-home-row-title")?.textContent, item.querySelector(".c-home-place")?.textContent, + item.querySelector(".c-home-row-meta > span:last-child")?.textContent, item.querySelector(".c-home-row-fig strong")?.textContent ?? null, + item.querySelector(".c-home-state")?.textContent ?? null]; describe("Needs you on Home", () => { - it("lists every commune's actions in the same order, then what others owe, more quietly", async () => { + it("puts the first thing first, then every commune's actions by when, then what others owe, more quietly", async () => { mount("/"); - const card = (await screen.findByRole("heading", { name: "Needs you" })).closest(".a-card") as HTMLElement; - await within(card).findByText("Ferry"); - const waiting = within(card).getByRole("region", { name: "Waiting on others" }); - const actions = [...card.querySelectorAll(".hub-feed-row")].filter((row) => !waiting.contains(row)); - expect(actions.map(feed)).toEqual([ - // Both reports, the trip's too, come before a share that was due earlier. - ["pill open", "Electricity", "Ben Baker says they sent it · £20.00"], - ["pill open", "Ferry", "Ben Baker says they sent it · €25.00"], - ["pill warn", "Internet", "You owe Ben Baker · overdue · £15.00"], - ["pill open", "Water", "You owe Cat Cole · £8.00"], - ["pill warn", "Bins", "Overdue · your turn"], - ["pill open", "Hoover", "Your turn today"], + const next = await screen.findByRole("article", { name: "Electricity" }); + expect(within(next).getByText("1 of 7")).toBeTruthy(); + expect(within(next).getByText("Our home · Ben Baker says they sent it")).toBeTruthy(); + 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"); + 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. + 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", ]); - expect(within(card).getByText("And 1 more.")).toBeTruthy(); - expect([...waiting.querySelectorAll(".hub-feed-row")].map(feed)).toEqual([ - ["pill", "Electricity", "Cat Cole owes you · £20.00"], - ["pill", "Takeaway", "3 people owe you · £30.00"], - ["pill", "Rent", "You said you sent it · waiting for Cat Cole · £500.00"], + + const waiting = screen.getByRole("region", { name: "Waiting on others" }); + expect(within(waiting).getByText("£50.00")).toBeTruthy(); + expect([...waiting.querySelectorAll(".c-home-row")].map(row)).toEqual([ + ["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(); + }); + + it("says where Maya stands per currency, never adding pounds to euros", async () => { + // Maya also owes Ben for the trip's tram passes. + vi.mocked(loadSnapshot).mockImplementation(async (commune) => commune.id === home.id ? homeSnapshot() : { ...tripSummary(), + expenses: [...tripSummary().expenses, expense("tram", "Tram passes", "ben", "2026-10-12", [share("ben", 1200, 1200), share("me", 1200)])] }); + 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."); + // The bar names the page; the sentence is Home's own heading. + expect(screen.getByRole("heading", { level: 1, name: "Home" })).toBeTruthy(); + }); + + const readAgain = () => vi.mocked(loadSnapshot).mock.calls.map(([commune]) => commune.id); + + 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"); + 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" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Tell Ben Baker" })); + expect(await screen.findByText("Ben Baker has been told you sent £15.00. Your balance changes when they confirm it.")).toBeTruthy(); + expect(post).toHaveBeenCalledWith("/shares/share-me-1500/payment-reports", expect.objectContaining({ amountMinor: 1500, sentOn: today })); + expect(readAgain()).toEqual(["home-1"]); + }); + + 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"); + vi.mocked(loadSnapshot).mockClear(); + fireEvent.click(within(needs).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 €25.00 received" })); + expect(await screen.findByText("€25.00 recorded as received from Ben Baker. Their share is settled.")).toBeTruthy(); + expect(post).toHaveBeenCalledWith("/payment-reports/report-ben-2500/resolve", expect.objectContaining({ decision: "confirm" })); + expect(readAgain()).toEqual(["trip-1"]); }); }); diff --git a/apps/web/tests/placeholder-screens.test.tsx b/apps/web/tests/placeholder-screens.test.tsx index a55bdaaa..971ab2aa 100644 --- a/apps/web/tests/placeholder-screens.test.tsx +++ b/apps/web/tests/placeholder-screens.test.tsx @@ -69,7 +69,7 @@ describe("Billing and Analytics", () => { it.each(["/billing", "/analytics"])("%s goes to Home", async (path) => { const location = mount(path); - await screen.findByRole("heading", { name: "Your communes, Test." }); + await screen.findByRole("heading", { name: "Home", level: 1 }); await waitFor(() => expect(location.history?.at(-1)).toBe("/")); }); }); diff --git a/apps/web/tests/workflow-screens.test.tsx b/apps/web/tests/workflow-screens.test.tsx index 994ed246..63d2c791 100644 --- a/apps/web/tests/workflow-screens.test.tsx +++ b/apps/web/tests/workflow-screens.test.tsx @@ -168,21 +168,34 @@ beforeEach(() => { afterEach(cleanup); describe("complete shared workflows", () => { - it.each([0, 1, 2])("puts the personal Home greeting and %i-space count in the top bar only", async count => { - const communes = Array.from({ length: count }, (_, index) => ({ ...commune, id: `home-${index + 1}`, name: `Home ${index + 1}` })); - vi.mocked(loadCommunes).mockResolvedValue({ communes }); - vi.mocked(loadSnapshot).mockImplementation(async id => ({ ...current, commune: communes.find(item => item.id === id)! })); + it("greets a member with no communes yet in the top bar only", async () => { + vi.mocked(loadCommunes).mockResolvedValue({ communes: [] }); mount("/"); const greeting = await screen.findByRole("heading", { name: "Your communes, Test.", level: 1 }); const banner = screen.getByRole("banner"); - const summary = `${count} ${count === 1 ? "space" : "spaces"} · shared details that stay in sync for everyone.`; + const summary = "0 spaces · shared details that stay in sync for everyone."; expect(banner.contains(greeting)).toBe(true); await within(banner).findByText(summary); expect(screen.getAllByText(summary)).toHaveLength(1); expect(screen.getAllByText("Your communes, Test.")).toHaveLength(1); - expect(document.querySelector("#home .a-home-greet")).toBeNull(); - expect(document.querySelectorAll("#home .a-place")).toHaveLength(count); - expect(document.querySelectorAll("#home .a-focus-lead")).toHaveLength(count ? 1 : 0); + expect(within(banner).getByRole("button", { name: "Search Commune" })).toBeTruthy(); + expect(within(banner).getByRole("button", { name: "Notifications" })).toBeTruthy(); + }); + + it.each([1, 2])("names the page Home in the top bar and lists %i communes on it", async count => { + const communes = Array.from({ length: count }, (_, index) => ({ ...commune, id: `home-${index + 1}`, name: `Home ${index + 1}` })); + vi.mocked(loadCommunes).mockResolvedValue({ communes }); + vi.mocked(loadSnapshot).mockImplementation(async id => ({ ...current, commune: communes.find(item => item.id === id)! })); + mount("/"); + const title = await screen.findByRole("heading", { name: "Home", level: 1 }); + const banner = screen.getByRole("banner"); + // Home opens on its own sentence, so the bar carries no greeting or count. + expect(banner.contains(title)).toBe(true); + expect(screen.queryByText("Your communes, Test.")).toBeNull(); + expect(screen.queryByText(/shared details that stay in sync/)).toBeNull(); + const list = await screen.findByRole("region", { name: "Your communes" }); + expect(within(list).getAllByRole("heading", { level: 3 }).map(heading => heading.textContent)).toEqual(communes.map(item => item.name)); + expect(list.querySelectorAll(".c-home-tag")).toHaveLength(count > 1 ? 1 : 0); expect(within(banner).getByRole("button", { name: "Search Commune" })).toBeTruthy(); expect(within(banner).getByRole("button", { name: "Notifications" })).toBeTruthy(); }); @@ -190,7 +203,9 @@ describe("complete shared workflows", () => { it.each(["/hub", "/operations", "/", "/expenses", "/recurring"])("uses the shared calendar for real dated records on %s", async path => { const date = todayIso(); current.operations = [{ id: "dated-task", hubId: commune.id, kind: "task", category: "chore", title: "Kitchen reset", date, allDay: true, assignee: user.name, repeat: "none", status: "open", notes: "" }]; - current.expenses = [{ id: "dated-expense", title: "House supplies", category: "Other", amountMinor: 1000, paidByUserId: "user-2", dueOn: date, shares: [{ id: "dated-share", userId: user.id, amountMinor: 1000, settledMinor: 0 }] }]; + // Two, so Home has a dated row as well as its next step. + current.expenses = [{ id: "dated-expense", title: "House supplies", category: "Other", amountMinor: 1000, paidByUserId: "user-2", dueOn: date, shares: [{ id: "dated-share", userId: user.id, amountMinor: 1000, settledMinor: 0 }] }, + { id: "dated-expense-2", title: "Bin bags", category: "Other", amountMinor: 600, paidByUserId: "user-2", dueOn: date, shares: [{ id: "dated-share-2", userId: user.id, amountMinor: 600, settledMinor: 0 }] }]; current.recurringBills = [{ id: "dated-bill", title: "Internet", amountMinor: 4800, paidByUserId: user.id, allocations: [{ userId: user.id, amountMinor: 4800 }], frequency: "monthly", nextDueOn: date, paused: false, createdBy: user.id }]; mount(path); await waitFor(() => expect(document.querySelector(".c-calendar-date.is-today")).not.toBeNull()); diff --git a/docs/DESIGN-LANGUAGE.md b/docs/DESIGN-LANGUAGE.md index e2718938..640f3376 100644 --- a/docs/DESIGN-LANGUAGE.md +++ b/docs/DESIGN-LANGUAGE.md @@ -83,7 +83,7 @@ These are system responsibilities, not a demand to rename every existing compone ## Screen roles -- Home: one large **selected/current commune**, others smaller below. Personal next actions, chronologically sorted activity and search span all communes; switching the feature also switches the active commune. Not a duplicate Hub. +- Home (Direction B, chosen 11 October 2026): **where you stand first**, one sentence with an amount per currency, never added across currencies; then the **one next step**, which holds the page's only filled button; then your communes in order of need, the current one tagged; Needs you grouped by when (payments to confirm, overdue, today, this week, later); Waiting on others kept calm (past due in amber, never red); and recent activity. When nothing is owed or waiting anywhere, the current commune's photo fills the top instead. On a phone the sentence sits on a navy band with the next step lifted over it, and several communes show as tiles. Paying and confirming act in place through the existing report and confirm dialogs; tasks and pledges open their record. Next actions, chronologically sorted activity and search span all communes; opening a commune also makes it the active one. Not a duplicate Hub. Its styles are `apps/web/src/screens/Home.css`. - Hub: place identity → your balance and immediate needs → this week and shared essentials → recent activity. Keep the new layout. Recover missing useful information selectively; do not automatically put the four old KPI tiles back. - People: keep hero access obvious; concise member context can be restored without reinstating a large repeated list. Roles, invites and member balances must remain reachable. - Expenses: individual **two-column boxes**, one saved expense per box; one column below 760px. Preserve payer, category, due date, status and filters. Label **Total paid** separately from **Your share**; explicitly say when the viewer is not included. Do not replace the boxes with full-width ledger rows. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 991cc128..3658e2ad 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -263,10 +263,11 @@ Use [the design brief](DESIGN-LANGUAGE.md) and the illustrative `/reference/design-language/index.html` (dev server only) with In context, Components and States. Keep the approved typography, wordmark, original switcher/avatar, compact rail, -large selected Home card, Hub photo and hierarchy, expense boxes, adjacent +Home's opening sentence and next step (Direction B), Hub photo and hierarchy, expense boxes, adjacent settlement panels, Notes board and shared Operations calendar-date badges. Do not change the design direction or remove functioning features for a mockup. -Latest refinements include the Home greeting in the top bar, right-aligned +Latest refinements include Home's Direction B layout (the greeting stays in +the top bar only until there is a commune), right-aligned Settle actions and stable split-editor layout across all four split methods. Em-dash cleanup already made before the pause is included in the checkpoint; no further copy-cleanup pass is needed for the handoff. From 9324031571b2c6aa8b0e1c76d9f904412753b66f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:34:46 +0000 Subject: [PATCH 2/4] Give the phone the B mock's top bar, side menu and layout The phone top bar is now one slim navy row: the menu, the page's name, then search, notifications and you as plain icons. On Home it carries no name and runs into the navy band under the sentence; when all square it sits over the photo until the page scrolls. The menu button opens the sidebar as a drawer from the left (from PR #22), with Members and Templates added in basic mode. It replaces the bottom More sheet; the page behind is inert while it is open and focus returns to the menu button when it closes. The four tabs use the sidebar's icons with the current one in a pill. Needs you shows its first two on a phone with Show more, and when all square the communes come before recent activity, as drawn. Also from PR #22: the full-width search row only at 701 to 850px, list toolbars wrap their chips onto their own row, phone text fields are 16px, and the Hub's avatars and View links no longer overlap. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01PxTvnaAvCxprncxCVPRh7u --- apps/web/src/screens/Home.css | 16 ++ apps/web/src/screens/Home.tsx | 47 ++-- apps/web/src/screens/Hub.css | 6 +- apps/web/src/shell/ProductShell.tsx | 2 +- apps/web/src/shell/Shell.tsx | 131 ++++++----- apps/web/src/styles/app.css | 326 ++------------------------ apps/web/src/styles/design-system.css | 77 +++++- apps/web/src/styles/overrides.css | 7 +- apps/web/tests/accessibility.test.tsx | 31 +++ apps/web/tests/design-tokens.test.ts | 5 +- apps/web/tests/needs-you.test.tsx | 11 + docs/DESIGN-LANGUAGE.md | 4 +- 12 files changed, 274 insertions(+), 389 deletions(-) diff --git a/apps/web/src/screens/Home.css b/apps/web/src/screens/Home.css index f85bfc98..cae96d7e 100644 --- a/apps/web/src/screens/Home.css +++ b/apps/web/src/screens/Home.css @@ -358,6 +358,9 @@ cursor: pointer; } .c-home-more svg.is-open { transform: rotate(180deg); } +.c-home-needs:not(.is-all) .is-extra, +.c-home-more.is-phone, +.c-home-more-phone { display: none; } /* Figures under the title: waiting rows in the narrow column, and every row on a phone. */ /* ---------- Recent activity ---------- */ @@ -510,7 +513,20 @@ .is-tiles :is(.c-home-commune-extra, .c-home-faces, .c-home-commune-go) { display: none; } .is-tiles .c-home-badge { height: 20px; padding: 0 7px; font-size: 11px; } + /* The phone shows two of what needs you, as drawn. */ + .c-home-needs:not(.is-all) .is-extra-phone { display: none; } + .c-home-more.is-phone { display: flex; } + .c-home-more-phone { display: inline; } + .c-home-more-wide { display: none; } + + .c-home.is-square .c-home-col:has(> .c-home-communes) { order: -1; } .c-home-square { min-height: 470px; margin: -16px -16px 0; border-radius: 0; } + /* The top bar sits over the photo here; a shade at the top keeps its icons clear. */ + .c-home-square::after { + background: + linear-gradient(180deg, rgba(14, 16, 24, 0.5) 0, rgba(14, 16, 24, 0) 140px), + linear-gradient(90deg, rgba(14, 16, 24, 0.4) 0%, rgba(14, 16, 24, 0) 60%); + } .c-home-square-copy { gap: 10px; padding: 0 20px 26px; } .c-home-square .c-home-display { font-size: 36px; line-height: 39px; } .c-home-square .c-home-sub { font-size: 14.5px; line-height: 21px; } diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 46e1a2f6..5cdb3826 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -291,8 +291,8 @@ 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 }: { - entry: Entry; today: string; waiting?: boolean; onAct?: (entry: Entry) => void; busy?: boolean; +function Row({ entry, today, waiting = false, onAct, busy, className }: { + entry: Entry; today: string; waiting?: boolean; onAct?: (entry: Entry) => void; busy?: boolean; className?: string; }) { const open = useOpen(); const { hub, item } = entry; @@ -301,7 +301,7 @@ function Row({ entry, today, waiting = false, onAct, busy }: { const verb = item.kind === "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 ( -

  • +
  • {(waiting || item.kind === "confirm") && who.length ? @@ -327,28 +327,47 @@ function Row({ entry, today, waiting = false, onAct, busy }: { ); } -// A long list shows its first five; a short one shows everything. +// A long list shows its first five, and a phone its first two; a short one +// shows everything. Every row is drawn, so the limit is the screen's. const SHOWN = 5; 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 }) { const [all, setAll] = useState(false); const collapsible = needs.length > COLLAPSE_OVER; - const shown = all || !collapsible ? needs : needs.slice(0, SHOWN); - const groups = GROUPS.map((name) => ({ name, rows: shown.filter(({ item }) => groupOf(item, today) === name) })).filter((group) => group.rows.length); - const totals = new Map(GROUPS.map((name) => [name, needs.filter(({ item }) => groupOf(item, today) === name).length])); + const phoneCollapsible = needs.length > PHONE_COLLAPSE_OVER; + const limit = collapsible ? SHOWN : Infinity; + const phoneLimit = phoneCollapsible ? PHONE_SHOWN : Infinity; + let shown = 0; + const groups = GROUPS.map((name) => { + const first = shown; + const rows = needs.filter(({ item }) => groupOf(item, today) === name); + shown += rows.length; + return { name, first, rows }; + }).filter((group) => group.rows.length); + // 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}
    {groups.map((group) => ( -
    -

    {group.name} {totals.get(group.name)}

    -
      {group.rows.map((entry, index) => )}
    +
    +

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

    +
      {group.rows.map((entry, index) => ( + + ))}
    ))} - {collapsible ? ( - ) : null}
    diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css index 4db83c3b..ed951c64 100644 --- a/apps/web/src/screens/Hub.css +++ b/apps/web/src/screens/Hub.css @@ -63,8 +63,7 @@ .c-hub-location span { margin-right: 8px; opacity: .85; } .c-hub-people-link { display: flex; align-items: center; gap: 12px; width: fit-content; margin-top: 17px; padding: 0; border: 0; background: none; color: inherit; text-shadow: none; font-size: 12px !important; } .c-hub-people-link .avatar-stack { display: flex; align-items: center; } -.c-hub-people-link .avatar { width: 28px; height: 28px; margin-right: -5px; border: 2px solid #fff; font-size: 9px; } -.c-hub-people-link .avatar:last-child { margin-right: 0; } +.c-hub-people-link .avatar { width: 28px; height: 28px; border: 2px solid #fff; font-size: 9px; } .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; } @@ -87,7 +86,8 @@ .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 { margin-top: 14px; } +.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; } diff --git a/apps/web/src/shell/ProductShell.tsx b/apps/web/src/shell/ProductShell.tsx index 7c2942c0..c32bbf72 100644 --- a/apps/web/src/shell/ProductShell.tsx +++ b/apps/web/src/shell/ProductShell.tsx @@ -54,7 +54,7 @@ export function ProductShell() { noteReader && "has-note-reader", )} > -
    +
    Skip to content
    diff --git a/apps/web/src/shell/Shell.tsx b/apps/web/src/shell/Shell.tsx index 8a2529a3..8dc3e4ff 100644 --- a/apps/web/src/shell/Shell.tsx +++ b/apps/web/src/shell/Shell.tsx @@ -4,7 +4,7 @@ import type { IconName } from "../ui/Icon"; import { Icon } from "../ui/Icon"; import { Avatar } from "../ui/widgets"; import { useApp } from "../state"; -import type { ScreenId, WindowId } from "../data/household"; +import type { ScreenId } from "../data/household"; import { QuickSearch } from "../ui/QuickSearch"; import { cx } from "../ui/cx"; @@ -26,6 +26,12 @@ const BASIC: NavItem[] = [ { id: "operations", label: "Operations", icon: "operations" }, ]; +// The phone menu has no other route to these in basic mode. +const DRAWER_BASIC: NavItem[] = [ + { id: "members", label: "Members", icon: "members" }, + { id: "templates", label: "Templates", icon: "templates" }, +]; + const MONEY: NavItem[] = [ { id: "expenses", label: "Expenses", icon: "expenses" }, { id: "settle", label: "Settle", icon: "settle" }, @@ -168,8 +174,12 @@ function NavButton({ ); } -export function Sidebar({ screen }: { screen: ScreenId }) { - const { go, hub, you } = useApp(); +export function Sidebar({ screen, drawer }: { screen: ScreenId; drawer?: boolean }) { + const { go, hub, you, setMoreOpen } = useApp(); + const closeRef = useRef(null); + useEffect(() => { + if (drawer) closeRef.current?.focus(); + }, [drawer]); const accountRole = hub.youMeta?.split(" · ")[0]; const active = (id: ScreenId) => screen === id || @@ -179,17 +189,29 @@ export function Sidebar({ screen }: { screen: ScreenId }) { (id === "expenses" && screen === "expenses-empty"); return ( -