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/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/e2e/expenses.spec.ts b/apps/web/e2e/expenses.spec.ts index 9832f9e9..001c6453 100644 --- a/apps/web/e2e/expenses.spec.ts +++ b/apps/web/e2e/expenses.spec.ts @@ -105,17 +105,23 @@ test.describe('expenses', () => { await startExpense(page, group, '200', description) await page.getByRole('radio', { name: t('splitTypes.percent.short') }).click() - const mine = page.getByRole('textbox', { - name: t('expenses.splitEditor.percentOf', { name: t('common.you') }), - }) - await mine.fill('50') - await mine.press('Tab') - await expect(page.getByText('100% / 100%')).toBeVisible() + const percentOf = (name: string) => + page.getByRole('textbox', { name: t('expenses.splitEditor.percentOf', { name }) }) + // Each typed value stays as typed; nothing rebalances, the status says what is left. + await percentOf(t('common.you')).fill('50') + await percentOf(ben.name).fill('30') + // Cleo still has her third: 113.33% (or .34) until her value is typed. + const over = t('expenses.percentOver', { percent: '13.3' }) + await expect(page.getByText(over.slice(0, over.indexOf('%')), { exact: false })).toBeVisible() + await percentOf(cleo.name).fill('20') + await expect(percentOf(t('common.you'))).toHaveValue('50') + await expect(percentOf(ben.name)).toHaveValue('30') + await expect(page.getByText(t('expenses.allAssigned'))).toBeVisible() await saveAndOpen(page, group, description) await expectShares(page, me, [ [me, 10000], - [ben, 5000], - [cleo, 5000], + [ben, 6000], + [cleo, 4000], ]) }) diff --git a/apps/web/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 ({ diff --git a/apps/web/src/features/expenses/form/category-picker.test.tsx b/apps/web/src/features/expenses/form/category-picker.test.tsx new file mode 100644 index 00000000..e6599640 --- /dev/null +++ b/apps/web/src/features/expenses/form/category-picker.test.tsx @@ -0,0 +1,78 @@ +import { CATEGORIES, subcategoriesOf, type CategoryId } from '@potluck/core' +import { t } from '@potluck/i18n' +import { fireEvent, render, screen, within } from '@testing-library/react' +import { useState } from 'react' +import { describe, expect, it } from 'vitest' +import { categoryLabel } from '../format' +import { CategoryPicker } from './category-picker' + +function Harness({ start = null }: { start?: CategoryId | null }) { + const [value, setValue] = useState(start) + return ( + <> + + + + {value ?? 'none'} + + ) +} + +const row = () => screen.getByRole('radiogroup', { name: t('categories.label') }) +const chip = (id: CategoryId) => within(row()).getByRole('radio', { name: categoryLabel(id) }) +const picked = () => screen.getByRole('status', { name: 'picked' }).textContent +const tabStops = (group: HTMLElement) => + within(group) + .getAllByRole('radio') + .filter((r) => r.tabIndex === 0) + +describe('CategoryPicker keyboard', () => { + it('is one Tab stop: the first chip when nothing is picked, else the picked one', () => { + const { unmount } = render() + expect(tabStops(row())).toEqual([chip(CATEGORIES[0]!.id)]) + unmount() + + render() + expect(tabStops(row())).toEqual([chip(CATEGORIES[3]!.id)]) + }) + + it('moves along the row with the arrow keys and picks as it goes, wrapping at the ends', () => { + render() + const first = CATEGORIES[0]!.id + const second = CATEGORIES[1]!.id + const last = CATEGORIES[CATEGORIES.length - 1]!.id + + chip(first).focus() + fireEvent.keyDown(chip(first), { key: 'ArrowRight' }) + expect(picked()).toBe(second) + expect(chip(second)).toHaveFocus() + expect(chip(second)).toHaveAttribute('aria-checked', 'true') + expect(tabStops(row())).toEqual([chip(second)]) + + fireEvent.keyDown(chip(second), { key: 'ArrowLeft' }) + fireEvent.keyDown(chip(first), { key: 'ArrowLeft' }) + expect(picked()).toBe(last) + expect(chip(last)).toHaveFocus() + + fireEvent.keyDown(chip(last), { key: 'Home' }) + expect(picked()).toBe(first) + fireEvent.keyDown(chip(first), { key: 'End' }) + expect(picked()).toBe(last) + }) + + it('gives the subcategory row its own single Tab stop and arrow keys', () => { + const parent = CATEGORIES.find((c) => subcategoriesOf(c.id).length > 1)!.id + const subs = subcategoriesOf(parent) + render() + const subRow = screen.getByRole('radiogroup', { name: t('categories.subLabel') }) + const subChip = (i: number) => + within(subRow).getByRole('radio', { name: categoryLabel(subs[i]!.id) }) + + expect(tabStops(subRow)).toEqual([subChip(0)]) + fireEvent.keyDown(subChip(0), { key: 'ArrowRight' }) + expect(picked()).toBe(subs[1]!.id) + expect(subChip(1)).toHaveFocus() + // The top row still has the parent as its one stop. + expect(tabStops(row())).toEqual([chip(parent)]) + }) +}) diff --git a/apps/web/src/features/expenses/form/category-picker.tsx b/apps/web/src/features/expenses/form/category-picker.tsx index eba52525..b7fb7e79 100644 --- a/apps/web/src/features/expenses/form/category-picker.tsx +++ b/apps/web/src/features/expenses/form/category-picker.tsx @@ -9,7 +9,7 @@ import { t } from '@potluck/i18n' import { cn, usePrefersReducedMotion } from '@potluck/ui' import { AnimatePresence, motion } from 'motion/react' import { Sparkles } from 'lucide-react' -import { useEffect, useId, useRef } from 'react' +import { useEffect, useId, useRef, type KeyboardEvent } from 'react' import { categoryLabel } from '../format' export interface CategoryPickerProps { @@ -23,10 +23,22 @@ export interface CategoryPickerProps { const chip = 'inline-flex shrink-0 items-center gap-1.5 rounded-pill border font-medium transition-[background-color,border-color,color,transform] duration-150 active:scale-[0.96]' +/** The chip an arrow key, Home or End moves to from `index`, wrapping around; null for other keys. */ +function arrowTarget(key: string, index: number, count: number): number | null { + if (key === 'ArrowRight' || key === 'ArrowDown') return (index + 1) % count + if (key === 'ArrowLeft' || key === 'ArrowUp') return (index - 1 + count) % count + if (key === 'Home') return 0 + if (key === 'End') return count - 1 + return null +} + /** * Category chips as a radio group, with a second row of subcategories under the picked one. * Tapping the picked category again clears it; tapping it while one of its subcategories is * picked goes back to the category itself. + * + * Each row is one Tab stop (the picked chip, else the first) and the arrow keys move along it + * and pick, like any radio group, so the form is not sixteen Tab presses long. */ export function CategoryPicker({ value, onChange, suggested = false }: CategoryPickerProps) { const reduced = usePrefersReducedMotion() @@ -37,6 +49,25 @@ export function CategoryPicker({ value, onChange, suggested = false }: CategoryP const subs = parent ? subcategoriesOf(parent) : [] const showSuggested = suggested && known !== null const rowRef = useRef(null) + const mainStop = Math.max( + 0, + CATEGORIES.findIndex((c) => c.id === parent), + ) + const subStop = Math.max( + 0, + subs.findIndex((c) => c.id === known), + ) + const onArrow = + (index: number, ids: readonly CategoryId[]) => (e: KeyboardEvent) => { + const next = arrowTarget(e.key, index, ids.length) + if (next === null) return + e.preventDefault() + onChange(ids[next]!) + // The chips of this row, in order; the picked one becomes the row's Tab stop. + e.currentTarget.parentElement?.querySelectorAll('[role="radio"]')[next]?.focus() + } + const mainIds = CATEGORIES.map((c) => c.id) + const subIds = subs.map((c) => c.id) // On a phone the chips scroll sideways: bring the picked one into view when it changes, // so a suggestion never lands off screen. @@ -85,7 +116,7 @@ export function CategoryPicker({ value, onChange, suggested = false }: CategoryP aria-label={t('categories.label')} className="-mx-4 flex [scrollbar-width:none] gap-2 overflow-x-auto px-4 pb-1 sm:mx-0 sm:flex-wrap sm:overflow-visible sm:px-0 sm:pb-0 [&::-webkit-scrollbar]:hidden" > - {CATEGORIES.map((c) => { + {CATEGORIES.map((c, index) => { const checked = parent === c.id return ( + + ) +} + +describe('PercentInput', () => { + function renderPercent(locale: string, basisPoints = 5000) { + render() + return { + input: screen.getByRole('textbox', { name: 'Share' }), + value: () => Number(screen.getByRole('status', { name: 'basis points' }).textContent), + } } it('reads "12,50" as 12.5% in German', () => { - const { onCommit, input } = renderPercent('de-DE') + const { input, value } = renderPercent('de-DE') fireEvent.change(input, { target: { value: '12,50' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - expect(onCommit).toHaveBeenLastCalledWith(1250) + expect(value()).toBe(1250) + expect(input).toHaveValue('12,50') }) it('agrees with the money fields in US English', () => { - const { onCommit, input } = renderPercent('en-US') + const { input, value } = renderPercent('en-US') fireEvent.change(input, { target: { value: '12,5' } }) expect(input).toHaveValue('125') - fireEvent.keyDown(input, { key: 'Enter' }) - expect(onCommit).toHaveBeenLastCalledWith(12500) + expect(value()).toBe(12500) + }) + + it('passes on every keystroke, without waiting for blur or Enter', () => { + const { input, value } = renderPercent('en-US') + fireEvent.change(input, { target: { value: '3' } }) + expect(value()).toBe(300) + fireEvent.change(input, { target: { value: '30.' } }) + expect(value()).toBe(3000) + // A trailing decimal mark stays while typing. + expect(input).toHaveValue('30.') + fireEvent.blur(input) + expect(input).toHaveValue('30') }) it('shows the value with the locale decimal mark', () => { expect(renderPercent('de-DE', 3333).input).toHaveValue('33,33') }) - it('commits an emptied field as 0%', () => { - const { onCommit, input } = renderPercent('en-US') + it('reads an emptied field as 0%', () => { + const { input, value } = renderPercent('en-US') fireEvent.change(input, { target: { value: '' } }) - fireEvent.blur(input) - expect(onCommit).toHaveBeenLastCalledWith(0) + expect(value()).toBe(0) + expect(input).toHaveValue('') + }) + + it('shows a value set from elsewhere', () => { + const { input } = renderPercent('en-US') + fireEvent.change(input, { target: { value: '40' } }) + fireEvent.click(screen.getByRole('button', { name: 'Quarter' })) + expect(input).toHaveValue('25') }) }) diff --git a/apps/web/src/features/expenses/form/compact-inputs.tsx b/apps/web/src/features/expenses/form/compact-inputs.tsx index d5972dac..25987a33 100644 --- a/apps/web/src/features/expenses/form/compact-inputs.tsx +++ b/apps/web/src/features/expenses/form/compact-inputs.tsx @@ -1,5 +1,6 @@ import { cn, parseDecimalInput, sanitizeDecimalInput, useMoneyText } from '@potluck/ui' import { useState, type InputHTMLAttributes } from 'react' +import { formatPercent } from './split-state' const inputClass = 'h-11 w-full rounded-input border border-line-strong bg-card px-3 text-right text-base font-semibold tabular text-ink ' + @@ -37,50 +38,41 @@ export function CompactMoneyInput({ ) } -const percentText = new Map() -function formatPercent(basisPoints: number, locale: string): string { - let f = percentText.get(locale) - if (!f) { - f = new Intl.NumberFormat(locale, { maximumFractionDigits: 2, useGrouping: false }) - percentText.set(locale, f) - } - return f.format(Math.round(basisPoints) / 100) -} - -/** Percent typed with up to two decimals, committed on blur or Enter (so others rebalance once). */ +/** + * Percent typed with up to two decimals. Every keystroke is passed on as it reads, so the value + * is always what is on screen, however fast the fields are filled; nothing else rebalances. + */ export function PercentInput({ basisPoints, - onCommit, + onValueChange, locale, className, ...props }: { basisPoints: number - onCommit: (basisPoints: number) => void + onValueChange: (basisPoints: number) => void locale: string } & Omit, 'value' | 'onChange'>) { - const shown = formatPercent(basisPoints, locale) - const [draft, setDraft] = useState(null) - const commit = () => { - if (draft === null) return - setDraft(null) - // Percent is two decimals, so the parsed units are basis points. Empty means 0%. - onCommit(parseDecimalInput(draft, 2, locale) ?? 0) - } + // Percent is two decimals, so the parsed units are basis points. Empty means 0%. + const parse = (text: string) => parseDecimalInput(text, 2, locale) ?? 0 + const [text, setText] = useState(null) + // The text as typed ("50.", "" or "07") while it still means the value; a value set from + // elsewhere (the slider, even it out) shows formatted instead. + const shown = + text !== null && parse(text) === basisPoints ? text : formatPercent(basisPoints, locale) return ( setDraft(sanitizeDecimalInput(e.target.value, 2, locale))} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - commit() - } + placeholder="0" + value={shown} + onChange={(e) => { + const next = sanitizeDecimalInput(e.target.value, 2, locale) + setText(next) + onValueChange(parse(next)) }} + onBlur={() => setText(null)} className={cn(inputClass, 'pr-7')} {...props} /> diff --git a/apps/web/src/features/expenses/form/expense-form.test.tsx b/apps/web/src/features/expenses/form/expense-form.test.tsx index 551dd6a8..34313d3f 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,9 @@ 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 { 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, @@ -36,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) => @@ -119,7 +120,7 @@ describe('ExpenseForm', () => { expect(percentOf('You').value).toBe('38') expect(percentOf('Ben').value).toBe('28.67') expect(percentOf('Cleo').value).toBe('33.33') - expect(screen.getByText('100% / 100%')).toBeInTheDocument() + expect(screen.getByText('Everything adds up')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) @@ -130,13 +131,73 @@ describe('ExpenseForm', () => { ]) }) - it('rebalances everyone else when a percent is typed', () => { + // Like a person at a keyboard: one change per keystroke, then on to the next field. + const typeKeys = (input: HTMLInputElement, text: string) => { + fireEvent.focus(input) + for (let i = 1; i <= text.length; i++) { + fireEvent.change(input, { target: { value: text.slice(0, i) } }) + } + fireEvent.blur(input) + } + + it('keeps typed percents exactly as typed and saves them', async () => { + setup() + fireEvent.click(screen.getByRole('radio', { name: 'Percent' })) + typeKeys(percentOf('You'), '50') + expect(percentOf('Ben').value).toBe('33.33') + expect(percentOf('Cleo').value).toBe('33.33') + expect(screen.getByText('16.66% too much')).toBeInTheDocument() + typeKeys(percentOf('Ben'), '30') + expect(screen.getByText('13.33% too much')).toBeInTheDocument() + typeKeys(percentOf('Cleo'), '20') + + expect([percentOf('You').value, percentOf('Ben').value, percentOf('Cleo').value]).toEqual([ + '50', + '30', + '20', + ]) + expect(screen.getByText('Everything adds up')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) + expect(create.mock.calls[0]![1].splits).toEqual([ + { user_id: ME, weight: 50 }, + { user_id: BEN, weight: 30 }, + { user_id: CLEO, weight: 20 }, + ]) + }) + + it('keeps every field when they are filled fast, without a blur in between', async () => { setup() fireEvent.click(screen.getByRole('radio', { name: 'Percent' })) + // A whole value per change and no blur, the way autofill or a test runner fills fields. fireEvent.change(percentOf('You'), { target: { value: '50' } }) - fireEvent.blur(percentOf('You')) - expect(percentOf('Ben').value).toBe('25') - expect(percentOf('Cleo').value).toBe('25') + fireEvent.change(percentOf('Ben'), { target: { value: '30' } }) + fireEvent.change(percentOf('Cleo'), { target: { value: '20' } }) + + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)) + expect(create.mock.calls[0]![1].splits).toEqual([ + { user_id: ME, weight: 50 }, + { user_id: BEN, weight: 30 }, + { user_id: CLEO, weight: 20 }, + ]) + }) + + it('does not save percents that do not add up to 100%', async () => { + setup() + fireEvent.click(screen.getByRole('radio', { name: 'Percent' })) + typeKeys(percentOf('You'), '50') + typeKeys(percentOf('Ben'), '30') + typeKeys(percentOf('Cleo'), '10') + expect(screen.getByText('10% left to assign')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Save expense' })) + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Percentages need to add up to 100%.', + ) + expect(create).not.toHaveBeenCalled() + // Typed values are left alone by a failed save. + expect(percentOf('Cleo').value).toBe('10') }) it('does not save exact amounts that do not add up', async () => { @@ -407,3 +468,78 @@ describe('ExpenseForm', () => { }) }) }) + +describe('ExpenseForm offline', () => { + beforeEach(() => create.mockClear()) + afterEach(() => goOnline()) + 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('keeps the entry when the connection drops during a save, and Try again saves it once', async () => { + 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') + const leave = new Event('beforeunload', { cancelable: true }) + window.dispatchEvent(leave) + expect(leave.defaultPrevented).toBe(true) + + 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() + + // 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 2bf4dc70..05ab421a 100644 --- a/apps/web/src/features/expenses/form/expense-form.tsx +++ b/apps/web/src/features/expenses/form/expense-form.tsx @@ -54,6 +54,9 @@ 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' import { openMemberIds, templateAsExpense } from '@/features/recurring/template' import { departedMembers } from '../expense-math' @@ -364,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(), @@ -394,12 +406,13 @@ 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: failSave, }) const save = useMutation({ onMutate: () => { saveRunning.current = true + setUnreached(false) }, onSettled: () => { saveRunning.current = false @@ -504,11 +517,12 @@ 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') { + setUnreached(false) + toast.error(t('expenses.receiptUpload.failed')) + } else { + failSave(error) + } }, }) @@ -549,8 +563,15 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { /> ) - const onSubmit = (e: FormEvent) => { - e.preventDefault() + // 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 typed = !!editing || amount !== null || description.trim() !== '' || notes.trim() !== '' + useWarnBeforeUnload(unreached || (!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 +602,13 @@ function LoadedExpenseForm(props: ExpenseFormProps & { group: MoneyGroup }) { {descriptionError ?? amountError ?? payersError ?? splitError ?? nextError}

)} + + )} + + ) +} diff --git a/apps/web/src/features/shell/route-error.test.tsx b/apps/web/src/features/shell/route-error.test.tsx new file mode 100644 index 00000000..73876d12 --- /dev/null +++ b/apps/web/src/features/shell/route-error.test.tsx @@ -0,0 +1,77 @@ +import { t } from '@potluck/i18n' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router' +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RouteError } from './route-error' + +// The router restores scroll on navigation; jsdom has no layout to scroll. +window.scrollTo = () => {} + +let broken = true + +function renderBrokenScreen() { + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), + errorComponent: RouteError, + }) + const screenRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/search', + component: function Search() { + if (broken) throw new TypeError('e.query?.trim is not a function') + return

Results

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