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..e59f74d1 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -9,7 +9,7 @@ import { } from "../data/household"; import type { OperationItem } from "../data/operations"; import { notePreview } from "../data/notePresentation"; -import { timestampDay, todayIso } from "../data/operations"; +import { addDays, timestampDay, todayIso } from "../data/operations"; import { formatDay, formatMoney, netBalances, overallNet } from "@commune/core"; export type CurrencyCode = "GBP" | "USD" | "EUR"; @@ -330,6 +330,29 @@ 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[]; + /** Coming up: turns and regular bills in the next fortnight (see comingUp). */ + coming: Upcoming[]; +}; + +/** + * One row of "Coming up": a task's next turn or a regular bill's next date in + * the next fortnight, with nothing to do yet. A task nobody holds can be taken. + */ +export type Upcoming = { + kind: "task" | "bill"; + title: string; + dueOn: string; + /** Whose turn, or what the bill comes to and who pays it. */ + when: string; + operationId?: string; + billId?: string; + /** A task nobody holds yet, which any member can take. */ + open?: boolean; }; /** @@ -346,12 +369,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 @@ -366,6 +399,30 @@ export function personName(members: Member[] | undefined, userId: string | null return options.left && member.active === false ? `${member.name} (left)` : member.name; } +/** Turns and regular bills from today to 13 days on, soonest first. The viewer's own task due today is in Needs you. */ +export function comingUp(snapshot: Snapshot, viewerId: string, today = todayIso()): Upcoming[] { + const currency = snapshot.commune.currency; + const last = addDays(today, 13); + const nameOf = (userId: string) => personName(snapshot.members, userId, "a former member", { left: true }); + const rows: Upcoming[] = []; + for (const item of snapshot.operations ?? []) { + if (item.kind !== "task" || item.status !== "open" || item.date < today || item.date > last) continue; + if (item.assigneeId === viewerId && item.date === today) continue; + rows.push({ kind: "task", title: item.title, dueOn: item.date, operationId: item.id, open: !item.assigneeId, + when: !item.assigneeId ? "nobody yet" : item.assigneeId === viewerId ? "your turn" : `${nameOf(item.assigneeId)}’s turn` }); + } + for (const bill of snapshot.recurringBills ?? []) { + if (bill.paused || bill.archivedAt || bill.nextDueOn < today || bill.nextDueOn > last) continue; + const shares = bill.allocations.filter((share) => share.amountMinor > 0); + const even = shares.length > 1 && shares.every((share) => share.amountMinor === shares[0].amountMinor); + const yours = shares.find((share) => share.userId === viewerId)?.amountMinor; + const split = even ? ` · ${money(shares[0].amountMinor, currency)} each` : yours ? ` · your share ${money(yours, currency)}` : ""; + rows.push({ kind: "bill", title: bill.title, dueOn: bill.nextDueOn, billId: bill.id, + when: `${money(bill.amountMinor, currency)}, ${bill.paidByUserId === viewerId ? "you pay" : `${nameOf(bill.paidByUserId)} pays`}${split}` }); + } + return rows.sort((a, b) => a.dueOn.localeCompare(b.dueOn)); +} + const NEED_ORDER: Need["kind"][] = ["confirm", "pay", "task", "pledge", "owed", "reported"]; /** Actions in their order, then the earliest date first; anything undated after the dated ones. */ export function compareNeeds(a: Need, b: Need): number { @@ -401,23 +458,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 +493,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 +580,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 +598,7 @@ export function hubFromSnapshot( }); } else if (expense.paidByUserId === viewer.id) { youAreOwed += due; + owingYou.add(share.userId); moves.push({ go: "settle", arrow: "→", @@ -631,6 +696,10 @@ export function hubFromSnapshot( ], now: needs.actions, waiting: needs.waiting, + youOweMinor: youOwe, + owedToYouMinor: youAreOwed, + owingYou: [...owingYou], + coming: comingUp(source, viewer.id, today), 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..42f28bd4 --- /dev/null +++ b/apps/web/src/screens/Home.css @@ -0,0 +1,548 @@ +/* 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; } +/* Coming up has no amount column: the date, the turn, and a button when nobody holds it. */ +.c-home-row.is-coming { 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); } +.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 ---------- */ +.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; } + /* Whose turn it is, or who pays, is the point of a Coming up row. */ + .c-home-row.is-coming { grid-template-areas: "lead main act"; } + .c-home-row.is-coming .c-home-row-meta > :not(.c-home-place) { display: inline; } +} +/* 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; } + + /* 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; } + + /* As drawn: section heads keep their counts but not their links, and the communes no count. */ + .c-home-link.is-wide, + .c-home-communes > .c-home-head .c-home-count, + .c-home-over-place { 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; } + .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..bc600fb0 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -1,120 +1,627 @@ -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, type Upcoming } 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 Coming = { hub: LiveHub; item: Upcoming }; +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, className }: { + entry: Entry; today: string; waiting?: boolean; onAct?: (entry: Entry) => void; busy?: boolean; className?: string; +}) { + const open = useOpen(); + const { hub, item } = entry; + const state = item.kind === "confirm" ? null : dueState(item.dueOn, today, waiting); + const who = item.kind === "owed" ? item.people ?? [] : item.person ? [item.person] : []; + const verb = item.kind === "pay" ? "I’ve paid" : item.kind === "confirm" ? "Confirm" : "Open"; + const 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, 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 { go } = useApp(); + const [all, setAll] = useState(false); + const collapsible = needs.length > COLLAPSE_OVER; + 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} {group.rows.length}

    +
      {group.rows.map((entry, index) => ( + + ))}
    +
    + ))} + {phoneCollapsible ? ( + + ) : 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."}

    } +
    + ); +} + +// Five is enough to see the fortnight; Operations has the rest. +const COMING_SHOWN = 5; + +/** Turns and regular bills in the next fortnight, across communes. A task nobody holds can be taken here. */ +function ComingUp({ coming, today, onTake, busy }: { coming: Coming[]; today: string; onTake: (entry: Coming) => void; busy: boolean }) { + const { go } = useApp(); + return ( +
    +
    +

    Coming up

    {coming.length} + +
    +
      + {coming.slice(0, COMING_SHOWN).map((entry) => { + const { hub, item } = entry; + return ( +
    • + + + + {item.when} + + {item.open ? ( + + + + ) : null} +
    • + ); + })} +
    +
    + ); +} + +/** 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))} · {hub.name}

    +

    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, go, handOverOperation } = useApp(); + const [message, setMessage] = useState(""); + const [taking, setTaking] = useState(false); + 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…"; - 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} + const coming = places.flatMap((hub) => hub.coming.map((item) => ({ hub, item }))).sort((a, b) => a.item.dueOn.localeCompare(b.item.dueOn)); + const [next, ...rest] = needs; + 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); + + // Taking a task nobody holds is a handover to yourself, checked against the task as Home saw it. + async function take({ hub, item }: Coming) { + if (!item.operationId) return; + setMessage(""); + setTaking(true); + try { + await handOverOperation(item.operationId, you.id, undefined, { communeId: hub.id, date: item.dueOn, assigneeId: null }); + setMessage(`${item.title} is yours now. Open the task to undo this.`); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Could not take this task. Try again."); + } finally { + setTaking(false); + } + } + + 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} + {square ? null : } +
    +
      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; + // Coming up fills the quiet states, where nothing needs you beyond the next step. + const comingUp = coming.length ? void take(entry)} busy={taking} /> : null; + + if (square) { + return ( +
    + + {status} +
    +
    {comingUp}
    +
    {communeList}
    -
    -
    -

    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} +
    + ); + } + return ( +
    +
    +
    + + {next ? : ready ? : null} + {status}
    +
    {communeList}
    - +
    + {rest.length ? ( + <> +
    +
    + {waiting.length ? : null} + +
    + + ) : waiting.length || comingUp ? ( + <> +
    {waiting.length ? : null}{comingUp}
    +
    + + ) :
    } +
    + {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 +631,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/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 8bb7aecc..ca0c1b83 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 ( -