Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,17 @@ 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.<screen>()` 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
landing page may touch `window` during render.
- 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
Expand Down
20 changes: 20 additions & 0 deletions apps/web/e2e/expense-entry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
22 changes: 14 additions & 8 deletions apps/web/e2e/expenses.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
])
})

Expand Down
5 changes: 5 additions & 0 deletions apps/web/e2e/insights.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/features/expenses/form/category-picker.test.tsx
Original file line number Diff line number Diff line change
@@ -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<CategoryId | null>(start)
return (
<>
<button type="button">Before</button>
<CategoryPicker value={value} onChange={setValue} />
<button type="button">After</button>
<output aria-label="picked">{value ?? 'none'}</output>
</>
)
}

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(<Harness />)
expect(tabStops(row())).toEqual([chip(CATEGORIES[0]!.id)])
unmount()

render(<Harness start={CATEGORIES[3]!.id} />)
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(<Harness />)
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(<Harness start={parent} />)
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)])
})
})
41 changes: 38 additions & 3 deletions apps/web/src/features/expenses/form/category-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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<HTMLDivElement>(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<HTMLButtonElement>) => {
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<HTMLElement>('[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.
Expand Down Expand Up @@ -85,14 +116,16 @@ 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 (
<button
key={c.id}
type="button"
role="radio"
aria-checked={checked}
tabIndex={index === mainStop ? 0 : -1}
onKeyDown={onArrow(index, mainIds)}
aria-controls={checked && subs.length > 0 ? subRowId : undefined}
onClick={() => onChange(checked && known === c.id ? null : c.id)}
className={cn(
Expand Down Expand Up @@ -131,14 +164,16 @@ export function CategoryPicker({ value, onChange, suggested = false }: CategoryP
<span className="mr-1 shrink-0 text-xs font-semibold text-mute">
{t('categories.subLabel')}
</span>
{subs.map((s) => {
{subs.map((s, index) => {
const checked = known === s.id
return (
<button
key={s.id}
type="button"
role="radio"
aria-checked={checked}
tabIndex={index === subStop ? 0 : -1}
onKeyDown={onArrow(index, subIds)}
onClick={() => onChange(checked ? parent : s.id)}
className={cn(
chip,
Expand Down
69 changes: 50 additions & 19 deletions apps/web/src/features/expenses/form/compact-inputs.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { MoneyInput } from '@potluck/ui'
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { CompactMoneyInput, PercentInput } from './compact-inputs'

function MoneyHarness({ locale, currency = 'EUR' }: { locale: string; currency?: string }) {
Expand Down Expand Up @@ -85,43 +85,74 @@ describe('CompactMoneyInput', () => {
})
})

describe('PercentInput', () => {
function renderPercent(locale: string, basisPoints = 5000) {
const onCommit = vi.fn()
render(
function PercentHarness({ locale, start }: { locale: string; start: number }) {
const [basisPoints, setBasisPoints] = useState(start)
return (
<>
<PercentInput
aria-label="Share"
basisPoints={basisPoints}
onCommit={onCommit}
onValueChange={setBasisPoints}
locale={locale}
/>,
)
return { onCommit, input: screen.getByRole('textbox', { name: 'Share' }) }
/>
<output aria-label="basis points">{basisPoints}</output>
<button type="button" onClick={() => setBasisPoints(2500)}>
Quarter
</button>
</>
)
}

describe('PercentInput', () => {
function renderPercent(locale: string, basisPoints = 5000) {
render(<PercentHarness locale={locale} start={basisPoints} />)
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')
})
})
Loading
Loading