Skip to content

fix(web): friend page, settle up and one-on-one tab fixes from dogfooding - #35

Merged
musa-kal merged 9 commits into
mainfrom
fix/friends-settle
Nov 14, 2026
Merged

musa-kal merged 9 commits into
mainfrom
fix/friends-settle

Conversation

@musa-kal

Copy link
Copy Markdown
Member

Intent

Fixes from the full-app dogfood pass:

  1. Friend page rows show only what each expense leaves between you and that friend (core expenseBetween, a share of the existing pairwise-debt rule, also used by the one-friend CSV export; pgTAP twin cases). With simplify off the rows add up to the standing.
  2. Settle up after a partial payment leads with the amount just paid, the remainder underneath.
  3. A one-on-one tab is named after the friend everywhere (home card, CSV, back links, activity, in-app notifications), never "Direct" or its internal name.
  4. The one-friend CSV export is also in the friend page menu.
  5. The home card lists each other-currency total instead of "Plus balances in EUR".
  6. Add expense on a friend page is disabled with a reason when either side blocked; can_split_with(person) is a new security definer RPC for authenticated only that never reveals who blocked whom.
  7. Accessible labels say "You are owed" / "You owe", never "You is owed".
    Must keep: money in integer minor units, additive migrations, no new anon-callable functions, i18n for every string.

What Changed

  • Friend page: each expense row now shows only what that expense leaves between you and the friend. This uses a new core expenseBetween, which is one expense's step of the pairwise-debt rule, with matching pgTAP cases in 23_payments. The one-friend CSV export uses the same value, and its row is now also in the friend page menu. Add expense is disabled with a reason when a new expense would be refused. That reason comes from a new can_split_with(person) RPC: security definer, callable by authenticated and service_role only, covered by 30_can_split_with and 01_schema. When the friend blocked you, it shows the generic not_a_friend message, so it never says who blocked whom.
  • One-on-one tab names: a new core groupDisplayName names a one-on-one tab after the other person, never "Direct" or its stored name. It is used on the home card, CSV and backup exports, back links, the activity feed and in-app notifications.
  • Money copy:
    • After a partial payment, settle up leads with the amount just paid and shows the remainder underneath. Only a full settle counts down to zero and fires confetti.
    • The home card lists each other-currency total ("you are owed 12.00 EUR and you owe 5.00 GBP") instead of "Plus balances in EUR".
    • Balance rows get a netLabel helper, so screen readers hear "You are owed" / "You owe" for the viewer.
    • New strings are added in all five locales, and docs and generated files are regenerated.

Risk Assessment

✅ Low: The diff covers all seven intent items: an additive migration with authenticated-only grants, integer money via expenseBetween (property-tested against pairwiseDebts), i18n in every locale, and both prior-round fixes (can-split invalidation on block and unblock, hiding only group.created and member.joined in 1:1 tabs) verified in code and tests with no remaining sibling sites.

Testing

  • ⏭️ Test - skipped

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ apps/web/src/features/me/blocked-people.tsx:22 - The peopleKeys.canSplit(friendId) query is never invalidated when a block changes. Global staleTime is 30s (apps/web/src/lib/query-client.ts:13). Repro: Ava blocks Ben, opens Ben's page (can_split_with returns false and is cached; the youBlocked note shows), unblocks him under Me within 30s, then returns to Ben's page. The my_blocks cache is invalidated, so the page skips the youBlocked note. It then reads the cached canSplit.data === false and shows the generic 'You can only split with friends.' with Add expense disabled until the cache goes stale. The same missing invalidation is in friend-page.tsx:368-371, where run() invalidates blocks/friends after blockUser but not can-split. Fix: also invalidate ['potluck','people','can-split'] (or peopleKeys.canSplit(id)) in both places.
  • ⚠️ apps/web/src/features/activity/activity-feed.tsx:71 - Simplification: the activity feed now also drops every member.* event of a one-on-one tab (item.verb.startsWith('member.')), where before it only dropped group.created. Intent item 3 asks for a tab to be named after the friend in activity, not for activity rows to be hidden, so no intent requirement needs this filter. It also hides any later member event in a tab (e.g. a member.left when a friend closes their account) along with the opening ones. Recommend removing the member. clause, or narrowing it to the join events emitted when the tab is opened, if the author wants to keep it.
  • ℹ️ packages/api/src/plans.ts:156 - fetchHomeBalances now embeds group_members (profiles (id, display_name)) for every group the viewer belongs to, though only is_direct groups need the friend's name. Payload on the home screen grows with total membership across all groups. Acceptable at current scale; noted as a tradeoff.

🔧 Fix applied.
1 info still open:

  • ℹ️ packages/api/src/plans.ts:156 - fetchHomeBalances now embeds group_members (profiles (id, display_name)) for every group the viewer belongs to, though only is_direct groups need the friend's name. Payload on the home screen grows with total membership across all groups. Acceptable at current scale; noted as a tradeoff.
⏭️ **Test** - skipped

Step was skipped.

✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

musa-kal and others added 9 commits November 14, 2026 01:24
expenseBetween is one expense's step of pairwiseDebts between two people: what the
other owes (or is owed) on it, 0 when both are in it but nothing passes between them,
null when one is not in it. Summed with their payments it is their pairwise debt, which
a property test checks. The one-friend export's between column now uses it instead of
its own copy, and pgTAP gets the same two cases for private.pairwise_debts.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Everyone on the balances tab (and the balances preview on import) reads as one
sentence per row for screen readers, with the viewer's own grammar: "You are owed
$1,535.80", "You owe $12.00 in total". The small label above the amount follows too.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Paying part of a debt showed the amount still owed as the big red headline. The
headline is now the payment, in green, with what is left underneath in plain type.
Settling in full still ticks down to $0.00 with confetti.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
The hidden 1:1 group is stored as "Direct" and that leaked into the CSV Group column
and file name, the expense and payment back links, the activity feed ("Just the two
of you", plus joining the tab as news) and notifications ("New expense in Owen
Owner"). groupDisplayName in core names a tab after the other person, groupTitle uses
it on the web, backups export it that way, and notifications say "your tab with Owen".
Push copy never names a group for a tab, and email already leaves the name out.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
The balance card said "Plus balances in EUR" with no amount. It now lists what you
are owed and owe in each other currency, unconverted: "Plus you are owed €12.00 and
you owe £5.00". Its group rows name a one-on-one tab after the friend.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Answers whether a new expense with a person would go in, by the same rules the writes
apply (an existing tab refuses only the blocked side; a new one needs a contact and no
block either way). A plain no, like not_a_friend, so a block is never named.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
Each expense row on a friend's page showed the viewer's whole lead in the expense
("Concert, you lent $200" when the friend owes $160). Rows now show expenseBetween:
what that expense left between the two of you, or "nothing between you" when you both
owe someone else. With simplify off they add up to the group's standing; with it on the
standing is the simplified debt and the rows are the history behind it.

The menu also exports the one-friend spreadsheet (the same file as on Me), and Add
expense is off with a short reason when you blocked them or can_split_with says no.

Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
@musa-kal
musa-kal merged commit 60b317d into main Nov 14, 2026
7 checks passed
@musa-kal
musa-kal deleted the fix/friends-settle branch November 14, 2026 07:14

This branch was successfully deployed

1 active deployment
preview — 636fb364 Deployed Nov 14, 2026 by musa-kal via Deploy web to Cloudflare #96
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant