Skip to content
Merged
53 changes: 52 additions & 1 deletion apps/web/e2e/people.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { readFile } from 'node:fs/promises'
import {
addFriend,
addPlaceholderMember,
blockUser,
createExpense,
fetchMyFriendLink,
removePlaceholder,
} from '@potluck/api'
import { formatMoney, t } from '@potluck/i18n'
import { groupWith } from './support/api'
import { addEqualExpense, groupWith } from './support/api'
import { expect, PASSWORD, test, unique } from './support/fixtures'

test('add Sam by name, split with him, and Sam signs up through his invite to find it all', async ({
Expand Down Expand Up @@ -162,3 +165,51 @@ test('Ava records that Pat, not on Potluck yet, paid her, then removes the settl
await page.goto(`/groups/${group.id}/members`)
await expect(page.getByRole('listitem').filter({ hasText: patName })).toHaveCount(0)
})

test("a friend's page shows only what is between you two, exports it, and a block stops new expenses", async ({
newUser,
openAs,
}) => {
const [ava, jules, owen] = await Promise.all([
newUser({ name: unique('Ava') }),
newUser({ name: unique('Jules') }),
newUser({ name: unique('Owen') }),
])
const group = await groupWith(ava, [jules, owen])
await addFriend(ava.client, jules.id)
// Ava paid 90 for the three of them: she is up 60, but only 30 of it is Jules's.
const dinner = unique('Dinner')
await addEqualExpense(ava, group, [ava, jules, owen], 9000, dinner)
// Owen paid 60 for the three: Ava and Jules each owe Owen, not each other.
const taxi = unique('Taxi')
await addEqualExpense(owen, group, [ava, jules, owen], 6000, taxi)

const page = await openAs(ava)
await page.goto(`/friends/${jules.id}`)
const history = page.getByRole('region', { name: t('people.friendPage.history') })
await expect(
history
.getByRole('link', { name: new RegExp(dinner) })
.getByText(t('expenses.youLent', { amount: formatMoney(3000, 'USD') })),
).toBeAttached()
await expect(
history
.getByRole('link', { name: new RegExp(taxi) })
.getByText(t('people.friendPage.nothingBetween')),
).toBeVisible()

await page
.getByRole('button', { name: t('people.friendPage.menu', { name: jules.name }) })
.click()
const download = page.waitForEvent('download')
await page.getByRole('button', { name: new RegExp(t('people.friendPage.export')) }).click()
const csv = await readFile((await (await download).path())!, 'utf8')
expect(csv).toContain(t('transfer.csv.between', { name: jules.name }))
expect(csv).toContain(dinner)

await blockUser(jules.client, ava.id)
await page.reload()
const add = page.getByRole('button', { name: t('people.friendPage.addExpense') })
await expect(add).toBeDisabled()
await expect(page.getByText(t('errors.codes.not_a_friend'))).toBeVisible()
})
44 changes: 36 additions & 8 deletions apps/web/src/features/activity/activity-feed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ACTIVITY_PAGE_SIZE } from '@potluck/api'
import { t } from '@potluck/i18n'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { group, groupTables, member } from '@/test/fixtures'
import { renderWithProviders } from '@/test/render'
import { argsOf, db, emitChange, resetDb, subscriptions } from '@/test/supabase'
import { BEN, ME, seededClient } from '../expenses/test-utils'
Expand All @@ -11,7 +12,14 @@ vi.mock('@/lib/supabase', () => import('@/test/supabase'))
vi.mock('@/lib/auth', () => ({ useUserId: () => 'aaaaaaaa-0000-4000-8000-000000000001' }))

const hike = { id: 'g-hike', name: 'Hike crew', emoji: '🥾', is_direct: false }
const direct = { id: 'g-ben', name: '', emoji: null, is_direct: true }
const direct = { id: 'g-ben', name: 'Direct', emoji: null, is_direct: true }
const tab = group({
id: 'g-ben',
name: 'Direct',
emoji: null,
is_direct: true,
members: [member(ME, 'Ava Chen', 'owner'), member(BEN, 'Ben Okafor', 'admin')],
})

function activity(id: string, created_at: string, patch: object = {}) {
return {
Expand All @@ -29,7 +37,8 @@ function activity(id: string, created_at: string, patch: object = {}) {
}

function renderFeed(rows: Record<string, unknown>[]) {
resetDb({ tables: { activity: rows, groups: [{ ...hike, group_members: [] }] } })
const { groups } = groupTables(tab)
resetDb({ tables: { activity: rows, groups: [{ ...hike, group_members: [] }, ...groups] } })
return renderWithProviders(<ActivityFeed />, { path: '/activity', client: seededClient() })
}

Expand All @@ -46,16 +55,32 @@ describe('ActivityFeed', () => {
group: direct,
}),
activity('a3', '2026-11-03T10:00:00Z', { verb: 'group.created', group: direct }),
activity('a4', '2026-11-03T10:00:00Z', {
verb: 'member.joined',
actor_id: ME,
data: {},
group: direct,
}),
activity('a5', '2026-11-03T09:00:00Z', {
verb: 'member.left',
actor_id: BEN,
data: { user_id: BEN },
group: direct,
}),
])
const tacos = await screen.findByRole('link', { name: /Ben added "Tacos"/ })
expect(tacos).toHaveAttribute('href', '/expenses/x-a1')
expect(within(tacos).getByText(/Hike crew/)).toBeInTheDocument()
expect(screen.getByRole('link', { name: /You paid Ben/ })).toHaveTextContent(
t('activity.direct'),
)
// A one-to-one tab goes by the friend, never by its stored name.
const paid = screen.getByRole('link', { name: /You paid Ben/ })
expect(paid).toHaveTextContent('Ben Okafor')
expect(paid).not.toHaveTextContent('Direct')
expect(screen.getAllByRole('heading', { level: 2 })).toHaveLength(2)
// Opening a hidden one-to-one tab is not news.
expect(screen.getAllByRole('listitem')).toHaveLength(2)
// Opening a hidden one-to-one tab, and joining it, is not news; leaving it is.
const left = screen.getByText(t('activity.member.left', { actor: 'Ben' })).closest('li')
expect(left).toHaveTextContent('Ben Okafor')
expect(left).not.toHaveTextContent('Direct')
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})

it('loads older pages on request', async () => {
Expand Down Expand Up @@ -150,7 +175,10 @@ describe('ActivityFeed', () => {
it("listens only to the viewer's groups", async () => {
renderFeed([])
await waitFor(() =>
expect(subscriptions()).toContainEqual({ table: 'activity', filter: 'group_id=in.(g-hike)' }),
expect(subscriptions()).toContainEqual({
table: 'activity',
filter: 'group_id=in.(g-ben,g-hike)',
}),
)
expect(subscriptions().filter((s) => s.table === 'activity')).toHaveLength(1)
})
Expand Down
34 changes: 25 additions & 9 deletions apps/web/src/features/activity/activity-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,25 @@ export function ActivityFeed({ groupId, person }: { groupId?: string; person?: s
: null

const sections = useMemo(() => {
// The hidden 1:1 groups are an implementation detail; their creation is not news.
// The hidden 1:1 groups are an implementation detail: opening one (the group and its two
// members joining) is not news. Anything later, like someone leaving, still is.
const items = (feed.data?.pages.flat() ?? []).filter(
(item) => !(item.verb === 'group.created' && item.group?.is_direct),
(item) =>
!(
item.group?.is_direct &&
(item.verb === 'group.created' || item.verb === 'member.joined')
),
)
return groupByDay(items, (item) => dayKey(item.created_at, timeZone))
}, [feed.data, timeZone])
const groupById = useMemo(() => new Map((groups.data ?? []).map((g) => [g.id, g])), [groups.data])
/** A 1:1 tab goes by the friend, never by its stored name. */
const groupName = (group: (typeof sections)[number]['items'][number]['group']) => {
if (!group) return ''
const full = groupById.get(group.id)
if (full) return groupTitle(full, me)
return group.is_direct ? t('people.friendPage.oneOnOne') : group.name
}

return (
<div className="mx-auto w-full max-w-3xl px-4 pt-6 pb-32 lg:px-8 lg:pt-10 lg:pb-12">
Expand Down Expand Up @@ -131,11 +144,14 @@ export function ActivityFeed({ groupId, person }: { groupId?: string; person?: s
<Card padding="none" className="overflow-hidden">
<ul className="divide-y divide-line">
{section.items.map((item, i) => {
const view = describeActivity(item, { viewerId: me, timeZone, nameOf })
const where = groupName(item.group)
const view = describeActivity(item, {
viewerId: me,
timeZone,
nameOf,
groupName: where,
})
const actor = item.actor_id ? people.get(item.actor_id) : undefined
const groupName = item.group?.is_direct
? t('activity.direct')
: (item.group?.name ?? '')
const body = (
<>
<span className="relative shrink-0">
Expand Down Expand Up @@ -165,15 +181,15 @@ export function ActivityFeed({ groupId, person }: { groupId?: string; person?: s
</span>
)}
<span className="text-xs text-mute">
{groupName && (
{where && (
<span className="font-semibold">
{item.group?.emoji && !item.group.is_direct
? `${item.group.emoji} `
: ''}
{groupName}
{where}
</span>
)}
{groupName && ', '}
{where && ', '}
<time dateTime={item.created_at}>
{formatTime(item.created_at, { timeZone })}
</time>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/features/activity/describe-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ interface Context {
timeZone: string
/** First name for a user id, or "you" style text for the viewer. */
nameOf: (id: string | null | undefined, options?: { lower?: boolean }) => string
/** The group's name as the viewer sees it (a 1:1 tab goes by the friend); defaults to its name. */
groupName?: string
}

const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
Expand Down Expand Up @@ -51,7 +53,7 @@ export function describeActivity(item: ActivityItem, ctx: Context): ActivityView
const params: Record<string, string> = {
actor,
// A deleted group is hidden, so its delete line carries the name.
group: item.group?.name ?? str(d.name) ?? '',
group: (item.group ? (ctx.groupName ?? item.group.name) : undefined) ?? str(d.name) ?? '',
description: str(d.description) ?? '',
amount,
event: [str(d.emoji), str(d.title)].filter(Boolean).join(' '),
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/features/balances/balances-tab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,38 @@ describe('BalancesTab', () => {
expect.stringContaining('You'),
])
expect(within(rows[0]!).getByText(t('balances.isOwedShort'))).toBeInTheDocument()
// Each row is one sentence for a screen reader, with "you" grammar for the viewer.
expect(
within(rows[0]!).getByText(t('balances.isOwed', { name: 'Ben', amount: '$60.00' })),
).toBeInTheDocument()
expect(
within(rows[1]!).getByText(t('balances.netOwe', { name: 'Cleo', amount: '$12.50' })),
).toBeInTheDocument()
expect(
within(rows[2]!).getByText(t('balances.youOweNet', { amount: '$47.50' })),
).toBeInTheDocument()
expect(within(rows[2]!).getByText(t('balances.oweShort'))).toBeInTheDocument()
})

it('says "You are owed" for the viewer, never "You is owed"', async () => {
renderTab({
nets: [
[ME, 153580],
[BEN, -153580],
[CLEO, 0],
],
simplified: [{ from_user: BEN, to_user: ME, amount_cents: 153580 }],
})
const everyone = await screen.findByRole('heading', { name: t('balances.members') })
const rows = within(everyone.closest('section')!).getAllByRole('listitem')
const mine = rows.find((row) => row.textContent?.includes('You'))!
expect(
within(mine).getByText(t('balances.youAreOwed', { amount: '$1,535.80' })),
).toBeInTheDocument()
expect(within(mine).getByText(t('balances.areOwedShort'))).toBeInTheDocument()
expect(mine.textContent).not.toContain(t('balances.isOwedShort'))
const cleo = rows.find((row) => row.textContent?.includes('Cleo'))!
expect(within(cleo).getByText(t('balances.settled', { name: 'Cleo' }))).toBeInTheDocument()
})

it('lists the debts you can act on before everyone else’s', async () => {
Expand Down
19 changes: 10 additions & 9 deletions apps/web/src/features/balances/balances-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ArrowRight, Plus } from 'lucide-react'
import { useState } from 'react'
import { RemindButton } from '@/features/settle/remind-button'
import { SettleAllSheet } from '@/features/settle/settle-all-sheet'
import { netLabel } from './net-label'
import { useDebts } from './use-debts'
import { useUserId } from '@/lib/auth'
import { supabase } from '@/lib/supabase'
Expand Down Expand Up @@ -236,6 +237,11 @@ export function BalancesTab({ groupId }: { groupId: string }) {
{rows.map((r) => {
const person = personOf(r.id)
const width = `${(Math.abs(r.cents) / scale) * 50}%`
const label = netLabel(r.cents, {
name: nameOf(r.id),
isViewer: r.id === me,
amount: formatMoney(Math.abs(r.cents), currency),
})
return (
<li key={r.id} className="flex flex-col gap-2 px-4 py-3">
<div className="flex items-center gap-3">
Expand All @@ -245,17 +251,12 @@ export function BalancesTab({ groupId }: { groupId: string }) {
src={avatarSrc(person?.avatar_path)}
size="sm"
/>
<span className="min-w-0 flex-1 truncate font-semibold">
<span className="sr-only">{label.full}</span>
<span aria-hidden className="min-w-0 flex-1 truncate font-semibold">
{nameOf(r.id)}
</span>
<span className="flex flex-col items-end">
<span className="text-xs text-mute">
{r.cents > 0
? t('balances.isOwedShort')
: r.cents < 0
? t('balances.owesShort')
: t('balances.settledShort')}
</span>
<span aria-hidden className="flex flex-col items-end">
<span className="text-xs text-mute">{label.short}</span>
{r.cents !== 0 && (
<span className="font-bold">
<Money
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/features/balances/net-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { t } from '@potluck/i18n'

/**
* Words for one person's net balance in a list row: `short` sits above the amount ("is owed",
* "owes"), `full` is the sentence a screen reader hears for the row ("Kai is owed $12.00").
* The viewer gets their own grammar ("You are owed $12.00", "are owed"), never "You is owed".
*/
export function netLabel(
cents: number,
{ name, isViewer, amount }: { name: string; isViewer: boolean; amount: string },
): { short: string; full: string } {
if (isViewer) {
if (cents > 0)
return { short: t('balances.areOwedShort'), full: t('balances.youAreOwed', { amount }) }
if (cents < 0)
return { short: t('balances.oweShort'), full: t('balances.youOweNet', { amount }) }
return { short: t('balances.settledShort'), full: t('balances.youAreSettled') }
}
if (cents > 0)
return { short: t('balances.isOwedShort'), full: t('balances.isOwed', { name, amount }) }
if (cents < 0)
return { short: t('balances.owesShort'), full: t('balances.netOwe', { name, amount }) }
return { short: t('balances.settledShort'), full: t('balances.settled', { name }) }
}
25 changes: 20 additions & 5 deletions apps/web/src/features/dashboard/balance-hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ import type { BalanceSummary } from './summary'
const TEAR =
'radial-gradient(circle 6px at 50% 100%, #0000 5.5px, #000 6px) 50% 100% / 16px 100% repeat-x'

/** "Plus you are owed 12.00 EUR and you owe 5.00 GBP": each other currency, unconverted. */
function otherTotals(summary: BalanceSummary): string {
const money = (cents: number, currency: string) =>
formatMoney(cents, currency, { locale: intlLocale() })
const parts = summary.otherCurrencies.flatMap(({ currency, owed, owe }) => [
...(owed > 0 ? [t('home.balance.otherOwed', { amount: money(owed, currency) })] : []),
...(owe < 0 ? [t('home.balance.otherOwe', { amount: money(-owe, currency) })] : []),
])
const list =
parts.length > 1
? `${parts.slice(0, -1).join(', ')} ${t('common.and')} ${parts[parts.length - 1]}`
: (parts[0] ?? '')
return t('home.balance.otherTotals', { list })
}

function GroupLinks({ summary }: { summary: BalanceSummary }) {
const groups = [...summary.oweGroups, ...summary.owedGroups].slice(0, 5)
if (groups.length === 0) return null
Expand All @@ -21,9 +36,11 @@ function GroupLinks({ summary }: { summary: BalanceSummary }) {
className="-mx-2 flex items-center gap-2.5 rounded-[0.625rem] px-2 py-1.5 text-sm hover:bg-on-hero/10"
>
<span aria-hidden className="w-5 shrink-0 text-center">
{g.groupEmoji ?? '•'}
{g.groupEmoji ?? (g.isDirect ? '🤝' : '•')}
</span>
<span className="min-w-0 flex-1 truncate">
{g.groupName || t('people.friendPage.oneOnOne')}
</span>
<span className="min-w-0 flex-1 truncate">{g.groupName}</span>
<Amount
cents={g.netCents}
currency={summary.currency}
Expand Down Expand Up @@ -122,9 +139,7 @@ export function BalanceHero({ summary }: { summary: BalanceSummary }) {
</div>
<GroupLinks summary={summary} />
{summary.otherCurrencies.length > 0 && (
<p className="mt-4 text-xs text-hero-mute">
{t('home.balance.otherCurrencies', { currencies: summary.otherCurrencies.join(', ') })}
</p>
<p className="mt-4 text-xs text-hero-mute">{otherTotals(summary)}</p>
)}
</section>
)
Expand Down
Loading
Loading