+ )
+}
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..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}
)}
+
{editing ? t('expenses.form.saveChanges') : t('expenses.form.save')}
diff --git a/apps/web/src/features/settle/settle-flow.test.tsx b/apps/web/src/features/settle/settle-flow.test.tsx
index 053758f7..1d3ff9d1 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,
@@ -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,19 @@ 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('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..bd1fdf49 100644
--- a/apps/web/src/features/settle/settle-flow.tsx
+++ b/apps/web/src/features/settle/settle-flow.tsx
@@ -31,6 +31,8 @@ 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 { 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'
@@ -359,8 +361,13 @@ export function PaymentForm({
onError: (e) => toast.error(moneyErrorMessage(e)),
})
+ // Offline, recording waits with the form filled in, as in the expense form.
+ const online = useOnline()
+ useWarnBeforeUnload(pay.isPaused || (!online && amount !== null))
+
const submit = (e?: FormEvent) => {
e?.preventDefault()
+ if (!online) return
setSubmitted(true)
if (problem || !amount) return
pay.mutate(amount)
@@ -414,12 +421,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..5babb7ac
--- /dev/null
+++ b/apps/web/src/features/shell/offline.test.tsx
@@ -0,0 +1,57 @@
+import { t } from '@potluck/i18n'
+import { act, render, renderHook, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } 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, and that a paused save goes through later', () => {
+ const { rerender } = render()
+ expect(screen.getByRole('status')).toHaveTextContent('')
+ act(() => goOffline())
+ expect(screen.getByRole('status')).toHaveTextContent(t('app.offline.form'))
+ rerender()
+ expect(screen.getByRole('status')).toHaveTextContent(t('app.offline.paused'))
+ })
+})
+
+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..1a6cbca0
--- /dev/null
+++ b/apps/web/src/features/shell/offline.tsx
@@ -0,0 +1,44 @@
+import { t } from '@potluck/i18n'
+import { 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; a save that went out just as the connection dropped is paused
+ * by React Query and resumes on its own, and this says so instead of an endless spinner.
+ */
+export function OfflineSaveNote({ paused = false }: { paused?: boolean }) {
+ const online = useOnline()
+ return (
+
diff --git a/apps/web/src/features/expenses/form/split-editor.test.tsx b/apps/web/src/features/expenses/form/split-editor.test.tsx
index a0899807..8c788530 100644
--- a/apps/web/src/features/expenses/form/split-editor.test.tsx
+++ b/apps/web/src/features/expenses/form/split-editor.test.tsx
@@ -1,5 +1,5 @@
import { t } from '@potluck/i18n'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, render, screen, within } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { BEN, CLEO, ME, members } from '../test-utils'
@@ -8,7 +8,15 @@ import { initialDraft, type SplitDraft } from './split-state'
vi.mock('@/lib/supabase', () => 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?
<>
{
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 51d7c099..95d4b373 100644
--- a/apps/web/src/features/expenses/form/split-editor.tsx
+++ b/apps/web/src/features/expenses/form/split-editor.tsx
@@ -31,6 +31,7 @@ import {
splitErrorText,
toggleParticipant,
typePercent,
+ viewerFirst,
type SplitDraft,
} from './split-state'
@@ -93,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) : []
@@ -356,7 +357,7 @@ export function SplitEditor({
aria-label={t('expenses.fields.splitBetween')}
className="flex flex-wrap gap-2"
>
- {members.map((m) => {
+ {shown.map((m) => {
const on = draft.participants.includes(m.id)
return (
(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 = {}
From 2d33148e4b4906afc0dad302db7454a3702c0ed2 Mon Sep 17 00:00:00 2001
From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com>
Date: Sat, 14 Nov 2026 01:17:33 -0500
Subject: [PATCH 05/12] receipts label and count read as two things
The count sat inside the label element, so the field read "Receipts and photos1 of 5" to screen
readers and in copied text. The label is now just the label, the count is separate text with a
real space before it, and the list takes the count as its description.
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
---
.../expenses/receipts/attachments-field.tsx | 16 ++++++++++++----
.../features/expenses/receipts/receipts.test.tsx | 7 +++++++
2 files changed, 19 insertions(+), 4 deletions(-)
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 }) }),
From 125515693f72845ecd00072be4cd2ed0c51b0eb9 Mon Sep 17 00:00:00 2001
From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com>
Date: Sat, 14 Nov 2026 01:18:00 -0500
Subject: [PATCH 06/12] docs: search params, error page, offline saves and
split order
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
---
apps/web/AGENTS.md | 5 ++++-
docs/development.md | 4 +++-
docs/testing.md | 4 ++--
3 files changed, 9 insertions(+), 4 deletions(-)
diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md
index 0fc63c67..d786d89a 100644
--- a/apps/web/AGENTS.md
+++ b/apps/web/AGENTS.md
@@ -14,7 +14,8 @@ Per-feature notes (payers and refunds, repeating expenses, language, two-step si
## Rules
-- Route files are in the first download: no schema libraries (search params use `src/lib/search.ts`),
+- Route files are in the first download: no schema libraries (search params use `src/lib/search.ts`;
+ a validator returns every key, `undefined` when dropped, or the raw value shows through),
no feature imports beyond the screen. A `loader` may call `prefetch.()` from
`src/lib/prefetch.ts` and must not await it.
- `/a/$code` (guest links) is signed out. `/` is prerendered (`prerender-landing.ts`): nothing on the
@@ -22,6 +23,8 @@ Per-feature notes (payers and refunds, repeating expenses, language, two-step si
- Server data goes through `@potluck/api` functions and `queryOptions`; never `supabase.from()` in a component.
- Realtime: subscribe with `useRealtime` / `useBatchedInvalidate` (`src/lib/realtime.ts`) and invalidate
query keys; never patch the cache from the payload.
+- Offline: forms in `MoneyPageShell` disable save with `OfflineSaveNote` and `useWarnBeforeUnload`
+ (`src/lib/online.ts`); a screen that throws shows `RouteError`.
- Errors: `errorMessage(error, scope)` from `src/lib/errors.ts` (scopes `expenses.errors`,
`events.errors`, then `errors.codes`). Not found is `isNotFoundError()`.
- Dates: pass the viewer's zone from `resolveTimeZone` (`src/lib/locale.ts`); calendar dates
diff --git a/docs/development.md b/docs/development.md
index 0889a15e..623c7748 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,8 @@ 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.
+- 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`.
diff --git a/docs/testing.md b/docs/testing.md
index a5b423f3..9a972741 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -156,8 +156,8 @@ Every test creates its own users and groups with unique names, so tests run in p
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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 |
+| `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 |
From bc5e39dfde15c628bea49cf76999d6709df884c8 Mon Sep 17 00:00:00 2001
From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com>
Date: Sat, 14 Nov 2026 01:28:53 -0500
Subject: [PATCH 07/12] e2e: a number in the search address searches for it
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
---
apps/web/e2e/insights.spec.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/apps/web/e2e/insights.spec.ts b/apps/web/e2e/insights.spec.ts
index 2e7811dc..de720f0c 100644
--- a/apps/web/e2e/insights.spec.ts
+++ b/apps/web/e2e/insights.spec.ts
@@ -48,6 +48,11 @@ test('search finds an expense in every group the viewer is in, and only those',
await page.getByRole('searchbox', { name: t('search.label') }).fill(cleo.name)
await expect(page.getByRole('link', { name: new RegExp(`Taco truck ${tag}`) })).toBeVisible()
await expect(page.getByRole('link', { name: new RegExp(`Taco night ${tag}`) })).toHaveCount(0)
+
+ // A number in the address (a shared link, a bookmark) is a search for that text, not a crash.
+ await page.goto('/search?q=24')
+ await expect(page.getByRole('searchbox', { name: t('search.label') })).toHaveValue('24')
+ await expect(page.getByRole('heading', { name: t('errors.screen.title') })).toHaveCount(0)
})
test('filtering a group to one person, one month and one category shows a matching total', async ({
From b11319b0306e9592f4f7e1406cafba63984fc740 Mon Sep 17 00:00:00 2001
From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com>
Date: Sat, 14 Nov 2026 01:37:08 -0500
Subject: [PATCH 08/12] shorter offline banner, so it stays one line on a phone
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
---
packages/i18n/src/de/shell.ts | 2 +-
packages/i18n/src/en/shell.ts | 2 +-
packages/i18n/src/es/shell.ts | 2 +-
packages/i18n/src/fr/shell.ts | 2 +-
packages/i18n/src/pt/shell.ts | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/i18n/src/de/shell.ts b/packages/i18n/src/de/shell.ts
index 45b8a3fb..7b7a97c3 100644
--- a/packages/i18n/src/de/shell.ts
+++ b/packages/i18n/src/de/shell.ts
@@ -60,7 +60,7 @@ export const app = {
},
},
offline: {
- banner: 'Du bist offline. Manches wartet, bis du zurück bist.',
+ 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.',
diff --git a/packages/i18n/src/en/shell.ts b/packages/i18n/src/en/shell.ts
index ca2f344c..bcc73f26 100644
--- a/packages/i18n/src/en/shell.ts
+++ b/packages/i18n/src/en/shell.ts
@@ -57,7 +57,7 @@ export const app = {
},
},
offline: {
- banner: 'You are offline. Some things wait until you are back.',
+ 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.',
},
diff --git a/packages/i18n/src/es/shell.ts b/packages/i18n/src/es/shell.ts
index ec5d308d..b23b1a9a 100644
--- a/packages/i18n/src/es/shell.ts
+++ b/packages/i18n/src/es/shell.ts
@@ -60,7 +60,7 @@ export const app = {
},
},
offline: {
- banner: 'Estás sin conexión. Algunas cosas esperan a que vuelvas.',
+ 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.',
diff --git a/packages/i18n/src/fr/shell.ts b/packages/i18n/src/fr/shell.ts
index 703f36af..5745a7ec 100644
--- a/packages/i18n/src/fr/shell.ts
+++ b/packages/i18n/src/fr/shell.ts
@@ -60,7 +60,7 @@ export const app = {
},
},
offline: {
- banner: 'Tu es hors ligne. Certaines choses attendront ton retour.',
+ 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.",
diff --git a/packages/i18n/src/pt/shell.ts b/packages/i18n/src/pt/shell.ts
index 702363b1..fa364cdf 100644
--- a/packages/i18n/src/pt/shell.ts
+++ b/packages/i18n/src/pt/shell.ts
@@ -60,7 +60,7 @@ export const app = {
},
},
offline: {
- banner: 'Você está offline. Algumas coisas esperam você voltar.',
+ 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.',
},
From 6d027d73526a56e680baa465e5249438ca963d6a Mon Sep 17 00:00:00 2001
From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com>
Date: Sat, 14 Nov 2026 01:39:17 -0500
Subject: [PATCH 09/12] pgtap: split rounding is file 31, after can_split_with
Co-authored-by: Ferrin <177692050+Ferrin-Y@users.noreply.github.com>
Co-authored-by: noorelmosrati <182049385+noorelmosrati@users.noreply.github.com>
---
docs/architecture.md | 2 +-
docs/testing.md | 2 +-
packages/core/src/money/split-remainder.test.ts | 2 +-
.../{30_split_rounding.test.sql => 31_split_rounding.test.sql} | 0
4 files changed, 3 insertions(+), 3 deletions(-)
rename supabase/tests/{30_split_rounding.test.sql => 31_split_rounding.test.sql} (100%)
diff --git a/docs/architecture.md b/docs/architecture.md
index 5478a879..434efaba 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1003,7 +1003,7 @@ 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. 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 `30_split_rounding.test.sql` share the cases).
+- **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.
diff --git a/docs/testing.md b/docs/testing.md
index 9a972741..dfce2117 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -114,7 +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 |
-| `30_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` |
+| `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.
diff --git a/packages/core/src/money/split-remainder.test.ts b/packages/core/src/money/split-remainder.test.ts
index 89061d3d..ffba0141 100644
--- a/packages/core/src/money/split-remainder.test.ts
+++ b/packages/core/src/money/split-remainder.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { splitAdjustment, splitEqual, splitPercent, splitShares } from './split'
-// Twin of supabase/tests/30_split_rounding.test.sql: same people, same cases, same answers.
+// 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
diff --git a/supabase/tests/30_split_rounding.test.sql b/supabase/tests/31_split_rounding.test.sql
similarity index 100%
rename from supabase/tests/30_split_rounding.test.sql
rename to supabase/tests/31_split_rounding.test.sql
From c8720b6102afa2b482ccc0bd58d4b35c7529c6f3 Mon Sep 17 00:00:00 2001
From: Musa Kaleem <131704022+Musa-Kal@users.noreply.github.com>
Date: Sat, 14 Nov 2026 02:25:33 -0500
Subject: [PATCH 10/12] show a Try again note when a save loses the connection
---
.../expenses/form/expense-form.test.tsx | 40 +++++++++++++------
.../features/expenses/form/expense-form.tsx | 27 +++++++------
.../src/features/settle/settle-flow.test.tsx | 17 ++++++++
apps/web/src/features/settle/settle-flow.tsx | 10 +++--
apps/web/src/features/shell/offline.test.tsx | 25 +++++++++---
apps/web/src/features/shell/offline.tsx | 30 ++++++++++----
apps/web/src/lib/errors.ts | 2 +-
docs/development.md | 2 +-
packages/i18n/src/de/shell.ts | 4 +-
packages/i18n/src/en/shell.ts | 2 +-
packages/i18n/src/es/shell.ts | 4 +-
packages/i18n/src/fr/shell.ts | 4 +-
packages/i18n/src/pt/shell.ts | 3 +-
13 files changed, 120 insertions(+), 50 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 ac4b3142..36a43e77 100644
--- a/apps/web/src/features/expenses/form/expense-form.test.tsx
+++ b/apps/web/src/features/expenses/form/expense-form.test.tsx
@@ -1,7 +1,6 @@
import type * as Api from '@potluck/api'
import { createExpense, moneyKeys, updateExpense } from '@potluck/api'
import { t } from '@potluck/i18n'
-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'
@@ -472,10 +471,7 @@ describe('ExpenseForm', () => {
describe('ExpenseForm offline', () => {
beforeEach(() => create.mockClear())
- afterEach(() => {
- goOnline()
- onlineManager.setOnline(true)
- })
+ afterEach(() => goOnline())
const saveButton = () => screen.getByRole('button', { name: 'Save expense' })
it('keeps the entry and holds the save until the connection is back', async () => {
@@ -504,17 +500,37 @@ describe('ExpenseForm offline', () => {
await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense'))
})
- it('says a save caught by the connection dropping will go through, then saves it', async () => {
+ it('keeps the entry when the connection drops during a save, and Try again saves it once', async () => {
const { onDone } = setup()
- // The save leaves just as the connection goes: React Query pauses it instead of failing.
- onlineManager.setOnline(false)
+ // 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.paused'))).toBeInTheDocument()
- expect(create).not.toHaveBeenCalled()
+ expect(await screen.findByText(t('app.offline.failed'))).toBeInTheDocument()
+ expect(create).toHaveBeenCalledTimes(1)
+ expect(onDone).not.toHaveBeenCalled()
+ expect(screen.getByRole('textbox', { name: 'What was it?' })).toHaveValue('Cabin deposit')
+ const leave = new Event('beforeunload', { cancelable: true })
+ window.dispatchEvent(leave)
+ expect(leave.defaultPrevented).toBe(true)
- act(() => onlineManager.setOnline(true))
- await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
+ fireEvent.click(screen.getByRole('button', { name: t('common.retry') }))
await waitFor(() => expect(onDone).toHaveBeenCalledWith('new-expense'))
+ expect(create).toHaveBeenCalledTimes(2)
+ expect(create.mock.calls[1]![1]).toEqual(create.mock.calls[0]![1])
+ expect(create.mock.calls[1]![1]).toMatchObject({
+ description: 'Cabin deposit',
+ amount_cents: 9000,
+ })
+ expect(screen.queryByText(t('app.offline.failed'))).toBeNull()
+ })
+
+ it('still reports a refused save as an error, not as the connection', async () => {
+ setup()
+ create.mockRejectedValueOnce({ message: 'payers_sum_mismatch', code: '23514' })
+ fireEvent.click(saveButton())
+ await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
+ 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 f212d04d..e832a547 100644
--- a/apps/web/src/features/expenses/form/expense-form.tsx
+++ b/apps/web/src/features/expenses/form/expense-form.tsx
@@ -54,6 +54,7 @@ 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 { isNetworkError } from '@/lib/errors'
import { useOnline, useWarnBeforeUnload } from '@/lib/online'
import { OfflineSaveNote } from '@/features/shell/offline'
import { supabase } from '@/lib/supabase'
@@ -396,7 +397,9 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) {
toast.success(t('expenses.recurring.savedToast', { description: saved.description }))
props.onDone(saved.id)
},
- onError: (error) => toast.error(moneyErrorMessage(error)),
+ onError: (error) => {
+ if (!isNetworkError(error)) toast.error(moneyErrorMessage(error))
+ },
})
const save = useMutation({
@@ -506,11 +509,11 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) {
props.onDone(saved.id)
},
onError: (error) => {
- toast.error(
- error instanceof Error && error.message === 'receipt_upload_failed'
- ? t('expenses.receiptUpload.failed')
- : moneyErrorMessage(error),
- )
+ if (error instanceof Error && error.message === 'receipt_upload_failed') {
+ toast.error(t('expenses.receiptUpload.failed'))
+ } else if (!isNetworkError(error)) {
+ toast.error(moneyErrorMessage(error))
+ }
},
})
@@ -552,14 +555,14 @@ 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.
+ // before closing). A save the network dropped leaves the form as typed, with a Try again.
const online = useOnline()
- const paused = save.isPaused || saveTemplate.isPaused
+ const unreached = [save, saveTemplate].some((m) => m.isError && isNetworkError(m.error))
const typed = !!editing || amount !== null || description.trim() !== '' || notes.trim() !== ''
- useWarnBeforeUnload(paused || (!online && typed))
+ useWarnBeforeUnload(unreached || (!online && typed))
- const onSubmit = (e: FormEvent) => {
- e.preventDefault()
+ const onSubmit = (e?: FormEvent) => {
+ e?.preventDefault()
if (!online) return
setSubmitted(true)
if (!description.trim()) return
@@ -591,7 +594,7 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) {
{descriptionError ?? amountError ?? payersError ?? splitError ?? nextError}
)}
-
+ {
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 () => {
+ setup({ to: BEN })
+ 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()
+ 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('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 bd1fdf49..1bd0f8f2 100644
--- a/apps/web/src/features/settle/settle-flow.tsx
+++ b/apps/web/src/features/settle/settle-flow.tsx
@@ -31,6 +31,7 @@ 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'
@@ -358,12 +359,15 @@ export function PaymentForm({
}
onDone({ from: payer, to: payee, owedBefore: owed, paid: cents, currency })
},
- onError: (e) => toast.error(moneyErrorMessage(e)),
+ onError: (e) => {
+ if (!isNetworkError(e)) toast.error(moneyErrorMessage(e))
+ },
})
// Offline, recording waits with the form filled in, as in the expense form.
const online = useOnline()
- useWarnBeforeUnload(pay.isPaused || (!online && amount !== null))
+ const unreached = pay.isError && isNetworkError(pay.error)
+ useWarnBeforeUnload(unreached || (!online && amount !== null))
const submit = (e?: FormEvent) => {
e?.preventDefault()
@@ -421,7 +425,7 @@ export function PaymentForm({
{error}
)}
-
+ {
})
describe('OfflineSaveNote', () => {
- it('says the entry waits while offline, and that a paused save goes through later', () => {
- const { rerender } = render()
+ it('says the entry waits while offline', () => {
+ render()
expect(screen.getByRole('status')).toHaveTextContent('')
act(() => goOffline())
expect(screen.getByRole('status')).toHaveTextContent(t('app.offline.form'))
- rerender()
- expect(screen.getByRole('status')).toHaveTextContent(t('app.offline.paused'))
+ 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)
})
})
diff --git a/apps/web/src/features/shell/offline.tsx b/apps/web/src/features/shell/offline.tsx
index 1a6cbca0..99f35713 100644
--- a/apps/web/src/features/shell/offline.tsx
+++ b/apps/web/src/features/shell/offline.tsx
@@ -1,5 +1,5 @@
import { t } from '@potluck/i18n'
-import { cn } from '@potluck/ui'
+import { Button, cn } from '@potluck/ui'
import { WifiOff } from 'lucide-react'
import { useOnline } from '@/lib/online'
@@ -31,14 +31,30 @@ export function OfflineBanner() {
/**
* Next to a save button: offline, the button is disabled and this says the entry stays put
- * until the connection is back; a save that went out just as the connection dropped is paused
- * by React Query and resumes on its own, and this says so instead of an endless spinner.
+ * 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({ paused = false }: { paused?: boolean }) {
+export function OfflineSaveNote({
+ failed = false,
+ onRetry,
+}: {
+ failed?: boolean
+ onRetry?: () => void
+}) {
const online = useOnline()
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.