From d2f31d6cca13993ccc6795b76fafc2f1dc8a5cf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:23:11 +0000 Subject: [PATCH 1/7] Rebuild the Hub as "the house at a glance" (direction B) The Hub now leads with a one-line pulse on the photo, your part in the commune with one next step (I've paid, or confirm received), a seven-day week strip with tasks, events and bills you can tick, people with their balance and next job, and shared essentials with facts in place and secrets behind Show and Copy. The owe line now ends with the overall position so Home and Hub tell one money story. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WR3jnRWPbGzGV9Uyrqq8Wg --- apps/web/src/live/model.ts | 9 +- apps/web/src/screens/Hub.css | 165 ++++++---- apps/web/src/screens/Hub.tsx | 370 ++++++++++++++--------- apps/web/tests/announcements.test.tsx | 4 - apps/web/tests/loading.test.tsx | 18 +- apps/web/tests/needs-you.test.tsx | 40 ++- apps/web/tests/workflow-screens.test.tsx | 3 +- 7 files changed, 378 insertions(+), 231 deletions(-) diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 49a70b9a..77748ee5 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -443,6 +443,13 @@ function namesList(names: string[]): string { * there is nothing for the viewer to do. A summary snapshot keeps pending * payment reports (0039), so Home sees them for every commune. */ +/** Owed both ways: say where that leaves the viewer, so the two figures never read as opposite answers. */ +function overallLine(netMinor: number, currency: CurrencyCode): string { + if (netMinor > 0) return `, so you’re ${money(netMinor, currency)} ahead overall`; + if (netMinor < 0) return `, so you owe ${money(-netMinor, currency)} overall`; + return ", so you’re even overall"; +} + export function needsYou(snapshot: Snapshot, viewerId: string, today = todayIso()): { actions: Need[]; waiting: Need[] } { const currency = snapshot.commune.currency; const nameOf = (userId: string) => personName(snapshot.members, userId, "a former member", { left: true }); @@ -683,7 +690,7 @@ export function hubFromSnapshot( oweLabel, oweAmount: money(moneyFocus, currency), oweCopy: youOwe > 0 - ? `You owe ${money(youOwe, currency)} in outstanding shares.${youAreOwed > 0 ? ` You’re also owed ${money(youAreOwed, currency)}.` : ""}` + ? `You owe ${money(youOwe, currency)} in outstanding shares.${youAreOwed > 0 ? ` You’re also owed ${money(youAreOwed, currency)}${overallLine(youAreOwed - youOwe, currency)}.` : ""}` : youAreOwed > 0 ? `You’re owed ${money(youAreOwed, currency)} in outstanding shares.` : "No outstanding shares involving you.", diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css index d3d2c899..624a84fa 100644 --- a/apps/web/src/screens/Hub.css +++ b/apps/web/src/screens/Hub.css @@ -14,8 +14,11 @@ --hub-action: #2d6a4f; --hub-action-text: #fff; --hub-action-hover: #245a42; + --hub-in: var(--sage); --hub-warning: #964417; --hub-heading-icon: #60706b; + --hub-late-soft: #fbeee6; + --hub-event: var(--lilac); color: var(--hub-ink); background: var(--hub-paper); } @@ -33,6 +36,9 @@ --hub-action-hover: #cfd8e1; --hub-warning: #f4ba94; --hub-heading-icon: #b5c6bb; + --hub-late-soft: #3a2a22; + --hub-event: #3a3448; + --hub-in: #7cc39f; } /* The phone app's layout (app.css) keeps its own frame background and page padding. */ .a-body.hub-editorial .a-app:where(:not(.is-native *)), @@ -40,7 +46,7 @@ /* Header geometry, typography and responsive behaviour belong to the shared shell. */ .hub-editorial .a-content:where(:not(.is-native *)) { padding: 22px 22px 32px; } -.c-hub { max-width: 1208px; margin-inline: auto; display: grid; gap: 22px; color: var(--hub-ink); font-family: var(--font); font-size: 14px; line-height: 1.5; } +.c-hub { max-width: 1208px; margin-inline: auto; display: grid; grid-template-columns: minmax(0, 1fr); gap: 22px; color: var(--hub-ink); font-family: var(--font); font-size: 14px; line-height: 1.5; } .c-hub :is(h1,h2,h3,p) { margin: 0; } .c-hub :is(button,summary,input):focus-visible { outline: 3px solid #649578; outline-offset: 4px; } .c-hub button { font: inherit; cursor: pointer; } @@ -67,67 +73,31 @@ .c-hub-hero-actions { position: absolute; right: 22px; top: 20px; display: flex; gap: 7px; } .c-hub-settings { display: flex; align-items: center; gap: 6px; padding: 9px 12px; min-height: 38px; border: 1px solid #ffffff55; border-radius: var(--radius-control); background: rgba(21, 34, 28, 0.65); color: #fff; font-size: 12px !important; } -.c-hub-overview { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 20px; } -/* The balance card keeps the prototype's pale sage so it reads first. */ -.c-hub-balance { padding: 23px; border: 1px solid var(--hub-sage-line); border-radius: var(--radius-surface); background: var(--hub-sage); } -.c-hub-amount { display: block; margin: 9px 0; font-size: clamp(32px,4vw,44px); font-weight: 500; letter-spacing: -1.8px; line-height: 1.2; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } -.c-hub-balance p { max-width: 42ch; margin-bottom: 22px; color: var(--hub-muted); } -.c-hub-balance-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 14px 24px; } .c-hub-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 40px; padding: 10px 17px; border: 0; border-radius: var(--radius-control); background: var(--hub-action); color: var(--hub-action-text); font-size: 13px !important; font-weight: 500 !important; } .c-hub-button:hover { background: var(--hub-action-hover); } .c-hub-lower { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); align-items: stretch; gap: 22px; } .c-hub-panel { min-width: 0; padding: 23px; border: 1px solid var(--hub-line); border-radius: var(--radius-surface); background: var(--hub-panel); } .c-hub-lower .c-hub-panel { min-height: 0; } -.c-hub-section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.c-hub-section-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 16px; } .c-hub-section-title { display: flex; align-items: center; gap: 9px; font-size: 18px; font-weight: 550; letter-spacing: -.4px; } .c-hub-heading-icon { width: 17px; height: 17px; flex: none; color: var(--hub-heading-icon); } -.c-hub-context { margin: 7px 0 16px !important; color: var(--hub-muted); font-size: 12px; } -.c-hub-section-head:not(:has(+ .c-hub-context)) { margin-bottom: 16px; } -.c-hub-count { flex: none; color: var(--hub-muted); font-size: 11px; white-space: nowrap; } .c-hub-link { padding: 4px 0; border: 0; background: none; color: var(--hub-action); font-size: 12px !important; font-weight: 500 !important; text-decoration: none; } .c-hub-link:hover { text-decoration-color: currentColor; } -.c-hub-more { display: block; margin-top: 14px; } -.c-hub-more + .c-hub-more { margin-top: 10px; } -.c-hub-invite { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; border: 1px solid #6f9b87; border-radius: var(--radius-control); background: var(--hub-panel); color: var(--hub-ink); font-size: 12px !important; } -.c-hub-invite:hover { background: var(--hub-quiet); } .c-hub-empty { padding: 14px 0; border-top: 1px solid var(--hub-line); color: var(--hub-muted); font-size: 13px; } .c-hub-empty .c-hub-link { display: block; margin-top: 8px; } -.c-hub-attention-row, .c-hub-task, .c-hub-note-row, .c-hub-expense-row { border-top: 1px solid var(--hub-line); } -.c-hub-attention-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 0; } -.c-hub-attention-row > span:first-child { min-width: 0; } -.c-hub-attention-row :is(strong,small) { display: block; overflow-wrap: anywhere; } -.c-hub-attention-row strong { font-size: 13px; font-weight: 500; } -.c-hub-attention-row small { margin-top: 4px; color: var(--hub-muted); font-size: 12px; } -.c-hub-attention-end { flex: none; text-align: right; } -.c-hub-attention-end button { display: block; margin: 8px 0 0 auto; padding: 5px 16px; border: 1px solid #d7ddd9; border-radius: var(--radius-control); background: var(--hub-panel); color: var(--hub-ink); font-size: 12px !important; } -.c-hub-attention-end button:hover { background: var(--hub-quiet); } - -.c-hub-task { display: flex; align-items: center; gap: 12px; padding: 16px 0; } -.c-hub-task-copy { min-width: 0; flex: 1; padding: 0; border: 0; background: none; color: var(--hub-ink); text-align: left; } -.c-hub-task-copy strong { display: block; font-size: 13px; font-weight: 500; overflow-wrap: anywhere; } -.c-hub-task-copy > span { display: block; margin-top: 4px; color: var(--hub-muted); font-size: 12px; overflow-wrap: anywhere; } -.c-hub-task.is-done .c-hub-task-copy strong { color: var(--hub-muted); text-decoration: line-through; } -.c-hub-task input { width: 17px; height: 17px; flex: none; margin: 0; accent-color: #426d50; cursor: pointer; } + .c-hub-late { color: var(--hub-warning); } .c-hub-doneby { margin: 0 0 16px; } -.c-hub-pinned { margin-top: 18px; padding: 0 15px; border-radius: 9px; background: var(--hub-sage); } -.c-hub-pinned .c-hub-note-row { border: 0; } -.c-hub-recent { padding: 6px 2px; } +.c-hub-recent { padding-bottom: 8px; } .c-hub-recent .c-hub-expense-row > span:first-child { flex: 1; } -.c-hub-attention-copy { display: flex; align-items: center; gap: 12px; } -.c-hub-icon-chip { width: 37px; height: 37px; flex: none; border-radius: 10px; background: var(--hub-quiet); color: var(--hub-heading-icon); display: grid; place-items: center; } -.c-hub-empty .c-hub-button { margin-top: 18px; } -/* Needs you: what others owe, with nothing to do, comes after the actions and quieter. */ -.c-hub-waiting { margin-top: 18px; } -.c-hub .c-hub-waiting h3 { margin-bottom: 2px; color: var(--hub-heading-icon); font-size: 11px; font-weight: 550; letter-spacing: .6px; text-transform: uppercase; } +.c-hub-note-row, .c-hub-expense-row { border-top: 1px solid var(--hub-line); } .c-hub-note-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; padding: 16px 0; border-right: 0; border-bottom: 0; border-left: 0; background: none; color: var(--hub-ink); text-align: left; } .c-hub-note-row > span { min-width: 0; } .c-hub-note-row :is(strong,small) { display: block; overflow-wrap: anywhere; } .c-hub-note-row strong { font-size: 13px; font-weight: 500; } .c-hub-note-row small { margin-top: 4px; color: var(--hub-muted); font-size: 12px; } -.c-hub-lower .c-hub-note-row { padding-block: 10px; } .c-hub-note-row svg { flex: none; color: var(--hub-muted); } .c-hub-note-row:hover strong { color: var(--hub-action); } @@ -142,7 +112,7 @@ .c-hub-expense-status.is-settled { background: #e8efe8; color: var(--sage) !important; } .c-hub-expense-row:hover strong { color: var(--hub-action); } @media(max-width:800px) { - .c-hub-overview, .c-hub-lower { grid-template-columns: 1fr; } + .c-hub-lower, .c-hub-top { grid-template-columns: minmax(0, 1fr); } .c-hub-lower .c-hub-panel { min-height: 0; } } @media(max-width:560px) { @@ -152,17 +122,108 @@ .c-hub .c-hub-identity h2 { font-size: 29px; } .c-hub-settings { white-space: nowrap; } .c-hub-settings span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } - .c-hub-balance { padding: 24px; } .c-hub-panel { padding: 18px; } .c-hub-expense-row > time { display: none; } } -.c-hub-task-feedback { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0; padding: 12px 0 0; border-top: 1px solid var(--hub-line); color: var(--hub-ink); font-size: 13px; line-height: 1.6; } -.c-hub-task-feedback:focus { outline: none; } -.c-hub-task-feedback .c-hub-link { flex: none; display: inline-flex; align-items: center; min-height: 40px; padding: 0 4px; text-decoration: underline; } -@media (max-width: 560px) { .c-hub-task-feedback .c-hub-link { min-height: 44px; } } -/* Announcements: a quiet list above the balance, shown only when there are any. */ + +/* The week on the photo, in one sentence. */ +.c-hub-pulse { width: fit-content; max-width: 100%; margin: 16px 0 0 !important; padding: 7px 14px; border-radius: var(--radius-pill); background: var(--hub-panel); color: var(--hub-ink); text-shadow: none; font-size: 13px; font-weight: 500; } +.c-hub-over { margin: 0; color: var(--hub-muted); font-size: 12px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; } +.c-hub-pill { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-height: 40px; padding: 8px 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-pill); background: var(--hub-panel); color: var(--hub-ink); font-size: 13px !important; font-weight: 500; text-decoration: none; } +.c-hub-pill:hover { background: var(--hub-quiet); } +.c-hub-pill.is-small { min-height: 32px; padding: 5px 12px; font-size: 12px !important; } + +/* Your part sits beside the pinned notice; alone, it takes the row. */ +.c-hub-top { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 20px; align-items: start; } +.c-hub-top > :only-child { grid-column: 1 / -1; } +.c-hub-part { display: grid; gap: 6px; padding: 23px; border: 1px solid var(--hub-sage-line); border-radius: var(--radius-surface); background: var(--hub-sage); } +.c-hub .c-hub-sentence { margin-top: 4px; font-family: var(--font-display); font-size: clamp(26px, 3vw, 34px); font-weight: 400; letter-spacing: -0.04em; line-height: 1.15; } +.c-hub-sentence em { padding: 0 .16em .04em; border-radius: 10px; background: var(--hub-panel); font-style: normal; font-variant-numeric: tabular-nums; } +.c-hub-part-copy { color: var(--hub-muted); } +.c-hub-next { margin-top: 10px; border-radius: 16px; background: var(--hub-panel); } +.c-hub-next-body { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 16px 12px; } +.c-hub-next-body > span { display: grid; min-width: 0; } +.c-hub-next-body strong { font-size: 14px; font-weight: 500; overflow-wrap: anywhere; } +.c-hub-next-body small { color: var(--hub-muted); font-size: 12.5px; } +.c-hub .c-hub-next-amount { flex: none; font-size: 22px; font-variant-numeric: tabular-nums; } +.c-hub-next-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; padding: 0 16px 16px; } +.c-hub .c-hub-button.is-large { min-height: 46px; padding: 12px 22px; font-size: 15px !important; } +.c-hub-pledge { padding-inline: 16px !important; margin-top: 6px; border: 0 !important; border-radius: 14px; background: var(--hub-panel) !important; } +.c-hub-part-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin-top: 10px; } +.c-hub-part-waiting { color: var(--hub-muted); font-size: 12px; } +.c-hub-top .c-hub-announcements { align-self: stretch; } + +/* This week: seven days you can scan, late first. */ +.c-hub-days { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 8px; } +.c-hub-day { display: flex; flex-direction: column; gap: 6px; min-width: 0; min-height: 150px; padding: 10px; border-radius: 16px; background: var(--hub-quiet); } +.c-hub-day.is-today { background: var(--hub-sage); box-shadow: inset 0 0 0 1.5px var(--hub-in); } +.c-hub-day.is-late { background: var(--hub-late-soft); } +.c-hub .c-hub-day-head { display: flex; align-items: baseline; justify-content: space-between; margin: 0 0 4px; color: var(--hub-muted); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; } +.c-hub-day-date { align-self: flex-start; margin-bottom: 4px; } +.c-hub-day-head b { color: var(--hub-ink); font-family: var(--font-display); font-size: 22px; font-weight: 400; letter-spacing: -0.03em; text-transform: none; } +.c-hub-day.is-late .c-hub-day-head :is(span, b) { color: var(--hub-warning); } +.c-hub-chip { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 4px 6px; width: 100%; min-width: 0; padding: 6px 8px; border: 0; border-radius: 10px; background: var(--hub-panel); color: var(--hub-ink); text-align: left; box-shadow: 0 1px 2px rgba(23, 27, 36, .05); } +.c-hub-chip .avatar { width: 22px; height: 22px; flex: none; font-size: 8px; } +.c-hub-chip-icon { flex: none; color: var(--hub-heading-icon); } +.c-hub-chip > .c-hub-chip-copy { grid-column: 1 / -1; grid-row: 2; } +.c-hub-chip:not(:has(> .avatar, > svg)) > .c-hub-chip-copy, .c-hub-chip.is-bill > .c-hub-chip-copy, .c-hub-chip.is-event > .c-hub-chip-copy { grid-column: 2 / 3; grid-row: 1; } +.c-hub-chip > .c-hub-check { grid-column: 3; grid-row: 1; justify-self: end; } +.c-hub-chip-copy { display: grid; min-width: 0; padding: 0; border: 0; background: none; color: inherit; text-align: left; } +.c-hub-chip-copy strong { font-size: 12.5px; font-weight: 500; line-height: 1.3; overflow-wrap: anywhere; } +.c-hub-chip-copy small { color: var(--hub-muted); font-size: 11.5px; } +.c-hub-chip.is-event { background: var(--hub-event); } +.c-hub-chip.is-bill { border: 1px dashed var(--border-strong); background: transparent; box-shadow: none; } +.c-hub-chip.is-late { box-shadow: inset 3px 0 0 var(--hub-warning); } +.c-hub-chip.is-late small { color: var(--hub-warning); } +.c-hub-chip input { width: 17px; height: 17px; flex: none; margin: 0; accent-color: var(--sage); cursor: pointer; } +.c-hub-chip-copy:hover strong { color: var(--hub-action); } +.c-hub-free { color: var(--hub-muted); font-size: 12px; opacity: .75; } + +/* People and essentials. */ +.c-hub-person { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; width: 100%; padding: 11px 0; border: 0; border-top: 1px solid var(--hub-line); background: none; color: var(--hub-ink); text-align: left; } +.c-hub-person .avatar { width: 38px; height: 38px; } +.c-hub-person > span:nth-child(2) { display: grid; min-width: 0; } +.c-hub-person strong { font-size: 14px; font-weight: 500; } +.c-hub-person small { color: var(--hub-muted); font-size: 12.5px; overflow-wrap: anywhere; } +.c-hub-person-balance { font-size: 14px; font-weight: 500; font-variant-numeric: tabular-nums; text-align: right; } +.c-hub-person-balance.is-in { color: var(--hub-in); } +.c-hub-person:hover strong { color: var(--hub-action); } +.c-hub-essential-grid { position: relative; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.c-hub-essential { position: relative; display: grid; align-content: start; gap: 4px; min-width: 0; padding: 14px; border-radius: 16px; background: var(--hub-quiet); } +.c-hub-essential-title { padding: 0; border: 0; background: none; color: var(--hub-ink); font-size: 15px !important; font-weight: 500 !important; text-align: left; overflow-wrap: anywhere; } +.c-hub-essential-title:hover { color: var(--hub-action); } +.c-hub-essential dl { display: grid; gap: 2px; margin: 2px 0 0; } +.c-hub-essential dl div { display: grid; grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 8px; font-size: 13px; } +.c-hub-essential dt { color: var(--hub-muted); } +.c-hub-essential dd { margin: 0; font-weight: 500; overflow-wrap: anywhere; } +.c-hub-essential-copy { color: var(--hub-muted); font-size: 13px; } +.c-hub-essential-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; } +.c-hub-copied { color: var(--hub-muted); font-size: 12px; } + +@media (max-width: 800px) { + .c-hub-top { grid-template-columns: minmax(0, 1fr); } + .c-hub-days { grid-auto-flow: row; grid-auto-columns: auto; } + .c-hub-day { flex-direction: row; flex-wrap: wrap; align-items: center; min-height: 0; padding: 8px 10px; } + .c-hub .c-hub-day-head { flex-direction: column; width: 46px; margin: 0; gap: 0; } + .c-hub-day-date { align-self: flex-start; margin-bottom: 4px; } +.c-hub-day-head b { font-size: 18px; } + .c-hub-chip { width: auto; flex: 1 1 200px; } + .c-hub-chip > .c-hub-chip-copy { grid-column: 2 / 3; grid-row: 1; } + .c-hub-day.is-free:not(.is-today) { display: none; } + .c-hub-free { display: none; } +} +@media (max-width: 560px) { + .c-hub-part { padding: 18px; } + .c-hub-next-foot { flex-direction: column; align-items: stretch; } + .c-hub-next-foot .c-hub-link { align-self: center; } + .c-hub-essential-grid { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; margin-inline: -18px; padding-inline: 18px; } + .c-hub-essential { flex: 0 0 76%; scroll-snap-align: start; } + .c-hub-chip input { width: 22px; height: 22px; } +} + +/* Announcements: the pinned notice beside your part, shown only when there are any. */ .c-hub-announcement { display: grid; gap: 6px; padding: 16px 0; border-top: 1px solid var(--hub-line); } .c-hub-announcement strong { font-size: 14px; font-weight: 500; overflow-wrap: anywhere; } .c-hub-announcement p { margin: 0; white-space: pre-line; overflow-wrap: anywhere; } @@ -176,10 +237,8 @@ /* Phones: links, View buttons and task boxes reach 44px through an invisible touch area, so the cards keep their look. */ @media (max-width: 560px) { - .c-hub-link, .c-hub-attention-end button { position: relative; } - .c-hub-link::before, .c-hub-attention-end button::before { content: ""; position: absolute; inset: -9px 0; } - .c-hub-attention-end button::before { inset: -8px 0; } - .c-hub-task-feedback .c-hub-link::before { content: none; } - .c-hub-check { align-items: center; width: 44px; height: 44px; margin: -13.5px -15px -13.5px -12px; padding-left: 12px; } + .c-hub-link { position: relative; } + .c-hub-link::before { content: ""; position: absolute; inset: -9px 0; } + .c-hub-check { align-items: center; justify-content: center; width: 44px; height: 44px; margin: -11px -8px -11px 0; } } } diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index e0008291..c2523041 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -1,38 +1,26 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; -import { Archive, CalendarDays, ChevronRight, CircleAlert, Megaphone, Plus, ReceiptText, UsersRound, Wallet, type LucideIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Archive, CalendarDays, ChevronRight, Copy, Eye, Megaphone, Phone, ReceiptText, UsersRound, type LucideIcon } from "lucide-react"; import { useApp } from "../state"; import { Avatar } from "../ui/widgets"; import { Icon } from "../ui/Icon"; import { CalendarDate } from "../ui/CalendarDate"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; -import { isNotePinned, noteCategory, notePreview } from "../data/notePresentation"; +import { isNotePinned, isSecretFact, noteCategory, notePreview } from "../data/notePresentation"; import { addDays, dateFromIso, needsDoneBy, taskDoneCopy, todayIso, type DoneBy, type OperationItem } from "../data/operations"; +import { useReceiptActions } from "../ui/Receipts"; +import type { Need } from "../live/model"; import { hideToast, showToast } from "../ui/Toast"; import { DoneByChoice } from "../ui/DoneByChoice"; import { formatTimestampDay } from "../data/reminders"; -import type { Hub, RouteId } from "../data/household"; +import type { Hub, NoteCard } from "../data/household"; + +const weekday = new Intl.DateTimeFormat("en-GB", { weekday: "short" }); import "./Hub.css"; function HubHeading({ title, icon: HeadingIcon }: { title: string; icon: LucideIcon }) { return

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

{context}

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

{hub.address}

: null; } -function Responsibilities() { - const { operations, toggleOperation, undoOperation, go, you } = useApp(); - const [pending, setPending] = useState(null); - // Someone else's task, ticked: it asks who did it before it is marked done. - const [asking, setAsking] = useState(null); - const [error, setError] = useState(""); - // Undo puts focus back on the task's checkbox once the controls are enabled again. - const [focusTask, setFocusTask] = useState(null); - const listRef = useRef(null); - useEffect(() => { - if (pending !== null || focusTask === null) return; - listRef.current?.querySelector(`[data-task="${focusTask}"] input`)?.focus(); - setFocusTask(null); - }, [pending, focusTask]); - const today = todayIso(); - const end = addDays(today, 6); - const items = operations.filter(item => item.status === "open" && item.date <= end && (item.date >= today || item.kind === "task")) - .sort((a, b) => a.date.localeCompare(b.date)).slice(0, 5); - // What was recorded is said in the shared toast, with Undo; the completed - // task leaves the list, so Undo takes focus there. Errors stay here. - async function complete(item: OperationItem, doneBy?: DoneBy) { - const { id } = item; - if (!doneBy && needsDoneBy(item, you.id)) { setAsking(id); setError(""); hideToast(); return; } - setAsking(null); setPending(id); setError(""); hideToast(); - try { - const result = await toggleOperation(id, doneBy); - showToast({ text: taskDoneCopy(item, doneBy, result), action: { label: "Undo", busyLabel: "Undoing…", undo: true, run: () => undo(result.occurrenceId, id) } }); - } - catch (cause) { setError(cause instanceof Error ? cause.message : "Could not update the task. Please try again."); } - finally { setPending(null); } - } - async function undo(occurrenceId: string, taskId: string) { - setPending(occurrenceId); setError(""); - try { - await undoOperation(occurrenceId); - setFocusTask(taskId); - showToast({ text: "Undone. The task is back as it was." }); - } - catch (cause) { setError(cause instanceof Error ? cause.message : "Could not undo that. Please try again."); } - finally { setPending(null); } - } - return -
- {items.map(item => { - const choice = asking === item.id && void complete(item, doneBy)} - onCancel={() => { setAsking(null); setFocusTask(item.id); }} />; - const due = item.date === today ? "Today" : new Intl.DateTimeFormat("en-GB", { weekday: "short", day: "numeric", month: "short" }).format(dateFromIso(item.date)); - const late = item.status === "open" && item.date < today; - return [
- - - {item.kind === "task" && } -
, choice]; - })} -
- {!items.length &&

Nothing planned for this week.

} - {error &&

{error}

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

Your part in {hub.name}

+

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

+

{hub.oweCopy}

+ {next &&
+
+ {next.title}{next.when} + {next.amount && {next.amount}} +
+
+ + {next.kind === "confirm" && } + {money.length > 1 && } +
+
} + {pledges.slice(0, 2).map((item, index) => )} +
+ + + {hub.waiting.length > 0 && {hub.waiting.length} waiting on others} +
+ {message &&

{message}

} + {receipts.dialogs} +
; +} + +/** The next seven days as a strip, late jobs first, bills on the day they fall due. */ +function Week() { + const { operations, expenses, toggleOperation, undoOperation, go, you, hub } = useApp(); + const [pending, setPending] = useState(null); + // Someone else's task, ticked: it asks who did it before it is marked done. + const [asking, setAsking] = useState(null); + const [error, setError] = useState(""); + // Undo puts focus back on the task's checkbox once the controls are enabled again. + const [focusTask, setFocusTask] = useState(null); + const listRef = useRef(null); + useEffect(() => { + if (pending !== null || focusTask === null) return; + listRef.current?.querySelector(`[data-task="${focusTask}"] input`)?.focus(); + setFocusTask(null); + }, [pending, focusTask]); + const today = todayIso(); + const days = Array.from({ length: 7 }, (_, index) => addDays(today, index)); + const end = days[6]; + const open = operations.filter(item => item.status === "open" && item.date <= end && (item.date >= today || item.kind === "task")); + const late = open.filter(item => item.date < today); + const bills = expenses.filter(row => row.dueOn && row.dueOn >= today && row.dueOn <= end && row.status !== "settled"); + async function complete(item: OperationItem, doneBy?: DoneBy) { + const { id } = item; + if (!doneBy && needsDoneBy(item, you.id)) { setAsking(id); setError(""); hideToast(); return; } + setAsking(null); setPending(id); setError(""); hideToast(); + try { + const result = await toggleOperation(id, doneBy); + showToast({ text: taskDoneCopy(item, doneBy, result), action: { label: "Undo", busyLabel: "Undoing…", undo: true, run: () => undo(result.occurrenceId, id) } }); + } + catch (cause) { setError(cause instanceof Error ? cause.message : "Could not update the task. Please try again."); } + finally { setPending(null); } + } + async function undo(occurrenceId: string, taskId: string) { + setPending(occurrenceId); setError(""); + try { + await undoOperation(occurrenceId); + setFocusTask(taskId); + showToast({ text: "Undone. The task is back as it was." }); + } + catch (cause) { setError(cause instanceof Error ? cause.message : "Could not undo that. Please try again."); } + finally { setPending(null); } + } + const face = (item: OperationItem) => { + const member = hub.members.find(entry => entry.userId === item.assigneeId); + return member ? : null; + }; + const chip = (item: OperationItem) =>
+ {item.kind === "event" ?
; + const asked = open.find(item => item.id === asking); + const empty = !open.length && !bills.length; + return
+
+
+ {asked && void complete(asked, doneBy)} + onCancel={() => { setAsking(null); setFocusTask(asked.id); }} />} + {empty ?

Nothing planned for this week.

+ :
+ {late.length > 0 &&

Late{late.length}

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

{error}

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

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

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

{notePreview(note)}

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

{weekSentence(late, dueToday, bills)}

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

{hub.oweCopy}

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

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

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

Waiting on others

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

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

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

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

} +
- {expenses.length > 0 &&
+ {expenses.length > 0 &&
{expenses.slice(0, 3).map(row => )}
} diff --git a/apps/web/tests/announcements.test.tsx b/apps/web/tests/announcements.test.tsx index d0e0cfaa..40453c77 100644 --- a/apps/web/tests/announcements.test.tsx +++ b/apps/web/tests/announcements.test.tsx @@ -75,9 +75,6 @@ describe("announcements on This Hub", () => { expect(within(section).getByText("Boiler service Thursday")).toBeTruthy(); expect(within(section).getByText("Please be in from 8.")).toBeTruthy(); expect(within(section).getByText("Seen by 0 of 2")).toBeTruthy(); - // It also counts as something that needs this member. - const needs = screen.getByRole("region", { name: "Needs you" }); - expect(within(needs).getByText("1 item")).toBeTruthy(); // A member cannot take down an admin's notice or post one. expect(within(section).queryByRole("button", { name: /^Take down/ })).toBeNull(); expect(screen.queryByRole("button", { name: "Announce" })).toBeNull(); @@ -85,7 +82,6 @@ describe("announcements on This Hub", () => { expect(vi.mocked(post)).toHaveBeenCalledWith("/announcements/notice-1/acknowledge", {}); expect(within(section).getByText("You have seen this")).toBeTruthy(); expect(within(section).getByText("Seen by 1 of 2")).toBeTruthy(); - expect(within(screen.getByRole("region", { name: "Needs you" })).getByText("0 items")).toBeTruthy(); }); it("shows an admin who is still to confirm, and takes a notice down after asking", async () => { diff --git a/apps/web/tests/loading.test.tsx b/apps/web/tests/loading.test.tsx index bf5c3320..47361c6a 100644 --- a/apps/web/tests/loading.test.tsx +++ b/apps/web/tests/loading.test.tsx @@ -131,15 +131,15 @@ describe("commune loading", () => { vi.mocked(loadSnapshot).mockResolvedValue({ ...snapshot, notes: [note], operations: [hubTask] }); mount(); await screen.findByRole("heading", { name: commune.name }); - expect(screen.getByRole("region", { name: "Pinned notes" }).textContent).toContain(note.title); + expect(screen.getByRole("region", { name: "Shared essentials" }).textContent).toContain(note.title); expect(screen.getByRole("checkbox", { name: "Mark Take recycling out done" })).toBeTruthy(); expect(document.querySelector(".c-hub-cover")?.getAttribute("src")).toBe(coverSource({ type: "Household", photo: HUBS.park.photo }, "hero").src); expect(document.querySelector(".c-hub")?.textContent).not.toContain("↗"); - expect(within(screen.getByRole("region", { name: "Your balance" })).getByRole("button", { name: "Add expense" })).toBeTruthy(); - expect(within(screen.getByRole("region", { name: "Your balance" })).getByRole("button", { name: "View settlement" })).toBeTruthy(); + expect(within(screen.getByRole("region", { name: "Your part" })).getByRole("button", { name: "Add expense" })).toBeTruthy(); + expect(within(screen.getByRole("region", { name: "Your part" })).getByRole("button", { name: "View settlement" })).toBeTruthy(); expect(within(screen.getByRole("region", { name: "Shared essentials" })).getByText(note.title)).toBeTruthy(); expect(within(screen.getByRole("region", { name: "Commune identity" })).getByRole("button", { name: /^\d+ (people|person) in this commune, view members$/ })).toBeTruthy(); - fireEvent.click(within(screen.getByRole("region", { name: "Pinned notes" })).getByRole("button", { name: /House essentials/ })); + fireEvent.click(within(screen.getByRole("region", { name: "Shared essentials" })).getByRole("button", { name: "House essentials" })); await screen.findByRole("complementary", { name: "House essentials" }); }); @@ -273,14 +273,18 @@ describe("commune loading", () => { }); const wifiNote = { id: "note-1", title: "Wi-Fi & internet", pill: "Wi-Fi", kicker: "Pinned · everyone", lede: "private-secret", body: "Connect upstairs. Password: private-secret", facts: [["Network", "Test network"], ["Password", "private-secret"], ["This Hub", "Pinned"]] as Array<[string, string]> }; - it("keeps credentials out of hub pinned cards and essentials previews", async () => { + it("keeps passwords on hub essentials hidden until someone asks to see them", async () => { const unpinnedWifi = { ...wifiNote, id: "note-2", title: "Guest network", kicker: "Everyone", facts: wifiNote.facts.filter(([key]) => key !== "This Hub") }; vi.mocked(loadSnapshot).mockResolvedValue({ ...snapshot, notes: [wifiNote, unpinnedWifi] }); mount(); await screen.findByRole("heading", { name: commune.name }); - expect(screen.getByRole("region", { name: "Pinned notes" }).textContent).toContain("Network and password. Open to view connection details."); - expect(screen.getByRole("region", { name: "Shared essentials" }).textContent).toContain("Guest network"); + const essentials = screen.getByRole("region", { name: "Shared essentials" }); + // The network name is shown; the password waits behind Show. + expect(essentials.textContent).toContain("Guest network"); + expect(essentials.textContent).toContain("Test network"); expect(document.body.textContent).not.toContain("private-secret"); + fireEvent.click(within(essentials).getAllByText("Show")[0].closest("button")!); + expect(essentials.textContent).toContain("private-secret"); }); it("restores default covers while preserving uploads and the activity owner's identity", () => { diff --git a/apps/web/tests/needs-you.test.tsx b/apps/web/tests/needs-you.test.tsx index 8732361f..9d657eb8 100644 --- a/apps/web/tests/needs-you.test.tsx +++ b/apps/web/tests/needs-you.test.tsx @@ -315,29 +315,23 @@ describe("Coming up on Home", () => { }); }); -describe("Needs you on This Hub", () => { - it("counts only what needs the viewer, in the same order, and keeps what others owe apart", async () => { +describe("Your part on This Hub", () => { + it("leads with the first payment to act on, in Home's order, and keeps what others owe apart", async () => { mount("/hub"); - const needs = await screen.findByRole("region", { name: "Needs you" }); - await within(needs).findAllByText("Electricity"); - expect(within(needs).getByText("6 items")).toBeTruthy(); - const waiting = within(needs).getByRole("region", { name: "Waiting on others" }); - const money = [...needs.querySelectorAll(".c-hub-attention-row")].map((row) => - [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent, row.querySelector(".c-hub-attention-end strong")?.textContent]); - expect(money).toEqual([ - ["Electricity", "Ben Baker says they sent it", "£20.00"], - ["Internet", "You owe Ben Baker · overdue", "£15.00"], - ["Water", "You owe Cat Cole", "£8.00"], - ]); - const others = [...needs.querySelectorAll(".c-hub-note-row")].filter((row) => !waiting.contains(row)) - .map((row) => [row.querySelector("strong")?.textContent, row.querySelector("small")?.textContent]); - expect(others).toEqual([ - ["Bins", "Overdue · your turn"], - ["Hoover", "Your turn today"], - ["Boiler", "Remaining on your pledge · overdue · £60.00"], - ]); - expect([...waiting.querySelectorAll(".c-hub-note-row")].map((row) => row.querySelector("small")?.textContent)).toEqual([ - "Cat Cole owes you · £20.00", "3 people owe you · £30.00", "You said you sent it · waiting for Cat Cole · £500.00", - ]); + const part = await screen.findByRole("region", { name: "Your part" }); + await within(part).findByText("Electricity"); + // A payment someone says they sent comes first, with the answer on the button. + expect(within(part).getByText("Ben Baker says they sent it")).toBeTruthy(); + expect(within(part).getByRole("button", { name: "Confirm £20.00 received" })).toBeTruthy(); + expect(within(part).getByRole("button", { name: "Not received" })).toBeTruthy(); + expect(within(part).getByRole("button", { name: "2 more to settle" })).toBeTruthy(); + // Pledges stay with the viewer's part; tasks live in the week. + const pledge = within(part).getByRole("button", { name: /Boiler/ }); + expect(pledge.querySelector("small")?.textContent).toBe("Remaining on your pledge · overdue · £60.00"); + expect(within(part).getByText("3 waiting on others")).toBeTruthy(); + const week = screen.getByRole("region", { name: "This week" }); + expect(within(week).getByRole("checkbox", { name: "Mark Bins done" })).toBeTruthy(); + expect(within(week).getByRole("checkbox", { name: "Mark Hoover done" })).toBeTruthy(); }); }); + diff --git a/apps/web/tests/workflow-screens.test.tsx b/apps/web/tests/workflow-screens.test.tsx index 63d2c791..8886599b 100644 --- a/apps/web/tests/workflow-screens.test.tsx +++ b/apps/web/tests/workflow-screens.test.tsx @@ -275,9 +275,8 @@ describe("complete shared workflows", () => { mount("/hub"); await screen.findByRole("heading", { name: "Shared essentials" }); const headings = [ - within(screen.getByRole("region", { name: "Your balance" })).getByRole("heading", { level: 2 }), - screen.getByRole("heading", { name: "Needs you" }), screen.getByRole("heading", { name: "This week" }), + screen.getByRole("heading", { name: "People" }), screen.getByRole("heading", { name: "Shared essentials" }), ]; if (state === "populated") headings.push(screen.getByRole("heading", { name: "Recently in your commune" })); From 11a24048a83243b1d426096fbe92da86c1e1bc2e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 07:43:40 +0000 Subject: [PATCH 2/7] Phone app: bring the new Hub and Home ideas to the native app Hub now leads with this week's pulse on the photo, your part with one "I've paid" (or confirm received), the week day by day with tasks you can tick and undo, people with their balance and next job, and shared essentials with facts in place, secrets behind Show and Copy, and Call. Home keeps its approved layout and adds where you stand overall, how the bill payer takes money, a pulse on each commune tile, Today and Still to pay in place of one mixed list, Confirm on the row when someone says they've paid, and amounts in the feed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WR3jnRWPbGzGV9Uyrqq8Wg --- apps/native/app/(tabs)/hub.tsx | 270 ++++++++++++++++++++++++++----- apps/native/app/(tabs)/index.tsx | 33 ++-- apps/native/src/lib/backend.ts | 13 +- apps/native/src/lib/demo.ts | 36 +++-- apps/native/src/lib/derive.ts | 214 +++++++++++++++++++++++- apps/native/src/lib/types.ts | 11 +- apps/native/src/state.tsx | 13 +- apps/native/src/ui/parts.tsx | 116 +++++++++++-- apps/native/src/ui/toast.tsx | 32 ++-- apps/native/tests/derive.test.ts | 78 ++++++++- 10 files changed, 725 insertions(+), 91 deletions(-) diff --git a/apps/native/app/(tabs)/hub.tsx b/apps/native/app/(tabs)/hub.tsx index c1335c2d..3ad99075 100644 --- a/apps/native/app/(tabs)/hub.tsx +++ b/apps/native/app/(tabs)/hub.tsx @@ -1,30 +1,38 @@ -import { ImageBackground, Text, View } from "react-native"; +import { useState } from "react"; +import { ImageBackground, Linking, Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; import { useRouter } from "expo-router"; -import { Check, Plus } from "lucide-react-native"; -import { activeMembers, oweTotals, owedTotals } from "../../src/lib/derive"; -import { money } from "../../src/lib/format"; -import { useApp } from "../../src/state"; +import * as Clipboard from "expo-clipboard"; +import { CalendarDays, Check, Copy, Eye, EyeOff, Phone, Plus, ReceiptText } from "lucide-react-native"; +import { + activeMembers, essentials, hubMoneyLine, oweTotals, owedTotals, people, week, weekSentence, + type Essential, type WeekItem, +} from "../../src/lib/derive"; +import { addDays, calParts, firstName, money } from "../../src/lib/format"; +import { failure, useApp } from "../../src/state"; import { Body, Pad, ScopeRow, TopBar, photoOf } from "../../src/ui/chrome"; -import { Avatar, Button, Empty, Faces, Panel, Row, SectionHead, T } from "../../src/ui/kit"; -import { NeedsList } from "../../src/ui/parts"; -import { KIND_NAME, useTheme } from "../../src/ui/theme"; +import { Avatar, Button, Cal, Empty, Faces, Panel, SectionHead, State, T, tap } from "../../src/ui/kit"; +import { TaskCheck } from "../../src/ui/parts"; +import { useToast } from "../../src/ui/toast"; +import { KIND_NAME, radius, useTheme } from "../../src/ui/theme"; +/** + * The Hub, the house at a glance: this week's pulse on the photo, your part + * with the one payment to record next, the week day by day, the people you + * share with, and the details everyone looks up. + */ export default function Hub() { const { c } = useTheme(); - const router = useRouter(); - const { current, needs, waiting, user } = useApp(); + const { current, user, today } = useApp(); if (!current) return You’re not in a commune yet.; const { commune } = current; - const people = activeMembers(current); - const owe = oweTotals(needs, commune.id)[commune.currency] ?? 0; - const owed = owedTotals(waiting, commune.id)[commune.currency] ?? 0; - const mine = needs.filter((need) => need.communeId === commune.id); - const standing = owe > 0 ? `You owe ${money(owe, commune.currency)}` : owed > 0 ? `You’re owed ${money(owed, commune.currency)}` : "You’re all square"; + const viewerId = user?.id ?? ""; + const members = activeMembers(current); + const thisWeek = week(current, viewerId, today); const place = commune.address || commune.destination || ""; return - + @@ -32,31 +40,221 @@ export default function Hub() { {commune.name} {place ? {place} : null} - member.name)} size={28} ring="rgba(255,255,255,0.9)" /> - {people.length} {people.length === 1 ? "person" : "people"} + member.name)} size={28} ring="rgba(255,255,255,0.9)" /> + {members.length} {members.length === 1 ? "person" : "people"} + + + {weekSentence(thisWeek)} - - {standing} - {owe > 0 && owed > 0 ? You’re also owed {money(owed, commune.currency)} here. : null} - - : null} + {item.kind === "pay" && item.person && hub.payWith?.[item.person]?.length ? ( + {first} takes {hub.payWith[item.person].map((name) => {name})} + ) : null} {note ? {note} : null}
@@ -288,6 +317,7 @@ function CommuneCard({ hub, current }: { hub: LiveHub; current: boolean }) { {state ? : null} {square ? : null} + {hub.activityState === "ready" && hub.pulse ? {hub.pulse.text} : null} @@ -338,7 +368,9 @@ const COLLAPSE_OVER = 7; const PHONE_SHOWN = 2; const PHONE_COLLAPSE_OVER = 3; -function NeedsList({ needs, today, onAct, busy }: { needs: Entry[]; today: string; onAct: (entry: Entry) => void; busy: boolean }) { +function NeedsList({ needs, today, onAct, busy, title = "Needs you", id = "home-needs" }: { + needs: Entry[]; today: string; onAct: (entry: Entry) => void; busy: boolean; title?: string; id?: string; +}) { const { go } = useApp(); const [all, setAll] = useState(false); const collapsible = needs.length > COLLAPSE_OVER; @@ -355,9 +387,9 @@ function NeedsList({ needs, today, onAct, busy }: { needs: Entry[]; today: strin // Past the limit on every screen, past it on a phone, or never. const extra = (index: number) => cx(index >= limit && "is-extra", index >= phoneLimit && "is-extra-phone"); return ( -
+
-

Needs you

{needs.length} +

{title}

{needs.length}
{groups.map((group) => ( @@ -382,7 +414,60 @@ function NeedsList({ needs, today, onAct, busy }: { needs: Entry[]; today: strin ); } -function WaitingList({ waiting, today }: { waiting: Entry[]; today: string }) { +/** Your chores that are due, each ticked off here with Undo on the toast, in whichever commune it is. */ +function Chores({ chores, today }: { chores: Entry[]; today: string }) { + const { toggleOperation, undoOperation } = useApp(); + const go = useOpen(); + const [pending, setPending] = useState(null); + const [error, setError] = useState(""); + async function done({ hub, item }: Entry) { + if (!item.operationId || !item.dueOn) return; + setPending(item.operationId); setError(""); + try { + const result = await toggleOperation(item.operationId, undefined, { communeId: hub.id, date: item.dueOn }); + showToast({ text: `${item.title} is done.`, action: { label: "Undo", busyLabel: "Undoing…", undo: true, run: () => undo(result.occurrenceId, hub.id) } }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not update the task. Please try again."); + } finally { + setPending(null); + } + } + async function undo(occurrenceId: string, communeId: string) { + try { + await undoOperation(occurrenceId, communeId); + showToast({ text: "Undone. The task is back as it was." }); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not undo that. Please try again."); + } + } + return ( +
+

Today

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

{error}

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

Waiting on others

{waiting.length} {total.length ? {total.join(" · ")} : null} -
    {waiting.map((entry, index) => )}
+
    {waiting.map((entry, index) => )}
); } @@ -416,7 +502,10 @@ function Activity({ places }: { places: LiveHub[] }) { {/* "Joined the commune · Maya Chen" under "Maya Chen" names her once. */} {[hub.name, activityWord(item.type), item.when.endsWith(` · ${item.title}`) ? item.when.slice(0, -` · ${item.title}`.length) : item.when].filter(Boolean).join(" · ")} - + + {item.amount ? {item.amount} : null} + + ))} @@ -598,6 +687,10 @@ function HomeWithCommunes() { const waiting = places.flatMap((hub) => hub.waiting.map((item) => ({ hub, item }))).sort((a, b) => compareNeeds(a.item, b.item)); const coming = places.flatMap((hub) => hub.coming.map((item) => ({ hub, item }))).sort((a, b) => a.item.dueOn.localeCompare(b.item.dueOn)); const [next, ...rest] = needs; + // After the next step: chores to tick, money to pay, and payments to confirm sit with what others owe. + const chores = rest.filter(({ item }) => item.kind === "task"); + const toPay = rest.filter(({ item }) => item.kind !== "task" && item.kind !== "confirm"); + const waitingAll = [...rest.filter(({ item }) => item.kind === "confirm"), ...waiting]; const current = places.find((place) => place.id === hubId) ?? places[0]; const communes = places.map((hub, index) => ({ hub, index })).sort((a, b) => needRank(a.hub) - needRank(b.hub) || a.index - b.index).map(({ hub }) => hub); const ready = places.every((hub) => hub.activityState === "ready"); @@ -675,17 +768,20 @@ function HomeWithCommunes() {
{communeList}
- {rest.length ? ( + {chores.length || toPay.length ? ( <> -
- {waiting.length ? : null} + {chores.length ? : null} + {toPay.length ? : null} +
+
+ {waitingAll.length ? : null}
- ) : waiting.length || comingUp ? ( + ) : waitingAll.length || comingUp ? ( <> -
{waiting.length ? : null}{comingUp}
+
{waitingAll.length ? : null}{comingUp}
) :
} diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 54b2a304..1368c0a6 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -398,7 +398,8 @@ export type AppState = { updateOperation: (id: string, item: NewOperation, expectedVersion?: number) => Promise; /** Completes an open task, or reopens a done one. The server advances repeats and rotation. */ /** Marking someone else's task done says who did it (needsDoneBy). */ - toggleOperation: (id: string, doneBy?: DoneBy) => Promise; + /** From another commune than the one in view (Home), name it and the date the task was seen on. */ + toggleOperation: (id: string, doneBy?: DoneBy, from?: { communeId: string; date: string }) => Promise; skipOperation: (id: string, reason?: string) => Promise; /** * from: the task as another screen saw it, in a commune that may not be the @@ -407,7 +408,7 @@ export type AppState = { handOverOperation: (id: string, toMemberId: string, reason?: string, from?: { communeId: string; date: string; assigneeId: string | null }) => Promise; /** Restores the task to exactly how it was before that action. */ - undoOperation: (occurrenceId: string) => Promise; + undoOperation: (occurrenceId: string, communeId?: string) => Promise; removeOperation: (id: string) => Promise; createHub: (input: CreatedHub, opts?: { openExpense?: boolean }) => Promise; updateCommune: (input: { @@ -1901,9 +1902,15 @@ export function AppProvider({ children }: { children: ReactNode }) { reason: reason?.trim() || null, requestId: newRequestId(), }, from?.communeId); }, [occurrenceCommand]); - const undoOperation = useCallback((occurrenceId: string) => - occurrenceCommand(`/occurrences/${encodeURIComponent(occurrenceId)}/undo`, {}), [occurrenceCommand]); - const toggleOperation = useCallback((id: string, doneBy?: DoneBy) => { + const undoOperation = useCallback((occurrenceId: string, communeId?: string) => + occurrenceCommand(`/occurrences/${encodeURIComponent(occurrenceId)}/undo`, {}, communeId), [occurrenceCommand]); + const toggleOperation = useCallback((id: string, doneBy?: DoneBy, from?: { communeId: string; date: string }) => { + // Home marks tasks done in any commune, checked against the date it showed. + if (from) { + return occurrenceCommand(`/operations/${encodeURIComponent(id)}/occurrences`, { + action: "completed", expectedDate: from.date, today: todayIso(), requestId: newRequestId(), ...(doneBy ? { doneBy } : {}), + }, from.communeId); + } const item = operationsRef.current.find((row) => row.id === id); if (item?.status !== "done") return taskAction(id, "completed", undefined, doneBy); // Undo the completion when it is still the latest state; a task finished diff --git a/apps/web/src/ui/components.tsx b/apps/web/src/ui/components.tsx index 0c717685..a8203924 100644 --- a/apps/web/src/ui/components.tsx +++ b/apps/web/src/ui/components.tsx @@ -179,7 +179,8 @@ export function focusAfterClose(opener: HTMLElement | null): () => void { return () => { settle(); if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => { if (focusLost()) settle(); }); - window.setTimeout(() => { if (focusLost()) settle(); }, 400); + // The page may be gone by then (a test that has finished, say). + window.setTimeout(() => { if (typeof document !== "undefined" && focusLost()) settle(); }, 400); }; } diff --git a/apps/web/tests/needs-you.test.tsx b/apps/web/tests/needs-you.test.tsx index 9d657eb8..fa723cb2 100644 --- a/apps/web/tests/needs-you.test.tsx +++ b/apps/web/tests/needs-you.test.tsx @@ -194,45 +194,44 @@ describe("Needs you on Home", () => { expect(within(next).getByRole("button", { name: "Confirm £20.00 received" })).toBeTruthy(); expect(within(next).getByRole("button", { name: "Not received" })).toBeTruthy(); - const needs = screen.getByRole("region", { name: "Needs you" }); - await within(needs).findByText("Ferry"); + // Chores due are ticked off from Home, in whichever commune they are. + const chores = screen.getByRole("region", { name: "Today" }); + expect([...chores.querySelectorAll(".c-home-row-title")].map((title) => title.textContent)).toEqual(["Bins", "Hoover"]); + expect(within(chores).getByRole("checkbox", { name: "Mark Bins done" })).toBeTruthy(); + expect(within(chores).getByRole("checkbox", { name: "Mark Hoover done" })).toBeTruthy(); + expect(within(chores).getByText("2 days overdue")).toBeTruthy(); + + const needs = screen.getByRole("region", { name: "Still to pay" }); const groups = [...needs.querySelectorAll(".c-home-group")].map((group) => [group.querySelector("h3")?.firstChild?.textContent?.trim(), [...group.querySelectorAll(".c-home-row")].map(row)]); expect(groups).toEqual([ - // The trip's report waits on Maya now, whenever its bill is due. - ["To confirm", [["Ferry", "Lisbon trip", "Ben Baker says they sent it", "€25.00", null]]], ["Overdue", [ ["Internet", "Our home", "You owe Ben Baker", "£15.00", "8 days overdue"], - ["Bins", "Our home", "Your turn", null, "2 days overdue"], ["Boiler", "Our home", "Remaining on your pledge", "£60.00", "4 days overdue"], ]], - ["Today", [["Hoover", "Our home", "Your turn today", null, "Due today"]]], ["No due date", [["Water", "Our home", "You owe Cat Cole", "£8.00", null]]], ]); - // Payments act where they are; tasks and pledges open their record. + // Payments act where they are; a pledge opens its record. expect(within(needs).getAllByRole("button").filter((button) => button.classList.contains("c-home-btn")).map((button) => button.textContent)).toEqual([ - "Confirm €25.00 from Ben Baker", "I’ve paid Ben Baker for Internet", "Open Bins", "Open Boiler", "Open Hoover", "I’ve paid Cat Cole for Water", + "I’ve paid Ben Baker for Internet", "Open Boiler", "I’ve paid Cat Cole for Water", ]); - // A phone shows the first two in the order drawn and offers the rest; a wider screen shows all six. - const extra = [...needs.querySelectorAll(".c-home-row")].map((item) => item.classList.contains("is-extra-phone")); - expect(extra).toEqual([false, false, true, true, true, true]); - expect(needs.querySelectorAll(".is-extra")).toHaveLength(0); - expect([...needs.querySelectorAll(".c-home-group.is-extra-phone h3")].map((h) => h.firstChild?.textContent?.trim())).toEqual(["Today", "No due date"]); - const more = within(needs).getByRole("button", { name: "Show 4 more" }); - expect(more.classList.contains("is-phone")).toBe(true); - expect(more.getAttribute("aria-expanded")).toBe("false"); - fireEvent.click(more); - expect(within(needs).getByRole("button", { name: "Show fewer" }).getAttribute("aria-expanded")).toBe("true"); - expect(needs.classList.contains("is-all")).toBe(true); + // Three rows fit a phone, so nothing is folded away. + expect(needs.querySelectorAll(".is-extra-phone")).toHaveLength(0); + expect(within(needs).queryByRole("button", { name: /Show \d+ more/ })).toBeNull(); + // A payment someone says they sent sits with what others owe, with Confirm on the row. const waiting = screen.getByRole("region", { name: "Waiting on others" }); + await within(waiting).findByText("Ferry"); expect(within(waiting).getByText("£50.00")).toBeTruthy(); expect([...waiting.querySelectorAll(".c-home-row")].map(row)).toEqual([ + // The trip's report waits on Maya now, whenever its bill is due. + ["Ferry", "Lisbon trip", "Ben Baker says they sent it", "€25.00", null], ["Electricity", "Our home", "Cat Cole owes you", "£20.00", "Due 20 Oct"], ["Takeaway", "Our home", "3 people owe you", "£30.00", null], ["Rent", "Our home", "You said you sent it · waiting for Cat Cole", "£500.00", "Due Thu"], ]); - expect(within(waiting).queryByRole("button", { name: /I’ve paid|Confirm/ })).toBeNull(); + expect(within(waiting).getAllByRole("button").filter((button) => button.classList.contains("c-home-btn")).map((button) => button.textContent)) + .toEqual(["Confirm €25.00 from Ben Baker"]); }); it("says where Maya stands per currency, never adding pounds to euros", async () => { @@ -242,7 +241,7 @@ describe("Needs you on Home", () => { mount("/"); const standing = await screen.findByRole("heading", { name: "You owe £523.00 and €12.00 across two communes." }); expect(standing.parentElement?.querySelector(".c-home-sub")?.textContent) - .toBe("You’re owed £70.00 and €25.00. Three things are overdue, the oldest eight days."); + .toBe("You’re owed £70.00 and €25.00, so in pounds you owe £453.00 and in euros you’re €13.00 ahead. Three things are overdue, the oldest eight days."); // The bar names the page; the sentence is Home's own heading. expect(screen.getByRole("heading", { level: 1, name: "Home" })).toBeTruthy(); // A phone's bar shows the wordmark beside the menu on Home; the h1 names the page. @@ -255,8 +254,8 @@ describe("Needs you on Home", () => { it("tells the bill payer from Home, then reads the share's own commune again", async () => { vi.mocked(post).mockResolvedValue({ duplicate: false }); mount("/"); - const needs = await screen.findByRole("region", { name: "Needs you" }); - await within(needs).findByText("Ferry"); + await screen.findByText("Ferry"); + const needs = screen.getByRole("region", { name: "Still to pay" }); vi.mocked(loadSnapshot).mockClear(); fireEvent.click(within(needs).getByRole("button", { name: "I’ve paid Ben Baker for Internet" })); const dialog = await screen.findByRole("dialog", { name: "Tell Ben Baker you’ve paid" }); @@ -269,10 +268,10 @@ describe("Needs you on Home", () => { it("confirms a payment in another commune where it sits, and reads that commune again", async () => { vi.mocked(post).mockResolvedValue({ outstandingMinor: 0 }); mount("/"); - const needs = await screen.findByRole("region", { name: "Needs you" }); - await within(needs).findByText("Ferry"); + const waiting = await screen.findByRole("region", { name: "Waiting on others" }); + await within(waiting).findByText("Ferry"); vi.mocked(loadSnapshot).mockClear(); - fireEvent.click(within(needs).getByRole("button", { name: "Confirm €25.00 from Ben Baker" })); + fireEvent.click(within(waiting).getByRole("button", { name: "Confirm €25.00 from Ben Baker" })); const dialog = await screen.findByRole("dialog", { name: "Confirm €25.00 received?" }); fireEvent.click(within(dialog).getByRole("button", { name: "Confirm Ben’s €25.00" })); await waitFor(() => expect(toastText()).toBe("Confirmed. Ben’s €25.00 is recorded as received.")); @@ -281,6 +280,29 @@ describe("Needs you on Home", () => { }); }); +describe("Today on Home", () => { + it("ticks off a chore in its own commune, checked against the date Home showed, with Undo", async () => { + vi.mocked(post).mockResolvedValue({ id: "occurrence-bins", date: "2026-10-14", assigneeId: "me", turnOwnerId: "me", status: "open" }); + mount("/"); + const chores = await screen.findByRole("region", { name: "Today" }); + vi.mocked(loadSnapshot).mockClear(); + fireEvent.click(within(chores).getByRole("checkbox", { name: "Mark Bins done" })); + await waitFor(() => expect(toastText()).toBe("Bins is done.")); + expect(post).toHaveBeenCalledWith("/operations/bins/occurrences", expect.objectContaining({ action: "completed", expectedDate: "2026-10-07", today })); + expect(readAgain()).toEqual(["home-1"]); + }); + + it("says how the bill payer takes money on the next step, and sums nothing across currencies", async () => { + vi.mocked(loadSnapshot).mockImplementation(async (commune) => commune.id === home.id + ? { ...homeSnapshot(), reports: undefined, expenses: homeSnapshot().expenses.filter((item) => item.title !== "Electricity"), + payLinks: { ben: [{ provider: "monzo", handle: "benbaker" }, { provider: "bank", accountName: "Ben Baker", sortCode: "204577", accountNumber: "41029388" }] } } + : { ...tripSummary(), expenses: [] }); + mount("/"); + const next = await screen.findByRole("article", { name: "Internet" }); + expect(next.querySelector(".c-home-pays")?.textContent).toBe("Ben takes MonzoBank transfer"); + }); +}); + describe("Coming up on Home", () => { // Nothing needs Maya: Ben owes her for the bins bags, and the trip's gutters need someone. const calm = () => snapshotOf(home, { From 27cd6636ae263bf73bf771f7a7bd17c7a683b87c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 08:03:58 +0000 Subject: [PATCH 4/7] Pay buttons: open the payee's own app from Home, Hub and Settle Each way someone takes payment is now a button beside what you owe them: Monzo and PayPal open with the amount filled in, Revolut opens the profile, and Bank transfer opens "I've paid" with the details to copy. Opening any of them also opens "I've paid", so the payment is recorded when you come back. Commune still moves no money. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WR3jnRWPbGzGV9Uyrqq8Wg --- apps/web/src/live/model.ts | 5 ---- apps/web/src/screens/Home.css | 4 ++-- apps/web/src/screens/Home.tsx | 10 ++++++-- apps/web/src/screens/Hub.css | 1 + apps/web/src/screens/Hub.tsx | 5 ++++ apps/web/src/screens/Money.css | 10 ++++++++ apps/web/src/screens/Money.tsx | 3 +++ apps/web/src/styles/app.css | 1 + apps/web/src/ui/PayLinks.tsx | 40 +++++++++++++++++++++++++++++++ apps/web/tests/needs-you.test.tsx | 11 +++++++-- apps/web/tests/pay-links.test.tsx | 10 ++++++++ 11 files changed, 89 insertions(+), 11 deletions(-) diff --git a/apps/web/src/live/model.ts b/apps/web/src/live/model.ts index 1742b0be..c1bcf779 100644 --- a/apps/web/src/live/model.ts +++ b/apps/web/src/live/model.ts @@ -9,7 +9,6 @@ import { } from "../data/household"; import type { OperationItem } from "../data/operations"; import { notePreview } from "../data/notePresentation"; -import { parsePayLinks, PAY_PROVIDER_NAME } from "../data/payLinks"; import { addDays, timestampDay, todayIso } from "../data/operations"; import { formatDay, formatMoney, netBalances, overallNet } from "@commune/core"; @@ -342,8 +341,6 @@ export type LiveHub = Omit & { coming: Upcoming[]; /** This commune's week in a few words, for its card on Home (see communePulse). */ pulse?: { text: string; late: boolean }; - /** How each member takes money, from the pay links they saved: provider names by user id. */ - payWith?: Record; }; /** @@ -740,8 +737,6 @@ export function hubFromSnapshot( owingYou: [...owingYou], coming: comingUp(source, viewer.id, today), pulse: communePulse(snapshot, today), - payWith: Object.fromEntries(Object.entries(snapshot.payLinks ?? {}).map(([userId, links]) => - [userId, parsePayLinks(links).map((link) => PAY_PROVIDER_NAME[link.provider])])), moves, members, activity: [...snapshot.expenses.map((expense) => ({ diff --git a/apps/web/src/screens/Home.css b/apps/web/src/screens/Home.css index 838ed920..5f7c0528 100644 --- a/apps/web/src/screens/Home.css +++ b/apps/web/src/screens/Home.css @@ -395,8 +395,8 @@ .c-home-feed-side strong { font-size: 13.5px; line-height: 19px; font-weight: 500; font-variant-numeric: tabular-nums; } /* How the bill payer takes money, under the next step's button. */ -.c-home-pays { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 12px; line-height: 16px; color: var(--home-ink-2); } -.c-home-pay { padding: 3px 9px; border: 1px solid var(--home-line-strong); border-radius: 999px; color: var(--home-ink); font-weight: 500; } +.c-home-next-foot .c-pay-buttons { flex-basis: 100%; } +.c-home-row-main .c-pay-buttons { margin-top: 6px; } /* A commune card's week in a few words. */ .c-home-pulse { display: block; margin-top: 4px; font-size: 12.5px; line-height: 17px; font-weight: 500; color: var(--home-action); } diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 1aaa3f17..2f393e83 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -9,6 +9,7 @@ import { cx } from "../ui/cx"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; import { activityStamp, activityWord } from "../ui/ActivityLog"; import { useReceiptActions } from "../ui/Receipts"; +import { PayButtons } from "../ui/PayLinks"; import { addDays, dateFromIso, todayIso } from "../data/operations"; import { coverSource } from "../data/covers"; import { animate } from "../ui/motion"; @@ -224,8 +225,9 @@ function NextStep({ entry, total, onAct, busy }: { entry: Entry; total: number; {item.kind === "confirm" ? : null} - {item.kind === "pay" && item.person && hub.payWith?.[item.person]?.length ? ( - {first} takes {hub.payWith[item.person].map((name) => {name})} + {item.kind === "pay" && item.person && item.minor ? ( + onAct(entry)} disabled={busy} load /> ) : null} {note ? {note} : null}
@@ -344,6 +346,10 @@ function Row({ entry, today, waiting = false, onAct, busy, className }: { {detail(item)} + {onAct && item.kind === "pay" && item.person && item.minor ? ( + onAct(entry)} disabled={busy} short /> + ) : null} {item.amount ? {item.amount} : null} diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css index 624a84fa..36ae3c17 100644 --- a/apps/web/src/screens/Hub.css +++ b/apps/web/src/screens/Hub.css @@ -143,6 +143,7 @@ .c-hub-sentence em { padding: 0 .16em .04em; border-radius: 10px; background: var(--hub-panel); font-style: normal; font-variant-numeric: tabular-nums; } .c-hub-part-copy { color: var(--hub-muted); } .c-hub-next { margin-top: 10px; border-radius: 16px; background: var(--hub-panel); } +.c-hub-next-pay { padding: 0 16px 12px; } .c-hub-next-body { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 16px 12px; } .c-hub-next-body > span { display: grid; min-width: 0; } .c-hub-next-body strong { font-size: 14px; font-weight: 500; overflow-wrap: anywhere; } diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index c2523041..dce90f4b 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -8,6 +8,7 @@ import { CoverImage, coverToneClass } from "../ui/CoverImage"; import { isNotePinned, isSecretFact, noteCategory, notePreview } from "../data/notePresentation"; import { addDays, dateFromIso, needsDoneBy, taskDoneCopy, todayIso, type DoneBy, type OperationItem } from "../data/operations"; import { useReceiptActions } from "../ui/Receipts"; +import { PayButtons } from "../ui/PayLinks"; import type { Need } from "../live/model"; import { hideToast, showToast } from "../ui/Toast"; import { DoneByChoice } from "../ui/DoneByChoice"; @@ -117,6 +118,10 @@ function YourPart() { {next.title}{next.when} {next.amount && {next.amount}} + {next.kind === "pay" && next.minor ?
+ act()} disabled={receipts.busy} /> +
: null}
; } +/** + * The ways someone takes payment, as buttons beside a payment you owe them: + * "Tom takes [Monzo £5.90] [Bank transfer]". A link opens their own app with + * the amount filled in where it can. Either button also runs onOpen, which + * opens "I’ve paid" (with the bank details to copy), so the payment is + * recorded when the person comes back. Commune moves no money. + */ +export function PayButtons({ userId, communeId, name, amountMinor, currency, note, onOpen, load = false, short = false, disabled = false }: { + userId: string | null | undefined; communeId?: string; name: string; amountMinor: number; currency: CurrencyCode; note?: string; + onOpen?: () => void; load?: boolean; short?: boolean; disabled?: boolean; +}) { + const { payLinksFor, loadPayLinksFor } = useApp(); + const links = payLinksFor(userId, communeId); + const unread = links === null; + // A commune held only as a summary has no pay links; reading its record is + // worth it for one next step, not for every row of a list. + useEffect(() => { + if (load && unread) void loadPayLinksFor(communeId).catch(() => undefined); + }, [load, unread, communeId, loadPayLinksFor]); + if (!links?.length) return null; + const amount = money(amountMinor, currency); + const first = name.split(" ")[0] || name; + return + {short ? null : {first} takes} + {links.map((link) => { + const provider = PAY_PROVIDER_NAME[link.provider]; + const target = payLinkHref(link, amountMinor, currency, note); + const label = target?.amountFilled && !short ? `${provider} ${amount}` : provider; + return target + ? { if (disabled) { event.preventDefault(); return; } onOpen?.(); }}> + {label}, opens {provider} to pay {first} {amount} + + : ; + })} + ; +} + const EMPTY: Record = { monzo: { handle: "" }, paypal: { handle: "" }, revolut: { handle: "" }, bank: { accountName: "", sortCode: "", accountNumber: "" }, diff --git a/apps/web/tests/needs-you.test.tsx b/apps/web/tests/needs-you.test.tsx index fa723cb2..8112ba64 100644 --- a/apps/web/tests/needs-you.test.tsx +++ b/apps/web/tests/needs-you.test.tsx @@ -292,14 +292,21 @@ describe("Today on Home", () => { expect(readAgain()).toEqual(["home-1"]); }); - it("says how the bill payer takes money on the next step, and sums nothing across currencies", async () => { + it("offers the bill payer’s own ways to pay on the next step, each opening I’ve paid too", async () => { vi.mocked(loadSnapshot).mockImplementation(async (commune) => commune.id === home.id ? { ...homeSnapshot(), reports: undefined, expenses: homeSnapshot().expenses.filter((item) => item.title !== "Electricity"), payLinks: { ben: [{ provider: "monzo", handle: "benbaker" }, { provider: "bank", accountName: "Ben Baker", sortCode: "204577", accountNumber: "41029388" }] } } : { ...tripSummary(), expenses: [] }); mount("/"); const next = await screen.findByRole("article", { name: "Internet" }); - expect(next.querySelector(".c-home-pays")?.textContent).toBe("Ben takes MonzoBank transfer"); + const ways = within(next).getByRole("group", { name: "Ways to pay Ben Baker" }); + const monzo = within(ways).getByRole("link", { name: /^Monzo £15\.00/ }); + expect(monzo.getAttribute("href")).toBe("https://monzo.me/benbaker/15.00?d=Internet"); + expect(monzo.getAttribute("target")).toBe("_blank"); + expect(within(ways).getByRole("button", { name: /^Bank transfer/ })).toBeTruthy(); + // Opening their app also opens "I’ve paid", so the payment is recorded on the way back. + fireEvent.click(monzo); + expect(await screen.findByRole("dialog", { name: "Tell Ben Baker you’ve paid" })).toBeTruthy(); }); }); diff --git a/apps/web/tests/pay-links.test.tsx b/apps/web/tests/pay-links.test.tsx index 079e5e0f..87bacf83 100644 --- a/apps/web/tests/pay-links.test.tsx +++ b/apps/web/tests/pay-links.test.tsx @@ -127,11 +127,21 @@ describe("paying someone who has shared their pay links", () => { expect(within(dialog).getByRole("button", { name: "Copy details" })).toBeTruthy(); }); + it("puts their ways to pay on the Settle row; opening one also opens I’ve sent it", async () => { + mount("/settle"); + const ways = await screen.findByRole("group", { name: "Ways to pay Sam" }); + const monzo = within(ways).getByRole("link", { name: /^Monzo/ }); + expect(monzo.getAttribute("href")).toBe("https://monzo.me/samokafor/16.00?d=Printer%20ink"); + fireEvent.click(monzo); + expect(screen.getByRole("dialog", { name: "Tell Sam you’ve paid" })).toBeTruthy(); + }); + it("says plainly when the bill payer has not added any", async () => { current.payLinks = {}; mount("/settle"); fireEvent.click(await screen.findByRole("button", { name: /^I’ve sent it/ })); const dialog = screen.getByRole("dialog", { name: "Tell Sam you’ve paid" }); + expect(screen.queryByRole("group", { name: "Ways to pay Sam" })).toBeNull(); expect(within(dialog).queryByRole("link")).toBeNull(); expect(within(dialog).getByText(/Sam hasn’t added how they like to be paid/)).toBeTruthy(); }); From 7e559df4d56a3c74593414e02c667fcebf24f92a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 08:16:21 +0000 Subject: [PATCH 5/7] Phone: keep the owe sentence on one line; pay pills slide in one row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On phones the Home sentence ("You owe £78.90 in Elm Road.") eases its size down to stay on one line, never below 22px; longer sentences wrap as before. Pay pills sit on one row that slides sideways, running to the card's edge so a cut-off pill shows there is more, with a fade while anything is hidden. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WR3jnRWPbGzGV9Uyrqq8Wg --- apps/web/src/screens/Home.css | 3 ++- apps/web/src/screens/Home.tsx | 38 +++++++++++++++++++++++++++++++++- apps/web/src/screens/Hub.css | 3 ++- apps/web/src/screens/Money.css | 7 ++++++- apps/web/src/ui/PayLinks.tsx | 16 ++++++++++++-- 5 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/web/src/screens/Home.css b/apps/web/src/screens/Home.css index 5f7c0528..40696729 100644 --- a/apps/web/src/screens/Home.css +++ b/apps/web/src/screens/Home.css @@ -65,6 +65,7 @@ text-wrap: balance; } .c-home-display em { font-style: normal; } +.c-home-display.is-one-line { white-space: nowrap; text-wrap: nowrap; contain: inline-size; } .c-home-sub { max-width: 560px; font-size: 16px; line-height: 24px; color: var(--home-ink-2); } /* ---------- Status ---------- */ @@ -395,7 +396,7 @@ .c-home-feed-side strong { font-size: 13.5px; line-height: 19px; font-weight: 500; font-variant-numeric: tabular-nums; } /* How the bill payer takes money, under the next step's button. */ -.c-home-next-foot .c-pay-buttons { flex-basis: 100%; } +.c-home-next-foot .c-pay-buttons { max-width: none; flex-basis: calc(100% + 20px); margin-right: -20px; padding-right: 20px; } .c-home-row-main .c-pay-buttons { margin-top: 6px; } /* A commune card's week in a few words. */ diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 2f393e83..9601981c 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -181,10 +181,11 @@ function Standing({ places, needs, waiting, today, name }: { 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(" "); + const display = useOneLine(sentence ? `${owe.join()}|${owed.join()}|${owing.length}|${name}` : ""); return (

{longDate.format(dateFromIso(today))}

- {sentence ?

{sentence}

:

Where you stand

} + {sentence ?

{sentence}

:

Where you stand

} {sub || !ready.length ?

{sub || "Loading your communes…"}

: null}
); @@ -235,6 +236,41 @@ function NextStep({ entry, total, onAct, busy }: { entry: Entry; total: number; ); } +/** + * On a phone the sentence keeps to one line ("You owe £78.90 in Elm Road.") + * by easing its size down, never below 22px; a sentence too long even then + * wraps at the usual size. Wider screens leave it to the stylesheet. + */ +const PHONE = "(max-width: 560px)"; +const SMALLEST_LINE = 22; +function useOneLine(key: string) { + const ref = useRef(null); + useLayoutEffect(() => { + const element = ref.current; + if (!element || typeof window.matchMedia !== "function") return undefined; + const fit = () => { + element.style.fontSize = ""; + element.style.lineHeight = ""; + element.classList.remove("is-one-line"); + if (!window.matchMedia(PHONE).matches) return; + element.classList.add("is-one-line"); + const size = parseFloat(getComputedStyle(element).fontSize); + const room = element.clientWidth; + const needed = element.scrollWidth; + if (!size || !room || needed <= room) return; + const next = Math.floor(size * room / needed * 10) / 10; + if (next < SMALLEST_LINE) { element.classList.remove("is-one-line"); return; } + element.style.fontSize = `${next}px`; + element.style.lineHeight = "1.15"; + }; + fit(); + window.addEventListener("resize", fit); + void document.fonts?.ready.then(fit); + return () => window.removeEventListener("resize", fit); + }, [key]); + return ref; +} + /** Nothing needs you: say so, and point at anything others are late with. */ function Calm({ waiting, today }: { waiting: Entry[]; today: string }) { const open = useOpen(); diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css index 36ae3c17..4861f485 100644 --- a/apps/web/src/screens/Hub.css +++ b/apps/web/src/screens/Hub.css @@ -143,7 +143,8 @@ .c-hub-sentence em { padding: 0 .16em .04em; border-radius: 10px; background: var(--hub-panel); font-style: normal; font-variant-numeric: tabular-nums; } .c-hub-part-copy { color: var(--hub-muted); } .c-hub-next { margin-top: 10px; border-radius: 16px; background: var(--hub-panel); } -.c-hub-next-pay { padding: 0 16px 12px; } +.c-hub-next-pay { padding: 0 0 12px 16px; } +.c-hub-next-pay .c-pay-buttons { padding-right: 16px; scroll-padding-right: 16px; } .c-hub-next-body { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 16px 12px; } .c-hub-next-body > span { display: grid; min-width: 0; } .c-hub-next-body strong { font-size: 14px; font-weight: 500; overflow-wrap: anywhere; } diff --git a/apps/web/src/screens/Money.css b/apps/web/src/screens/Money.css index 71a475ed..481531e8 100644 --- a/apps/web/src/screens/Money.css +++ b/apps/web/src/screens/Money.css @@ -231,7 +231,12 @@ } /* Ways to pay someone, beside a payment you owe them. */ -.c-pay-buttons { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; font-size: 12.5px; line-height: 16px; color: var(--ink-soft); } +/* One row that slides sideways: a pill cut off at the edge says there is more. */ +.c-pay-buttons { display: flex; flex-wrap: nowrap; align-items: center; gap: 8px; min-width: 0; max-width: 100%; contain: inline-size; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; -webkit-overflow-scrolling: touch; font-size: 12.5px; line-height: 16px; color: var(--ink-soft); } +.c-pay-buttons::-webkit-scrollbar { display: none; } +.c-pay-buttons.has-more { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent); } +.c-pay-buttons > * { flex: none; } +.c-pay-buttons-lead { white-space: nowrap; } .c-pay-button { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--ink); font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap; cursor: pointer; } .c-pay-button:hover { border-color: var(--ink-soft); } .c-pay-button:focus-visible { outline: 2px solid var(--focus, currentColor); outline-offset: 2px; } diff --git a/apps/web/src/ui/PayLinks.tsx b/apps/web/src/ui/PayLinks.tsx index a884aecf..773db94c 100644 --- a/apps/web/src/ui/PayLinks.tsx +++ b/apps/web/src/ui/PayLinks.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type FormEvent } from "react"; import { useApp } from "../state"; import { money, type CurrencyCode } from "../live/model"; import { commandFailure } from "../data/commands"; @@ -76,10 +76,22 @@ export function PayButtons({ userId, communeId, name, amountMinor, currency, not useEffect(() => { if (load && unread) void loadPayLinksFor(communeId).catch(() => undefined); }, [load, unread, communeId, loadPayLinksFor]); + // The row slides sideways; while more is hidden off the edge it fades there. + const row = useRef(null); + const count = links?.length ?? 0; + useLayoutEffect(() => { + const element = row.current; + if (!element) return undefined; + const mark = () => element.classList.toggle("has-more", element.scrollLeft + element.clientWidth < element.scrollWidth - 1); + mark(); + element.addEventListener("scroll", mark, { passive: true }); + window.addEventListener("resize", mark); + return () => { element.removeEventListener("scroll", mark); window.removeEventListener("resize", mark); }; + }, [count]); if (!links?.length) return null; const amount = money(amountMinor, currency); const first = name.split(" ")[0] || name; - return + return {short ? null : {first} takes} {links.map((link) => { const provider = PAY_PROVIDER_NAME[link.provider]; From a0493efd0028f36d2e3d315e21418bc6180d89b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 08:49:49 +0000 Subject: [PATCH 6/7] Pay first, tell after: a favourite pay link, a pay sheet and "did it go?" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 0043 (local only): a person may mark one pay link as their favourite (set_preferred_pay_link); it is listed first. Settings offers "Make favourite". schema.sql regenerated; pay_links and grants tests cover it. - The next step on Home and the Hub, by what the person you owe has saved: a favourite opens it in one tap ("Pay £5.90 with Monzo") with "Other ways" and "Already paid" beside it; ways but no favourite show "Pay Tom £5.90", which opens a sheet of every way with "I've already paid, tell Tom"; no ways keep "I've paid Tom £5.90" and say why. - Coming back after opening their app asks "Did £5.90 go to Tom?": yes records the whole amount as sent today with the app as the reference; a different amount opens the full form; not yet changes nothing. - Still to pay rows and Settle offer Pay and Already paid when the payee has pay links. The sliding pill row is replaced by the sheet. Commune still moves no money. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WR3jnRWPbGzGV9Uyrqq8Wg --- CLAUDE.md | 2 +- apps/local-api/src/server.ts | 8 + apps/web/src/data/payLinks.ts | 6 +- apps/web/src/live/neon.ts | 2 + apps/web/src/screens/Home.css | 2 - apps/web/src/screens/Home.tsx | 75 +++++--- apps/web/src/screens/Hub.css | 2 - apps/web/src/screens/Hub.tsx | 19 +- apps/web/src/screens/Money.css | 32 ++-- apps/web/src/screens/Money.tsx | 16 +- apps/web/src/state.tsx | 11 +- apps/web/src/ui/PayLinks.tsx | 169 ++++++++++++------ apps/web/src/ui/Receipts.tsx | 89 ++++++++- apps/web/tests/needs-you.test.tsx | 58 ++++-- apps/web/tests/pay-links.test.tsx | 46 ++++- infra/neon/README.md | 5 + .../migrations/0043_preferred_pay_link.sql | 51 ++++++ infra/neon/schema.sql | 51 +++++- infra/neon/tests/grants.sql | 3 +- infra/neon/tests/pay_links.sql | 24 ++- 20 files changed, 527 insertions(+), 144 deletions(-) create mode 100644 infra/neon/migrations/0043_preferred_pay_link.sql diff --git a/CLAUDE.md b/CLAUDE.md index 963a66cf..c113484e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Commune is a shared communal expense management and group management platform. The application was migrated. The stack below is what runs today. - **Web (active app):** `apps/web`. React 19 + Vite 7 + TypeScript + wouter, plain CSS with shared tokens, tested with Vitest. pnpm. Vercel serves the production build (`apps/web/vercel.json`). -- **Hosted data and auth:** Neon (Managed Auth, Postgres, Data API). Schema and functions live in `infra/neon/migrations`: 41 migrations, 0001 to 0041, applied in numeric order. `infra/neon/schema.sql` is the schema they produce, with every function in full. Production builds use `apps/web/src/live/neon.ts`. +- **Hosted data and auth:** Neon (Managed Auth, Postgres, Data API). Schema and functions live in `infra/neon/migrations`: 43 migrations, 0001 to 0043, applied in numeric order. `infra/neon/schema.sql` is the schema they produce, with every function in full. Production builds use `apps/web/src/live/neon.ts`. - **Local development backend:** `apps/local-api`, a loopback-only PostgreSQL API on port 3002 that calls the same database functions. Vite proxies `/api` to it. Its database is set up as `infra/local/README.md` describes. It is never deployed. - **Email:** `apps/reminder-worker`, a Neon Function that sends reminder and invitation emails from the outbox through Resend, with one-click unsubscribe and a bounce webhook. - **Shared logic:** `packages/core` (split arithmetic, balances, reading typed amounts, and writing money and dates), imported as `@commune/core` through a path in `apps/web/tsconfig.json` and an alias in `apps/web/vite.config.ts`. It has no `package.json`. Amounts and day-and-month dates are written by its `formatMoney` and `formatDay`; `apps/web/tests/formatting.test.ts` fails if app code uses `Intl.NumberFormat`, `toLocaleString` or `toLocaleDateString`. diff --git a/apps/local-api/src/server.ts b/apps/local-api/src/server.ts index 9bae2bb2..4a3a761d 100644 --- a/apps/local-api/src/server.ts +++ b/apps/local-api/src/server.ts @@ -602,6 +602,14 @@ route("PUT", /^\/api\/pay-links\/(monzo|paypal|revolut|bank)$/, async (req, res, send(res, { links: result.rows[0].links }); }); +// The favourite way to be paid, or none (provider null). +route("PUT", /^\/api\/pay-links-preferred$/, async (req, res) => { + const input = await body(req); + const result = await userAction(req, async (client) => client.query( + "SELECT public.set_preferred_pay_link($1) AS links", [optionalText(input.provider, "provider", 20)])); + send(res, { links: result.rows[0].links }); +}); + route("DELETE", /^\/api\/pay-links\/(monzo|paypal|revolut|bank)$/, async (req, res, match) => { const result = await userAction(req, async (client) => client.query( "SELECT public.remove_pay_link($1) AS links", [match[1]])); diff --git a/apps/web/src/data/payLinks.ts b/apps/web/src/data/payLinks.ts index 6517f53f..0a33b03a 100644 --- a/apps/web/src/data/payLinks.ts +++ b/apps/web/src/data/payLinks.ts @@ -12,6 +12,8 @@ export type PayLink = { accountName: string | null; sortCode: string | null; accountNumber: string | null; + /** The one way they would most like to be paid, if they picked one. */ + preferred: boolean; }; export type PayLinkInput = { handle?: string; accountName?: string; sortCode?: string; accountNumber?: string; @@ -39,11 +41,11 @@ export function parsePayLinks(value: unknown): PayLink[] { if (typeof provider !== "string" || !PAY_PROVIDERS.includes(provider as PayProvider)) return []; const link: PayLink = { provider: provider as PayProvider, handle: text(row.handle), accountName: text(row.accountName), - sortCode: text(row.sortCode), accountNumber: text(row.accountNumber), + sortCode: text(row.sortCode), accountNumber: text(row.accountNumber), preferred: row.preferred === true, }; if (link.provider === "bank" ? !(link.accountName && link.sortCode && link.accountNumber) : !link.handle) return []; return [link]; - }).sort((a, b) => PAY_PROVIDERS.indexOf(a.provider) - PAY_PROVIDERS.indexOf(b.provider)); + }).sort((a, b) => Number(b.preferred) - Number(a.preferred) || PAY_PROVIDERS.indexOf(a.provider) - PAY_PROVIDERS.indexOf(b.provider)); } /** 204577 becomes 20-45-77. */ diff --git a/apps/web/src/live/neon.ts b/apps/web/src/live/neon.ts index d92fbdd8..2f37ddbb 100644 --- a/apps/web/src/live/neon.ts +++ b/apps/web/src/live/neon.ts @@ -704,6 +704,8 @@ async function route(path: string, init: RequestInit): Promise { p_provider: pathId(path, /^\/pay-links\/(monzo|paypal|revolut|bank)$/), p_handle: body.handle ?? "", p_account_name: body.accountName ?? "", p_sort_code: body.sortCode ?? "", p_account_number: body.accountNumber ?? "", })) ?? [] }; + } else if (method === "PUT" && path === "/pay-links-preferred") { + response = { links: unwrap(await db().rpc("set_preferred_pay_link", { p_provider: body.provider ?? "" })) ?? [] }; } else if (method === "DELETE" && pathId(path, /^\/pay-links\/(monzo|paypal|revolut|bank)$/)) { response = { links: unwrap(await db().rpc("remove_pay_link", { p_provider: pathId(path, /^\/pay-links\/(monzo|paypal|revolut|bank)$/), diff --git a/apps/web/src/screens/Home.css b/apps/web/src/screens/Home.css index 40696729..19e4857f 100644 --- a/apps/web/src/screens/Home.css +++ b/apps/web/src/screens/Home.css @@ -396,8 +396,6 @@ .c-home-feed-side strong { font-size: 13.5px; line-height: 19px; font-weight: 500; font-variant-numeric: tabular-nums; } /* How the bill payer takes money, under the next step's button. */ -.c-home-next-foot .c-pay-buttons { max-width: none; flex-basis: calc(100% + 20px); margin-right: -20px; padding-right: 20px; } -.c-home-row-main .c-pay-buttons { margin-top: 6px; } /* A commune card's week in a few words. */ .c-home-pulse { display: block; margin-top: 4px; font-size: 12.5px; line-height: 17px; font-weight: 500; color: var(--home-action); } diff --git a/apps/web/src/screens/Home.tsx b/apps/web/src/screens/Home.tsx index 9601981c..8be21641 100644 --- a/apps/web/src/screens/Home.tsx +++ b/apps/web/src/screens/Home.tsx @@ -9,7 +9,8 @@ import { cx } from "../ui/cx"; import { CoverImage, coverToneClass } from "../ui/CoverImage"; import { activityStamp, activityWord } from "../ui/ActivityLog"; import { useReceiptActions } from "../ui/Receipts"; -import { PayButtons } from "../ui/PayLinks"; +import { PayPrimary, usePayeeLinks } from "../ui/PayLinks"; +import type { PayLink } from "../data/payLinks"; import { addDays, dateFromIso, todayIso } from "../data/operations"; import { coverSource } from "../data/covers"; import { animate } from "../ui/motion"; @@ -192,7 +193,10 @@ function Standing({ places, needs, waiting, today, name }: { } /** 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 }) { +function NextStep({ entry, total, onAct, onPay, onOpened, busy }: { + entry: Entry; total: number; onAct: (entry: Entry, decline?: boolean) => void; + onPay: (entry: Entry) => void; onOpened: (entry: Entry, link: PayLink) => void; busy: boolean; +}) { const { hub, item } = entry; const today = todayIso(); // The bill's due date says nothing about a payment waiting to be confirmed. @@ -202,8 +206,7 @@ function NextStep({ entry, total, onAct, busy }: { entry: Entry; total: number; 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.` : ""; + const note = item.kind === "confirm" ? `Only once it has reached you. ${first} is told either way.` : ""; return (
Your next step{total > 1 ? 1 of {total} : null}
@@ -221,15 +224,17 @@ function NextStep({ entry, total, onAct, busy }: { entry: Entry; total: number; {item.amount ? {item.amount} : null} -
- - {item.kind === "confirm" - ? - : null} - {item.kind === "pay" && item.person && item.minor ? ( - onAct(entry)} disabled={busy} load /> - ) : null} +
+ {item.kind === "pay" && item.minor ? ( + onPay(entry)} onTell={() => onAct(entry)} onOpened={(link) => onOpened(entry, link)} + target={{ payerId: item.person, payerName: nameOf(hub, item.person, "the bill payer"), communeId: hub.id, + outstandingMinor: item.minor, currency: hub.currency, expenseName: item.title }} /> + ) : <> + + {item.kind === "confirm" + ? + : null} + } {note ? {note} : null}
@@ -363,14 +368,16 @@ function CommuneCard({ hub, current }: { hub: LiveHub; current: boolean }) { } /** A row in Needs you or Waiting on others. The title opens the record; the button acts on it. */ -function Row({ entry, today, waiting = false, onAct, busy, className }: { - entry: Entry; today: string; waiting?: boolean; onAct?: (entry: Entry) => void; busy?: boolean; className?: string; +function Row({ entry, today, waiting = false, onAct, onPay, busy, className }: { + entry: Entry; today: string; waiting?: boolean; onAct?: (entry: Entry) => void; onPay?: (entry: Entry) => void; busy?: boolean; className?: string; }) { const open = useOpen(); const { hub, item } = entry; const state = item.kind === "confirm" ? null : dueState(item.dueOn, today, waiting); const who = item.kind === "owed" ? item.people ?? [] : item.person ? [item.person] : []; - const verb = item.kind === "pay" ? "I’ve paid" : item.kind === "confirm" ? "Confirm" : "Open"; + const links = usePayeeLinks(item.kind === "pay" ? item.person : null, hub.id); + const payable = item.kind === "pay" && Boolean(links?.length) && Boolean(onPay); + const verb = item.kind === "pay" ? payable ? "Pay" : "I’ve paid" : item.kind === "confirm" ? "Confirm" : "Open"; const context = item.kind === "pay" ? `${nameOf(hub, item.person)} for ${item.title}` : item.kind === "confirm" ? `${item.amount} from ${nameOf(hub, item.person)}` : item.title; return (
  • @@ -382,10 +389,6 @@ function Row({ entry, today, waiting = false, onAct, busy, className }: { {detail(item)} - {onAct && item.kind === "pay" && item.person && item.minor ? ( - onAct(entry)} disabled={busy} short /> - ) : null} {item.amount ? {item.amount} : null} @@ -394,7 +397,7 @@ function Row({ entry, today, waiting = false, onAct, busy, className }: { {onAct ? ( @@ -410,8 +413,8 @@ const COLLAPSE_OVER = 7; const PHONE_SHOWN = 2; const PHONE_COLLAPSE_OVER = 3; -function NeedsList({ needs, today, onAct, busy, title = "Needs you", id = "home-needs" }: { - needs: Entry[]; today: string; onAct: (entry: Entry) => void; busy: boolean; title?: string; id?: string; +function NeedsList({ needs, today, onAct, onPay, busy, title = "Needs you", id = "home-needs" }: { + needs: Entry[]; today: string; onAct: (entry: Entry) => void; onPay?: (entry: Entry) => void; busy: boolean; title?: string; id?: string; }) { const { go } = useApp(); const [all, setAll] = useState(false); @@ -438,7 +441,7 @@ function NeedsList({ needs, today, onAct, busy, title = "Needs you", id = "home-

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

      {group.rows.map((entry, index) => ( - ))}
    @@ -757,12 +760,26 @@ function HomeWithCommunes() { } } + const payTarget = ({ hub, item }: Entry) => item.shareId && item.minor + ? { shareId: item.shareId, payerName: nameOf(hub, item.person, "the bill payer"), expenseName: item.title, + outstandingMinor: item.minor, currency: hub.currency, communeId: hub.id, payerId: item.person } + : null; + // Pay opens their ways to be paid; opening one asks on the way back whether it went. + function pay(entry: Entry) { + const target = payTarget(entry); + setMessage(""); + if (target) receipts.pay(target); else open(entry); + } + function opened(entry: Entry, link: PayLink) { + const target = payTarget(entry); + if (target) receipts.opened(target, link); + } function act(entry: Entry, decline = false) { const { hub, item } = entry; setMessage(""); - if (item.kind === "pay" && item.shareId && item.minor) { - receipts.report({ shareId: item.shareId, payerName: nameOf(hub, item.person, "the bill payer"), expenseName: item.title, - outstandingMinor: item.minor, currency: hub.currency, communeId: hub.id, payerId: item.person }); + const target = item.kind === "pay" ? payTarget(entry) : null; + if (target) { + receipts.report(target); } else if (item.kind === "confirm" && item.report) { receipts.answer({ report: item.report, debtorName: nameOf(hub, item.person, "a former member"), expenseName: item.title, currency: hub.currency, decision: decline ? "decline" : "confirm", communeId: hub.id, expenseId: item.expenseId }); @@ -804,7 +821,7 @@ function HomeWithCommunes() {
    - {next ? : ready ? : null} + {next ? : ready ? : null} {status}
    {communeList}
    @@ -814,7 +831,7 @@ function HomeWithCommunes() { <>
    {chores.length ? : null} - {toPay.length ? : null} + {toPay.length ? : null}
    {waitingAll.length ? : null} diff --git a/apps/web/src/screens/Hub.css b/apps/web/src/screens/Hub.css index 4861f485..624a84fa 100644 --- a/apps/web/src/screens/Hub.css +++ b/apps/web/src/screens/Hub.css @@ -143,8 +143,6 @@ .c-hub-sentence em { padding: 0 .16em .04em; border-radius: 10px; background: var(--hub-panel); font-style: normal; font-variant-numeric: tabular-nums; } .c-hub-part-copy { color: var(--hub-muted); } .c-hub-next { margin-top: 10px; border-radius: 16px; background: var(--hub-panel); } -.c-hub-next-pay { padding: 0 0 12px 16px; } -.c-hub-next-pay .c-pay-buttons { padding-right: 16px; scroll-padding-right: 16px; } .c-hub-next-body { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 16px 12px; } .c-hub-next-body > span { display: grid; min-width: 0; } .c-hub-next-body strong { font-size: 14px; font-weight: 500; overflow-wrap: anywhere; } diff --git a/apps/web/src/screens/Hub.tsx b/apps/web/src/screens/Hub.tsx index dce90f4b..706e9204 100644 --- a/apps/web/src/screens/Hub.tsx +++ b/apps/web/src/screens/Hub.tsx @@ -8,7 +8,7 @@ import { CoverImage, coverToneClass } from "../ui/CoverImage"; import { isNotePinned, isSecretFact, noteCategory, notePreview } from "../data/notePresentation"; import { addDays, dateFromIso, needsDoneBy, taskDoneCopy, todayIso, type DoneBy, type OperationItem } from "../data/operations"; import { useReceiptActions } from "../ui/Receipts"; -import { PayButtons } from "../ui/PayLinks"; +import { PayPrimary } from "../ui/PayLinks"; import type { Need } from "../live/model"; import { hideToast, showToast } from "../ui/Toast"; import { DoneByChoice } from "../ui/DoneByChoice"; @@ -109,6 +109,9 @@ function YourPart() { } else go("expense", { expense: next.expenseId }); } const square = hub.oweLabel === "You’re even"; + const payTarget = next?.kind === "pay" && next.shareId && next.minor + ? { shareId: next.shareId, payerName: who, expenseName: next.title, outstandingMinor: next.minor, currency: hub.currency, communeId: hub.id, payerId: next.person } + : null; return

    Your part in {hub.name}

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

    @@ -118,14 +121,12 @@ function YourPart() { {next.title}{next.when} {next.amount && {next.amount}}
    - {next.kind === "pay" && next.minor ?
    - act()} disabled={receipts.busy} /> -
    : null} -
    - +
    + {payTarget ? { setMessage(""); receipts.pay(payTarget); }} + onTell={() => act()} onOpened={(link) => receipts.opened(payTarget, link)} /> + : } {next.kind === "confirm" && } {money.length > 1 && }
    diff --git a/apps/web/src/screens/Money.css b/apps/web/src/screens/Money.css index 481531e8..bc88d113 100644 --- a/apps/web/src/screens/Money.css +++ b/apps/web/src/screens/Money.css @@ -231,17 +231,23 @@ } /* Ways to pay someone, beside a payment you owe them. */ -/* One row that slides sideways: a pill cut off at the edge says there is more. */ -.c-pay-buttons { display: flex; flex-wrap: nowrap; align-items: center; gap: 8px; min-width: 0; max-width: 100%; contain: inline-size; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; -webkit-overflow-scrolling: touch; font-size: 12.5px; line-height: 16px; color: var(--ink-soft); } -.c-pay-buttons::-webkit-scrollbar { display: none; } -.c-pay-buttons.has-more { -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent); } -.c-pay-buttons > * { flex: none; } -.c-pay-buttons-lead { white-space: nowrap; } -.c-pay-button { display: inline-flex; align-items: center; min-height: 32px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--ink); font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap; cursor: pointer; } -.c-pay-button:hover { border-color: var(--ink-soft); } -.c-pay-button:focus-visible { outline: 2px solid var(--focus, currentColor); outline-offset: 2px; } -.c-pay-button[aria-disabled="true"], .c-pay-button:disabled { opacity: .5; cursor: default; } -.a-move .c-pay-buttons { margin-top: 6px; } -.c-pay-buttons.is-short .c-pay-button { min-height: 28px; padding: 0 10px; font-size: 12px; } -@media (pointer: coarse) { .c-pay-button, .c-pay-buttons.is-short .c-pay-button { min-height: 44px; } } +/* Paying: the main button (their favourite way, or "Pay Tom"), what else + there is, and the way to say it was already paid. */ +.c-pay-primary { display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 46px; padding: 0 20px; border: 0; border-radius: var(--radius-control); background: var(--action); color: var(--on-action); font: inherit; font-size: 15px; font-weight: 600; text-decoration: none; cursor: pointer; } +.c-pay-primary:hover { background: var(--action-hover); } +.c-pay-primary:focus-visible, .c-pay-secondary:focus-visible, .c-pay-tell:focus-visible { outline: 2px solid var(--action); outline-offset: 2px; } +.c-pay-primary:disabled, .c-pay-primary[aria-disabled="true"], .c-pay-secondary:disabled, .c-pay-tell:disabled { opacity: .45; cursor: not-allowed; } +.c-pay-more { display: inline-flex; flex-wrap: wrap; gap: 8px; } +.c-pay-secondary { min-height: 46px; padding: 0 18px; border: 1px solid var(--border); border-radius: var(--radius-control); background: transparent; color: var(--ink); font: inherit; font-size: 14.5px; font-weight: 600; cursor: pointer; } +.c-pay-hint { flex-basis: 100%; margin: 0; font-size: 12.5px; line-height: 18px; color: var(--ink-soft); } +.c-pay-tell { min-height: 44px; padding: 0 4px; border: 0; background: none; color: var(--action); font: inherit; font-size: 14.5px; font-weight: 600; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; } +.c-pay-intro { margin: 0 0 12px; font-size: 14px; line-height: 1.5; color: var(--ink-soft); } +@media (max-width: 560px) { + .c-pay-primary { flex: 1 1 100%; height: 50px; font-size: 16px; } + .c-pay-more { flex: 1 1 100%; } + .c-pay-more .c-pay-secondary { flex: 1 1 0; } + .c-pay-hint { text-align: center; } + .c-pay-tell { flex: 1 1 100%; } +} +.c-pay-favourite { color: var(--action); font-weight: 600; } } diff --git a/apps/web/src/screens/Money.tsx b/apps/web/src/screens/Money.tsx index a9ec49e3..cbd80726 100644 --- a/apps/web/src/screens/Money.tsx +++ b/apps/web/src/screens/Money.tsx @@ -8,7 +8,6 @@ import { LIMITS } from "../data/limits"; import { ReminderFlowDialog, useReminderFlow } from "../ui/ReminderConfirm"; import { formatReminderDay, reminderEligibility } from "../data/reminders"; import { LatestReportAnswer, pendingReport, reportSummary, useReceiptActions } from "../ui/Receipts"; -import { PayButtons } from "../ui/PayLinks"; import { CalendarDate } from "../ui/CalendarDate"; import { initialsOf } from "../data/household"; import { formatMoney } from "@commune/core"; @@ -147,7 +146,7 @@ export function ExpensesEmptyScreen() { } export function SettleScreen() { - const { go, hub, expenses, session, reminderTargets, reminderUsage, sentReminders, sendReminder, personName } = useApp(); + const { go, hub, expenses, session, reminderTargets, reminderUsage, sentReminders, sendReminder, personName, payLinksFor } = useApp(); const reminder = useReminderFlow(sendReminder); const [message, setMessage] = useState(""); const receipts = useReceiptActions(setMessage); @@ -212,8 +211,6 @@ export function SettleScreen() { {report ?
    {receiving ? `${reportSummary(report, hub.currency, personName(share.userId, "A member"))}. Not yet confirmed.` : `You said you sent ${formatMoney(report.amountMinor, hub.currency)} on ${formatReminderDay(report.sentOn)}. Waiting for ${payerName}.`}
    : null} - {!receiving && !report ? { setMessage(""); receipts.report({ shareId: share.id, payerName, payerId: expense.paidByUserId, expenseName: expense.name, outstandingMinor: outstanding, currency: hub.currency }); }} /> : null} {!receiving ? personName(userId, "The bill payer")} /> : null} {receiving && (target || (reminderUsage?.recipientUsage[share.userId] ?? 0) > 0) ? ( @@ -247,9 +244,16 @@ export function SettleScreen() {
    ) : (
    - + + : + }
    )}
    diff --git a/apps/web/src/state.tsx b/apps/web/src/state.tsx index 1368c0a6..b61be2ee 100644 --- a/apps/web/src/state.tsx +++ b/apps/web/src/state.tsx @@ -293,6 +293,8 @@ export type AppState = { loadMyPayLinks: () => Promise; savePayLink: (provider: PayProvider, input: PayLinkInput) => Promise; removePayLink: (provider: PayProvider) => Promise; + /** Mark one of your pay links as your favourite, or none (null). */ + setPreferredPayLink: (provider: PayProvider | null) => Promise; native: boolean; you: { id: string; name: string; email: string; initials: string; photo?: string }; lastExpense: { name: string; amount: string; paidBy: string } | null; @@ -1656,6 +1658,13 @@ export function AppProvider({ children }: { children: ReactNode }) { await refreshRecords(); return links; }), [mutate, refreshRecords]); + const setPreferredPayLink = useCallback((provider: PayProvider | null) => mutate(async () => { + const result = await api<{ links: unknown }>("/pay-links-preferred", { method: "PUT", body: JSON.stringify({ provider }) }); + const links = parsePayLinks(result.links); + setMyPayLinks(links); + await refreshRecords(); + return links; + }), [mutate, refreshRecords]); const heldSnapshots = data?.snapshots; const payLinksFor = useCallback((userId: string | null | undefined, communeId?: string) => { const record = heldSnapshots?.[communeId ?? activeId]; @@ -2030,7 +2039,7 @@ export function AppProvider({ children }: { children: ReactNode }) { operationTime, operationsByHub, operations, selectedOperation, fundKey, dark: device.theme === "dark" || (device.theme === "system" && prefersDark), theme: device.theme, noticesOn: device.noticesOn, searchTarget, searchOpened, invites: data?.invites[activeId] ?? NONE, payMethods: prefs.payMethods, - payLinksFor, loadPayLinksFor, myPayLinks, loadMyPayLinks, savePayLink, removePayLink, native, you, + payLinksFor, loadPayLinksFor, myPayLinks, loadMyPayLinks, savePayLink, removePayLink, setPreferredPayLink, native, you, lastExpense, expenses, selectedExpense, createdExpenses: expenses, voidedExpenses, expenseRead, retryExpenseRead, expenseHistory, loadEarlierExpenses, loadFullHistory, funds, recurringBills, splitTemplates, expenseTemplateId, reminderTargets, reminderUsage, sentReminders, notifications, diff --git a/apps/web/src/ui/PayLinks.tsx b/apps/web/src/ui/PayLinks.tsx index 773db94c..a954e1ed 100644 --- a/apps/web/src/ui/PayLinks.tsx +++ b/apps/web/src/ui/PayLinks.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { useApp } from "../state"; import { money, type CurrencyCode } from "../live/model"; import { commandFailure } from "../data/commands"; @@ -6,7 +6,7 @@ import { bankDetailsText, formatSortCode, payLinkHref, payLinkLabel, PAY_PROVIDER_NAME, PAY_PROVIDERS, type PayLink, type PayLinkInput, type PayProvider, } from "../data/payLinks"; -import { Button, Feedback, Field } from "./components"; +import { Button, Dialog, Feedback, Field } from "./components"; // Styles sit with the other money surfaces. import "../screens/Money.css"; @@ -15,8 +15,10 @@ import "../screens/Money.css"; * Each link opens the payer's own app; Commune records nothing until the * person reports the payment. */ -export function PayeeLinks({ userId, communeId, name, amountMinor, currency, note }: { +export function PayeeLinks({ userId, communeId, name, amountMinor, currency, note, onOpen }: { userId: string | null | undefined; communeId?: string; name: string; amountMinor: number; currency: CurrencyCode; note?: string; + /** Runs when one of their apps is opened, so Commune can ask afterwards whether it went. */ + onOpen?: (link: PayLink) => void; }) { const { payLinksFor, loadPayLinksFor } = useApp(); const links = payLinksFor(userId, communeId); @@ -50,63 +52,107 @@ export function PayeeLinks({ userId, communeId, name, amountMinor, currency, not {target ? {target.amountFilled ? `${amount} filled in` : `Enter ${amount} when it opens`} : null}
    {target - ? Open {PAY_PROVIDER_NAME[link.provider]} + ? onOpen?.(link)}>Open {PAY_PROVIDER_NAME[link.provider]} : } ; })} ; } +/** Someone you owe, as the pay controls need them. */ +export type PayeeTarget = { + payerId?: string; payerName: string; communeId?: string; + outstandingMinor: number; currency: CurrencyCode; expenseName: string; +}; + +/** Their pay links, reading the commune's full record when only a summary is held. */ +export function usePayeeLinks(payerId: string | null | undefined, communeId?: string): PayLink[] | null { + const { payLinksFor, loadPayLinksFor } = useApp(); + const links = payLinksFor(payerId, communeId); + const unread = links === null && Boolean(payerId); + useEffect(() => { + if (unread) void loadPayLinksFor(communeId).catch(() => undefined); + }, [unread, communeId, loadPayLinksFor]); + return payerId ? links : []; +} + +const firstOf = (name: string) => name.split(" ")[0] || name; + /** - * The ways someone takes payment, as buttons beside a payment you owe them: - * "Tom takes [Monzo £5.90] [Bank transfer]". A link opens their own app with - * the amount filled in where it can. Either button also runs onOpen, which - * opens "I’ve paid" (with the bank details to copy), so the payment is - * recorded when the person comes back. Commune moves no money. + * The main payment action, by what the person you owe has saved: + * - a favourite way: one button that opens it ("Pay £5.90 with Monzo"), + * with their other ways and "Already paid" beside it; + * - ways but no favourite: "Pay Tom £5.90", which lists them; + * - none: "I’ve paid Tom £5.90", which records a payment made however you + * usually pay them. + * Commune moves no money: a link opens their own app, and coming back asks + * whether it went. */ -export function PayButtons({ userId, communeId, name, amountMinor, currency, note, onOpen, load = false, short = false, disabled = false }: { - userId: string | null | undefined; communeId?: string; name: string; amountMinor: number; currency: CurrencyCode; note?: string; - onOpen?: () => void; load?: boolean; short?: boolean; disabled?: boolean; +export function PayPrimary({ target, onPay, onTell, onOpened, busy = false, size = "large" }: { + target: PayeeTarget; onPay: () => void; onTell: () => void; onOpened: (link: PayLink) => void; + busy?: boolean; size?: "large" | "normal"; }) { - const { payLinksFor, loadPayLinksFor } = useApp(); - const links = payLinksFor(userId, communeId); - const unread = links === null; - // A commune held only as a summary has no pay links; reading its record is - // worth it for one next step, not for every row of a list. - useEffect(() => { - if (load && unread) void loadPayLinksFor(communeId).catch(() => undefined); - }, [load, unread, communeId, loadPayLinksFor]); - // The row slides sideways; while more is hidden off the edge it fades there. - const row = useRef(null); - const count = links?.length ?? 0; - useLayoutEffect(() => { - const element = row.current; - if (!element) return undefined; - const mark = () => element.classList.toggle("has-more", element.scrollLeft + element.clientWidth < element.scrollWidth - 1); - mark(); - element.addEventListener("scroll", mark, { passive: true }); - window.addEventListener("resize", mark); - return () => { element.removeEventListener("scroll", mark); window.removeEventListener("resize", mark); }; - }, [count]); - if (!links?.length) return null; - const amount = money(amountMinor, currency); - const first = name.split(" ")[0] || name; - return - {short ? null : {first} takes} - {links.map((link) => { - const provider = PAY_PROVIDER_NAME[link.provider]; - const target = payLinkHref(link, amountMinor, currency, note); - const label = target?.amountFilled && !short ? `${provider} ${amount}` : provider; - return target - ? { if (disabled) { event.preventDefault(); return; } onOpen?.(); }}> - {label}, opens {provider} to pay {first} {amount} + const links = usePayeeLinks(target.payerId, target.communeId); + const amount = money(target.outstandingMinor, target.currency); + const first = firstOf(target.payerName); + const big = size === "large" ? " is-large" : ""; + if (!links?.length) { + return <> + +

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

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

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

    + ; + } + return <> + +

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

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

    {target.expenseName}

    + {links?.length ?

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

    : null} + +

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

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

    Loading your pay links…

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

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

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

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

    +

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

    + +
    + + + +
    +

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

    +
    ; +} + /** * One place to open the receipt and report dialogs from any screen. What was * recorded is said in the toast; onError gets what could not be done, to show @@ -396,6 +442,34 @@ export function useReceiptActions(onError: (message: string) => void, options: { const [reporting, setReporting] = useState(null); const [answering, setAnswering] = useState(null); const [withdrawing, setWithdrawing] = useState(false); + // Paying: the sheet of their ways to be paid, then, once the person has + // left for their app and come back, a question whether it went. + const [paying, setPaying] = useState(null); + const [away, setAway] = useState<{ target: PaymentReportTarget; provider: string } | null>(null); + const [asking, setAsking] = useState<{ target: PaymentReportTarget; provider: string } | null>(null); + useEffect(() => { + if (!away) return undefined; + let left = false; + const leave = () => { left = true; }; + const back = () => { + if (!left || document.visibilityState === "hidden") return; + setAsking(away); + setAway(null); + }; + const visibility = () => (document.visibilityState === "hidden" ? leave() : back()); + window.addEventListener("blur", leave); + window.addEventListener("focus", back); + document.addEventListener("visibilitychange", visibility); + return () => { + window.removeEventListener("blur", leave); + window.removeEventListener("focus", back); + document.removeEventListener("visibilitychange", visibility); + }; + }, [away]); + function opened(target: PaymentReportTarget, link: PayLink) { + setPaying(null); + setAway({ target, provider: PAY_PROVIDER_NAME[link.provider] }); + } async function withdraw(report: PaymentReport) { if (withdrawing) return; setWithdrawing(true); @@ -411,6 +485,11 @@ export function useReceiptActions(onError: (message: string) => void, options: { } } const dialogs = <> + {paying && setPaying(null)} + onTell={() => { const target = paying; setPaying(null); setReporting(target); }} onOpened={(link) => opened(paying, link)} />} + {asking && setAsking(null)} + onDifferent={() => { const target = asking.target; setAsking(null); setReporting(target); }} + onReported={toast => { setAsking(null); done(toast); }} />} {reporting && setReporting(null)} onReported={toast => { setReporting(null); done(toast); }} />} {answering && setAnswering(null)} @@ -422,6 +501,10 @@ export function useReceiptActions(onError: (message: string) => void, options: { ; return { record: setRecording, reverse: setReversing, report: setReporting, answer: setAnswering, withdraw, dialogs, - busy: recording !== null || reversing !== null || reporting !== null || answering !== null || withdrawing, + /** Opens "Pay Tom £5.90": every way they take money. */ + pay: setPaying, + /** One of their apps was opened from the page itself: ask on the way back. */ + opened, + busy: recording !== null || reversing !== null || reporting !== null || answering !== null || withdrawing || paying !== null || asking !== null, }; } diff --git a/apps/web/tests/needs-you.test.tsx b/apps/web/tests/needs-you.test.tsx index 8112ba64..f0996683 100644 --- a/apps/web/tests/needs-you.test.tsx +++ b/apps/web/tests/needs-you.test.tsx @@ -1,5 +1,5 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; import { App } from "../src/App"; @@ -292,22 +292,54 @@ describe("Today on Home", () => { expect(readAgain()).toEqual(["home-1"]); }); - it("offers the bill payer’s own ways to pay on the next step, each opening I’ve paid too", async () => { - vi.mocked(loadSnapshot).mockImplementation(async (commune) => commune.id === home.id - ? { ...homeSnapshot(), reports: undefined, expenses: homeSnapshot().expenses.filter((item) => item.title !== "Electricity"), - payLinks: { ben: [{ provider: "monzo", handle: "benbaker" }, { provider: "bank", accountName: "Ben Baker", sortCode: "204577", accountNumber: "41029388" }] } } - : { ...tripSummary(), expenses: [] }); + const withLinks = (links: unknown[]) => vi.mocked(loadSnapshot).mockImplementation(async (commune) => commune.id === home.id + ? { ...homeSnapshot(), reports: undefined, expenses: homeSnapshot().expenses.filter((item) => item.title !== "Electricity"), + payLinks: { ben: links } } + : { ...tripSummary(), expenses: [] }); + const monzo = { provider: "monzo", handle: "benbaker" }; + const bank = { provider: "bank", accountName: "Ben Baker", sortCode: "204577", accountNumber: "41029388" }; + + it("with no favourite, Pay lists every way the bill payer takes money, and Already paid tells them", async () => { + withLinks([monzo, bank]); mount("/"); const next = await screen.findByRole("article", { name: "Internet" }); - const ways = within(next).getByRole("group", { name: "Ways to pay Ben Baker" }); - const monzo = within(ways).getByRole("link", { name: /^Monzo £15\.00/ }); - expect(monzo.getAttribute("href")).toBe("https://monzo.me/benbaker/15.00?d=Internet"); - expect(monzo.getAttribute("target")).toBe("_blank"); - expect(within(ways).getByRole("button", { name: /^Bank transfer/ })).toBeTruthy(); - // Opening their app also opens "I’ve paid", so the payment is recorded on the way back. - fireEvent.click(monzo); + expect(within(next).getByText("Monzo · Bank transfer")).toBeTruthy(); + fireEvent.click(within(next).getByRole("button", { name: "Pay Ben £15.00" })); + const sheet = await screen.findByRole("dialog", { name: "Pay Ben £15.00" }); + expect(within(sheet).getByRole("link", { name: "Open Monzo" }).getAttribute("href")).toBe("https://monzo.me/benbaker/15.00?d=Internet"); + expect(within(sheet).getByRole("button", { name: "Copy details" })).toBeTruthy(); + fireEvent.click(within(sheet).getByRole("button", { name: "I’ve already paid, tell Ben" })); expect(await screen.findByRole("dialog", { name: "Tell Ben Baker you’ve paid" })).toBeTruthy(); }); + + it("with a favourite, one button opens it, and coming back asks whether it went", async () => { + withLinks([bank, { ...monzo, preferred: true }]); + vi.mocked(post).mockResolvedValue({ duplicate: false }); + mount("/"); + const next = await screen.findByRole("article", { name: "Internet" }); + const open = within(next).getByRole("link", { name: /^Pay £15\.00 with Monzo/ }); + expect(open.getAttribute("href")).toBe("https://monzo.me/benbaker/15.00?d=Internet"); + expect(open.getAttribute("target")).toBe("_blank"); + expect(within(next).getByRole("button", { name: "Other ways (1)" })).toBeTruthy(); + expect(within(next).getByRole("button", { name: "Already paid" })).toBeTruthy(); + fireEvent.click(open); + // Nothing is asked until the person has been away and come back. + expect(screen.queryByRole("dialog")).toBeNull(); + act(() => { window.dispatchEvent(new Event("blur")); window.dispatchEvent(new Event("focus")); }); + const back = await screen.findByRole("dialog", { name: "Did £15.00 go to Ben?" }); + fireEvent.click(within(back).getByRole("button", { name: "Yes, tell Ben" })); + await waitFor(() => expect(toastText()).toBe("Recorded. Ben will confirm your £15.00.")); + expect(post).toHaveBeenCalledWith("/shares/share-me-1500/payment-reports", + expect.objectContaining({ amountMinor: 1500, sentOn: today, reference: "Monzo" })); + }); + + it("with no pay links saved, keeps I’ve paid and says why", async () => { + withLinks([]); + mount("/"); + const next = await screen.findByRole("article", { name: "Internet" }); + expect(within(next).getByRole("button", { name: "I’ve paid Ben £15.00" })).toBeTruthy(); + expect(within(next).getByText(/Ben hasn’t said how they like to be paid/)).toBeTruthy(); + }); }); describe("Coming up on Home", () => { diff --git a/apps/web/tests/pay-links.test.tsx b/apps/web/tests/pay-links.test.tsx index 87bacf83..9497b2d9 100644 --- a/apps/web/tests/pay-links.test.tsx +++ b/apps/web/tests/pay-links.test.tsx @@ -39,6 +39,9 @@ describe("pay link addresses", () => { "nonsense", ]); expect(links.map((link) => link.provider)).toEqual(["monzo", "bank"]); + // A favourite comes first. + expect(parsePayLinks([{ provider: "monzo", handle: "sam" }, { provider: "revolut", handle: "sam", preferred: true }]) + .map((link) => [link.provider, link.preferred])).toEqual([["revolut", true], ["monzo", false]]); expect(parsePayLinks(undefined)).toEqual([]); }); @@ -117,7 +120,7 @@ afterEach(cleanup); describe("paying someone who has shared their pay links", () => { it("offers their Monzo with the amount filled in, and their bank details, before you report it", async () => { mount("/settle"); - fireEvent.click(await screen.findByRole("button", { name: /^I’ve sent it/ })); + fireEvent.click(await screen.findByRole("button", { name: "Already paid Sam for Printer ink" })); const dialog = screen.getByRole("dialog", { name: "Tell Sam you’ve paid" }); const open = within(dialog).getByRole("link", { name: "Open Monzo" }); expect(open.getAttribute("href")).toBe("https://monzo.me/samokafor/16.00?d=Printer%20ink"); @@ -127,12 +130,15 @@ describe("paying someone who has shared their pay links", () => { expect(within(dialog).getByRole("button", { name: "Copy details" })).toBeTruthy(); }); - it("puts their ways to pay on the Settle row; opening one also opens I’ve sent it", async () => { + it("Pay on the Settle row lists their ways to be paid; opening one asks on the way back", async () => { mount("/settle"); - const ways = await screen.findByRole("group", { name: "Ways to pay Sam" }); - const monzo = within(ways).getByRole("link", { name: /^Monzo/ }); - expect(monzo.getAttribute("href")).toBe("https://monzo.me/samokafor/16.00?d=Printer%20ink"); - fireEvent.click(monzo); + fireEvent.click(await screen.findByRole("button", { name: "Pay Sam for Printer ink" })); + const sheet = screen.getByRole("dialog", { name: "Pay Sam £16.00" }); + fireEvent.click(within(sheet).getByRole("link", { name: "Open Monzo" })); + expect(screen.queryByRole("dialog", { name: "Pay Sam £16.00" })).toBeNull(); + act(() => { document.dispatchEvent(new Event("visibilitychange")); window.dispatchEvent(new Event("blur")); window.dispatchEvent(new Event("focus")); }); + const back = await screen.findByRole("dialog", { name: "Did £16.00 go to Sam?" }); + fireEvent.click(within(back).getByRole("button", { name: "Paid a different amount" })); expect(screen.getByRole("dialog", { name: "Tell Sam you’ve paid" })).toBeTruthy(); }); @@ -141,7 +147,7 @@ describe("paying someone who has shared their pay links", () => { mount("/settle"); fireEvent.click(await screen.findByRole("button", { name: /^I’ve sent it/ })); const dialog = screen.getByRole("dialog", { name: "Tell Sam you’ve paid" }); - expect(screen.queryByRole("group", { name: "Ways to pay Sam" })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Pay Sam/ })).toBeNull(); expect(within(dialog).queryByRole("link")).toBeNull(); expect(within(dialog).getByText(/Sam hasn’t added how they like to be paid/)).toBeTruthy(); }); @@ -163,6 +169,32 @@ describe("your own pay links", () => { expect(vi.mocked(api).mock.calls.some(([path, init]) => path === "/pay-links/monzo" && init?.method === "DELETE")).toBe(true); expect(statusLines()).toContain("Monzo removed."); }); + + it("marks one way as the favourite, and can take it off again", async () => { + saved = [monzo]; + vi.mocked(api).mockImplementation(async (path, init) => { + if (path === "/auth/me") return { user }; + if (path === "/reminders/usage") return { totalUsed: 0, totalLimit: 60, recipientLimit: 8, recipientUsage: {}, nextSlotAt: null }; + if (path.endsWith("/invites")) return { invites: [] }; + if (path === "/pay-links") return { links: saved }; + if (path === "/pay-links-preferred" && init?.method === "PUT") { + const provider = JSON.parse(String(init.body)).provider; + saved = [{ ...monzo, preferred: provider === "monzo" }]; + return { links: saved }; + } + return { ok: true }; + }); + mount("/settings"); + fireEvent.click(await screen.findByRole("button", { name: /How people pay you/ })); + const make = await screen.findByRole("button", { name: "Make favourite Monzo" }); + await act(async () => { fireEvent.click(make); }); + const put = vi.mocked(api).mock.calls.find(([path]) => path === "/pay-links-preferred"); + expect(JSON.parse(String(put?.[1]?.body))).toEqual({ provider: "monzo" }); + expect(await screen.findByText("Your favourite")).toBeTruthy(); + expect(statusLines().some((line) => line.includes("Monzo is your favourite"))).toBe(true); + await act(async () => { fireEvent.click(await screen.findByRole("button", { name: "Unfavourite Monzo" })); }); + expect(screen.queryByText("Your favourite")).toBeNull(); + }); }); /** Every live status line's words; the toast region is one of them, and empty here. */ diff --git a/infra/neon/README.md b/infra/neon/README.md index 168bfdd2..5201aa02 100644 --- a/infra/neon/README.md +++ b/infra/neon/README.md @@ -19,6 +19,11 @@ Pay links, **0042** (local only), add `my_pay_links`, `set_pay_link`, Apply it after 0041, then deploy the client that calls them. The previous client ignores the new snapshot field. Test: `tests/pay_links.sql`. +A favourite pay link, **0043** (local only), adds `preferred` to each pay +link (the favourite listed first, at most one per person) and +`set_preferred_pay_link`. Apply it after 0042, then deploy the client that +offers it. The previous client ignores the new field. Test: `tests/pay_links.sql`. + Migrations **0015 to 0041 are local only**. They have not been applied to any Neon branch. Release order when they are: apply 0013 to 0041 in numeric order to the preview branch, refresh that branch's Data API schema cache, then diff --git a/infra/neon/migrations/0043_preferred_pay_link.sql b/infra/neon/migrations/0043_preferred_pay_link.sql new file mode 100644 index 00000000..c5fcde0f --- /dev/null +++ b/infra/neon/migrations/0043_preferred_pay_link.sql @@ -0,0 +1,51 @@ +-- A favourite way to be paid. A person may mark one of their pay links as the +-- one they prefer; people who owe them then get a single button that opens +-- it, with the others a tap away. Marking none is fine: they see all of them. +-- Depends on 0042. +BEGIN; + +ALTER TABLE public.member_pay_links ADD COLUMN preferred boolean NOT NULL DEFAULT false; +CREATE UNIQUE INDEX member_pay_links_one_preferred ON public.member_pay_links (user_id) WHERE preferred; + +-- The favourite first, then the usual order. +CREATE OR REPLACE FUNCTION commune_private.pay_links_for(p_user_id text) +RETURNS jsonb LANGUAGE sql STABLE SET search_path = public, pg_temp AS $$ + SELECT coalesce(jsonb_agg(jsonb_build_object('provider', l.provider, 'handle', l.handle, + 'accountName', l.account_name, 'sortCode', l.sort_code, 'accountNumber', l.account_number, + 'preferred', l.preferred, 'updatedAt', l.updated_at) + ORDER BY l.preferred DESC, array_position(ARRAY['monzo', 'paypal', 'revolut', 'bank'], l.provider)), '[]'::jsonb) + FROM public.member_pay_links l WHERE l.user_id = p_user_id; +$$; +REVOKE ALL ON FUNCTION commune_private.pay_links_for(text) FROM PUBLIC; + +-- Mark one of your own links as the favourite, or pass nothing to have none. +-- Marking the one already marked is harmless. +CREATE FUNCTION public.set_preferred_pay_link(p_provider text) +RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_temp AS $$ +DECLARE + v_actor text := commune_private.data_api_user_id(); + v_provider text := nullif(lower(btrim(coalesce(p_provider, ''))), ''); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'Sign in to change how people pay you' USING ERRCODE = '42501'; + END IF; + IF v_provider IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM public.member_pay_links WHERE user_id = v_actor AND provider = v_provider + ) THEN + RAISE EXCEPTION 'Add that way to be paid before making it your favourite' USING ERRCODE = '22023'; + END IF; + UPDATE public.member_pay_links SET preferred = false + WHERE user_id = v_actor AND preferred AND provider IS DISTINCT FROM v_provider; + IF v_provider IS NOT NULL THEN + UPDATE public.member_pay_links SET preferred = true + WHERE user_id = v_actor AND provider = v_provider AND NOT preferred; + END IF; + RETURN commune_private.pay_links_for(v_actor); +END; +$$; + +REVOKE ALL ON FUNCTION public.set_preferred_pay_link(text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.set_preferred_pay_link(text) TO authenticated; + +NOTIFY pgrst, 'reload schema'; +COMMIT; diff --git a/infra/neon/schema.sql b/infra/neon/schema.sql index 77271ab8..cc02f9e0 100644 --- a/infra/neon/schema.sql +++ b/infra/neon/schema.sql @@ -708,8 +708,8 @@ CREATE FUNCTION commune_private.pay_links_for(p_user_id text) RETURNS jsonb AS $$ SELECT coalesce(jsonb_agg(jsonb_build_object('provider', l.provider, 'handle', l.handle, 'accountName', l.account_name, 'sortCode', l.sort_code, 'accountNumber', l.account_number, - 'updatedAt', l.updated_at) - ORDER BY array_position(ARRAY['monzo', 'paypal', 'revolut', 'bank'], l.provider)), '[]'::jsonb) + 'preferred', l.preferred, 'updatedAt', l.updated_at) + ORDER BY l.preferred DESC, array_position(ARRAY['monzo', 'paypal', 'revolut', 'bank'], l.provider)), '[]'::jsonb) FROM public.member_pay_links l WHERE l.user_id = p_user_id; $$; @@ -3922,6 +3922,37 @@ END; $_$; +-- +-- Name: set_preferred_pay_link(text); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.set_preferred_pay_link(p_provider text) RETURNS jsonb + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO 'public', 'pg_temp' + AS $$ +DECLARE + v_actor text := commune_private.data_api_user_id(); + v_provider text := nullif(lower(btrim(coalesce(p_provider, ''))), ''); +BEGIN + IF v_actor IS NULL THEN + RAISE EXCEPTION 'Sign in to change how people pay you' USING ERRCODE = '42501'; + END IF; + IF v_provider IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM public.member_pay_links WHERE user_id = v_actor AND provider = v_provider + ) THEN + RAISE EXCEPTION 'Add that way to be paid before making it your favourite' USING ERRCODE = '22023'; + END IF; + UPDATE public.member_pay_links SET preferred = false + WHERE user_id = v_actor AND preferred AND provider IS DISTINCT FROM v_provider; + IF v_provider IS NOT NULL THEN + UPDATE public.member_pay_links SET preferred = true + WHERE user_id = v_actor AND provider = v_provider AND NOT preferred; + END IF; + RETURN commune_private.pay_links_for(v_actor); +END; +$$; + + -- -- Name: set_recurring_bill_archived(uuid, boolean); Type: FUNCTION; Schema: public; Owner: - -- @@ -5228,6 +5259,7 @@ CREATE TABLE public.member_pay_links ( sort_code text, account_number text, updated_at timestamp with time zone DEFAULT now() NOT NULL, + preferred boolean DEFAULT false NOT NULL, CONSTRAINT member_pay_links_account_name_check CHECK (((length(btrim(account_name)) >= 1) AND (length(btrim(account_name)) <= 60))), CONSTRAINT member_pay_links_account_number_check CHECK ((account_number ~ '^[0-9]{8}$'::text)), CONSTRAINT member_pay_links_check CHECK ( @@ -6013,6 +6045,13 @@ CREATE INDEX invitation_email_recipient_idx ON public.invitation_email_deliverie CREATE INDEX invitation_email_sender_idx ON public.invitation_email_deliveries USING btree (sender_id, created_at); +-- +-- Name: member_pay_links_one_preferred; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX member_pay_links_one_preferred ON public.member_pay_links USING btree (user_id) WHERE preferred; + + -- -- Name: note_revisions_note_idx; Type: INDEX; Schema: public; Owner: - -- @@ -8434,6 +8473,14 @@ REVOKE ALL ON FUNCTION public.set_pay_link(p_provider text, p_handle text, p_acc GRANT ALL ON FUNCTION public.set_pay_link(p_provider text, p_handle text, p_account_name text, p_sort_code text, p_account_number text) TO authenticated; +-- +-- Name: FUNCTION set_preferred_pay_link(p_provider text); Type: ACL; Schema: public; Owner: - +-- + +REVOKE ALL ON FUNCTION public.set_preferred_pay_link(p_provider text) FROM PUBLIC; +GRANT ALL ON FUNCTION public.set_preferred_pay_link(p_provider text) TO authenticated; + + -- -- Name: FUNCTION set_recurring_bill_archived(p_id uuid, p_archived boolean); Type: ACL; Schema: public; Owner: - -- diff --git a/infra/neon/tests/grants.sql b/infra/neon/tests/grants.sql index 9fdafac9..e0aafc63 100644 --- a/infra/neon/tests/grants.sql +++ b/infra/neon/tests/grants.sql @@ -36,7 +36,8 @@ BEGIN END IF; IF has_function_privilege('anonymous', 'public.set_pay_link(text,text,text,text,text)', 'EXECUTE') OR has_function_privilege('anonymous', 'public.my_pay_links()', 'EXECUTE') - OR has_function_privilege('anonymous', 'public.remove_pay_link(text)', 'EXECUTE') THEN + OR has_function_privilege('anonymous', 'public.remove_pay_link(text)', 'EXECUTE') + OR has_function_privilege('anonymous', 'public.set_preferred_pay_link(text)', 'EXECUTE') THEN RAISE EXCEPTION 'Anonymous pay link execution grant'; END IF; IF has_function_privilege('anonymous', 'public.commune_snapshot(uuid)', 'EXECUTE') diff --git a/infra/neon/tests/pay_links.sql b/infra/neon/tests/pay_links.sql index 2e964cc7..4bde14e9 100644 --- a/infra/neon/tests/pay_links.sql +++ b/infra/neon/tests/pay_links.sql @@ -1,5 +1,5 @@ -- Run only in an empty disposable local DB after local_auth_stub.sql and --- migrations 0001 through 0025 and 0042. Real Neon verification must use signed JWTs. +-- migrations 0001 through 0025, 0042 and 0043. Real Neon verification must use signed JWTs. BEGIN; DO $$ @@ -124,8 +124,28 @@ BEGIN IF jsonb_array_length(v_links) <> 3 OR v_links->0->>'handle' <> 'alicehale' THEN RAISE EXCEPTION 'Bob changed Alice''s links: %', v_links; END IF; + -- A favourite comes first; marking another moves it, and nothing clears it. + v_links := public.set_preferred_pay_link('bank'); + IF v_links->0->>'provider' <> 'bank' OR (v_links->0->>'preferred')::boolean IS NOT TRUE + OR (SELECT count(*) FROM jsonb_array_elements(v_links) e WHERE (e->>'preferred')::boolean) <> 1 THEN + RAISE EXCEPTION 'Bank was not made the favourite: %', v_links; + END IF; + v_links := public.set_preferred_pay_link('PayPal'); + IF v_links->0->>'provider' <> 'paypal' + OR (SELECT count(*) FROM jsonb_array_elements(v_links) e WHERE (e->>'preferred')::boolean) <> 1 THEN + RAISE EXCEPTION 'The favourite did not move to PayPal: %', v_links; + END IF; + BEGIN PERFORM public.set_preferred_pay_link('revolut'); + RAISE EXCEPTION 'A link Alice has not added became her favourite'; + EXCEPTION WHEN SQLSTATE '22023' THEN NULL; END; + v_links := public.set_preferred_pay_link(NULL); + IF v_links->0->>'provider' <> 'monzo' OR v_links @> '[{"preferred":true}]' THEN + RAISE EXCEPTION 'Clearing the favourite did not restore the usual order: %', v_links; + END IF; + PERFORM public.set_preferred_pay_link('paypal'); + -- Removing the favourite leaves none. v_links := public.remove_pay_link('paypal'); - IF jsonb_array_length(v_links) <> 2 OR v_links @> '[{"provider":"paypal"}]' THEN + IF jsonb_array_length(v_links) <> 2 OR v_links @> '[{"provider":"paypal"}]' OR v_links @> '[{"preferred":true}]' THEN RAISE EXCEPTION 'PayPal was not removed: %', v_links; END IF; v_links := public.remove_pay_link('paypal'); From 24fc537bce80473d11fe869035d8a712315d5955 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 08:57:42 +0000 Subject: [PATCH 7/7] Phone app: pay first, with their favourite, every way, and Did it go? The phone app gets the same pay flow as the web. With a favourite pay link the main button opens it with the amount filled in, beside Other ways and Already paid; with links but no favourite, Pay opens a sheet of every way plus I've already paid; with none, I've paid stays, with a line saying they haven't said how they like to be paid. Coming back from the bank app asks whether the money went. Settings gets a star to pick your favourite. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WR3jnRWPbGzGV9Uyrqq8Wg --- apps/native/app/(tabs)/hub.tsx | 3 +- apps/native/app/(tabs)/settle.tsx | 12 +++-- apps/native/app/_layout.tsx | 48 +++++++++++++++++- apps/native/app/expense/[id].tsx | 7 ++- apps/native/app/paid/[shareId].tsx | 66 +++++++++++++++++++++++++ apps/native/app/pay-links.tsx | 28 ++++++++--- apps/native/app/pay/[shareId].tsx | 79 ++++++++++++++++++++++++++++++ apps/native/src/lib/backend.ts | 5 ++ apps/native/src/lib/demo.ts | 11 +++-- apps/native/src/lib/payLinks.ts | 6 ++- apps/native/src/state.tsx | 23 ++++++++- apps/native/src/ui/kit.tsx | 6 +-- apps/native/src/ui/parts.tsx | 25 +++------- apps/native/src/ui/pay.tsx | 65 ++++++++++++++++++++++++ apps/native/tests/derive.test.ts | 26 +++++++++- 15 files changed, 365 insertions(+), 45 deletions(-) create mode 100644 apps/native/app/paid/[shareId].tsx create mode 100644 apps/native/app/pay/[shareId].tsx create mode 100644 apps/native/src/ui/pay.tsx diff --git a/apps/native/app/(tabs)/hub.tsx b/apps/native/app/(tabs)/hub.tsx index 3ad99075..684062da 100644 --- a/apps/native/app/(tabs)/hub.tsx +++ b/apps/native/app/(tabs)/hub.tsx @@ -12,6 +12,7 @@ import { failure, useApp } from "../../src/state"; import { Body, Pad, ScopeRow, TopBar, photoOf } from "../../src/ui/chrome"; import { Avatar, Button, Cal, Empty, Faces, Panel, SectionHead, State, T, tap } from "../../src/ui/kit"; import { TaskCheck } from "../../src/ui/parts"; +import { PayPrimary } from "../../src/ui/pay"; import { useToast } from "../../src/ui/toast"; import { KIND_NAME, radius, useTheme } from "../../src/ui/theme"; @@ -104,7 +105,7 @@ function YourPart() { {money(first.amountMinor, first.currency)} -