From f4d382e45d4100152d1e321321517e5311fd47df Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:03:49 -0500 Subject: [PATCH 01/12] percent splits keep what was typed Typing a percent used to rebalance everyone else in proportion, and the fields only committed on blur or Enter, so 50 / 30 / 20 could save as 48.7 / 31.3 / 20 and fast filling as 0 / 80 / 20, all while the status read 100%. Each field now passes on every keystroke and changes only that person; the status says how much is left to assign or how much too much, and saving waits for 100%. Cents are only worked out from the percents, by the same largest remainder rule as shares and adjustments, now pinned by shared cases in core and pgTAP. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- apps/web/e2e/expenses.spec.ts | 22 +++-- .../expenses/form/compact-inputs.test.tsx | 69 +++++++++++----- .../features/expenses/form/compact-inputs.tsx | 50 +++++------- .../expenses/form/expense-form.test.tsx | 70 ++++++++++++++-- .../features/expenses/form/percent-slider.tsx | 15 ++-- .../expenses/form/split-editor.test.tsx | 5 +- .../features/expenses/form/split-editor.tsx | 20 +++-- .../expenses/form/split-state.test.ts | 81 +++++++++++++++---- .../src/features/expenses/form/split-state.ts | 56 ++++++------- docs/architecture.md | 4 +- docs/design.md | 2 +- docs/development.md | 1 + docs/testing.md | 1 + .../core/src/money/split-remainder.test.ts | 64 +++++++++++++++ packages/i18n/src/en/money.ts | 2 +- supabase/tests/30_split_rounding.test.sql | 81 +++++++++++++++++++ 16 files changed, 419 insertions(+), 124 deletions(-) create mode 100644 packages/core/src/money/split-remainder.test.ts create mode 100644 supabase/tests/30_split_rounding.test.sql diff --git a/apps/web/e2e/expenses.spec.ts b/apps/web/e2e/expenses.spec.ts index 9832f9e9..001c6453 100644 --- a/apps/web/e2e/expenses.spec.ts +++ b/apps/web/e2e/expenses.spec.ts @@ -105,17 +105,23 @@ test.describe('expenses', () => { await startExpense(page, group, '200', description) await page.getByRole('radio', { name: t('splitTypes.percent.short') }).click() - const mine = page.getByRole('textbox', { - name: t('expenses.splitEditor.percentOf', { name: t('common.you') }), - }) - await mine.fill('50') - await mine.press('Tab') - await expect(page.getByText('100% / 100%')).toBeVisible() + const percentOf = (name: string) => + page.getByRole('textbox', { name: t('expenses.splitEditor.percentOf', { name }) }) + // Each typed value stays as typed; nothing rebalances, the status says what is left. + await percentOf(t('common.you')).fill('50') + await percentOf(ben.name).fill('30') + // Cleo still has her third: 113.33% (or .34) until her value is typed. + const over = t('expenses.percentOver', { percent: '13.3' }) + await expect(page.getByText(over.slice(0, over.indexOf('%')), { exact: false })).toBeVisible() + await percentOf(cleo.name).fill('20') + await expect(percentOf(t('common.you'))).toHaveValue('50') + await expect(percentOf(ben.name)).toHaveValue('30') + await expect(page.getByText(t('expenses.allAssigned'))).toBeVisible() await saveAndOpen(page, group, description) await expectShares(page, me, [ [me, 10000], - [ben, 5000], - [cleo, 5000], + [ben, 6000], + [cleo, 4000], ]) }) diff --git a/apps/web/src/features/expenses/form/compact-inputs.test.tsx b/apps/web/src/features/expenses/form/compact-inputs.test.tsx index e0339c87..3d9f61b7 100644 --- a/apps/web/src/features/expenses/form/compact-inputs.test.tsx +++ b/apps/web/src/features/expenses/form/compact-inputs.test.tsx @@ -1,7 +1,7 @@ import { MoneyInput } from '@potluck/ui' import { fireEvent, render, screen } from '@testing-library/react' import { useState } from 'react' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { CompactMoneyInput, PercentInput } from './compact-inputs' function MoneyHarness({ locale, currency = 'EUR' }: { locale: string; currency?: string }) { @@ -85,43 +85,74 @@ describe('CompactMoneyInput', () => { }) }) -describe('PercentInput', () => { - function renderPercent(locale: string, basisPoints = 5000) { - const onCommit = vi.fn() - render( +function PercentHarness({ locale, start }: { locale: string; start: number }) { + const [basisPoints, setBasisPoints] = useState(start) + return ( + <> , - ) - return { onCommit, input: screen.getByRole('textbox', { name: 'Share' }) } + /> + {basisPoints} + + + ) +} + +describe('PercentInput', () => { + function renderPercent(locale: string, basisPoints = 5000) { + render() + return { + input: screen.getByRole('textbox', { name: 'Share' }), + value: () => Number(screen.getByRole('status', { name: 'basis points' }).textContent), + } } it('reads "12,50" as 12.5% in German', () => { - const { onCommit, input } = renderPercent('de-DE') + const { input, value } = renderPercent('de-DE') fireEvent.change(input, { target: { value: '12,50' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - expect(onCommit).toHaveBeenLastCalledWith(1250) + expect(value()).toBe(1250) + expect(input).toHaveValue('12,50') }) it('agrees with the money fields in US English', () => { - const { onCommit, input } = renderPercent('en-US') + const { input, value } = renderPercent('en-US') fireEvent.change(input, { target: { value: '12,5' } }) expect(input).toHaveValue('125') - fireEvent.keyDown(input, { key: 'Enter' }) - expect(onCommit).toHaveBeenLastCalledWith(12500) + expect(value()).toBe(12500) + }) + + it('passes on every keystroke, without waiting for blur or Enter', () => { + const { input, value } = renderPercent('en-US') + fireEvent.change(input, { target: { value: '3' } }) + expect(value()).toBe(300) + fireEvent.change(input, { target: { value: '30.' } }) + expect(value()).toBe(3000) + // A trailing decimal mark stays while typing. + expect(input).toHaveValue('30.') + fireEvent.blur(input) + expect(input).toHaveValue('30') }) it('shows the value with the locale decimal mark', () => { expect(renderPercent('de-DE', 3333).input).toHaveValue('33,33') }) - it('commits an emptied field as 0%', () => { - const { onCommit, input } = renderPercent('en-US') + it('reads an emptied field as 0%', () => { + const { input, value } = renderPercent('en-US') fireEvent.change(input, { target: { value: '' } }) - fireEvent.blur(input) - expect(onCommit).toHaveBeenLastCalledWith(0) + expect(value()).toBe(0) + expect(input).toHaveValue('') + }) + + it('shows a value set from elsewhere', () => { + const { input } = renderPercent('en-US') + fireEvent.change(input, { target: { value: '40' } }) + fireEvent.click(screen.getByRole('button', { name: 'Quarter' })) + expect(input).toHaveValue('25') }) }) diff --git a/apps/web/src/features/expenses/form/compact-inputs.tsx b/apps/web/src/features/expenses/form/compact-inputs.tsx index d5972dac..25987a33 100644 --- a/apps/web/src/features/expenses/form/compact-inputs.tsx +++ b/apps/web/src/features/expenses/form/compact-inputs.tsx @@ -1,5 +1,6 @@ import { cn, parseDecimalInput, sanitizeDecimalInput, useMoneyText } from '@potluck/ui' import { useState, type InputHTMLAttributes } from 'react' +import { formatPercent } from './split-state' const inputClass = 'h-11 w-full rounded-input border border-line-strong bg-card px-3 text-right text-base font-semibold tabular text-ink ' + @@ -37,50 +38,41 @@ export function CompactMoneyInput({ ) } -const percentText = new Map() -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 f.format(Math.round(basisPoints) / 100) -} - -/** Percent typed with up to two decimals, committed on blur or Enter (so others rebalance once). */ +/** + * Percent typed with up to two decimals. Every keystroke is passed on as it reads, so the value + * is always what is on screen, however fast the fields are filled; nothing else rebalances. + */ export function PercentInput({ basisPoints, - onCommit, + onValueChange, locale, className, ...props }: { basisPoints: number - onCommit: (basisPoints: number) => void + onValueChange: (basisPoints: number) => void locale: string } & Omit, 'value' | 'onChange'>) { - const shown = formatPercent(basisPoints, locale) - const [draft, setDraft] = useState(null) - const commit = () => { - if (draft === null) return - setDraft(null) - // Percent is two decimals, so the parsed units are basis points. Empty means 0%. - onCommit(parseDecimalInput(draft, 2, locale) ?? 0) - } + // Percent is two decimals, so the parsed units are basis points. Empty means 0%. + const parse = (text: string) => parseDecimalInput(text, 2, locale) ?? 0 + const [text, setText] = useState(null) + // The text as typed ("50.", "" or "07") while it still means the value; a value set from + // elsewhere (the slider, even it out) shows formatted instead. + const shown = + text !== null && parse(text) === basisPoints ? text : formatPercent(basisPoints, locale) return ( setDraft(sanitizeDecimalInput(e.target.value, 2, locale))} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - commit() - } + placeholder="0" + value={shown} + onChange={(e) => { + const next = sanitizeDecimalInput(e.target.value, 2, locale) + setText(next) + onValueChange(parse(next)) }} + onBlur={() => setText(null)} className={cn(inputClass, 'pr-7')} {...props} /> diff --git a/apps/web/src/features/expenses/form/expense-form.test.tsx b/apps/web/src/features/expenses/form/expense-form.test.tsx index 551dd6a8..8219c577 100644 --- a/apps/web/src/features/expenses/form/expense-form.test.tsx +++ b/apps/web/src/features/expenses/form/expense-form.test.tsx @@ -119,7 +119,7 @@ describe('ExpenseForm', () => { expect(percentOf('You').value).toBe('38') expect(percentOf('Ben').value).toBe('28.67') expect(percentOf('Cleo').value).toBe('33.33') - expect(screen.getByText('100% / 100%')).toBeInTheDocument() + expect(screen.getByText('Everything adds up')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) @@ -130,13 +130,73 @@ describe('ExpenseForm', () => { ]) }) - it('rebalances everyone else when a percent is typed', () => { + // Like a person at a keyboard: one change per keystroke, then on to the next field. + const typeKeys = (input: HTMLInputElement, text: string) => { + fireEvent.focus(input) + for (let i = 1; i <= text.length; i++) { + fireEvent.change(input, { target: { value: text.slice(0, i) } }) + } + fireEvent.blur(input) + } + + it('keeps typed percents exactly as typed and saves them', async () => { + setup() + fireEvent.click(screen.getByRole('radio', { name: 'Percent' })) + typeKeys(percentOf('You'), '50') + expect(percentOf('Ben').value).toBe('33.33') + expect(percentOf('Cleo').value).toBe('33.33') + expect(screen.getByText('16.66% too much')).toBeInTheDocument() + typeKeys(percentOf('Ben'), '30') + expect(screen.getByText('13.33% too much')).toBeInTheDocument() + typeKeys(percentOf('Cleo'), '20') + + expect([percentOf('You').value, percentOf('Ben').value, percentOf('Cleo').value]).toEqual([ + '50', + '30', + '20', + ]) + expect(screen.getByText('Everything adds up')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) + expect(create.mock.calls[0]![1].splits).toEqual([ + { user_id: ME, weight: 50 }, + { user_id: BEN, weight: 30 }, + { user_id: CLEO, weight: 20 }, + ]) + }) + + it('keeps every field when they are filled fast, without a blur in between', async () => { setup() fireEvent.click(screen.getByRole('radio', { name: 'Percent' })) + // A whole value per change and no blur, the way autofill or a test runner fills fields. fireEvent.change(percentOf('You'), { target: { value: '50' } }) - fireEvent.blur(percentOf('You')) - expect(percentOf('Ben').value).toBe('25') - expect(percentOf('Cleo').value).toBe('25') + fireEvent.change(percentOf('Ben'), { target: { value: '30' } }) + fireEvent.change(percentOf('Cleo'), { target: { value: '20' } }) + + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) + expect(create.mock.calls[0]![1].splits).toEqual([ + { user_id: ME, weight: 50 }, + { user_id: BEN, weight: 30 }, + { user_id: CLEO, weight: 20 }, + ]) + }) + + it('does not save percents that do not add up to 100%', async () => { + setup() + fireEvent.click(screen.getByRole('radio', { name: 'Percent' })) + typeKeys(percentOf('You'), '50') + typeKeys(percentOf('Ben'), '30') + typeKeys(percentOf('Cleo'), '10') + expect(screen.getByText('10% left to assign')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Percentages need to add up to 100%.', + ) + expect(create).not.toHaveBeenCalled() + // Typed values are left alone by a failed save. + expect(percentOf('Cleo').value).toBe('10') }) it('does not save exact amounts that do not add up', async () => { 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}%` }} > { 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', () => { diff --git a/apps/web/src/features/expenses/form/split-editor.tsx b/apps/web/src/features/expenses/form/split-editor.tsx index e89736eb..51d7c099 100644 --- a/apps/web/src/features/expenses/form/split-editor.tsx +++ b/apps/web/src/features/expenses/form/split-editor.tsx @@ -23,13 +23,14 @@ 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, type SplitDraft, } from './split-state' @@ -211,8 +212,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 +221,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 })} + + +
+ } + /> + + ) +} 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 {shown.join(' ')} + }, + }) + 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..3fae73d1 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -3,6 +3,7 @@ import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' import { t } from '@potluck/i18n' import { Toaster, useMediaQuery } from '@potluck/ui' import { Fragment } from 'react' +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 +14,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() { diff --git a/apps/web/src/routes/_app/new/plan.tsx b/apps/web/src/routes/_app/new/plan.tsx index 7ec90f3a..3855a407 100644 --- a/apps/web/src/routes/_app/new/plan.tsx +++ b/apps/web/src/routes/_app/new/plan.tsx @@ -6,8 +6,9 @@ interface PlanSearch { } export const Route = createFileRoute('/_app/new/plan')({ - validateSearch: (search: Record): 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/packages/i18n/src/de/shell.ts b/packages/i18n/src/de/shell.ts index c3f84e90..03077f66 100644 --- a/packages/i18n/src/de/shell.ts +++ b/packages/i18n/src/de/shell.ts @@ -169,6 +169,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/shell.ts b/packages/i18n/src/en/shell.ts index 4434472d..9bb83c88 100644 --- a/packages/i18n/src/en/shell.ts +++ b/packages/i18n/src/en/shell.ts @@ -166,6 +166,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..8825f5d4 100644 --- a/packages/i18n/src/es/shell.ts +++ b/packages/i18n/src/es/shell.ts @@ -168,6 +168,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..8ded34de 100644 --- a/packages/i18n/src/fr/shell.ts +++ b/packages/i18n/src/fr/shell.ts @@ -169,6 +169,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..64bffdc3 100644 --- a/packages/i18n/src/pt/shell.ts +++ b/packages/i18n/src/pt/shell.ts @@ -169,6 +169,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.', From c503b2503bef26bbab140b4f5ff69879b38ce570 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:11:35 -0500 Subject: [PATCH 03/12] say when the app is offline, and never lose an entry to it A small banner shows on every screen while the browser is offline. The expense and payment forms turn their save button off with a note that what was typed stays put, and the tab asks before closing while that entry would be lost. A save already on its way when the connection drops is paused by React Query; it now says it will go through once the connection is back instead of spinning with no word. React Query also starts from navigator.onLine rather than assuming it is online. Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com> Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com> --- apps/web/e2e/expense-entry.spec.ts | 20 +++++++ .../expenses/form/expense-form.test.tsx | 55 +++++++++++++++++- .../features/expenses/form/expense-form.tsx | 12 ++++ .../src/features/settle/settle-flow.test.tsx | 19 ++++++- apps/web/src/features/settle/settle-flow.tsx | 9 +++ apps/web/src/features/shell/offline.test.tsx | 57 +++++++++++++++++++ apps/web/src/features/shell/offline.tsx | 44 ++++++++++++++ apps/web/src/lib/online.ts | 40 +++++++++++++ apps/web/src/lib/query-client.ts | 7 ++- apps/web/src/routes/__root.tsx | 2 + apps/web/src/test/network.ts | 12 ++++ packages/i18n/src/de/shell.ts | 6 ++ packages/i18n/src/en/shell.ts | 5 ++ packages/i18n/src/es/shell.ts | 6 ++ packages/i18n/src/fr/shell.ts | 6 ++ packages/i18n/src/pt/shell.ts | 5 ++ 16 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/features/shell/offline.test.tsx create mode 100644 apps/web/src/features/shell/offline.tsx create mode 100644 apps/web/src/lib/online.ts create mode 100644 apps/web/src/test/network.ts diff --git a/apps/web/e2e/expense-entry.spec.ts b/apps/web/e2e/expense-entry.spec.ts index 611ba281..16694d03 100644 --- a/apps/web/e2e/expense-entry.spec.ts +++ b/apps/web/e2e/expense-entry.spec.ts @@ -326,4 +326,24 @@ test.describe('expense entry', () => { page.getByRole('button', { name: `${t('expenses.fields.currency')}, USD` }), ).toBeVisible() }) + + test('offline, the entry waits in the form and saves once back', async ({ newUser, openAs }) => { + const { me, group } = await threePeople(newUser) + const page = await openAs(me) + const description = unique('Offline tacos') + + await startExpense(page, group, '30', description) + await page.context().setOffline(true) + await expect(page.getByText(t('app.offline.form'))).toBeVisible() + const saveButton = page.getByRole('button', { name: t('expenses.form.save') }) + await expect(saveButton).toBeDisabled() + + await page.context().setOffline(false) + await expect(page.getByText(t('app.offline.form'))).toHaveCount(0) + await expect(page.getByRole('textbox', { name: t('expenses.fields.description') })).toHaveValue( + description, + ) + await save(page, group) + await expect(page.getByRole('link', { name: new RegExp(description) })).toBeVisible() + }) }) diff --git a/apps/web/src/features/expenses/form/expense-form.test.tsx b/apps/web/src/features/expenses/form/expense-form.test.tsx index 8219c577..ac4b3142 100644 --- a/apps/web/src/features/expenses/form/expense-form.test.tsx +++ b/apps/web/src/features/expenses/form/expense-form.test.tsx @@ -1,8 +1,10 @@ import type * as Api from '@potluck/api' import { createExpense, moneyKeys, updateExpense } 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 { onlineManager } from '@tanstack/react-query' +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, @@ -467,3 +469,52 @@ describe('ExpenseForm', () => { }) }) }) + +describe('ExpenseForm offline', () => { + beforeEach(() => create.mockClear()) + afterEach(() => { + goOnline() + onlineManager.setOnline(true) + }) + const saveButton = () => screen.getByRole('button', { name: 'Save expense' }) + + it('keeps the entry and holds the save until the connection is back', async () => { + const { onDone } = setup() + act(() => goOffline()) + + expect(screen.getByText(t('app.offline.form'))).toBeInTheDocument() + expect(saveButton()).toBeDisabled() + // Enter in a field does not sneak a save through either. + fireEvent.submit(document.querySelector('form')!) + expect(create).not.toHaveBeenCalled() + // Closing the tab now would lose what was typed, so the browser asks first. + const leave = new Event('beforeunload', { cancelable: true }) + window.dispatchEvent(leave) + expect(leave.defaultPrevented).toBe(true) + + act(() => goOnline()) + expect(screen.queryByText(t('app.offline.form'))).toBeNull() + expect(screen.getByRole('textbox', { name: 'What was it?' })).toHaveValue('Cabin deposit') + fireEvent.click(saveButton()) + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) + expect(create.mock.calls[0]![1]).toMatchObject({ + description: 'Cabin deposit', + amount_cents: 9000, + }) + await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense')) + }) + + it('says a save caught by the connection dropping will go through, then saves it', async () => { + const { onDone } = setup() + // The save leaves just as the connection goes: React Query pauses it instead of failing. + onlineManager.setOnline(false) + fireEvent.click(saveButton()) + + expect(await screen.findByText(t('app.offline.paused'))).toBeInTheDocument() + expect(create).not.toHaveBeenCalled() + + act(() => onlineManager.setOnline(true)) + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense')) + }) +}) diff --git a/apps/web/src/features/expenses/form/expense-form.tsx b/apps/web/src/features/expenses/form/expense-form.tsx index 2bf4dc70..f212d04d 100644 --- a/apps/web/src/features/expenses/form/expense-form.tsx +++ b/apps/web/src/features/expenses/form/expense-form.tsx @@ -54,6 +54,8 @@ import { Link } from '@tanstack/react-router' import { CalendarHeart, Repeat } from 'lucide-react' import { useEffect, useId, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react' import { useUserId } from '@/lib/auth' +import { useOnline, useWarnBeforeUnload } from '@/lib/online' +import { OfflineSaveNote } from '@/features/shell/offline' import { supabase } from '@/lib/supabase' import { openMemberIds, templateAsExpense } from '@/features/recurring/template' import { departedMembers } from '../expense-math' @@ -549,8 +551,16 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { /> ) + // Offline, saving waits: the button is off and the entry stays in the form (and the tab asks + // before closing). A save already out when the connection drops is paused, not lost. + const online = useOnline() + const paused = save.isPaused || saveTemplate.isPaused + const typed = !!editing || amount !== null || description.trim() !== '' || notes.trim() !== '' + useWarnBeforeUnload(paused || (!online && typed)) + const onSubmit = (e: FormEvent) => { e.preventDefault() + if (!online) return setSubmitted(true) if (!description.trim()) return if (!locked && (total <= 0 || !preview.ok || !payersCheck.ok || formerError)) return @@ -581,11 +591,13 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { {descriptionError ?? amountError ?? payersError ?? splitError ?? nextError}

)} + + + + {value ?? 'none'} + + ) +} + +const row = () => screen.getByRole('radiogroup', { name: t('categories.label') }) +const chip = (id: CategoryId) => within(row()).getByRole('radio', { name: categoryLabel(id) }) +const picked = () => screen.getByRole('status', { name: 'picked' }).textContent +const tabStops = (group: HTMLElement) => + within(group) + .getAllByRole('radio') + .filter((r) => r.tabIndex === 0) + +describe('CategoryPicker keyboard', () => { + it('is one Tab stop: the first chip when nothing is picked, else the picked one', () => { + const { unmount } = render() + expect(tabStops(row())).toEqual([chip(CATEGORIES[0]!.id)]) + unmount() + + render() + expect(tabStops(row())).toEqual([chip(CATEGORIES[3]!.id)]) + }) + + it('moves along the row with the arrow keys and picks as it goes, wrapping at the ends', () => { + render() + const first = CATEGORIES[0]!.id + const second = CATEGORIES[1]!.id + const last = CATEGORIES[CATEGORIES.length - 1]!.id + + chip(first).focus() + fireEvent.keyDown(chip(first), { key: 'ArrowRight' }) + expect(picked()).toBe(second) + expect(chip(second)).toHaveFocus() + expect(chip(second)).toHaveAttribute('aria-checked', 'true') + expect(tabStops(row())).toEqual([chip(second)]) + + fireEvent.keyDown(chip(second), { key: 'ArrowLeft' }) + fireEvent.keyDown(chip(first), { key: 'ArrowLeft' }) + expect(picked()).toBe(last) + expect(chip(last)).toHaveFocus() + + fireEvent.keyDown(chip(last), { key: 'Home' }) + expect(picked()).toBe(first) + fireEvent.keyDown(chip(first), { key: 'End' }) + expect(picked()).toBe(last) + }) + + it('gives the subcategory row its own single Tab stop and arrow keys', () => { + const parent = CATEGORIES.find((c) => subcategoriesOf(c.id).length > 1)!.id + const subs = subcategoriesOf(parent) + render() + const subRow = screen.getByRole('radiogroup', { name: t('categories.subLabel') }) + const subChip = (i: number) => + within(subRow).getByRole('radio', { name: categoryLabel(subs[i]!.id) }) + + expect(tabStops(subRow)).toEqual([subChip(0)]) + fireEvent.keyDown(subChip(0), { key: 'ArrowRight' }) + expect(picked()).toBe(subs[1]!.id) + expect(subChip(1)).toHaveFocus() + // The top row still has the parent as its one stop. + expect(tabStops(row())).toEqual([chip(parent)]) + }) +}) diff --git a/apps/web/src/features/expenses/form/category-picker.tsx b/apps/web/src/features/expenses/form/category-picker.tsx index eba52525..b7fb7e79 100644 --- a/apps/web/src/features/expenses/form/category-picker.tsx +++ b/apps/web/src/features/expenses/form/category-picker.tsx @@ -9,7 +9,7 @@ import { t } from '@potluck/i18n' import { cn, usePrefersReducedMotion } from '@potluck/ui' import { AnimatePresence, motion } from 'motion/react' import { Sparkles } from 'lucide-react' -import { useEffect, useId, useRef } from 'react' +import { useEffect, useId, useRef, type KeyboardEvent } from 'react' import { categoryLabel } from '../format' export interface CategoryPickerProps { @@ -23,10 +23,22 @@ export interface CategoryPickerProps { const chip = 'inline-flex shrink-0 items-center gap-1.5 rounded-pill border font-medium transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.96]' +/** The chip an arrow key, Home or End moves to from `index`, wrapping around; null for other keys. */ +function arrowTarget(key: string, index: number, count: number): number | null { + if (key === 'ArrowRight' || key === 'ArrowDown') return (index + 1) % count + if (key === 'ArrowLeft' || key === 'ArrowUp') return (index - 1 + count) % count + if (key === 'Home') return 0 + if (key === 'End') return count - 1 + return null +} + /** * Category chips as a radio group, with a second row of subcategories under the picked one. * Tapping the picked category again clears it; tapping it while one of its subcategories is * picked goes back to the category itself. + * + * Each row is one Tab stop (the picked chip, else the first) and the arrow keys move along it + * and pick, like any radio group, so the form is not sixteen Tab presses long. */ export function CategoryPicker({ value, onChange, suggested = false }: CategoryPickerProps) { const reduced = usePrefersReducedMotion() @@ -37,6 +49,25 @@ export function CategoryPicker({ value, onChange, suggested = false }: CategoryP const subs = parent ? subcategoriesOf(parent) : [] const showSuggested = suggested && known !== null const rowRef = useRef(null) + const mainStop = Math.max( + 0, + CATEGORIES.findIndex((c) => c.id === parent), + ) + const subStop = Math.max( + 0, + subs.findIndex((c) => c.id === known), + ) + const onArrow = + (index: number, ids: readonly CategoryId[]) => (e: KeyboardEvent) => { + const next = arrowTarget(e.key, index, ids.length) + if (next === null) return + e.preventDefault() + onChange(ids[next]!) + // The chips of this row, in order; the picked one becomes the row's Tab stop. + e.currentTarget.parentElement?.querySelectorAll('[role="radio"]')[next]?.focus() + } + const mainIds = CATEGORIES.map((c) => c.id) + const subIds = subs.map((c) => c.id) // On a phone the chips scroll sideways: bring the picked one into view when it changes, // so a suggestion never lands off screen. @@ -85,7 +116,7 @@ export function CategoryPicker({ value, onChange, suggested = false }: CategoryP aria-label={t('categories.label')} className="-mx-4 flex [scrollbar-width:none] gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0 [&::-webkit-scrollbar]:hidden" > - {CATEGORIES.map((c) => { + {CATEGORIES.map((c, index) => { const checked = parent === c.id return ( + )} + ) } 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/docs/development.md b/docs/development.md index 623c7748..3bd84fdf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -300,7 +300,7 @@ 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` (also for a save React Query paused mid-flight, which resumes by itself) and calls `useWarnBeforeUnload` while its entry would be lost; do the same for any new save button there. +- 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`) gets no toast: the form stays as typed and `OfflineSaveNote` (`failed`, `onRetry`) says so with Try again. 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). diff --git a/packages/i18n/src/de/shell.ts b/packages/i18n/src/de/shell.ts index 7b7a97c3..ffd35546 100644 --- a/packages/i18n/src/de/shell.ts +++ b/packages/i18n/src/de/shell.ts @@ -62,8 +62,8 @@ export const app = { offline: { banner: 'Offline. Manches muss kurz warten.', form: 'Du bist offline. Deine Eingaben bleiben hier; speichere, sobald du wieder online bist.', - paused: - 'Beim Speichern ging die Verbindung weg. Lass die Seite offen: Es wird gespeichert, sobald du zurück bist.', + failed: + 'Potluck war nicht erreichbar. Deine Eingaben sind noch da; versuch es nochmal, sobald du wieder online bist.', }, } satisfies Translation diff --git a/packages/i18n/src/en/shell.ts b/packages/i18n/src/en/shell.ts index bcc73f26..22fd763d 100644 --- a/packages/i18n/src/en/shell.ts +++ b/packages/i18n/src/en/shell.ts @@ -59,7 +59,7 @@ export const app = { offline: { banner: 'Offline for now. Some things will wait.', form: 'You are offline. What you entered stays here; save it once you are back.', - paused: 'You went offline mid-save. Keep this page open: it saves as soon as you are back.', + failed: 'Could not reach Potluck. What you typed is still here; try again when you are back.', }, } as const diff --git a/packages/i18n/src/es/shell.ts b/packages/i18n/src/es/shell.ts index b23b1a9a..3ca5e8f7 100644 --- a/packages/i18n/src/es/shell.ts +++ b/packages/i18n/src/es/shell.ts @@ -62,8 +62,8 @@ export const app = { 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.', - paused: - 'Te quedaste sin conexión al guardar. Deja esta página abierta: se guarda en cuanto vuelvas.', + failed: + 'No pudimos conectar con Potluck. Lo que escribiste sigue aquí; vuelve a intentarlo cuando tengas conexión.', }, } satisfies Translation diff --git a/packages/i18n/src/fr/shell.ts b/packages/i18n/src/fr/shell.ts index 5745a7ec..4cab6573 100644 --- a/packages/i18n/src/fr/shell.ts +++ b/packages/i18n/src/fr/shell.ts @@ -62,8 +62,8 @@ export const app = { 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.', - paused: - "La connexion a coupé pendant l'enregistrement. Garde cette page ouverte : tout sera enregistré dès ton retour.", + failed: + 'Impossible de joindre Potluck. Ce que tu as saisi est toujours là ; réessaie quand tu es de retour.', }, } satisfies Translation diff --git a/packages/i18n/src/pt/shell.ts b/packages/i18n/src/pt/shell.ts index fa364cdf..9233d699 100644 --- a/packages/i18n/src/pt/shell.ts +++ b/packages/i18n/src/pt/shell.ts @@ -62,7 +62,8 @@ export const app = { offline: { banner: 'Sem conexão. Algumas coisas vão esperar.', form: 'Você está offline. O que você preencheu fica aqui; salve quando voltar.', - paused: 'A conexão caiu ao salvar. Deixe esta página aberta: salva assim que você voltar.', + failed: + 'Não deu para falar com o Potluck. O que você preencheu continua aqui; tente de novo quando voltar.', }, } satisfies Translation From 60333d3e43e02d19ecf2e989d8b0d5a9c68e75b9 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:32:27 -0500 Subject: [PATCH 11/12] classify failed saves once and refetch group money after --- .../expenses/form/expense-form.test.tsx | 15 +++++++++++--- .../features/expenses/form/expense-form.tsx | 20 +++++++++++++------ .../src/features/settle/settle-flow.test.tsx | 19 ++++++++++++++++-- apps/web/src/features/settle/settle-flow.tsx | 9 +++++++-- docs/development.md | 2 +- packages/i18n/src/de/shell.ts | 2 +- packages/i18n/src/en/shell.ts | 3 ++- packages/i18n/src/es/shell.ts | 2 +- packages/i18n/src/fr/shell.ts | 2 +- packages/i18n/src/pt/shell.ts | 2 +- 10 files changed, 57 insertions(+), 19 deletions(-) diff --git a/apps/web/src/features/expenses/form/expense-form.test.tsx b/apps/web/src/features/expenses/form/expense-form.test.tsx index 36a43e77..34313d3f 100644 --- a/apps/web/src/features/expenses/form/expense-form.test.tsx +++ b/apps/web/src/features/expenses/form/expense-form.test.tsx @@ -37,14 +37,14 @@ const update = vi.mocked(updateExpense) function setup() { const onDone = vi.fn() - renderWithClient( + const { client } = renderWithClient( {}} />, ) fireEvent.change(screen.getByRole('textbox', { name: 'Amount' }), { target: { value: '90' } }) fireEvent.change(screen.getByRole('textbox', { name: 'What was it?' }), { target: { value: 'Cabin deposit' }, }) - return { onDone } + return { onDone, client } } const percentOf = (name: string) => @@ -501,12 +501,15 @@ describe('ExpenseForm offline', () => { }) it('keeps the entry when the connection drops during a save, and Try again saves it once', async () => { - const { onDone } = setup() + const { onDone, client } = setup() + client.setQueryData(moneyKeys.expenses(GROUP_ID), []) // What supabase-js hands back when fetch never got an answer. create.mockRejectedValueOnce({ message: 'TypeError: Failed to fetch', code: '' }) fireEvent.click(saveButton()) expect(await screen.findByText(t('app.offline.failed'))).toBeInTheDocument() + // The save may have landed anyway, so the group's expenses are fetched again. + expect(client.getQueryState(moneyKeys.expenses(GROUP_ID))?.isInvalidated).toBe(true) expect(create).toHaveBeenCalledTimes(1) expect(onDone).not.toHaveBeenCalled() expect(screen.getByRole('textbox', { name: 'What was it?' })).toHaveValue('Cabin deposit') @@ -532,5 +535,11 @@ describe('ExpenseForm offline', () => { await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) expect(screen.queryByText(t('app.offline.failed'))).toBeNull() expect(screen.queryByRole('button', { name: t('common.retry') })).toBeNull() + + // Losing the connection afterwards does not turn the refusal into a lost connection. + 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() }) }) diff --git a/apps/web/src/features/expenses/form/expense-form.tsx b/apps/web/src/features/expenses/form/expense-form.tsx index e832a547..05ab421a 100644 --- a/apps/web/src/features/expenses/form/expense-form.tsx +++ b/apps/web/src/features/expenses/form/expense-form.tsx @@ -367,7 +367,16 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { const canSaveDefault = !template && !locked && !!defaultSplit && !isDefault && !(plainEqual && !group.default_split) + const [unreached, setUnreached] = useState(false) + const failSave = (error: unknown) => { + const network = isNetworkError(error) + setUnreached(network) + if (network) void queryClient.invalidateQueries({ queryKey: moneyKeys.group(group.id) }) + else toast.error(moneyErrorMessage(error)) + } + const saveTemplate = useMutation({ + onMutate: () => setUnreached(false), mutationFn: async (current: RecurringExpense) => { const payload: RecurringUpdate = { description: description.trim(), @@ -397,14 +406,13 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { toast.success(t('expenses.recurring.savedToast', { description: saved.description })) props.onDone(saved.id) }, - onError: (error) => { - if (!isNetworkError(error)) toast.error(moneyErrorMessage(error)) - }, + onError: failSave, }) const save = useMutation({ onMutate: () => { saveRunning.current = true + setUnreached(false) }, onSettled: () => { saveRunning.current = false @@ -510,9 +518,10 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { }, onError: (error) => { if (error instanceof Error && error.message === 'receipt_upload_failed') { + setUnreached(false) toast.error(t('expenses.receiptUpload.failed')) - } else if (!isNetworkError(error)) { - toast.error(moneyErrorMessage(error)) + } else { + failSave(error) } }, }) @@ -557,7 +566,6 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { // Offline, saving waits: the button is off and the entry stays in the form (and the tab asks // before closing). A save the network dropped leaves the form as typed, with a Try again. const online = useOnline() - const unreached = [save, saveTemplate].some((m) => m.isError && isNetworkError(m.error)) const typed = !!editing || amount !== null || description.trim() !== '' || notes.trim() !== '' useWarnBeforeUnload(unreached || (!online && typed)) diff --git a/apps/web/src/features/settle/settle-flow.test.tsx b/apps/web/src/features/settle/settle-flow.test.tsx index 2f23c7e7..150f0eac 100644 --- a/apps/web/src/features/settle/settle-flow.test.tsx +++ b/apps/web/src/features/settle/settle-flow.test.tsx @@ -63,7 +63,7 @@ function setup(props: { from?: string; to?: string; amount?: number; role?: 'adm />, client, ) - return { onPickPerson, onFinish } + return { onPickPerson, onFinish, client } } describe('SettleFlow', () => { @@ -122,12 +122,15 @@ describe('SettleFlow', () => { }) it('keeps the payment when the connection drops while recording, and Try again records it once', async () => { - setup({ to: BEN }) + 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') @@ -138,6 +141,18 @@ describe('SettleFlow', () => { 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 1bd0f8f2..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, @@ -327,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) { @@ -360,13 +363,15 @@ export function PaymentForm({ onDone({ from: payer, to: payee, owedBefore: owed, paid: cents, currency }) }, onError: (e) => { - if (!isNetworkError(e)) toast.error(moneyErrorMessage(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() - const unreached = pay.isError && isNetworkError(pay.error) useWarnBeforeUnload(unreached || (!online && amount !== null)) const submit = (e?: FormEvent) => { diff --git a/docs/development.md b/docs/development.md index 3bd84fdf..41512399 100644 --- a/docs/development.md +++ b/docs/development.md @@ -300,7 +300,7 @@ 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`) gets no toast: the form stays as typed and `OfflineSaveNote` (`failed`, `onRetry`) says so with Try again. Saves are never retried by themselves, since a request that did arrive would be saved twice. +- 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). diff --git a/packages/i18n/src/de/shell.ts b/packages/i18n/src/de/shell.ts index ffd35546..e5f06266 100644 --- a/packages/i18n/src/de/shell.ts +++ b/packages/i18n/src/de/shell.ts @@ -63,7 +63,7 @@ export const app = { banner: 'Offline. Manches muss kurz warten.', form: 'Du bist offline. Deine Eingaben bleiben hier; speichere, sobald du wieder online bist.', failed: - 'Potluck war nicht erreichbar. Deine Eingaben sind noch da; versuch es nochmal, sobald du wieder online bist.', + '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 diff --git a/packages/i18n/src/en/shell.ts b/packages/i18n/src/en/shell.ts index 22fd763d..23d6cc77 100644 --- a/packages/i18n/src/en/shell.ts +++ b/packages/i18n/src/en/shell.ts @@ -59,7 +59,8 @@ export const app = { 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: 'Could not reach Potluck. What you typed is still here; try again when 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 diff --git a/packages/i18n/src/es/shell.ts b/packages/i18n/src/es/shell.ts index 3ca5e8f7..20a80722 100644 --- a/packages/i18n/src/es/shell.ts +++ b/packages/i18n/src/es/shell.ts @@ -63,7 +63,7 @@ export const app = { 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: - 'No pudimos conectar con Potluck. Lo que escribiste sigue aquí; vuelve a intentarlo cuando tengas conexión.', + '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 diff --git a/packages/i18n/src/fr/shell.ts b/packages/i18n/src/fr/shell.ts index 4cab6573..d75af697 100644 --- a/packages/i18n/src/fr/shell.ts +++ b/packages/i18n/src/fr/shell.ts @@ -63,7 +63,7 @@ export const app = { 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: - 'Impossible de joindre Potluck. Ce que tu as saisi est toujours là ; réessaie quand tu es de retour.', + "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 diff --git a/packages/i18n/src/pt/shell.ts b/packages/i18n/src/pt/shell.ts index 9233d699..0e8718b9 100644 --- a/packages/i18n/src/pt/shell.ts +++ b/packages/i18n/src/pt/shell.ts @@ -63,7 +63,7 @@ export const app = { banner: 'Sem conexão. Algumas coisas vão esperar.', form: 'Você está offline. O que você preencheu fica aqui; salve quando voltar.', failed: - 'Não deu para falar com o Potluck. O que você preencheu continua aqui; tente de novo quando voltar.', + '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 From bad17f9349df598c7479a91e496fb3ec50777043 Mon Sep 17 00:00:00 2001 From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:37:13 -0500 Subject: [PATCH 12/12] note number search in insights e2e testing row --- docs/testing.md | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index dfce2117..4e87cfb0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -152,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 (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; 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.