diff --git a/apps/web/src/features/expenses/form/percent-slider.tsx b/apps/web/src/features/expenses/form/percent-slider.tsx
index 6179d9ab..2f714d11 100644
--- a/apps/web/src/features/expenses/form/percent-slider.tsx
+++ b/apps/web/src/features/expenses/form/percent-slider.tsx
@@ -23,7 +23,9 @@ function pct(bp: number): string {
/**
* Signature moment: one bar, one colored segment per person. Drag a handle (or use the arrow
* keys on it) to move share between the two neighbours; everyone else stays put, so the total
- * is always exactly 100%. Avatars ride on top of their segment and hop as it changes.
+ * never changes. Typed percents can add up to less than 100% (the rest of the bar stays empty)
+ * or more (the bar is drawn to the total). Avatars ride on top of their segment and hop as it
+ * changes.
*/
export function PercentSlider({
people,
@@ -38,6 +40,9 @@ export function PercentSlider({
const reduced = usePrefersReducedMotion()
const [dragging, setDragging] = useState(null)
const order = people.map((p) => p.id)
+ const total = people.reduce((sum, p) => sum + (percent[p.id] ?? 0), 0)
+ // What the full width of the bar stands for.
+ const scale = Math.max(FULL, total)
const segments = people.map((p, i) => ({
...p,
@@ -55,7 +60,7 @@ export function PercentSlider({
const fromPointer = (e: PointerEvent) => {
const rect = trackRef.current?.getBoundingClientRect()
if (!rect || rect.width === 0) return null
- return ((e.clientX - rect.left) / rect.width) * FULL
+ return ((e.clientX - rect.left) / rect.width) * scale
}
const onPointerDown = (index: number) => (e: PointerEvent) => {
@@ -98,7 +103,7 @@ export function PercentSlider({
className="absolute top-0 -translate-x-1/2"
initial={false}
animate={{
- left: `${((s.start + s.value / 2) / FULL) * 100}%`,
+ left: `${((s.start + s.value / 2) / scale) * 100}%`,
// A bigger slice gets a slightly bigger face.
scale: s.value === 0 ? 0.7 : 0.9 + 0.35 * (s.value / FULL),
opacity: s.value === 0 ? 0 : 1,
@@ -120,7 +125,7 @@ export function PercentSlider({
className="flex h-full min-w-0 items-center justify-center overflow-hidden"
style={{ backgroundColor: s.color.bg, color: s.color.fg }}
initial={false}
- animate={{ width: `${(s.value / FULL) * 100}%` }}
+ animate={{ width: `${(s.value / scale) * 100}%` }}
transition={dragging !== null || reduced ? { duration: 0 } : springs.snappy}
>
{s.value >= 1200 && (
@@ -153,7 +158,7 @@ export function PercentSlider({
className={cn(
'group absolute top-12 z-10 flex h-12 w-8 -translate-x-1/2 cursor-ew-resize touch-none items-center justify-center outline-none',
)}
- style={{ left: `${(boundary / FULL) * 100}%` }}
+ style={{ left: `${(boundary / scale) * 100}%` }}
>
import('../fake-supabase'))
-function Harness({ mode, onAddPerson }: { mode: SplitDraft['mode']; onAddPerson?: () => void }) {
+function Harness({
+ mode,
+ onAddPerson,
+ viewerId = ME,
+}: {
+ mode: SplitDraft['mode']
+ onAddPerson?: () => void
+ viewerId?: string
+}) {
const [draft, setDraft] = useState(() => ({
...initialDraft(members.map((m) => m.id)),
mode,
@@ -17,7 +25,7 @@ function Harness({ mode, onAddPerson }: { mode: SplitDraft['mode']; onAddPerson?
<>
{
render()
const you = screen.getByRole('textbox', { name: 'You, percent' })
fireEvent.change(you, { target: { value: '12,5' } })
- fireEvent.blur(you)
expect(draft().percent[ME]).toBe(1250)
- expect(draft().percent[BEN]! + draft().percent[CLEO]!).toBe(8750)
+ // Nobody else moves; what is left shows with the same decimal mark.
+ expect(draft().percent[BEN]! + draft().percent[CLEO]!).toBe(6666)
+ expect(screen.getByText('20,84% left to assign')).toBeInTheDocument()
})
it('reads item prices, tax and tip in the given locale', () => {
@@ -79,3 +88,46 @@ describe('SplitEditor people', () => {
expect(onAddPerson).toHaveBeenCalledOnce()
})
})
+
+describe('SplitEditor order and faces', () => {
+ // Who each person chip is, in order, and the initials on its avatar.
+ const chips = (group: HTMLElement) =>
+ within(group)
+ .getAllByRole('button', { pressed: true })
+ .map((b) => {
+ const name = b.lastChild?.textContent ?? ''
+ const initials = (b.textContent ?? '').slice(0, -name.length)
+ return `${name}:${initials}`
+ })
+
+ it('lists you first with the same initials in every split type, by item too', () => {
+ const expected = ['You:CP', 'Ava:AC', 'Ben:BO']
+ for (const mode of ['equal', 'exact', 'percent', 'shares', 'adjustment'] as const) {
+ const { unmount } = render()
+ expect(chips(screen.getByRole('group', { name: t('expenses.fields.splitBetween') }))).toEqual(
+ expected,
+ )
+ unmount()
+ }
+ render()
+ expect(
+ chips(
+ screen.getByRole('group', {
+ name: t('expenses.itemizedEditor.assign', {
+ item: t('expenses.itemizedEditor.untitled', { number: 1 }),
+ }),
+ }),
+ ),
+ ).toEqual(expected)
+ })
+
+ it('shows the rows in the same order as the chips', () => {
+ render()
+ expect(
+ screen
+ .getAllByRole('textbox')
+ .map((input) => input.getAttribute('aria-label'))
+ .slice(0, 3),
+ ).toEqual(['You, percent', 'Ava, percent', 'Ben, percent'])
+ })
+})
diff --git a/apps/web/src/features/expenses/form/split-editor.tsx b/apps/web/src/features/expenses/form/split-editor.tsx
index e89736eb..95d4b373 100644
--- a/apps/web/src/features/expenses/form/split-editor.tsx
+++ b/apps/web/src/features/expenses/form/split-editor.tsx
@@ -23,13 +23,15 @@ import { CompactMoneyInput, PercentInput, SignedMoneyInput } from './compact-inp
import { ItemizedEditor } from './itemized-editor'
import { PercentSlider } from './percent-slider'
import {
- FULL,
evenPercent,
+ formatPercent,
+ percentLeft,
previewSplit,
roundingExtras,
- setPercent,
splitErrorText,
toggleParticipant,
+ typePercent,
+ viewerFirst,
type SplitDraft,
} from './split-state'
@@ -92,9 +94,9 @@ export function SplitEditor({
const byId = new Map(members.map((m) => [m.id, m]))
const result = previewSplit(draft, amountCents)
const shares = result.ok ? result.shares : null
- const participants = draft.participants
- .map((id) => byId.get(id))
- .filter((m): m is MoneyMember => !!m)
+ // Chips, rows and the slider all list people the same way, in every split type.
+ const shown = viewerFirst(members, viewerId)
+ const participants = shown.filter((m) => draft.participants.includes(m.id))
const money = (cents: number) => formatMoney(cents, currency)
const extras = amountCents > 0 ? roundingExtras(draft, result) : []
@@ -211,8 +213,8 @@ export function SplitEditor({
- onChange({ ...draft, percent: setPercent(draft.percent, draft.participants, m.id, bp) })
+ onValueChange={(bp) =>
+ onChange({ ...draft, percent: typePercent(draft.percent, m.id, bp) })
}
aria-label={t('expenses.splitEditor.percentOf', { name: displayName(m, viewerId) })}
className="w-24"
@@ -220,10 +222,17 @@ export function SplitEditor({
{shareCell(m.id)}
))
- const total = participants.reduce((a, m) => a + (draft.percent[m.id] ?? 0), 0)
+ const left = percentLeft(draft)
+ const percent = formatPercent(Math.abs(left), locale)
status = (
- {`${total / 100}% / 100%`}
+ 0 ? 'left' : 'over'}>
+ {left === 0
+ ? t('expenses.allAssigned')
+ : left > 0
+ ? t('expenses.percentRemaining', { percent })
+ : t('expenses.percentOver', { percent })}
+
- {members.map((m) => {
+ {shown.map((m) => {
const on = draft.participants.includes(m.id)
return (
) => Object.values(r).reduce((a, b) => a + b, 0)
describe('tip shortcuts', () => {
it('works out a tip from the subtotal, to the cent', () => {
@@ -46,26 +46,77 @@ describe('tip shortcuts', () => {
})
})
-describe('percent rebalancing', () => {
- it('starts even and always totals 100%', () => {
+describe('typed percentages', () => {
+ const percentDraft = (percent: Record) => ({
+ ...initialDraft(ids),
+ mode: 'percent' as const,
+ percent,
+ })
+ // Each step types one person's value, the way the fields pass on every keystroke.
+ const typeAll = (steps: Array<[string, number]>) =>
+ steps.reduce((p, [id, bp]) => typePercent(p, id, bp), evenPercent(ids))
+
+ it('starts even and totals 100%', () => {
expect(evenPercent(ids)).toEqual({ [A]: 3334, [B]: 3333, [C]: 3333 })
})
- it('takes what one person gains from the others in proportion', () => {
- const next = setPercent({ [A]: 2500, [B]: 2500, [C]: 5000 }, ids, A, 5000)
- expect(next).toEqual({ [A]: 5000, [B]: 1667, [C]: 3333 })
- expect(sum(next)).toBe(FULL)
+ it('keeps 50, 30, 20 exactly as typed and saves those weights', () => {
+ const percent = typeAll([
+ [A, 500],
+ [A, 5000],
+ [B, 300],
+ [B, 3000],
+ [C, 200],
+ [C, 2000],
+ ])
+ expect(percent).toEqual({ [A]: 5000, [B]: 3000, [C]: 2000 })
+ const draft = percentDraft(percent)
+ expect(percentLeft(draft)).toBe(0)
+ expect(splitsPayload(draft, 20000)).toEqual([
+ { user_id: A, weight: 50 },
+ { user_id: B, weight: 30 },
+ { user_id: C, weight: 20 },
+ ])
+ })
+
+ it('never moves anyone else, so fields filled one after another all stay', () => {
+ // Fast filling: the first field is typed while the others still hold the even split.
+ const afterFirst = typePercent(evenPercent(ids), A, 5000)
+ expect(afterFirst).toEqual({ [A]: 5000, [B]: 3333, [C]: 3333 })
+ expect(percentLeft(percentDraft(afterFirst))).toBe(-1666)
+ const afterSecond = typePercent(afterFirst, B, 3000)
+ expect(afterSecond).toEqual({ [A]: 5000, [B]: 3000, [C]: 3333 })
+ const afterThird = typePercent(afterSecond, C, 2000)
+ expect(afterThird).toEqual({ [A]: 5000, [B]: 3000, [C]: 2000 })
})
- it('clamps out of range values', () => {
- const next = setPercent(evenPercent(ids), ids, B, 15_000)
- expect(next[B]).toBe(FULL)
- expect(sum(next)).toBe(FULL)
+ it('says how much is left or too much, and the preview fails until it makes 100%', () => {
+ const under = percentDraft({ [A]: 5000, [B]: 3000, [C]: 0 })
+ expect(percentLeft(under)).toBe(2000)
+ expect(previewSplit(under, 20000)).toMatchObject({ ok: false, error: { code: 'percent_sum' } })
+ const over = percentDraft({ [A]: 5000, [B]: 3000, [C]: 3000 })
+ expect(percentLeft(over)).toBe(-1000)
+ expect(previewSplit(over, 20000)).toMatchObject({ ok: false, error: { code: 'percent_sum' } })
})
- it('spreads evenly when everyone else was at zero', () => {
- const next = setPercent({ [A]: FULL, [B]: 0, [C]: 0 }, ids, A, 6000)
- expect(next).toEqual({ [A]: 6000, [B]: 2000, [C]: 2000 })
+ it('counts only the people in the split', () => {
+ const draft = toggleParticipant(percentDraft({ [A]: 5000, [B]: 5000, [C]: 0 }), C, ids)
+ expect(percentLeft({ ...draft, percent: { [A]: 5000, [B]: 4000, [C]: 9000 } })).toBe(1000)
+ })
+
+ it('clamps a typed value to 0 to 100%', () => {
+ expect(typePercent(evenPercent(ids), B, 15_000)[B]).toBe(FULL)
+ expect(typePercent(evenPercent(ids), B, -5)[B]).toBe(0)
+ })
+
+ it('rounds to cents only when working out amounts, the leftover cent by largest remainder', () => {
+ // 33.33 / 33.33 / 33.34 of 100.01 is 33.3333, 33.3333 and 33.3433 (and a bit): the cent
+ // left over goes to the largest remainder, the third.
+ const draft = percentDraft({ [A]: 3333, [B]: 3333, [C]: 3334 })
+ expect(previewSplit(draft, 10001)).toEqual({
+ ok: true,
+ shares: { [A]: 3333, [B]: 3333, [C]: 3335 },
+ })
})
it('moves only the two neighbours when a handle is dragged', () => {
diff --git a/apps/web/src/features/expenses/form/split-state.ts b/apps/web/src/features/expenses/form/split-state.ts
index 0ac3ff43..cf5ae73b 100644
--- a/apps/web/src/features/expenses/form/split-state.ts
+++ b/apps/web/src/features/expenses/form/split-state.ts
@@ -23,7 +23,10 @@ export function splitErrorText(result: SplitResult): string | null {
return t(`errors.split.${result.error.code}`)
}
-/** Percentages are edited in basis points (1% = 100) so they always add up to exactly 100%. */
+/**
+ * 100% in basis points. Percentages are edited in basis points (1% = 100), so a typed 33.33 stays
+ * exactly 33.33; cents are only worked out from them by core `splitPercent`.
+ */
export const FULL = 10_000
export interface ItemDraft {
@@ -39,7 +42,10 @@ export interface SplitDraft {
participants: string[]
/** Exact amounts in minor units. Missing means empty. */
exact: Record
- /** Basis points per participant, always summing to FULL. */
+ /**
+ * Basis points per participant, exactly as typed or dragged. Typing never moves anyone else's
+ * value, so they can add up to more or less than FULL; the split only saves once they make FULL.
+ */
percent: Record
/** Share counts per participant. */
shares: Record
@@ -59,6 +65,14 @@ export function newItem(userIds: string[] = []): ItemDraft {
return { id: `item-${itemSeq}`, name: '', amountCents: null, userIds }
}
+/**
+ * The order people are shown in, the same in every split type: you first (your chip is always
+ * in the same spot), then everyone else in member order. Payloads keep member order.
+ */
+export function viewerFirst(people: readonly T[], viewerId: string): T[] {
+ return [...people].sort((a, b) => Number(b.id === viewerId) - Number(a.id === viewerId))
+}
+
/** Splits `FULL` evenly; the leftover basis points go to the first people. */
export function evenPercent(ids: readonly string[]): Record {
const out: Record = {}
@@ -281,44 +295,32 @@ export function toggleParticipant(
}
}
-/** Largest remainder spread of `total` over `weights` (all integers, non-negative). */
-function spread(total: number, ids: string[], weights: number[]): Record {
- const sum = weights.reduce((a, b) => a + b, 0)
- const w = sum === 0 ? ids.map(() => 1) : weights
- const wSum = sum === 0 ? ids.length : sum
- const rows = ids.map((id, i) => {
- const exact = (total * w[i]!) / wSum
- return { id, value: Math.floor(exact), rem: exact - Math.floor(exact) }
- })
- let left = total - rows.reduce((a, r) => a + r.value, 0)
- for (const row of [...rows].sort((a, b) => b.rem - a.rem)) {
- if (left <= 0) break
- row.value += 1
- left -= 1
+const percentText = new Map()
+/** Basis points as a percent number in `locale`, up to two decimals and without the sign. */
+export function formatPercent(basisPoints: number, locale: string): string {
+ let f = percentText.get(locale)
+ if (!f) {
+ f = new Intl.NumberFormat(locale, { maximumFractionDigits: 2, useGrouping: false })
+ percentText.set(locale, f)
}
- return Object.fromEntries(rows.map((r) => [r.id, r.value]))
+ return f.format(Math.round(basisPoints) / 100)
}
/**
- * Set one person's percentage (in basis points) and rebalance everyone else in proportion to
- * what they had, so the total stays at exactly 100%.
+ * One person's percentage (in basis points), exactly as typed: nobody else's changes, so what
+ * was typed for them stays too. Clamped to 0 to 100%.
*/
-export function setPercent(
+export function typePercent(
percent: Record,
- order: readonly string[],
id: string,
value: number,
): Record {
- const ids = order.filter((x) => x in percent)
- if (ids.length <= 1) return { ...percent, [id]: FULL }
- const next = Math.max(0, Math.min(FULL, Math.round(value)))
- const others = ids.filter((x) => x !== id)
- const rest = spread(
- FULL - next,
- others,
- others.map((x) => percent[x] ?? 0),
- )
- return { ...rest, [id]: next }
+ return { ...percent, [id]: Math.max(0, Math.min(FULL, Math.round(value))) }
+}
+
+/** Basis points still to give out: positive is what is left, negative is how much too much. */
+export function percentLeft(draft: Pick): number {
+ return FULL - draft.participants.reduce((sum, id) => sum + (draft.percent[id] ?? 0), 0)
}
/**
diff --git a/apps/web/src/features/expenses/receipts/attachments-field.tsx b/apps/web/src/features/expenses/receipts/attachments-field.tsx
index a626cd49..dcc4bb06 100644
--- a/apps/web/src/features/expenses/receipts/attachments-field.tsx
+++ b/apps/web/src/features/expenses/receipts/attachments-field.tsx
@@ -75,10 +75,14 @@ export function AttachmentsField({
return (
-
- {t('expenses.attachments.label')}
+ {/* The label names the field; the count is its own text (and the list's description),
+ so neither reads as "Receipts and photos1 of 5". */}
+
+
+ {t('expenses.attachments.label')}
+ {' '}
{value.length > 0 && (
-
+
{t('expenses.attachments.count', { count: value.length, max: MAX_ATTACHMENTS })}
)}
@@ -110,7 +114,11 @@ export function AttachmentsField({
void pick(files)
}}
/>
-
+
0 ? `${id}-count` : undefined}
+ className="flex flex-wrap gap-2.5"
+ >
{value.map((draft, index) => (
{
screen.getByRole('img', { name: t('expenses.attachments.pdf', { number: 2 }) }),
).toBeInTheDocument()
expect(screen.getByText(t('expenses.attachments.count', { count: 2, max: 5 }))).toBeVisible()
+ // The count is separate from the label, for eyes and for screen readers.
+ const list = screen.getByRole('list', { name: t('expenses.attachments.label') })
+ expect(list).toHaveAccessibleName(t('expenses.attachments.label'))
+ expect(list).toHaveAccessibleDescription(t('expenses.attachments.count', { count: 2, max: 5 }))
+ expect(
+ screen.getByLabelText(t('expenses.attachments.label'), { selector: 'input' }),
+ ).toHaveAccessibleName(t('expenses.attachments.label'))
fireEvent.click(
screen.getByRole('button', { name: t('expenses.attachments.remove', { name: one }) }),
diff --git a/apps/web/src/features/expenses/search-params.test.ts b/apps/web/src/features/expenses/search-params.test.ts
index 0a6eacde..ab30d205 100644
--- a/apps/web/src/features/expenses/search-params.test.ts
+++ b/apps/web/src/features/expenses/search-params.test.ts
@@ -24,7 +24,8 @@ describe('search params', () => {
preset: 'rent',
})
expect(validateNewExpenseSearch({ preset: 'utilities' }).preset).toBe('utilities')
- expect(validateNewExpenseSearch({ preset: 'yacht' })).not.toHaveProperty('preset')
+ expect(validateNewExpenseSearch({ preset: 'yacht' }).preset).toBeUndefined()
+ expect(validateNewExpenseSearch({ preset: 5 }).preset).toBeUndefined()
})
it('accepts a positive whole amount for settling, as a number or a string', () => {
diff --git a/apps/web/src/features/expenses/search-params.ts b/apps/web/src/features/expenses/search-params.ts
index 8b3616e8..d026edc0 100644
--- a/apps/web/src/features/expenses/search-params.ts
+++ b/apps/web/src/features/expenses/search-params.ts
@@ -18,7 +18,8 @@ export function validateNewExpenseSearch(search: Record): NewEx
return {
groupId: uuidParam(search.groupId),
eventId: uuidParam(search.eventId),
- ...(preset ? { preset } : {}),
+ // Every key, even when dropped: the router lays these over the raw params (src/lib/search.ts).
+ preset,
}
}
diff --git a/apps/web/src/features/insights/search-params.ts b/apps/web/src/features/insights/search-params.ts
index ee582aea..1f5be291 100644
--- a/apps/web/src/features/insights/search-params.ts
+++ b/apps/web/src/features/insights/search-params.ts
@@ -1,7 +1,9 @@
/**
* Search params for /search and /activity. Imported by route files, so nothing heavy here.
- * Anything malformed is dropped.
+ * Anything malformed is dropped, and every key is returned (undefined when dropped) so the raw
+ * value can never show through (see `src/lib/search.ts`).
*/
+import { textValue } from '@/lib/search'
import type { MoneyFilters } from './filter-state'
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -23,22 +25,24 @@ export interface MoneySearch {
}
export function validateMoneySearch(search: Record): MoneySearch {
- const out: MoneySearch = {}
- if (typeof search.q === 'string' && search.q.trim()) out.q = search.q.slice(0, 200)
- const groupId = uuid(search.groupId)
- if (groupId) out.groupId = groupId
- const person = uuid(search.person)
- if (person) out.person = person
- const from = day(search.from)
- if (from) out.from = from
- const to = day(search.to)
- if (to) out.to = to
- if (typeof search.cats === 'string') {
- const cats = search.cats.split(',').filter((c) => CATEGORY.test(c))
- if (cats.length) out.cats = cats.join(',')
+ // `?q=30` is read as the number 30: it was typed as text, so search for "30".
+ const q = textValue(search.q)
+ const cats =
+ typeof search.cats === 'string'
+ ? search.cats
+ .split(',')
+ .filter((c) => CATEGORY.test(c))
+ .join(',')
+ : ''
+ return {
+ q: q?.trim() ? q.slice(0, 200) : undefined,
+ groupId: uuid(search.groupId),
+ person: uuid(search.person),
+ from: day(search.from),
+ to: day(search.to),
+ cats: cats || undefined,
+ kind: search.kind === 'expense' || search.kind === 'payment' ? search.kind : undefined,
}
- if (search.kind === 'expense' || search.kind === 'payment') out.kind = search.kind
- return out
}
export interface ActivitySearch {
@@ -47,12 +51,7 @@ export interface ActivitySearch {
}
export function validateActivitySearch(search: Record): ActivitySearch {
- const out: ActivitySearch = {}
- const groupId = uuid(search.groupId)
- if (groupId) out.groupId = groupId
- const person = uuid(search.person)
- if (person) out.person = person
- return out
+ return { groupId: uuid(search.groupId), person: uuid(search.person) }
}
/** A group's expense list filters, in the Expenses tab's address. */
@@ -60,13 +59,7 @@ export type GroupListSearch = Pick): GroupListSearch {
const { person, from, to, cats, kind } = validateMoneySearch(search)
- const out: GroupListSearch = {}
- if (person) out.person = person
- if (from) out.from = from
- if (to) out.to = to
- if (cats) out.cats = cats
- if (kind) out.kind = kind
- return out
+ return { person, from, to, cats, kind }
}
export function filtersFromSearch(search: GroupListSearch): MoneyFilters {
diff --git a/apps/web/src/features/settle/settle-flow.test.tsx b/apps/web/src/features/settle/settle-flow.test.tsx
index 053758f7..150f0eac 100644
--- a/apps/web/src/features/settle/settle-flow.test.tsx
+++ b/apps/web/src/features/settle/settle-flow.test.tsx
@@ -7,8 +7,9 @@ import {
type Settlement,
} 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 { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { goOffline, goOnline } from '@/test/network'
import {
BEN,
CLEO,
@@ -62,7 +63,7 @@ function setup(props: { from?: string; to?: string; amount?: number; role?: 'adm
/>,
client,
)
- return { onPickPerson, onFinish }
+ return { onPickPerson, onFinish, client }
}
describe('SettleFlow', () => {
@@ -70,6 +71,7 @@ describe('SettleFlow', () => {
pay.mockClear()
update.mockClear()
})
+ afterEach(() => goOnline())
it('lists who you owe and who owes you, with the amounts', () => {
const { onPickPerson } = setup()
@@ -106,6 +108,51 @@ describe('SettleFlow', () => {
expect(onFinish).toHaveBeenCalled()
})
+ it('holds a payment while offline and records it once back', async () => {
+ setup({ to: BEN })
+ act(() => goOffline())
+ const record = screen.getByRole('button', { name: 'Record payment' })
+ expect(record).toBeDisabled()
+ expect(screen.getByText(t('app.offline.form'))).toBeInTheDocument()
+
+ act(() => goOnline())
+ fireEvent.click(record)
+ await waitFor(() => expect(pay).toHaveBeenCalledTimes(1))
+ expect(pay.mock.calls[0]![1]).toMatchObject({ toUser: BEN, amountCents: 4750 })
+ })
+
+ it('keeps the payment when the connection drops while recording, and Try again records it once', async () => {
+ const { client } = setup({ to: BEN })
+ client.setQueryData(moneyKeys.recurring(GROUP_ID), [])
+ fireEvent.change(screen.getByRole('textbox', { name: 'Note' }), { target: { value: 'Venmo' } })
+ pay.mockRejectedValueOnce({ message: 'TypeError: Failed to fetch', code: '' })
+ fireEvent.click(screen.getByRole('button', { name: 'Record payment' }))
+
+ expect(await screen.findByText(t('app.offline.failed'))).toBeInTheDocument()
+ // The payment may have landed anyway, so the group's money is fetched again.
+ expect(client.getQueryState(moneyKeys.recurring(GROUP_ID))?.isInvalidated).toBe(true)
+ expect(pay).toHaveBeenCalledTimes(1)
+ expect(screen.getByRole('textbox', { name: 'Amount' })).toHaveValue('47.50')
+ expect(screen.getByRole('textbox', { name: 'Note' })).toHaveValue('Venmo')
+
+ fireEvent.click(screen.getByRole('button', { name: t('common.retry') }))
+ expect(await screen.findByRole('heading', { name: 'All square with Ben' })).toBeInTheDocument()
+ expect(pay).toHaveBeenCalledTimes(2)
+ expect(pay.mock.calls[1]![1]).toMatchObject({ toUser: BEN, amountCents: 4750, note: 'Venmo' })
+ })
+
+ it('keeps a refused payment a refusal after the connection goes', async () => {
+ setup({ to: BEN })
+ pay.mockRejectedValueOnce({ message: 'member_not_in_group', code: '23514' })
+ fireEvent.click(screen.getByRole('button', { name: 'Record payment' }))
+ await waitFor(() => expect(pay).toHaveBeenCalledTimes(1))
+
+ act(() => goOffline())
+ expect(screen.getByText(t('app.offline.form'))).toBeInTheDocument()
+ expect(screen.queryByText(t('app.offline.failed'))).toBeNull()
+ expect(screen.queryByRole('button', { name: t('common.retry') })).toBeNull()
+ })
+
it('says what is left after a partial payment', async () => {
setup({ to: BEN, amount: 2000 })
fireEvent.click(screen.getByRole('button', { name: 'Record payment' }))
diff --git a/apps/web/src/features/settle/settle-flow.tsx b/apps/web/src/features/settle/settle-flow.tsx
index 05312d68..29ad92c8 100644
--- a/apps/web/src/features/settle/settle-flow.tsx
+++ b/apps/web/src/features/settle/settle-flow.tsx
@@ -1,5 +1,6 @@
import { avatarSrc } from '@/lib/queries'
import {
+ moneyKeys,
paymentKeys,
recordPayment,
updateSettlement,
@@ -31,6 +32,9 @@ import { ArrowLeftRight, Check, ChevronRight } from 'lucide-react'
import { animate, motion } from 'motion/react'
import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { useUserId } from '@/lib/auth'
+import { isNetworkError } from '@/lib/errors'
+import { useOnline, useWarnBeforeUnload } from '@/lib/online'
+import { OfflineSaveNote } from '@/features/shell/offline'
import { supabase } from '@/lib/supabase'
import { useDebts } from '@/features/balances/use-debts'
import { groupTitle, todayIn } from '@/features/expenses/format'
@@ -324,7 +328,9 @@ export function PaymentForm({
? t(`expenses.errors.${problem}`)
: undefined
+ const [unreached, setUnreached] = useState(false)
const pay = useMutation({
+ onMutate: () => setUnreached(false),
mutationFn: (cents: number) => {
const trimmed = note.trim() || null
if (editing) {
@@ -356,11 +362,21 @@ export function PaymentForm({
}
onDone({ from: payer, to: payee, owedBefore: owed, paid: cents, currency })
},
- onError: (e) => toast.error(moneyErrorMessage(e)),
+ onError: (e) => {
+ const network = isNetworkError(e)
+ setUnreached(network)
+ if (network) void queryClient.invalidateQueries({ queryKey: moneyKeys.group(group.id) })
+ else toast.error(moneyErrorMessage(e))
+ },
})
+ // Offline, recording waits with the form filled in, as in the expense form.
+ const online = useOnline()
+ useWarnBeforeUnload(unreached || (!online && amount !== null))
+
const submit = (e?: FormEvent) => {
e?.preventDefault()
+ if (!online) return
setSubmitted(true)
if (problem || !amount) return
pay.mutate(amount)
@@ -414,12 +430,14 @@ export function PaymentForm({
{error}
)}
+ }
>
{editing ? t('settle.saveChanges') : t('settle.recordPayment')}
diff --git a/apps/web/src/features/shell/offline.test.tsx b/apps/web/src/features/shell/offline.test.tsx
new file mode 100644
index 00000000..c40595c3
--- /dev/null
+++ b/apps/web/src/features/shell/offline.test.tsx
@@ -0,0 +1,70 @@
+import { t } from '@potluck/i18n'
+import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { goOffline, goOnline } from '@/test/network'
+import { useWarnBeforeUnload } from '@/lib/online'
+import { OfflineBanner, OfflineSaveNote } from './offline'
+
+afterEach(() => goOnline())
+
+describe('OfflineBanner', () => {
+ it('appears while offline and goes away when the connection is back', () => {
+ render()
+ expect(screen.queryByText(t('app.offline.banner'))).toBeNull()
+
+ act(() => goOffline())
+ expect(screen.getByRole('status')).toHaveTextContent(t('app.offline.banner'))
+
+ act(() => goOnline())
+ expect(screen.queryByText(t('app.offline.banner'))).toBeNull()
+ })
+
+ it('shows at once when the app opens offline', () => {
+ goOffline()
+ render()
+ expect(screen.getByText(t('app.offline.banner'))).toBeInTheDocument()
+ })
+})
+
+describe('OfflineSaveNote', () => {
+ it('says the entry waits while offline', () => {
+ render()
+ expect(screen.getByRole('status')).toHaveTextContent('')
+ act(() => goOffline())
+ expect(screen.getByRole('status')).toHaveTextContent(t('app.offline.form'))
+ expect(screen.queryByRole('button')).toBeNull()
+ })
+
+ it('after a failed save, offers Try again once the connection is back', () => {
+ const onRetry = vi.fn()
+ goOffline()
+ render()
+ expect(screen.getByRole('status')).toHaveTextContent(t('app.offline.failed'))
+ const retry = screen.getByRole('button', { name: t('common.retry') })
+ expect(retry).toBeDisabled()
+
+ act(() => goOnline())
+ expect(screen.getByRole('status')).toHaveTextContent(t('app.offline.failed'))
+ fireEvent.click(retry)
+ expect(onRetry).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('useWarnBeforeUnload', () => {
+ const leave = () => {
+ const event = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(event)
+ return event.defaultPrevented
+ }
+
+ it('asks before the tab closes only while active', () => {
+ const { rerender, unmount } = renderHook(({ active }) => useWarnBeforeUnload(active), {
+ initialProps: { active: false },
+ })
+ expect(leave()).toBe(false)
+ rerender({ active: true })
+ expect(leave()).toBe(true)
+ unmount()
+ expect(leave()).toBe(false)
+ })
+})
diff --git a/apps/web/src/features/shell/offline.tsx b/apps/web/src/features/shell/offline.tsx
new file mode 100644
index 00000000..99f35713
--- /dev/null
+++ b/apps/web/src/features/shell/offline.tsx
@@ -0,0 +1,60 @@
+import { t } from '@potluck/i18n'
+import { Button, cn } from '@potluck/ui'
+import { WifiOff } from 'lucide-react'
+import { useOnline } from '@/lib/online'
+
+/**
+ * A small pill while the browser is offline, on every screen: above the tab bar on a phone, at
+ * the top on a desktop (toasts take the bottom right there). The live region is always in the
+ * page so a screen reader hears it appear. Full-screen forms cover it and say it themselves
+ * next to their save button (`OfflineSaveNote`).
+ */
+export function OfflineBanner() {
+ const online = useOnline()
+ return (
+
+ {!online && (
+
+
+ {t('app.offline.banner')}
+
+ )}
+
+ )
+}
+
+/**
+ * Next to a save button: offline, the button is disabled and this says the entry stays put
+ * until the connection is back. When a save `failed` because Potluck could not be reached, it
+ * says what was typed is still here and offers Try again (`onRetry`) once back online. Nothing
+ * retries on its own: a request that did arrive could otherwise be saved twice.
+ */
+export function OfflineSaveNote({
+ failed = false,
+ onRetry,
+}: {
+ failed?: boolean
+ onRetry?: () => void
+}) {
+ const online = useOnline()
+ return (
+
+ )
+}
diff --git a/apps/web/src/features/shell/route-error.test.tsx b/apps/web/src/features/shell/route-error.test.tsx
new file mode 100644
index 00000000..73876d12
--- /dev/null
+++ b/apps/web/src/features/shell/route-error.test.tsx
@@ -0,0 +1,77 @@
+import { t } from '@potluck/i18n'
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ Outlet,
+ RouterProvider,
+} from '@tanstack/react-router'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { RouteError } from './route-error'
+
+// The router restores scroll on navigation; jsdom has no layout to scroll.
+window.scrollTo = () => {}
+
+let broken = true
+
+function renderBrokenScreen() {
+ const rootRoute = createRootRoute({
+ component: () => (
+ <>
+
+
+ >
+ ),
+ errorComponent: RouteError,
+ })
+ const screenRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/search',
+ component: function Search() {
+ if (broken) throw new TypeError('e.query?.trim is not a function')
+ return
Results
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([screenRoute]),
+ history: createMemoryHistory({ initialEntries: ['/search'] }),
+ defaultErrorComponent: RouteError,
+ })
+ return render()
+}
+
+describe('RouteError', () => {
+ beforeEach(() => {
+ broken = true
+ // React and the router report the caught error; the test expects it.
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ })
+ afterEach(() => vi.restoreAllMocks())
+
+ it('shows our own page inside the app frame instead of the bare router screen', async () => {
+ renderBrokenScreen()
+ expect(
+ await screen.findByRole('heading', { level: 1, name: t('errors.screen.title') }),
+ ).toBeInTheDocument()
+ expect(screen.getByText(t('errors.screen.body'))).toBeInTheDocument()
+ expect(screen.queryByText('Something went wrong!')).toBeNull()
+ // The rest of the app is still there.
+ expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: t('errors.screen.home') })).toHaveAttribute(
+ 'href',
+ '/home',
+ )
+ })
+
+ it('tries the screen again', async () => {
+ renderBrokenScreen()
+ const retry = await screen.findByRole('button', { name: t('common.retry') })
+ broken = false
+ fireEvent.click(retry)
+ expect(await screen.findByText('Results')).toBeInTheDocument()
+ expect(screen.queryByRole('heading', { name: t('errors.screen.title') })).toBeNull()
+ })
+})
diff --git a/apps/web/src/features/shell/route-error.tsx b/apps/web/src/features/shell/route-error.tsx
new file mode 100644
index 00000000..ce3f8712
--- /dev/null
+++ b/apps/web/src/features/shell/route-error.tsx
@@ -0,0 +1,42 @@
+import { t } from '@potluck/i18n'
+import { Button, EmptyState } from '@potluck/ui'
+import { Link, useRouter, type ErrorComponentProps } from '@tanstack/react-router'
+import { House, RotateCcw } from 'lucide-react'
+
+/**
+ * What a screen shows when it throws while rendering or loading, instead of the router's bare
+ * "Something went wrong!". Set as the router's `defaultErrorComponent` (so the app frame and
+ * its navigation stay around the broken screen) and as the root route's `errorComponent`.
+ */
+export function RouteError({ reset }: ErrorComponentProps) {
+ const router = useRouter()
+ return (
+
+
+ }
+ onClick={() => {
+ // Loaders run again too, so a failed load gets another go.
+ reset()
+ void router.invalidate()
+ }}
+ >
+ {t('common.retry')}
+
+ }>
+
+ {t('errors.screen.home')}
+
+
+
+ }
+ />
+
+ )
+}
diff --git a/apps/web/src/lib/errors.ts b/apps/web/src/lib/errors.ts
index be261984..dfedbe2b 100644
--- a/apps/web/src/lib/errors.ts
+++ b/apps/web/src/lib/errors.ts
@@ -48,7 +48,7 @@ const FETCH_FAILURES = [
'Network request failed',
]
-function isNetworkError(error: unknown): boolean {
+export function isNetworkError(error: unknown): boolean {
if (typeof navigator !== 'undefined' && navigator.onLine === false) return true
if (!error || typeof error !== 'object' || errorCode(error)) return false
const message = (error as { message?: unknown }).message
diff --git a/apps/web/src/lib/online.ts b/apps/web/src/lib/online.ts
new file mode 100644
index 00000000..49305c76
--- /dev/null
+++ b/apps/web/src/lib/online.ts
@@ -0,0 +1,40 @@
+import { useEffect, useSyncExternalStore } from 'react'
+
+function subscribe(onChange: () => void) {
+ window.addEventListener('online', onChange)
+ window.addEventListener('offline', onChange)
+ return () => {
+ window.removeEventListener('online', onChange)
+ window.removeEventListener('offline', onChange)
+ }
+}
+
+/**
+ * Whether the browser thinks it is online: `navigator.onLine`, kept fresh by the `online` and
+ * `offline` events. React Query's `onlineManager` follows the same events, so a save that would
+ * pause offline and this flag agree.
+ */
+export function useOnline(): boolean {
+ return useSyncExternalStore(
+ subscribe,
+ () => navigator.onLine,
+ () => true,
+ )
+}
+
+/**
+ * While `active`, closing or reloading the tab asks first (the browser's own "Leave site?"),
+ * for a form whose entry would otherwise be lost, like one that cannot be saved offline.
+ */
+export function useWarnBeforeUnload(active: boolean) {
+ useEffect(() => {
+ if (!active) return
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
+ e.preventDefault()
+ // Older browsers only ask when returnValue is set.
+ e.returnValue = ''
+ }
+ window.addEventListener('beforeunload', onBeforeUnload)
+ return () => window.removeEventListener('beforeunload', onBeforeUnload)
+ }, [active])
+}
diff --git a/apps/web/src/lib/query-client.ts b/apps/web/src/lib/query-client.ts
index 780361e8..834ca59a 100644
--- a/apps/web/src/lib/query-client.ts
+++ b/apps/web/src/lib/query-client.ts
@@ -1,4 +1,4 @@
-import { QueryClient } from '@tanstack/react-query'
+import { onlineManager, QueryClient } from '@tanstack/react-query'
import { isNotFoundError } from './errors'
const MAX_RETRIES = 3
@@ -13,3 +13,8 @@ export const queryClient = new QueryClient({
queries: { staleTime: 30_000, retry: shouldRetry },
},
})
+
+// React Query assumes it starts online and only learns otherwise from the next offline event.
+// Opening the app with no connection should pause queries and saves from the start, the same
+// as `useOnline()` reads it.
+if (typeof navigator !== 'undefined') onlineManager.setOnline(navigator.onLine)
diff --git a/apps/web/src/lib/search.test.tsx b/apps/web/src/lib/search.test.tsx
new file mode 100644
index 00000000..5f6f1a35
--- /dev/null
+++ b/apps/web/src/lib/search.test.tsx
@@ -0,0 +1,74 @@
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRoute,
+ createRouter,
+ RouterProvider,
+} from '@tanstack/react-router'
+import { cleanup, render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { validateNewExpenseSearch } from '@/features/expenses/search-params'
+import { validateGroupListSearch, validateMoneySearch } from '@/features/insights/search-params'
+import { booleanParam, stringParam, textValue } from './search'
+
+// The router restores scroll on navigation; jsdom has no layout to scroll.
+window.scrollTo = () => {}
+
+/** Opens `url` on a route validated by `validate` and shows what `useSearch()` hands the screen. */
+async function searchAt(url: string, validate: (search: Record) => object) {
+ const rootRoute = createRootRoute()
+ const page = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/page',
+ validateSearch: validate,
+ component: function Page() {
+ const search = page.useSearch() as Record
+ const shown = Object.entries(search)
+ .filter(([, value]) => value !== undefined)
+ .map(([key, value]) => `${key}:${typeof value}:${String(value)}`)
+ return
+ },
+ })
+ const router = createRouter({
+ routeTree: rootRoute.addChildren([page]),
+ history: createMemoryHistory({ initialEntries: [url] }),
+ })
+ cleanup()
+ render()
+ return (await screen.findByRole('status', { name: 'search' })).textContent
+}
+
+describe('search params', () => {
+ it('reads numbers and booleans the address parsed back as text', () => {
+ expect(textValue('pizza')).toBe('pizza')
+ expect(textValue(30)).toBe('30')
+ expect(textValue(42.35)).toBe('42.35')
+ expect(textValue(true)).toBe('true')
+ expect(textValue(Number.NaN)).toBeUndefined()
+ expect(textValue({ a: 1 })).toBeUndefined()
+ expect(textValue(null)).toBeUndefined()
+ })
+
+ it('gives a search for a number to the screen as text', async () => {
+ expect(await searchAt('/page?q=30', validateMoneySearch)).toBe('q:string:30')
+ expect(await searchAt('/page?q=42.35', validateMoneySearch)).toBe('q:string:42.35')
+ })
+
+ it('never lets a dropped value through from the address', async () => {
+ // The router lays validated params over the raw ones: a key left out would show the raw value.
+ expect(
+ await searchAt('/page?q=%7B%22a%22%3A1%7D&kind=7&groupId=12&cats=3', validateMoneySearch),
+ ).toBe('')
+ expect(await searchAt('/page?person=5&from=20260101', validateGroupListSearch)).toBe('')
+ expect(await searchAt('/page?preset=5', validateNewExpenseSearch)).toBe('')
+ expect(await searchAt('/page?new=1', booleanParam('new'))).toBe('')
+ expect(await searchAt('/page?token=%5B1%5D', stringParam('token'))).toBe('')
+ })
+
+ it('keeps good values', async () => {
+ expect(await searchAt('/page?new=true', booleanParam('new'))).toBe('new:boolean:true')
+ expect(await searchAt('/page?redirect=%2Fhome', stringParam('redirect'))).toBe(
+ 'redirect:string:/home',
+ )
+ })
+})
diff --git a/apps/web/src/lib/search.ts b/apps/web/src/lib/search.ts
index 927820f4..07f3c1f5 100644
--- a/apps/web/src/lib/search.ts
+++ b/apps/web/src/lib/search.ts
@@ -1,19 +1,37 @@
/**
* Tiny search param validators for route definitions. Route files are part of the first
* download, so they stay free of schema libraries. Anything malformed is dropped.
+ *
+ * Two router habits shape them:
+ * - The address is read as JSON where it can be, so `?q=30` arrives as the number 30 and
+ * `?new=true` as a boolean. Text params take numbers and booleans back as text.
+ * - TanStack Router lays a route's validated params over the raw ones, so a key a validator
+ * leaves out still reaches `useSearch()` as whatever the address held. A validator therefore
+ * returns every key it owns, `undefined` when the value is no good.
*/
type Search = Record
-/** `{ [key]: string }` when the param is a string, `{}` otherwise. */
+/** A param as text: strings as they are, numbers and booleans the address parsed back to text. */
+export function textValue(value: unknown): string | undefined {
+ if (typeof value === 'string') return value
+ if ((typeof value === 'number' && Number.isFinite(value)) || typeof value === 'boolean') {
+ return String(value)
+ }
+ return undefined
+}
+
+/** `{ [key]: string }`, the param as text, or `undefined` for anything else. */
export function stringParam(key: K) {
return (search: Search): { [P in K]?: string } =>
- typeof search[key] === 'string' ? ({ [key]: search[key] } as { [P in K]?: string }) : {}
+ ({ [key]: textValue(search[key]) }) as { [P in K]?: string }
}
-/** `{ [key]: boolean }` when the param is a boolean, `{}` otherwise. */
+/** `{ [key]: boolean }` when the param is a boolean, `undefined` otherwise. */
export function booleanParam(key: K) {
return (search: Search): { [P in K]?: boolean } =>
- typeof search[key] === 'boolean' ? ({ [key]: search[key] } as { [P in K]?: boolean }) : {}
+ ({ [key]: typeof search[key] === 'boolean' ? search[key] : undefined }) as {
+ [P in K]?: boolean
+ }
}
export const redirectSearch = stringParam('redirect')
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 6d829920..b38e111e 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -2,6 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
+import { RouteError } from '@/features/shell/route-error'
import { startLanguage } from '@/lib/language'
import { queryClient } from '@/lib/query-client'
import { initTheme } from '@/styles/theme-preference'
@@ -13,7 +14,14 @@ import './index.css'
installTokens()
initTheme()
-const router = createRouter({ routeTree, context: { queryClient }, defaultPreload: 'intent' })
+const router = createRouter({
+ routeTree,
+ context: { queryClient },
+ defaultPreload: 'intent',
+ // A screen that throws shows our own page, with a retry and a way home, in place of the
+ // router's unstyled default.
+ defaultErrorComponent: RouteError,
+})
declare module '@tanstack/react-router' {
interface Register {
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index c86323c3..de508c68 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -3,6 +3,8 @@ import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { t } from '@potluck/i18n'
import { Toaster, useMediaQuery } from '@potluck/ui'
import { Fragment } from 'react'
+import { OfflineBanner } from '@/features/shell/offline'
+import { RouteError } from '@/features/shell/route-error'
import { useAuthRedirects } from '@/features/shell/use-auth-redirects'
import { useLocale } from '@/lib/language'
import { useTheme } from '@/styles/use-theme'
@@ -13,6 +15,9 @@ export interface RouterContext {
export const Route = createRootRouteWithContext()({
component: RootLayout,
+ // Errors in a screen land in the router's defaultErrorComponent (main.tsx), inside the app
+ // frame; this one catches anything that breaks the root itself.
+ errorComponent: RouteError,
})
function RootLayout() {
@@ -26,6 +31,7 @@ function RootLayout() {
<>
+
{/* Bottom right on desktop, clear of page titles and section tabs. */}
): PlanSearch =>
- typeof search.groupId === 'string' && search.groupId ? { groupId: search.groupId } : {},
+ validateSearch: (search: Record): PlanSearch => ({
+ groupId: typeof search.groupId === 'string' && search.groupId ? search.groupId : undefined,
+ }),
component: NewPlanRoute,
})
diff --git a/apps/web/src/test/network.ts b/apps/web/src/test/network.ts
new file mode 100644
index 00000000..398a819e
--- /dev/null
+++ b/apps/web/src/test/network.ts
@@ -0,0 +1,12 @@
+/**
+ * Flip the browser's connection the way a real one changes: `navigator.onLine` and the window
+ * event that `useOnline()` and React Query's `onlineManager` both listen for. Wrap in `act()`
+ * when something is already rendered.
+ */
+function setOnline(online: boolean) {
+ Object.defineProperty(window.navigator, 'onLine', { configurable: true, get: () => online })
+ window.dispatchEvent(new Event(online ? 'online' : 'offline'))
+}
+
+export const goOffline = () => setOnline(false)
+export const goOnline = () => setOnline(true)
diff --git a/docs/architecture.md b/docs/architecture.md
index 5c5727df..434efaba 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1003,8 +1003,8 @@ Each recurring template (in both recurring jobs) runs inside its own `begin ...
- **Integer minor units.** Every amount is a `bigint` in the currency's minor unit: cents for USD, yen for JPY, fils for KWD. `private.minor_units()` and `minorUnits()` in `packages/core/src/currency` share the same ISO 4217 table (0, 2 or 3 digits). No floats anywhere, in SQL or TypeScript. Core uses `BigInt` for intermediate products.
- **Rounding is half away from zero.** Postgres `round(numeric)` does this; `convert()` in core reproduces it, so a client preview equals the stored value.
-- **Largest remainder, uuid tie-break.** Splitting an amount by weights gives everyone `floor(amount * w / sum)`. The leftover units go one each to the largest fractional remainders. Ties go to the smaller user id compared as plain code points, which matches Postgres `uuid` ordering. `allocate()` in core, `private.compute_splits()` and the `group_balances` view all use this rule, so the same input gives the same cents on every client and on the server.
-- **Fixed-point weights.** Percent and shares weights are scaled to 6 decimals before splitting. Percent weights must sum to exactly 100.
+- **Largest remainder, uuid tie-break.** Splitting an amount by weights gives everyone `floor(amount * w / sum)`. The leftover units go one each to the largest fractional remainders. Ties go to the smaller user id compared as plain code points, which matches Postgres `uuid` ordering. `allocate()` in core, `private.compute_splits()` and the `group_balances` view all use this rule, so the same input gives the same cents on every client and on the server. Percent, shares and the equal part of an adjustment split all round this way, so the same split typed as percents, shares or adjustments puts the extra cent on the same person (`split-remainder.test.ts` and `31_split_rounding.test.sql` share the cases).
+- **Fixed-point weights.** Percent and shares weights are scaled to 6 decimals before splitting. Percent weights must sum to exactly 100. The expense form keeps typed percents exactly as typed (no rebalancing) and blocks saving until they make 100; cents are only worked out from them.
- **FX snapshot.** `fx_rate` is written once, when the expense or settlement is saved: the latest `fx_rates` row on or before `spent_at` (settlements: the day they were paid, today unless one was picked), with the inverse pair as a fallback. Clients cannot send a rate, except in an import: a row may carry the rate its Potluck file was written with, or one the importer typed for a day without a stored rate ([ADR 18](adr/0018-export-backup-and-import.md)). Editing an expense keeps its rate unless the currency changes. A missing rate raises `fx_rate_unavailable`.
- **Rates travel as text.** `fx_rate` is `numeric(24,12)`, up to 24 digits; a JS number keeps about 16. `packages/api` selects `fx_rate::text` and `rate::text`, and `convert()` / `invertRate()` in core work on the decimal text, so a client total matches `group_balances` to the minor unit even for rates like 16000.123456789012.
- **Convert once per expense.** Balances convert the expense total into group currency once, spread that converted total over the payers (by what each paid) and over the splits (by share), both by largest remainder. Each expense nets to exactly zero in group currency. A refund flips the signs.
diff --git a/docs/design.md b/docs/design.md
index 089fb06d..c08beadd 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -47,7 +47,7 @@ Heat scale for availability (0 to max): `#f1e9db`, `#f9dccf`, `#f4ab95`, `#ee7a5
1. Settle-up: amount ticks to $0.00, confetti burst in palette colors, short haptic (`navigator.vibrate`) on mobile.
2. Live heatmap: cells warm up in real time as friends paint availability (Supabase Realtime).
3. Availability painter: press and drag to paint, works with touch, mouse, and keyboard (arrow keys + space).
-4. Split slider: drag to rebalance percentages; avatars nudge as shares change; total always reconciles.
+4. Split slider: drag a handle to move share between two neighbours; avatars nudge as shares change. Typed percents stay as typed and the status says how much is left or too much.
5. Lock-in: the chosen slot "stamps" onto the timeline with a scale-and-settle animation.
6. Invite page: big group emoji, stacked member faces, one-tap join.
7. Skeletons and optimistic updates everywhere; no spinner visible longer than 300ms.
diff --git a/docs/development.md b/docs/development.md
index 63ed06b9..41512399 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -279,7 +279,7 @@ Routes are files under `apps/web/src/routes/`, picked up by the TanStack Router
- Files and folders starting with `-` are ignored by the router (`routes/-dev-kit/`), so colocated helpers go there.
- Keep route files thin. Put screens and hooks in `apps/web/src/features//` and import them.
- Code splitting is automatic (`autoCodeSplitting: true`).
-- Route files are part of the first download, so keep their non-component parts light: no schema libraries (search params use `src/lib/search.ts`) and no feature modules. A `loader` that calls `prefetch.()` from `src/lib/prefetch.ts` starts the screen's queries while its code loads; it must not await them.
+- Route files are part of the first download, so keep their non-component parts light: no schema libraries (search params use `src/lib/search.ts`) and no feature modules. A `validateSearch` returns every key it owns, `undefined` when the value is no good: TanStack Router lays validated params over the raw ones, so a dropped key would still reach the screen as whatever the address held (`?q=30` is the number 30; text params take it back as `"30"` with `textValue`). A `loader` that calls `prefetch.()` from `src/lib/prefetch.ts` starts the screen's queries while its code loads; it must not await them.
- `/` is prerendered at build time (`prerender-landing.ts`) and, for signed-out visitors, stays static apart from the demo. A change to the landing page shows up there automatically; anything on it must render without a browser (no `window` during render). `pnpm dev` does not prerender.
```tsx
@@ -300,6 +300,9 @@ Notes for changing particular screens in `apps/web`. The general rules are in `a
- Categories: pick with `CategoryPicker` (`features/expenses/form/category-picker.tsx`, top level chips plus a row of subcategories) and label with `categoryLabel()`. A new expense's category follows core `suggestCategory` (group history from `categoryHistoryQueryOptions`) until someone taps one. Subcategories are stored as their own ids; totals and charts roll them up with core `parentCategory()`.
- Groups ([ADR 20](adr/0020-group-types-restorable-deletes-and-shared-notes.md)): a deleted group is gone from every query for 30 days; "Recently deleted" (`features/groups/recently-deleted.tsx`) reads `my_deleted_groups`, and its old address shows the deleted page with Restore (`MissingGroup`). Covers render through `GroupCover` / `coverSrc()` (public URL, works signed out). The Notes tab (`notes-page.tsx`) saves with a base version and subscribes to `group_whiteboards`, refetching through `useRealtime`'s `onSubscribed` once the channel joins (an edit saved while it was joining sends no change event, and every channel asks Realtime to hold `SUBSCRIBED` until Postgres changes flow with `postgres_changes_options.wait`; the plan page's `useEventRealtime` does the same for paints and guest answers); keep that pattern for any other shared text. Long names with no spaces use `wrap-anywhere`, not `break-words`, so they cannot widen a grid column or the phone page.
+- Errors and offline: a screen that throws renders `RouteError` (`features/shell/route-error.tsx`, the router's `defaultErrorComponent` and the root's `errorComponent`) with Try again and Go home. `OfflineBanner` (root layout) shows while `useOnline()` (`src/lib/online.ts`, `navigator.onLine` plus its events) is false. A form in `MoneyPageShell` disables its save offline, shows `OfflineSaveNote` and calls `useWarnBeforeUnload` while its entry would be lost; do the same for any new save button there. A save that fails because Potluck could not be reached (`isNetworkError` in `src/lib/errors.ts`, classified once in the mutation's `onError`) gets no toast: the form stays as typed, `OfflineSaveNote` (`failed`, `onRetry`) asks to check the group before Try again, and the group's money queries are invalidated so a save that did arrive shows up once back online. Saves are never retried by themselves, since a request that did arrive would be saved twice.
+- Split editor people are listed with `viewerFirst()` (you, then member order) in every split type, by item too, with the same avatar size; payloads keep member order. `CategoryPicker` rows are radio groups with one Tab stop and arrow keys.
+- Percent splits (`features/expenses/form/split-state.ts`): `PercentInput` passes on every keystroke and `typePercent` changes only that person, so a typed percent is never rewritten; `percentLeft` drives the "left to assign" or "too much" status, and core `splitPercent` refuses the save until the total is 100. Only the slider handles and "Even it out" move several people at once.
- Receipts (`features/expenses/receipts`, [ADR 21](adr/0021-receipt-scanning-and-attachments.md)): only `ocr.ts` imports `tesseract.js`, lazily; a scan only fills the form (the merchant becomes the description, so the category suggestion follows it). Full rules in [receipts](receipts.md).
- Plan pages add guests from `/a/$code` (guest links, signed out) to `members` with `guest: true`.
- Money: `fx_rate` arrives as decimal text; pass it to `convert()` unchanged. An expense can have several payers and be a refund ([ADR 12](adr/0012-several-payers-refunds-and-stored-items.md)): read who paid with `payersOf()` and feed core with `toBalanceExpense()` (`features/expenses/expense-math.ts`), never `paid_by` alone. What an expense means for the viewer (`viewerLine`) comes from core `expenseEffect` in the group currency, so a row can never disagree with the balance.
diff --git a/docs/testing.md b/docs/testing.md
index ea553232..4e87cfb0 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -114,6 +114,7 @@ Each file runs in its own transaction and rolls back, so tests do not leak into
| `25_search_insights.test.sql` | `search_money` (who sees what across groups, every kind of match, filters, paging, input errors), `deleted_items` (order, who deleted, members only), `friend_activity` (only what involves both people), `expense_ledger` (shares add up, paid minus share is the balance, several payers, refunds, no cross-group rows), `spending_summary` (the same flat as the core test, net equals the balance, per viewer, converted into one currency at each date, through a common base, missing rates counted), trigram index use |
| `28_groups.test.sql` | group type and trip dates (`create_group_v2`, the dates check, who may change them), deleting that waits 30 days (hidden from every policy and RPC, invites closed and reopened, no new expenses, members or notifications), `restore_group` by any member, `my_deleted_groups`, the purge job, cover storage (who uploads and lists, two-step sign in, deleted groups), the whiteboard (who reads and writes, version conflicts, the size cap, one activity line per sitting, archived and deleted groups); deleted groups leaving the timeline, plans, RSVPs, availability and the monthly summary, the delete notice and restore notice, the delete line in the feed, one-on-one tabs that cannot be deleted, covers under their own folder, debts in a circle, guest links closing and reopening, backups and imports leaving deleted groups alone |
| `29_expense_attachments.test.sql` | `expense_attachments` and `set_expense_attachments` (order, five at most, group folder only, uploaded files only, who may, deleted and archived, an edit right after the create logged, a create with files making exactly one entry and nothing when a file is refused), the `receipt_path` mirror for older clients, storage from other groups, anon and an unfinished two-step session, deleting files by uploader only, the orphan sweep |
+| `31_split_rounding.test.sql` | where the leftover cent goes in `compute_split_rows` for equal, percent, shares and adjustment splits (largest remainder, ties to the smaller id, not the first listed), two decimal percents, the core cases from `split-remainder.test.ts` |
Every table in `public` needs tests for both who can and who cannot read and write it. A new RPC needs tests for each error code it can raise.
@@ -151,26 +152,26 @@ Where the test data comes from:
Every test creates its own users and groups with unique names, so tests run in parallel and never depend on seed rows or on each other. Selectors are roles and labels built with `t()` from `@potluck/i18n`, never CSS classes: copy changes do not break them, and a missing label does.
-| Spec | Covers |
-| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `onboarding.spec.ts` | landing page, sign up, onboarding (name, time zone, currency), skipping the first group step, home greets you by name |
-| `groups-invites.spec.ts` | create a group, create an invite link, a signed out friend opens `/join/`, signs in and joins, the owner sees them |
-| `expenses.spec.ts` | equal, exact, percent, shares and itemized splits with each person's share on the detail page, editing the amount, delete with Undo, a EUR expense in a USD group showing the converted amount |
-| `expense-entry.spec.ts` | two payers agree across row, detail, balances and CSV; payers that do not add up are refused; adjustment split and edit history; a refund; the two-person shortcut; by item reopening its items; default split and last currency |
-| `people.spec.ts` | add Sam by name and email, split with him, Sam opens his claim link signed out, signs up with that email and finds the group, expense and balance; a friend link makes friends, blocking from the friend page, Unblock on Me; a friend page row shows only what is between the two (not the viewer's whole lead), the menu exports the one-friend spreadsheet, and a block turns Add expense off |
-| `balances.spec.ts` | balances follow an expense, settle up reaches all square ($0.00), activity lists the payment |
-| `settle-up.spec.ts` | "Ben paid me" on a past day and Ben's notification, comment, delete and restore a payment, a saved PayPal.me handle as a prefilled pay button, Remind with a note twice in a day, settling with a friend across three groups |
-| `plans.spec.ts` | create a plan, a second user paints availability with the keyboard, the host's heatmap updates live (realtime), best slot listed, lock it in, RSVP, download the `.ics` |
-| `members.spec.ts` | promote to admin, a member who owes money cannot be removed, remove a settled member, admins get no owner actions, members get no member actions and no delete button |
-| `me.spec.ts` | theme choice survives a reload, calendar feed link shown, deleting the account signs out and the password stops working |
-| `notifications.spec.ts` | turning Payments push off on the Me page survives a reload; a payment then still lands in the inbox but is not routed to push, while a new expense is |
-| `guest-links.spec.ts` | a member makes a guest link (cap and expiry) and copies it, a signed out guest opens `/a/`, names themselves and paints, comes back to edit, the host sees them live with a Guest tag; a full link turns a second guest away; an expired link says it has closed |
-| `insights.spec.ts` | search from the app bar finds an expense in every group the viewer is in and nothing else; filtering a group to one person, a month and a category shows the matching total and keeps the filters on the Expenses tab; Totals adds up the viewer's share, a month column picked by keyboard, the table view, restoring a deleted expense and a deleted payment; a group's activity link; activity with a friend; Spending converted into your currency |
-| `a11y.spec.ts` | axe (WCAG 2.1 A and AA) on landing, home, group expenses, add expense, balances, event page, the signed out guest link page, Me, Friends (owing both ways) and the unsubscribe page: fails on serious or critical violations and on any sideways page scroll |
-| `account.spec.ts` | change password (wrong current refused, other devices signed out, a security email sent), sign out everywhere, two-step setup (codes only close with "I saved them") and sign in with a code (wrong code refused, API and Storage refused at aal1), turning it off clears the codes, a recovery code, five wrong recovery tries then a pause, email change through Mailpit links and calling one off, the language switching live (text and amounts) and following the account to a new device |
-| `recurring.spec.ts` | editing a repeating rent changes the series and leaves the posted copy; someone else sees it read only; making an expense repeat every 2 weeks from its page, then skipping the next one |
-| `receipts.spec.ts` | scan the fixture receipt (`e2e/fixtures/receipt.png`) on the device, check and edit what it read, fill the by-item editor, add a second photo, save, open both full screen; group members can sign the files, an outsider cannot |
-| `group-life.spec.ts` | a trip with dates and a cover that a member and the signed out join page show, the invite as a QR code; notes typed by one member appear live for another and in the activity; a deleted group waits under Recently deleted and a member restores it; the category follows the description ("Uber" is Transport, a description used before keeps its category) |
+| Spec | Covers |
+| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `onboarding.spec.ts` | landing page, sign up, onboarding (name, time zone, currency), skipping the first group step, home greets you by name |
+| `groups-invites.spec.ts` | create a group, create an invite link, a signed out friend opens `/join/`, signs in and joins, the owner sees them |
+| `expenses.spec.ts` | equal, exact, percent (typed values kept as typed), shares and itemized splits with each person's share on the detail page, editing the amount, delete with Undo, a EUR expense in a USD group showing the converted amount |
+| `expense-entry.spec.ts` | two payers agree across row, detail, balances and CSV; payers that do not add up are refused; adjustment split and edit history; a refund; the two-person shortcut; by item reopening its items; default split and last currency; offline save held with the entry kept |
+| `people.spec.ts` | add Sam by name and email, split with him, Sam opens his claim link signed out, signs up with that email and finds the group, expense and balance; a friend link makes friends, blocking from the friend page, Unblock on Me; a friend page row shows only what is between the two (not the viewer's whole lead), the menu exports the one-friend spreadsheet, and a block turns Add expense off |
+| `balances.spec.ts` | balances follow an expense, settle up reaches all square ($0.00), activity lists the payment |
+| `settle-up.spec.ts` | "Ben paid me" on a past day and Ben's notification, comment, delete and restore a payment, a saved PayPal.me handle as a prefilled pay button, Remind with a note twice in a day, settling with a friend across three groups |
+| `plans.spec.ts` | create a plan, a second user paints availability with the keyboard, the host's heatmap updates live (realtime), best slot listed, lock it in, RSVP, download the `.ics` |
+| `members.spec.ts` | promote to admin, a member who owes money cannot be removed, remove a settled member, admins get no owner actions, members get no member actions and no delete button |
+| `me.spec.ts` | theme choice survives a reload, calendar feed link shown, deleting the account signs out and the password stops working |
+| `notifications.spec.ts` | turning Payments push off on the Me page survives a reload; a payment then still lands in the inbox but is not routed to push, while a new expense is |
+| `guest-links.spec.ts` | a member makes a guest link (cap and expiry) and copies it, a signed out guest opens `/a/`, names themselves and paints, comes back to edit, the host sees them live with a Guest tag; a full link turns a second guest away; an expired link says it has closed |
+| `insights.spec.ts` | search from the app bar finds an expense in every group the viewer is in and nothing else, and a number in the address searches for that text; filtering a group to one person, a month and a category shows the matching total and keeps the filters on the Expenses tab; Totals adds up the viewer's share, a month column picked by keyboard, the table view, restoring a deleted expense and a deleted payment; a group's activity link; activity with a friend; Spending converted into your currency |
+| `a11y.spec.ts` | axe (WCAG 2.1 A and AA) on landing, home, group expenses, add expense, balances, event page, the signed out guest link page, Me, Friends (owing both ways) and the unsubscribe page: fails on serious or critical violations and on any sideways page scroll |
+| `account.spec.ts` | change password (wrong current refused, other devices signed out, a security email sent), sign out everywhere, two-step setup (codes only close with "I saved them") and sign in with a code (wrong code refused, API and Storage refused at aal1), turning it off clears the codes, a recovery code, five wrong recovery tries then a pause, email change through Mailpit links and calling one off, the language switching live (text and amounts) and following the account to a new device |
+| `recurring.spec.ts` | editing a repeating rent changes the series and leaves the posted copy; someone else sees it read only; making an expense repeat every 2 weeks from its page, then skipping the next one |
+| `receipts.spec.ts` | scan the fixture receipt (`e2e/fixtures/receipt.png`) on the device, check and edit what it read, fill the by-item editor, add a second photo, save, open both full screen; group members can sign the files, an outsider cannot |
+| `group-life.spec.ts` | a trip with dates and a cover that a member and the signed out join page show, the invite as a QR code; notes typed by one member appear live for another and in the activity; a deleted group waits under Recently deleted and a member restores it; the category follows the description ("Uber" is Transport, a description used before keeps its category) |
The landing audit emulates reduced motion: the hero demo is decorative and loops fade-ins, so the audit checks its settled state.
diff --git a/packages/core/src/money/split-remainder.test.ts b/packages/core/src/money/split-remainder.test.ts
new file mode 100644
index 00000000..ffba0141
--- /dev/null
+++ b/packages/core/src/money/split-remainder.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest'
+import { splitAdjustment, splitEqual, splitPercent, splitShares } from './split'
+
+// Twin of supabase/tests/31_split_rounding.test.sql: same people, same cases, same answers.
+// Every weighted split rounds the same way: floor each exact share, then the cents left over go
+// one each to the largest remainders, ties to the smaller user id (never to whoever is listed
+// first). So the same split typed as percents, shares or adjustments charges the extra cent to
+// the same person.
+const A = '00000000-0000-4000-8000-00000000000a'
+const B = '00000000-0000-4000-8000-00000000000b'
+const C = '00000000-0000-4000-8000-00000000000c'
+
+const sharesOf = (result: ReturnType) => {
+ if (!result.ok) throw new Error(result.error.code)
+ return result.shares
+}
+
+describe('the leftover cent', () => {
+ it('goes to the smaller user id on a tie, in every mode', () => {
+ // 1.01 between two people: 50.5 cents each.
+ const expected = { [A]: 51, [B]: 50 }
+ expect(sharesOf(splitEqual(101, [B, A]))).toEqual(expected)
+ expect(sharesOf(splitPercent(101, { [B]: 50, [A]: 50 }))).toEqual(expected)
+ expect(sharesOf(splitShares(101, { [B]: 1, [A]: 1 }))).toEqual(expected)
+ expect(sharesOf(splitAdjustment(101, { [B]: 0, [A]: 0 }))).toEqual(expected)
+ })
+
+ it('goes to the largest remainder before any tie break', () => {
+ // 10.01 at 50 / 30 / 20: 500.5, 300.3 and 200.2.
+ const expected = { [A]: 501, [B]: 300, [C]: 200 }
+ expect(sharesOf(splitPercent(1001, { [C]: 20, [B]: 30, [A]: 50 }))).toEqual(expected)
+ expect(sharesOf(splitShares(1001, { [C]: 2, [B]: 3, [A]: 5 }))).toEqual(expected)
+ })
+
+ it('reaches the largest remainder even when it is not the smallest id', () => {
+ // 1.01 at 25 / 25 / 50: 25.25, 25.25 and 50.5.
+ const expected = { [A]: 25, [B]: 25, [C]: 51 }
+ expect(sharesOf(splitPercent(101, { [A]: 25, [B]: 25, [C]: 50 }))).toEqual(expected)
+ expect(sharesOf(splitShares(101, { [A]: 1, [B]: 1, [C]: 2 }))).toEqual(expected)
+ })
+
+ it('rounds only the equal part of an adjustment split', () => {
+ // 10.00 with 1.00 more for C: 9.00 left, 3.00 each, nothing over. 10.01: A takes the cent.
+ expect(sharesOf(splitAdjustment(1000, { [A]: 0, [B]: 0, [C]: 100 }))).toEqual({
+ [A]: 300,
+ [B]: 300,
+ [C]: 400,
+ })
+ expect(sharesOf(splitAdjustment(1001, { [C]: 100, [B]: 0, [A]: 0 }))).toEqual({
+ [A]: 301,
+ [B]: 300,
+ [C]: 400,
+ })
+ })
+
+ it('keeps typed percents with decimals exact until the cents are worked out', () => {
+ // 100.01 at 33.33 / 33.33 / 33.34: 3333.33, 3333.33 and 3334.33 (and a bit).
+ expect(sharesOf(splitPercent(10001, { [A]: 33.33, [B]: 33.33, [C]: 33.34 }))).toEqual({
+ [A]: 3333,
+ [B]: 3333,
+ [C]: 3335,
+ })
+ })
+})
diff --git a/packages/i18n/src/de/shell.ts b/packages/i18n/src/de/shell.ts
index c3f84e90..e5f06266 100644
--- a/packages/i18n/src/de/shell.ts
+++ b/packages/i18n/src/de/shell.ts
@@ -59,6 +59,12 @@ export const app = {
source: 'Quellcode',
},
},
+ offline: {
+ banner: 'Offline. Manches muss kurz warten.',
+ form: 'Du bist offline. Deine Eingaben bleiben hier; speichere, sobald du wieder online bist.',
+ failed:
+ 'Potluck konnte das Speichern nicht bestätigen. Deine Eingaben sind noch da: Schau in der Gruppe nach, ob es angekommen ist, bevor du es nochmal versuchst.',
+ },
} satisfies Translation
export const toast = {
@@ -169,6 +175,11 @@ export const errors = {
updateRequired: 'Diese Version ist veraltet. Lade neu, um die aktuelle zu bekommen.',
groupNotFound:
'Diese Gruppe steht nicht auf deiner Liste. Vielleicht bist du ausgetreten oder der Link stimmt nicht.',
+ screen: {
+ title: 'Na, da ist was übergelaufen.',
+ body: 'Auf dieser Seite ist etwas kaputtgegangen. Versuch es nochmal oder fang auf der Startseite neu an.',
+ home: 'Zur Startseite',
+ },
codes: {
not_authenticated: 'Melde dich dafür an.',
account_deleted: 'Dieses Konto wurde geschlossen.',
diff --git a/packages/i18n/src/en/money.ts b/packages/i18n/src/en/money.ts
index d295be0c..dc3c8ce5 100644
--- a/packages/i18n/src/en/money.ts
+++ b/packages/i18n/src/en/money.ts
@@ -36,7 +36,7 @@ export const expenses = {
remaining: '{amount} left to assign',
overBy: '{amount} too much',
allAssigned: 'Everything adds up',
- percentRemaining: '{percent}% left',
+ percentRemaining: '{percent}% left to assign',
percentOver: '{percent}% too much',
shareCount_one: '{count} share',
shareCount_other: '{count} shares',
diff --git a/packages/i18n/src/en/shell.ts b/packages/i18n/src/en/shell.ts
index 4434472d..23d6cc77 100644
--- a/packages/i18n/src/en/shell.ts
+++ b/packages/i18n/src/en/shell.ts
@@ -56,6 +56,12 @@ export const app = {
source: 'Source code',
},
},
+ offline: {
+ banner: 'Offline for now. Some things will wait.',
+ form: 'You are offline. What you entered stays here; save it once you are back.',
+ failed:
+ 'Potluck could not confirm this save. What you typed is still here: check the group to see if it went through before you try again.',
+ },
} as const
export const toast = {
@@ -166,6 +172,11 @@ export const errors = {
rateLimited: 'Easy there. Try again in a moment.',
updateRequired: 'This version is out of date. Refresh to get the latest.',
groupNotFound: 'That group is not on your list. Maybe you left, or the link is off.',
+ screen: {
+ title: 'Well, that spilled.',
+ body: 'Something on this page broke. Try again, or head home and start fresh.',
+ home: 'Go home',
+ },
/** Stable codes raised by the database RPCs (error.message). */
codes: {
not_authenticated: 'Sign in to do that.',
diff --git a/packages/i18n/src/es/shell.ts b/packages/i18n/src/es/shell.ts
index 35051bff..20a80722 100644
--- a/packages/i18n/src/es/shell.ts
+++ b/packages/i18n/src/es/shell.ts
@@ -59,6 +59,12 @@ export const app = {
source: 'Código fuente',
},
},
+ offline: {
+ banner: 'Sin conexión. Algunas cosas esperarán.',
+ form: 'Estás sin conexión. Lo que escribiste se queda aquí; guárdalo cuando vuelvas.',
+ failed:
+ 'Potluck no pudo confirmar el guardado. Lo que escribiste sigue aquí: revisa el grupo para ver si se guardó antes de volver a intentarlo.',
+ },
} satisfies Translation
export const toast = {
@@ -168,6 +174,11 @@ export const errors = {
rateLimited: 'Con calma. Inténtalo de nuevo en un momento.',
updateRequired: 'Esta versión está desactualizada. Recarga para tener la última.',
groupNotFound: 'Ese grupo no está en tu lista. Quizá saliste, o el enlace no es correcto.',
+ screen: {
+ title: 'Vaya, algo se derramó.',
+ body: 'Algo falló en esta página. Inténtalo de nuevo o vuelve al inicio.',
+ home: 'Ir al inicio',
+ },
codes: {
not_authenticated: 'Inicia sesión para hacer eso.',
account_deleted: 'Esta cuenta se ha cerrado.',
diff --git a/packages/i18n/src/fr/shell.ts b/packages/i18n/src/fr/shell.ts
index 2d108190..d75af697 100644
--- a/packages/i18n/src/fr/shell.ts
+++ b/packages/i18n/src/fr/shell.ts
@@ -59,6 +59,12 @@ export const app = {
source: 'Code source',
},
},
+ offline: {
+ banner: 'Hors ligne. Certaines choses attendront.',
+ form: 'Tu es hors ligne. Ce que tu as saisi reste ici ; enregistre dès que tu es de retour.',
+ failed:
+ "Potluck n'a pas pu confirmer l'enregistrement. Ce que tu as saisi est toujours là : regarde dans le groupe si c'est passé avant de réessayer.",
+ },
} satisfies Translation
export const toast = {
@@ -169,6 +175,11 @@ export const errors = {
updateRequired: "Cette version n'est plus à jour. Actualise pour avoir la dernière.",
groupNotFound:
"Ce groupe n'est pas dans ta liste. Tu l'as peut-être quitté, ou le lien est faux.",
+ screen: {
+ title: 'Oups, ça a débordé.',
+ body: "Quelque chose a cassé sur cette page. Réessaie, ou repars de l'accueil.",
+ home: "Aller à l'accueil",
+ },
codes: {
not_authenticated: 'Connecte-toi pour faire ça.',
account_deleted: 'Ce compte a été fermé.',
diff --git a/packages/i18n/src/pt/shell.ts b/packages/i18n/src/pt/shell.ts
index af819768..0e8718b9 100644
--- a/packages/i18n/src/pt/shell.ts
+++ b/packages/i18n/src/pt/shell.ts
@@ -59,6 +59,12 @@ export const app = {
source: 'Código-fonte',
},
},
+ offline: {
+ banner: 'Sem conexão. Algumas coisas vão esperar.',
+ form: 'Você está offline. O que você preencheu fica aqui; salve quando voltar.',
+ failed:
+ 'O Potluck não conseguiu confirmar o salvamento. O que você preencheu continua aqui: veja no grupo se foi salvo antes de tentar de novo.',
+ },
} satisfies Translation
export const toast = {
@@ -169,6 +175,11 @@ export const errors = {
updateRequired: 'Esta versão está desatualizada. Recarregue para ter a mais recente.',
groupNotFound:
'Esse grupo não está na sua lista. Talvez você tenha saído, ou o link esteja errado.',
+ screen: {
+ title: 'Opa, algo derramou.',
+ body: 'Algo quebrou nesta página. Tente de novo ou volte ao início.',
+ home: 'Ir para o início',
+ },
codes: {
not_authenticated: 'Entre na sua conta para fazer isso.',
account_deleted: 'Esta conta foi encerrada.',
diff --git a/supabase/tests/31_split_rounding.test.sql b/supabase/tests/31_split_rounding.test.sql
new file mode 100644
index 00000000..f55e5145
--- /dev/null
+++ b/supabase/tests/31_split_rounding.test.sql
@@ -0,0 +1,81 @@
+-- Where the leftover cent goes, the same in every split type. Twin of
+-- packages/core/src/money/split-remainder.test.ts: same people, same cases, same answers.
+-- Floor each exact share, then one cent each to the largest remainders, ties to the smaller
+-- user id (never to whoever is listed first).
+begin;
+select plan(12);
+
+create temporary table people (name text primary key, id uuid not null) on commit drop;
+insert into people values
+ ('a', '00000000-0000-4000-8000-00000000000a'),
+ ('b', '00000000-0000-4000-8000-00000000000b'),
+ ('c', '00000000-0000-4000-8000-00000000000c');
+
+-- Each person's cents as "a=51,b=50", in name order.
+create function pg_temp.cents(amount bigint, kind public.split_type, splits jsonb) returns text
+language sql as $$
+ select string_agg(p.name || '=' || r.share_cents, ',' order by p.name)
+ from private.compute_split_rows(amount, kind, splits) r
+ join people p on p.id = r.user_id
+$$;
+
+-- A split listed in the given order: [name, field, value] triples.
+create function pg_temp.splits(variadic parts text[]) returns jsonb
+language sql as $$
+ select jsonb_agg(
+ case
+ when parts[i + 1] = 'none' then jsonb_build_object('user_id', p.id)
+ else jsonb_build_object('user_id', p.id, parts[i + 1], parts[i + 2]::numeric)
+ end
+ order by i
+ )
+ from generate_series(1, array_length(parts, 1), 3) as i
+ join people p on p.name = parts[i]
+$$;
+
+-- 1.01 between two people, listed b first: 50.5 cents each, a takes the cent everywhere.
+select is(pg_temp.cents(101, 'equal', pg_temp.splits('b', 'none', '0', 'a', 'none', '0')),
+ 'a=51,b=50', 'equal: a tie goes to the smaller id');
+select is(pg_temp.cents(101, 'percent', pg_temp.splits('b', 'weight', '50', 'a', 'weight', '50')),
+ 'a=51,b=50', 'percent: a tie goes to the smaller id');
+select is(pg_temp.cents(101, 'shares', pg_temp.splits('b', 'weight', '1', 'a', 'weight', '1')),
+ 'a=51,b=50', 'shares: a tie goes to the smaller id');
+select is(pg_temp.cents(101, 'adjustment',
+ pg_temp.splits('b', 'adjustment_cents', '0', 'a', 'adjustment_cents', '0')),
+ 'a=51,b=50', 'adjustment: a tie goes to the smaller id');
+
+-- 10.01 at 50 / 30 / 20, listed c first: 500.5, 300.3 and 200.2.
+select is(pg_temp.cents(1001, 'percent',
+ pg_temp.splits('c', 'weight', '20', 'b', 'weight', '30', 'a', 'weight', '50')),
+ 'a=501,b=300,c=200', 'percent: the largest remainder takes the cent');
+select is(pg_temp.cents(1001, 'shares',
+ pg_temp.splits('c', 'weight', '2', 'b', 'weight', '3', 'a', 'weight', '5')),
+ 'a=501,b=300,c=200', 'shares: the largest remainder takes the cent');
+
+-- 1.01 at 25 / 25 / 50: 25.25, 25.25 and 50.5, so c takes it although a is smaller.
+select is(pg_temp.cents(101, 'percent',
+ pg_temp.splits('a', 'weight', '25', 'b', 'weight', '25', 'c', 'weight', '50')),
+ 'a=25,b=25,c=51', 'percent: the largest remainder wins over the smaller id');
+select is(pg_temp.cents(101, 'shares',
+ pg_temp.splits('a', 'weight', '1', 'b', 'weight', '1', 'c', 'weight', '2')),
+ 'a=25,b=25,c=51', 'shares: the largest remainder wins over the smaller id');
+
+-- Adjustments: only the equal part rounds.
+select is(pg_temp.cents(1000, 'adjustment',
+ pg_temp.splits('a', 'adjustment_cents', '0', 'b', 'adjustment_cents', '0', 'c', 'adjustment_cents', '100')),
+ 'a=300,b=300,c=400', 'adjustment: nothing over when the rest divides evenly');
+select is(pg_temp.cents(1001, 'adjustment',
+ pg_temp.splits('c', 'adjustment_cents', '100', 'b', 'adjustment_cents', '0', 'a', 'adjustment_cents', '0')),
+ 'a=301,b=300,c=400', 'adjustment: the cent over the equal part goes to the smaller id');
+
+-- Typed percents with decimals stay exact until the cents: 3333.33, 3333.33, 3334.33 (and a bit).
+select is(pg_temp.cents(10001, 'percent',
+ pg_temp.splits('a', 'weight', '33.33', 'b', 'weight', '33.33', 'c', 'weight', '33.34')),
+ 'a=3333,b=3333,c=3335', 'percent: two decimal percents round once, at the cents');
+
+select throws_ok(
+ $$ select pg_temp.cents(1000, 'percent', pg_temp.splits('a', 'weight', '50', 'b', 'weight', '30', 'c', 'weight', '10')) $$,
+ '22023', 'percent_sum', 'percents that do not make 100 are refused');
+
+select * from finish();
+rollback;