diff --git a/web/src/features/accounts/AccountBalanceHeader.module.css b/web/src/features/accounts/AccountBalanceHeader.module.css new file mode 100644 index 0000000..3fc24cf --- /dev/null +++ b/web/src/features/accounts/AccountBalanceHeader.module.css @@ -0,0 +1,77 @@ +.header { + display: flex; + flex-direction: column; + gap: var(--gap-detail); + padding: 18px var(--s5) var(--s5); + border-radius: var(--radius-extra-large); + color: var(--on-surface); +} +.top { + display: flex; + align-items: center; + gap: var(--s3); +} +.split { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.splitText { + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); + overflow-wrap: anywhere; +} +.bar { + display: flex; + gap: 2px; + height: 8px; + max-width: 220px; + border-radius: var(--radius-full); + overflow: hidden; +} +.slot1 { + background: var(--avatar-1); +} +.slot2 { + background: var(--avatar-2); +} +.badge { + display: inline-flex; + align-items: center; + gap: var(--s1); + flex: none; + padding: 2px var(--s2); + border-radius: var(--radius-full); + background: var(--surface-container-high); + color: var(--on-surface-variant); + font: var(--type-label-medium); +} +.balance { + display: flex; + flex-direction: column; + gap: 2px; +} +.wording { + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); +} +.owed { + color: var(--owed); +} +.owing { + color: var(--owing); +} +.settled { + display: flex; + align-items: center; + gap: var(--s2); + font: var(--type-headline-small); + color: var(--settled); +} +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--s2); +} diff --git a/web/src/features/accounts/AccountBalanceHeader.tsx b/web/src/features/accounts/AccountBalanceHeader.tsx new file mode 100644 index 0000000..d0dda28 --- /dev/null +++ b/web/src/features/accounts/AccountBalanceHeader.tsx @@ -0,0 +1,117 @@ +import type { Account, User } from '../../api/types'; +import { avatarSlot } from '../../components/Avatar'; +import { Button, ButtonLink } from '../../components/Button'; +import { Money } from '../../components/Money'; +import { Icon } from '../../design/Icon'; +import { useI18n } from '../../i18n/useI18n'; +import { basisPoints, BP_TOTAL } from '../../lib/money'; +import { AccountTile } from './AccountTile'; +import { balanceWording } from './balance'; +import { settleFor } from './links'; +import styles from './AccountBalanceHeader.module.css'; +import { viewerShareBp } from './model'; + +interface Props { + account: Account; + colourIndex: number; + netOre: number; + viewer: User; + partner: User; + userIds: readonly string[]; + onUnarchive: () => void; + unarchiving: boolean; +} + +/** + * design-spec §3.6 `AccountBalanceHeader` (S-5): tile, default split with names and a split bar, archived badge, the + * account balance in the §9.3 wording, "Gör upp det här kontot" when non-zero (archived too, AC-6.4, D-052) and + * "Återställ från arkiv" first for an archived account. + */ +export function AccountBalanceHeader({ + account, + colourIndex, + netOre, + viewer, + partner, + userIds, + onUnarchive, + unarchiving, +}: Props) { + const i18n = useI18n(); + const { t, language } = i18n; + const archived = account.archived_at !== null; + const viewerBp = viewerShareBp(account, viewer.id); + const ratio = t('common_ratio', basisPoints(viewerBp, language), basisPoints(BP_TOTAL - viewerBp, language)); + const split = t('account_default_split', ratio, t('common_and', viewer.display_name, partner.display_name)); + const balance = balanceWording(netOre, partner.display_name, 'account', i18n); + const viewerSlot = avatarSlot(viewer.id, userIds); + return ( +
+
+ +
+

+ {t('account_default_split_spoken', '')} + {split} +

+ +
+ {archived ? ( + + + {t('common_archived')} + + ) : null} +
+ {balance.amountOre === null ? ( +

+ + {balance.wording} +

+ ) : ( +
+ {balance.sentence} + + +
+ )} + {archived || netOre !== 0 ? ( +
+ {archived ? ( + + ) : null} + {netOre !== 0 ? ( + }> + {t('account_settle_this')} + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/web/src/features/accounts/AccountDetailPage.module.css b/web/src/features/accounts/AccountDetailPage.module.css new file mode 100644 index 0000000..6f9d7a2 --- /dev/null +++ b/web/src/features/accounts/AccountDetailPage.module.css @@ -0,0 +1,53 @@ +.page { + container-type: inline-size; +} +.body { + display: flex; + flex-direction: column; + gap: var(--gap-section); + padding: 0 var(--gutter); +} +.headerColumn, +.ledger { + display: flex; + flex-direction: column; + gap: var(--gap-section); + min-width: 0; +} +.month { + display: flex; + flex-direction: column; +} +.monthHeader { + padding: var(--s3) var(--s2) 2px; + margin-bottom: var(--s2); + font: var(--type-title-small); + letter-spacing: var(--tracking-title-small); + color: var(--on-surface-variant); +} +.addLabel { + white-space: nowrap; +} +@media (max-width: 479px) { + .addLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} +/* Desktop: the header beside a wider ledger. */ +@container (min-width: 760px) { + .body { + display: grid; + grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); + align-items: start; + gap: var(--s6); + } + .headerColumn { + position: sticky; + top: var(--s4); + } +} diff --git a/web/src/features/accounts/AccountDetailPage.test.tsx b/web/src/features/accounts/AccountDetailPage.test.tsx new file mode 100644 index 0000000..573cd19 --- /dev/null +++ b/web/src/features/accounts/AccountDetailPage.test.tsx @@ -0,0 +1,245 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { errorResponse, json } from '../../test/fakeServer'; +import { JACOB_ID } from '../../test/fixtures'; +import { renderApp } from '../../test/render'; +import { + acct, + exp, + GOTLAND, + GOTLAND_ID, + loose, + NOW, + serverWith, + snapshot, + STUGA, + STUGA_ID, + VARDAG, + VARDAG_ID, +} from './testData'; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +const path = (id: string) => `/accounts/${id}`; + +function topBar() { + const header = screen.getByRole('heading', { level: 1 }).closest('header'); + if (header === null) throw new Error('no top bar'); + return within(header); +} + +async function openMenu(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'Fler alternativ' })); + return within(screen.getByRole('menu', { name: 'Fler alternativ' })); +} + +describe('Account detail (W-3)', () => { + it('shows the split, the balance and the ledger grouped by month with household spend', async () => { + renderApp(path(VARDAG_ID), { server: serverWith() }); + expect(await screen.findByRole('heading', { level: 1, name: 'Vardag' })).toBeInTheDocument(); + const header = screen.getByRole('region', { name: 'Vardag' }); + expect(within(header).getByText(/50\/50 · Jacob och Anna/)).toBeInTheDocument(); + expect(within(header).getByText(loose('Anna är skyldig dig 623,80 kr'))).toBeInTheDocument(); + expect(within(header).getByRole('link', { name: 'Gör upp det här kontot' })).toHaveAttribute( + 'href', + `/app/settle?account=${VARDAG_ID}`, + ); + expect(topBar().getByRole('link', { name: 'Lägg till utgift' })).toHaveAttribute( + 'href', + `/app/expenses/new?account=${VARDAG_ID}`, + ); + + const months = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent.replace(/\s/g, ' ')); + expect(months).toEqual(['Oktober · 1 647,60 kr', 'September · 1 000,00 kr']); + const october = screen.getByRole('region', { name: loose('Oktober · 1 647,60 kr') }); + const rows = within(october).getAllByRole('link'); + expect(rows.map((r) => r.getAttribute('href'))).toEqual([ + '/app/expenses/01929c5e-0000-7000-8000-0000000000e1', + '/app/settlements/01929c5e-0000-7000-8000-0000000000f1', + '/app/expenses/01929c5e-0000-7000-8000-0000000000e2', + ]); + expect(rows[0]).toHaveTextContent(loose('ICA Maxi LindhagenDu lånade ut 623,80 kr1 247,60 kr16 okt.')); + expect(rows[1]).toHaveTextContent(loose('Anna betalade dig 300,00 kr · 2 kontonBetalning10 okt.')); + expect(rows[2]).toHaveTextContent(loose('ApoteketDu lånade 200,00 kr400,00 kr3 okt.')); + expect(screen.queryByText('Borttagen')).not.toBeInTheDocument(); + }); + + it('offers Edit, Archive and a disabled Delete with its reason', async () => { + const user = userEvent.setup(); + const { router } = renderApp(path(VARDAG_ID), { server: serverWith() }); + await screen.findByRole('heading', { level: 1, name: 'Vardag' }); + const menu = await openMenu(user); + expect(menu.getAllByRole('menuitem').map((i) => i.textContent)).toEqual([ + 'Ändra konto', + 'Arkivera', + 'Ta bort kontoKontot kan inte tas bort så länge det har utgifter, betalningar eller återkommande utgifter', + ]); + const remove = menu.getByRole('menuitem', { name: 'Ta bort konto' }); + expect(remove).toHaveAttribute('aria-disabled', 'true'); + expect(remove).toHaveAccessibleDescription(/Kontot kan inte tas bort/); + await user.click(remove); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Fler alternativ' })); + await user.click(screen.getByRole('menuitem', { name: 'Ändra konto' })); + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/app/accounts/${VARDAG_ID}/edit`); + }); + }); + + it('archives with an undo snackbar on Home (D-071)', async () => { + const user = userEvent.setup(); + const server = serverWith() + .on('POST', `/accounts/${VARDAG_ID}/archive`, () => + json({ ...VARDAG, archived_at: '2026-10-21T10:00:00.000Z', server_version: 50 }), + ) + .on('POST', `/accounts/${VARDAG_ID}/unarchive`, () => json({ ...VARDAG, server_version: 51 })); + const { router } = renderApp(path(VARDAG_ID), { server }); + await screen.findByRole('heading', { level: 1, name: 'Vardag' }); + await user.click((await openMenu(user)).getByRole('menuitem', { name: 'Arkivera' })); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app'); + }); + expect(await screen.findByText('Vardag arkiverades')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Arkiverade (2)' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Ångra' })); + await waitFor(() => { + expect(server.calls('POST', `/accounts/${VARDAG_ID}/unarchive`)).toHaveLength(1); + }); + expect(await screen.findByRole('button', { name: 'Arkiverade (1)' })).toBeInTheDocument(); + }); + + it('keeps the account and offers a retry when archiving cannot reach the server', async () => { + const user = userEvent.setup(); + const server = serverWith().on('POST', `/accounts/${VARDAG_ID}/archive`, () => { + throw new TypeError('Failed to fetch'); + }); + const { router } = renderApp(path(VARDAG_ID), { server }); + await screen.findByRole('heading', { level: 1, name: 'Vardag' }); + await user.click((await openMenu(user)).getByRole('menuitem', { name: 'Arkivera' })); + expect(await screen.findByText('Kunde inte nå servern')).toBeInTheDocument(); + expect(router.state.location.pathname).toBe(`/app/accounts/${VARDAG_ID}`); + await user.click(screen.getByRole('button', { name: 'Försök igen' })); + await waitFor(() => { + expect(server.calls('POST', `/accounts/${VARDAG_ID}/archive`)).toHaveLength(2); + }); + }); + + it('shows an archived account with its badge, Restore first and no New expense', async () => { + const user = userEvent.setup(); + const server = serverWith().on('POST', `/accounts/${GOTLAND_ID}/unarchive`, () => + json({ ...GOTLAND, archived_at: null, server_version: 52 }), + ); + renderApp(path(GOTLAND_ID), { server }); + const header = await screen.findByRole('region', { name: 'Gotland 2026' }); + expect(within(header).getByText('Arkiverat')).toBeInTheDocument(); + expect( + within(header) + .getAllByRole('button') + .map((b) => b.textContent), + ).toEqual(['Återställ från arkiv']); + expect(within(header).getByRole('link', { name: 'Gör upp det här kontot' })).toBeInTheDocument(); + expect(topBar().queryByRole('link', { name: 'Lägg till utgift' })).not.toBeInTheDocument(); + expect((await openMenu(user)).getByRole('menuitem', { name: 'Återställ från arkiv' })).toBeInTheDocument(); + await user.keyboard('{Escape}'); + + await user.click(within(header).getByRole('button', { name: 'Återställ från arkiv' })); + expect(await screen.findByText('Gotland 2026 återställdes')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.queryByText('Arkiverat')).not.toBeInTheDocument(); + }); + expect(topBar().getByRole('link', { name: 'Lägg till utgift' })).toBeInTheDocument(); + }); + + it('shows an empty account as settled, and deletes it after confirmation', async () => { + const user = userEvent.setup(); + const server = serverWith(snapshot({ accounts: [VARDAG, STUGA] })).on('DELETE', `/accounts/${STUGA_ID}`, () => + json({ ...STUGA, deleted_at: '2026-10-21T10:00:00.000Z', server_version: 60 }), + ); + const { router } = renderApp(path(STUGA_ID), { server }); + expect(await screen.findByRole('heading', { name: 'Inga utgifter än' })).toBeInTheDocument(); + expect(screen.getByText('Lägg till den första utgiften i Sommarstugan.')).toBeInTheDocument(); + const header = screen.getByRole('region', { name: 'Sommarstugan' }); + expect(within(header).getByText(/60\/40 · Jacob och Anna/)).toBeInTheDocument(); + expect(within(header).getByText('Kvitt')).toBeInTheDocument(); + expect(within(header).queryByRole('link')).not.toBeInTheDocument(); + + await user.click((await openMenu(user)).getByRole('menuitem', { name: 'Ta bort konto' })); + const dialog = screen.getByRole('alertdialog', { name: 'Ta bort kontot Sommarstugan?' }); + expect(dialog).toHaveTextContent('Det går inte att ångra.'); + await user.click(within(dialog).getByRole('button', { name: 'Ta bort' })); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app'); + }); + expect(server.calls('DELETE', `/accounts/${STUGA_ID}`)).toHaveLength(1); + }); + + it('explains account_not_empty when the server refuses the delete', async () => { + const user = userEvent.setup(); + const server = serverWith(snapshot({ accounts: [VARDAG, STUGA] })).on('DELETE', `/accounts/${STUGA_ID}`, () => + errorResponse('account_not_empty', 409), + ); + const { router } = renderApp(path(STUGA_ID), { server }); + await screen.findByRole('heading', { level: 1, name: 'Sommarstugan' }); + await user.click((await openMenu(user)).getByRole('menuitem', { name: 'Ta bort konto' })); + await user.click(screen.getByRole('button', { name: 'Ta bort' })); + expect(await screen.findByText(/Kontot kan inte tas bort så länge/)).toBeInTheDocument(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(router.state.location.pathname).toBe(`/app/accounts/${STUGA_ID}`); + }); + + it('says so when the account does not exist', async () => { + renderApp(path('01929c5e-0000-7000-8000-00000000dead'), { server: serverWith() }); + expect(await screen.findByRole('heading', { name: 'Det här finns inte längre' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Gå till Översikt' })).toHaveAttribute('href', '/app'); + }); + + it('shows the loading state during the first pull', async () => { + renderApp(path(VARDAG_ID), { + server: serverWith().on('GET', '/sync', () => new Promise(() => undefined)), + }); + expect(await screen.findByText('Hämtar era utgifter…')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 1, name: 'Konto' })).toBeInTheDocument(); + }); + + it('shows the error state with a working retry', async () => { + const user = userEvent.setup(); + const server = serverWith().on('GET', '/sync', () => { + throw new TypeError('Failed to fetch'); + }); + renderApp(path(VARDAG_ID), { server }); + expect(await screen.findByRole('alert')).toHaveTextContent('Kunde inte hämta hushållets utgifter'); + server.on('GET', '/sync', () => json(snapshot())); + await user.click(screen.getByRole('button', { name: 'Försök igen' })); + expect(await screen.findByRole('heading', { level: 1, name: 'Vardag' })).toBeInTheDocument(); + }); + + it('shows long names, 7-digit amounts and month totals in full', async () => { + const name = 'Sommarstugan i Österåker 2027 med bryggan och bastun'; + const long = acct(VARDAG_ID, name, 20, { createdAt: '2026-01-01T08:00:00.000Z' }); + const big = exp('01929c5e-0000-7000-8000-0000000000e9', VARDAG_ID, '2025-12-24', 123_456_789, JACOB_ID, 0, 30, { + description: 'En mycket lång beskrivning av en julklapp som aldrig tar slut i listan', + }); + renderApp(path(VARDAG_ID), { + server: serverWith(snapshot({ accounts: [long], expenses: [big], settlements: [] })), + }); + expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { level: 2, name: loose('December 2025 · 1 234 567,89 kr') }), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /julklapp/ })).toHaveTextContent( + loose( + 'En mycket lång beskrivning av en julklapp som aldrig tar slut i listanDu lånade ut 1 234 567,89 kr1 234 567,89 kr24 dec. 2025', + ), + ); + }); +}); diff --git a/web/src/features/accounts/AccountDetailPage.tsx b/web/src/features/accounts/AccountDetailPage.tsx index 6856a80..6931f6a 100644 --- a/web/src/features/accounts/AccountDetailPage.tsx +++ b/web/src/features/accounts/AccountDetailPage.tsx @@ -1,9 +1,332 @@ -import { PlaceholderPage } from '../../app/PlaceholderPage'; +import { useMemo, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import type { Ledger } from '../../api/ledger'; +import { useLedger } from '../../api/ledger'; import { paths } from '../../app/routes'; +import { TopBar } from '../../app/TopBar'; +import { Button, ButtonLink } from '../../components/Button'; +import { CategoryIcon } from '../../components/CategoryIcon'; +import { Dialog } from '../../components/Dialog'; +import { EmptyState } from '../../components/EmptyState'; +import { LedgerRow, SegmentedGroup, SegmentedItem } from '../../components/LedgerRow'; +import { useToast } from '../../components/Toast'; +import { Icon } from '../../design/Icon'; import { useI18n } from '../../i18n/useI18n'; +import { listDate, monthHeader, parseDate, type LocalDate } from '../../lib/dates'; +import { expenseEffect } from '../../lib/effect'; +import { formatMoney } from '../../lib/money'; +import { AccountBalanceHeader } from './AccountBalanceHeader'; +import styles from './AccountDetailPage.module.css'; +import { isNetworkError, useAccountWrites, writeErrorKey } from './accountWrites'; +import { newExpenseFor } from './links'; +import { accountDetail, type AccountRow, type MonthGroup } from './model'; +import { OverflowMenu, type MenuItem } from './OverflowMenu'; +import { AccountNotFound, LoadFailedCard, LoadingCard } from './PageStates'; +import { useToday } from './useToday'; +import { useViewerId } from './useViewer'; -/** S-5 Account detail, /app/accounts/:id. */ +type Busy = 'archive' | 'unarchive' | 'delete' | null; + +/** W-3 Account detail, /app/accounts/:id (mirrors S-5). */ export default function AccountDetailPage() { + const { id = '' } = useParams(); const { t } = useI18n(); - return ; + const viewerId = useViewerId(); + const ledger = useLedger(); + const data = ledger.data; + const detail = useMemo( + () => (data === undefined || viewerId === null ? null : accountDetail(data, id, viewerId)), + [data, id, viewerId], + ); + + if (data === undefined) { + return ( + <> + +
+ {ledger.isError ? ( + { + void ledger.refetch(); + }} + /> + ) : ( + + )} +
+ + ); + } + if (detail === null || viewerId === null) { + return ( + <> + + + + ); + } + return ; +} + +function Loaded({ + ledger, + detail, + viewerId, +}: { + ledger: Ledger; + detail: NonNullable>; + viewerId: string; +}) { + const { t } = useI18n(); + const today = useToday(); + const toast = useToast(); + const navigate = useNavigate(); + const writes = useAccountWrites(); + const [busy, setBusy] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const { account } = detail; + const archived = account.archived_at !== null; + + const failed = (error: unknown, retry: () => void) => { + toast({ + message: t(writeErrorKey(error)), + action: isNetworkError(error) ? { label: t('common_retry'), run: retry } : undefined, + }); + }; + + const unarchive = async () => { + setBusy('unarchive'); + try { + const row = await writes.unarchive(account.id); + toast({ message: t('account_unarchived_snackbar', row.name) }); + } catch (error) { + failed(error, () => void unarchive()); + } finally { + setBusy(null); + } + }; + + const archive = async () => { + setBusy('archive'); + try { + const row = await writes.archive(account.id); + toast({ + message: t('account_archived_snackbar', row.name), + action: { + label: t('common_undo'), + run: () => { + writes.unarchive(row.id).catch((error: unknown) => { + failed(error, () => void writes.unarchive(row.id)); + }); + }, + }, + }); + void navigate(paths.home); + } catch (error) { + failed(error, () => void archive()); + } finally { + setBusy(null); + } + }; + + const remove = async () => { + setBusy('delete'); + try { + await writes.remove(account.id); + setConfirmDelete(false); + void navigate(paths.home, { replace: true }); + } catch (error) { + setConfirmDelete(false); + failed(error, () => void remove()); + } finally { + setBusy(null); + } + }; + + const menu: MenuItem[] = [ + { + key: 'edit', + label: t('account_edit'), + icon: 'edit', + onSelect: () => { + void navigate(paths.accountEdit(account.id)); + }, + }, + archived + ? { key: 'unarchive', label: t('account_unarchive'), icon: 'unarchive', onSelect: () => void unarchive() } + : { key: 'archive', label: t('account_archive'), icon: 'inventory_2', onSelect: () => void archive() }, + { + key: 'delete', + label: t('account_delete'), + icon: 'delete', + onSelect: () => { + setConfirmDelete(true); + }, + disabledReason: detail.deletable ? undefined : t('account_delete_blocked'), + }, + ]; + + return ( + <> + + {archived ? null : ( + }> + {t('fab_add_expense')} + + )} + + + } + /> +
+
+
+ void unarchive()} + /> +
+
+ {detail.groups.length === 0 ? ( + + ) : ( + detail.groups.map((group) => ( + + )) + )} +
+
+
+ { + if (busy !== 'delete') setConfirmDelete(false); + }} + actions={ + <> + + + + } + > + {t('account_delete_confirm_body')} + + + ); +} + +/** One month: `LedgerMonthHeader` (household spend, not sticky: SPEC §13.3 S-5) and its rows in AC-8.10 order. */ +function Month({ + group, + ledger, + viewerId, + today, +}: { + group: MonthGroup; + ledger: Ledger; + viewerId: string; + today: LocalDate; +}) { + const { t, language } = useI18n(); + const heading = t( + 'ledger_month_total', + monthHeader(group.period, today, language), + formatMoney(group.totalOre, language), + ); + return ( +
+

{heading}

+ + {group.rows.map((row) => ( + + + + ))} + +
+ ); +} + +function Row({ + row, + ledger, + viewerId, + today, +}: { + row: AccountRow; + ledger: Ledger; + viewerId: string; + today: LocalDate; +}) { + const { t, language } = useI18n(); + const date = listDate(parseDate(row.date), today, language); + if (row.kind === 'expense') { + const { expense } = row; + const category = ledger.categories.get(expense.category_id); + const shares = Object.fromEntries(expense.splits.map((s) => [s.user_id, s.share_ore])); + return ( + + } + /> + ); + } + const { settlement } = row; + const amount = formatMoney(row.allocationOre, language); + const nameOf = (userId: string) => ledger.users.get(userId)?.display_name ?? ''; + const title = + settlement.from_user_id === viewerId + ? t('ledger_settlement_you_paid_partner', nameOf(settlement.to_user_id), amount) + : t('ledger_settlement_partner_paid_you', nameOf(settlement.from_user_id), amount); + return ( + + ); } diff --git a/web/src/features/accounts/AccountEditorPage.module.css b/web/src/features/accounts/AccountEditorPage.module.css new file mode 100644 index 0000000..8c0ec63 --- /dev/null +++ b/web/src/features/accounts/AccountEditorPage.module.css @@ -0,0 +1,66 @@ +.body { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 640px; + padding: 0 var(--gutter); +} +.splitCard { + gap: 14px; + padding: 18px; +} +.hint { + font: var(--type-body-medium); + letter-spacing: var(--tracking-body-medium); + color: var(--on-surface-variant); +} +.people { + display: flex; + justify-content: space-between; + gap: var(--s3); +} +.person { + display: inline-flex; + align-items: center; + gap: var(--s2); + min-width: 0; + font: var(--type-title-small); + letter-spacing: var(--tracking-title-small); +} +.personName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.slider { + width: 100%; + min-height: var(--touch-target); + accent-color: var(--primary); +} +.fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--s3); +} +.unit { + color: var(--on-surface-variant); +} +.presets { + display: flex; + flex-wrap: wrap; + gap: var(--s2); +} +.preview { + display: flex; + align-items: center; + gap: var(--s2); + padding: var(--s3) 14px; + border-radius: var(--radius-large); + background: var(--surface-container-high); + font: var(--type-body-medium); + letter-spacing: var(--tracking-body-medium); +} +.preview > svg { + flex: none; + color: var(--on-surface-variant); +} diff --git a/web/src/features/accounts/AccountEditorPage.test.tsx b/web/src/features/accounts/AccountEditorPage.test.tsx new file mode 100644 index 0000000..0dc2c61 --- /dev/null +++ b/web/src/features/accounts/AccountEditorPage.test.tsx @@ -0,0 +1,197 @@ +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Account } from '../../api/types'; +import { errorResponse, json, type RecordedRequest } from '../../test/fakeServer'; +import { ANNA_ID, JACOB, JACOB_ID } from '../../test/fixtures'; +import { renderApp } from '../../test/render'; +import { acct, NEW_ID, NOW, serverWith, snapshot, STUGA, STUGA_ID, VARDAG, VARDAG_ID } from './testData'; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +function created(request: RecordedRequest): Account { + const body = request.body as Pick; + return { ...acct(NEW_ID, body.name, 70, { createdAt: NOW.toISOString() }), default_shares: body.default_shares }; +} + +const field = (name: string) => screen.getByRole('textbox', { name }); +const save = () => screen.getByRole('button', { name: 'Spara' }); +const preview = () => screen.getByText(/^Av 100,00/); + +describe('Account editor (W-4)', () => { + it('creates an account with a ratio whose fields always sum to 100', async () => { + const user = userEvent.setup(); + const server = serverWith().on('POST', '/accounts', (request) => json(created(request), 201)); + const { router } = renderApp('/accounts/new', { server }); + expect(await screen.findByRole('heading', { level: 1, name: 'Nytt konto' })).toBeInTheDocument(); + expect(await screen.findByRole('textbox', { name: 'Namn' })).toHaveFocus(); + expect(field('Jacob')).toHaveValue('50'); + expect(field('Anna')).toHaveValue('50'); + expect(preview().textContent.replace(/\s/g, ' ')).toBe('Av 100,00 kr: Jacob 50,00 kr, Anna 50,00 kr'); + expect(save()).toBeDisabled(); + + await user.clear(field('Jacob')); + expect(field('Anna')).toHaveValue(''); + expect(screen.getByText('Delarna måste bli 100 %')).toBeInTheDocument(); + await user.type(field('Jacob'), '60,5'); + expect(field('Anna')).toHaveValue('39,5'); + await user.type(field('Jacob'), '55'); + expect(field('Jacob')).toHaveValue('60,55'); + await user.type(field('Jacob'), '1'); + expect(field('Jacob')).toHaveValue('60,55'); + expect(field('Anna')).toHaveValue('39,45'); + expect(preview().textContent.replace(/\s/g, ' ')).toBe('Av 100,00 kr: Jacob 60,55 kr, Anna 39,45 kr'); + + await user.clear(field('Anna')); + await user.type(field('Anna'), '101'); + expect(field('Anna')).toHaveValue('10'); + expect(field('Jacob')).toHaveValue('90'); + + const presets = within(screen.getByRole('group', { name: 'Snabbval' })); + await user.click(presets.getByRole('button', { name: 'Jacob 40 procent, Anna 60 procent' })); + expect(field('Jacob')).toHaveValue('40'); + expect(field('Anna')).toHaveValue('60'); + expect(presets.getByRole('button', { name: /Jacob 40 procent/ })).toHaveAttribute('aria-pressed', 'true'); + + const slider = screen.getByRole('slider', { name: 'Jacobs andel' }); + fireEvent.change(slider, { target: { value: '7000' } }); + expect(field('Anna')).toHaveValue('30'); + expect(slider).toHaveAttribute('aria-valuetext', 'Jacob 70 procent, Anna 30 procent'); + + await user.type(field('Namn'), ' Sommarstugan '); + expect(save()).toBeEnabled(); + await user.click(save()); + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/app/accounts/${NEW_ID}`); + }); + expect(server.calls('POST', '/accounts')[0]?.body).toEqual({ + name: 'Sommarstugan', + default_shares: [ + { user_id: JACOB_ID, share_bp: 7000 }, + { user_id: ANNA_ID, share_bp: 3000 }, + ].sort((a, b) => (a.user_id < b.user_id ? -1 : 1)), + }); + expect(await screen.findByRole('heading', { level: 1, name: 'Sommarstugan' })).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('shows name_taken on the name field and keeps the form', async () => { + const user = userEvent.setup(); + const server = serverWith().on('POST', '/accounts', () => errorResponse('name_taken', 409, 'name')); + renderApp('/accounts/new', { server }); + await user.type(await screen.findByRole('textbox', { name: 'Namn' }), 'vardag'); + await user.click(save()); + expect(await screen.findByText('Det finns redan ett konto som heter vardag')).toBeInTheDocument(); + expect(field('Namn')).toHaveAttribute('aria-invalid', 'true'); + expect(save()).toBeEnabled(); + await user.type(field('Namn'), '2'); + expect(field('Namn')).not.toHaveAttribute('aria-invalid'); + }); + + it('keeps the values and offers a retry when the server cannot be reached', async () => { + const user = userEvent.setup(); + const server = serverWith().on('POST', '/accounts', () => { + throw new TypeError('Failed to fetch'); + }); + const { router } = renderApp('/accounts/new', { server }); + await user.type(await screen.findByRole('textbox', { name: 'Namn' }), 'Resor'); + await user.click(save()); + expect(await screen.findByText('Kunde inte nå servern')).toBeInTheDocument(); + expect(field('Namn')).toHaveValue('Resor'); + expect(router.state.location.pathname).toBe('/app/accounts/new'); + server.on('POST', '/accounts', (request) => json(created(request), 201)); + await user.click(screen.getByRole('button', { name: 'Försök igen' })); + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/app/accounts/${NEW_ID}`); + }); + }); + + it("shows another error code's text", async () => { + const user = userEvent.setup(); + const server = serverWith().on('POST', '/accounts', () => + errorResponse('validation_failed', 422, 'default_shares'), + ); + renderApp('/accounts/new', { server }); + await user.type(await screen.findByRole('textbox', { name: 'Namn' }), 'Resor'); + await user.click(save()); + expect(await screen.findByText('Servern godtog inte värdena')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Försök igen' })).not.toBeInTheDocument(); + }); + + it('asks before discarding changes; the dialog has a title and no body (D-080)', async () => { + const user = userEvent.setup(); + const { router } = renderApp('/accounts/new', { server: serverWith() }); + await user.type(await screen.findByRole('textbox', { name: 'Namn' }), 'Resor'); + await user.click(screen.getByRole('button', { name: 'Avbryt' })); + const dialog = await screen.findByRole('dialog', { name: 'Slänga ändringarna?' }); + expect(dialog.textContent).toBe('Slänga ändringarna?Fortsätt redigeraSläng'); + await user.click(within(dialog).getByRole('button', { name: 'Fortsätt redigera' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(field('Namn')).toHaveValue('Resor'); + + await user.keyboard('{Escape}'); + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Släng' })); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app'); + }); + }); + + it('leaves without asking when nothing changed', async () => { + const user = userEvent.setup(); + const { router } = renderApp('/accounts/new', { server: serverWith() }); + await screen.findByRole('textbox', { name: 'Namn' }); + await user.click(screen.getByRole('button', { name: 'Avbryt' })); + await waitFor(() => { + expect(router.state.location.pathname).toBe('/app'); + }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('edits an account: prefilled, sends only what changed', async () => { + const user = userEvent.setup(); + const server = serverWith(snapshot({ accounts: [VARDAG, STUGA] })).on('PATCH', `/accounts/${STUGA_ID}`, (request) => + json({ ...STUGA, ...(request.body as object), server_version: 80 }), + ); + const { router } = renderApp(`/accounts/${STUGA_ID}/edit`, { server }); + expect(await screen.findByRole('heading', { level: 1, name: 'Ändra konto' })).toBeInTheDocument(); + expect(await screen.findByRole('textbox', { name: 'Namn' })).toHaveValue('Sommarstugan'); + expect(field('Jacob')).toHaveValue('60'); + expect(field('Anna')).toHaveValue('40'); + expect(screen.getByRole('link', { name: 'Tillbaka' })).toHaveAttribute('href', `/app/accounts/${STUGA_ID}`); + await user.clear(field('Namn')); + await user.type(field('Namn'), 'Stugan'); + await user.click(save()); + await waitFor(() => { + expect(router.state.location.pathname).toBe(`/app/accounts/${STUGA_ID}`); + }); + expect(server.calls('PATCH', `/accounts/${STUGA_ID}`)[0]?.body).toEqual({ name: 'Stugan' }); + expect(await screen.findByRole('heading', { level: 1, name: 'Stugan' })).toBeInTheDocument(); + }); + + it('limits the name to 60 characters', async () => { + const user = userEvent.setup(); + renderApp('/accounts/new', { server: serverWith() }); + await user.type(await screen.findByRole('textbox', { name: 'Namn' }), 'x'.repeat(61)); + expect(screen.getByText('Högst 60 tecken')).toBeInTheDocument(); + expect(save()).toBeDisabled(); + }); + + it('offers no account before the partner has joined (AC-5.1)', async () => { + renderApp('/accounts/new', { + server: serverWith(snapshot({ users: [JACOB], accounts: [], expenses: [], settlements: [] })), + }); + expect(await screen.findByRole('heading', { name: 'Din partner behöver gå med först' })).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('says so when the edited account does not exist', async () => { + renderApp(`/accounts/${VARDAG_ID}x/edit`, { server: serverWith() }); + expect(await screen.findByRole('heading', { name: 'Det här finns inte längre' })).toBeInTheDocument(); + }); +}); diff --git a/web/src/features/accounts/AccountEditorPage.tsx b/web/src/features/accounts/AccountEditorPage.tsx index cd9859e..b47249b 100644 --- a/web/src/features/accounts/AccountEditorPage.tsx +++ b/web/src/features/accounts/AccountEditorPage.tsx @@ -1,15 +1,389 @@ -import { useParams } from 'react-router'; -import { PlaceholderPage } from '../../app/PlaceholderPage'; +import { useId, useRef, useState, type SyntheticEvent } from 'react'; +import { useBlocker, useNavigate, useParams } from 'react-router'; +import { ApiError } from '../../api/client'; +import { useLedger } from '../../api/ledger'; +import type { Account, User } from '../../api/types'; import { paths } from '../../app/routes'; +import { TopBar } from '../../app/TopBar'; +import { Avatar, avatarSlot } from '../../components/Avatar'; +import { Button, ButtonLink } from '../../components/Button'; +import { Card } from '../../components/Card'; +import { Dialog } from '../../components/Dialog'; +import { EmptyState } from '../../components/EmptyState'; +import { TextField } from '../../components/TextField'; +import { useToast } from '../../components/Toast'; +import { useUserPicture } from '../../api/pictures'; +import { Icon } from '../../design/Icon'; import { useI18n } from '../../i18n/useI18n'; +import type { Language } from '../../i18n/language'; +import { + amountInputDisplay, + amountInputFromText, + amountInputOre, + basisPoints, + BP_TOTAL, + formatMoney, + percentInputOf, +} from '../../lib/money'; +import styles from './AccountEditorPage.module.css'; +import { isNetworkError, useAccountWrites, writeErrorKey } from './accountWrites'; +import { ACCOUNT_NAME_MAX, codePoints, defaultShares, householdOf, viewerShareBp } from './model'; +import { AccountNotFound, LoadFailedCard, LoadingCard } from './PageStates'; +import { useViewerId } from './useViewer'; -/** S-6 Account editor: /app/accounts/new and /app/accounts/:id/edit. */ +/** design-spec S-6 presets, as the viewer's share: 40/60 · 50/50 · 60/40. */ +const PRESETS_BP = [4000, 5000, 6000] as const; +/** The slider moves in whole percent; the fields take two decimals (AC-6.1). */ +const SLIDER_STEP_BP = 100; +/** The preview amount, 100,00 kr (S-6). */ +const PREVIEW_ORE = 10_000; + +/** W-4 Account editor: /app/accounts/new and /app/accounts/:id/edit (mirrors S-6). */ export default function AccountEditorPage() { const { t } = useI18n(); const { id } = useParams(); - return id === undefined ? ( - - ) : ( - + const viewerId = useViewerId(); + const ledger = useLedger(); + const title = t(id === undefined ? 'account_editor_new' : 'account_editor_edit'); + const back = id === undefined ? paths.home : paths.account(id); + const data = ledger.data; + + if (data === undefined || viewerId === null) { + return ( + <> + +
+ {ledger.isError ? ( + { + void ledger.refetch(); + }} + /> + ) : ( + + )} +
+ + ); + } + const household = householdOf(data, viewerId); + const account = id === undefined ? null : (data.accounts.get(id) ?? null); + if (id !== undefined && (account === null || account.deleted_at !== null)) { + return ( + <> + + + + ); + } + if (household?.partner == null) { + // AC-5.1: no accounts before the partner has joined. + return ( + <> + + + {t('web_not_found_action')} + + } + /> + + ); + } + return ( + + ); +} + +function percentText(bp: number, language: Language): string { + return amountInputDisplay(percentInputOf(bp), language); +} + +/** Basis points of a percent field ("60,5" -> 6050), or null when empty. Two decimals of percent are bp. */ +function bpOf(text: string): number | null { + const input = amountInputFromText(text, BP_TOTAL); + return input === null ? null : amountInputOre(input); +} + +interface FormProps { + title: string; + back: string; + account: Account | null; + viewer: User; + partner: User; + userIds: readonly string[]; +} + +function EditorForm({ title, back, account, viewer, partner, userIds }: FormProps) { + const { t, tp, language } = useI18n(); + const navigate = useNavigate(); + const toast = useToast(); + const writes = useAccountWrites(); + const formId = useId(); + const initialName = account?.name ?? ''; + const initialBp = account === null ? BP_TOTAL / 2 : viewerShareBp(account, viewer.id); + + const [name, setName] = useState(initialName); + const [viewerText, setViewerText] = useState(() => percentText(initialBp, language)); + const [partnerText, setPartnerText] = useState(() => percentText(BP_TOTAL - initialBp, language)); + const [nameTaken, setNameTaken] = useState(null); + const [saving, setSaving] = useState(false); + const leaving = useRef(false); + + const viewerBp = bpOf(viewerText); + const partnerBp = bpOf(partnerText); + const ratioValid = viewerBp !== null && partnerBp !== null && viewerBp + partnerBp === BP_TOTAL; + const trimmed = name.trim(); + const nameLength = codePoints(name); + const tooLong = nameLength > ACCOUNT_NAME_MAX; + const valid = nameLength > 0 && !tooLong && ratioValid; + const nameChanged = trimmed !== initialName.trim(); + const ratioChanged = viewerBp !== initialBp; + const dirty = name !== initialName || ratioChanged; + + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => + dirty && !leaving.current && currentLocation.pathname !== nextLocation.pathname, + ); + + const setRatio = (bp: number) => { + setViewerText(percentText(bp, language)); + setPartnerText(percentText(BP_TOTAL - bp, language)); + }; + /** One field changed: the other follows as 100 minus it (AC-6.1). Keystrokes that break the rules are refused. */ + const onPercent = (text: string, own: (value: string) => void, other: (value: string) => void) => { + const input = amountInputFromText(text, BP_TOTAL); + if (input === null) return; + own(text); + const bp = amountInputOre(input); + other(bp === null ? '' : percentText(BP_TOTAL - bp, language)); + }; + + const leave = (to: string) => { + leaving.current = true; + void navigate(to, { replace: true }); + }; + /** Back to where the editor was opened from (S-5 or Home), or to `back` when the editor was the first page. */ + const goBack = () => { + const index = (window.history.state as { idx?: number } | null)?.idx ?? 0; + if (index > 0) void navigate(-1); + else void navigate(back, { replace: true }); + }; + + const save = async () => { + if (!valid || saving) return; + if (account !== null && !nameChanged && !ratioChanged) { + goBack(); + return; + } + setSaving(true); + setNameTaken(null); + const shares = defaultShares(viewer.id, partner.id, viewerBp); + try { + const row = + account === null + ? await writes.create({ name: trimmed, default_shares: shares }) + : await writes.update(account.id, { + ...(nameChanged ? { name: trimmed } : {}), + ...(ratioChanged ? { default_shares: shares } : {}), + }); + if (account === null) { + leave(paths.account(row.id)); + } else { + leaving.current = true; + goBack(); + } + } catch (error) { + if (error instanceof ApiError && error.code === 'name_taken') { + setNameTaken(t('account_editor_name_taken', trimmed)); + } else { + toast({ + message: t(writeErrorKey(error)), + action: isNetworkError(error) ? { label: t('common_retry'), run: () => void save() } : undefined, + }); + } + } finally { + setSaving(false); + } + }; + + const onSubmit = (event: SyntheticEvent) => { + event.preventDefault(); + void save(); + }; + + const nameError = nameTaken ?? (tooLong ? tp('error_too_long', ACCOUNT_NAME_MAX) : null); + + return ( + <> + + + + + } + /> +
+ { + setName(value); + setNameTaken(null); + }} + /> + +

{t('account_editor_split_hint')}

+
+ + +
+ { + setRatio(Number(event.target.value)); + }} + /> +
+ {t('editor_percent_suffix')}} + onChange={(text) => { + onPercent(text, setViewerText, setPartnerText); + }} + /> + {t('editor_percent_suffix')}} + onChange={(text) => { + onPercent(text, setPartnerText, setViewerText); + }} + /> +
+
+ {PRESETS_BP.map((bp) => ( + + ))} +
+ {/* Of 100,00 kr each share in öre equals its bp (10 000 öre × bp / 10 000), so no rounding applies. */} +

+ + + {viewerBp === null || !ratioValid + ? t('error_ratio_invalid') + : t( + 'account_editor_preview', + formatMoney(PREVIEW_ORE, language), + [ + t('common_user_split', viewer.display_name, formatMoney(viewerBp, language)), + t('common_user_split', partner.display_name, formatMoney(BP_TOTAL - viewerBp, language)), + ].join(t('common_separator_comma')), + )} + +

+
+ + { + blocker.reset?.(); + }} + actions={ + <> + + + + } + /> + + ); +} + +function Person({ user, userIds }: { user: User; userIds: readonly string[] }) { + const picture = useUserPicture(user.id, user.picture_version); + return ( + + + {user.display_name} + ); } diff --git a/web/src/features/accounts/AccountTile.module.css b/web/src/features/accounts/AccountTile.module.css new file mode 100644 index 0000000..8ab6737 --- /dev/null +++ b/web/src/features/accounts/AccountTile.module.css @@ -0,0 +1,36 @@ +.tile { + display: inline-grid; + place-items: center; + flex: none; + border-radius: var(--radius-large); + color: var(--on-cat); + font-weight: 700; + line-height: 1; +} +.muted { + opacity: 0.7; +} +.c1 { + background: var(--chart-cat-1); +} +.c2 { + background: var(--chart-cat-2); +} +.c3 { + background: var(--chart-cat-3); +} +.c4 { + background: var(--chart-cat-4); +} +.c5 { + background: var(--chart-cat-5); +} +.c6 { + background: var(--chart-cat-6); +} +.c7 { + background: var(--chart-cat-7); +} +.c8 { + background: var(--chart-cat-8); +} diff --git a/web/src/features/accounts/AccountTile.tsx b/web/src/features/accounts/AccountTile.tsx new file mode 100644 index 0000000..7da6952 --- /dev/null +++ b/web/src/features/accounts/AccountTile.tsx @@ -0,0 +1,34 @@ +import styles from './AccountTile.module.css'; + +/** The first character of the name, uppercase (design-spec §3.4). */ +export function tileLetter(name: string): string { + return (Array.from(name.trim())[0] ?? '').toUpperCase(); +} + +/** + * design-spec §3.4 `AccountTile`: rounded tile in `chart-cat-n` (§2.4) with the name's first letter in `on-cat`; + * muted for an archived account. Always decorative (the name is printed beside it). + */ +export function AccountTile({ + name, + colourIndex, + size = 44, + muted = false, +}: { + name: string; + /** 1 to 8. */ + colourIndex: number; + size?: number; + muted?: boolean; +}) { + const colour = styles[`c${colourIndex}`]; + return ( + + ); +} diff --git a/web/src/features/accounts/OverflowMenu.module.css b/web/src/features/accounts/OverflowMenu.module.css new file mode 100644 index 0000000..d2babee --- /dev/null +++ b/web/src/features/accounts/OverflowMenu.module.css @@ -0,0 +1,67 @@ +.root { + position: relative; +} +.trigger { + display: grid; + place-items: center; + width: var(--touch-target); + height: var(--touch-target); + border: none; + border-radius: 50%; + background: transparent; + color: var(--on-surface-variant); + cursor: pointer; +} +.trigger:hover, +.trigger[aria-expanded='true'] { + background: color-mix(in srgb, var(--on-surface) 8%, transparent); +} +.menu { + position: absolute; + top: calc(100% + var(--s1)); + right: 0; + z-index: 10; + display: flex; + flex-direction: column; + min-width: 220px; + max-width: min(320px, calc(100vw - 2 * var(--gutter))); + padding: var(--s2) 0; + border-radius: var(--radius-large); + background: var(--surface-container); + box-shadow: var(--elevation-3); +} +.item { + display: flex; + align-items: flex-start; + gap: var(--s3); + min-height: var(--touch-target); + padding: var(--s3) var(--s4); + border: none; + background: transparent; + color: var(--on-surface); + font: var(--type-label-large); + letter-spacing: var(--tracking-label); + text-align: start; + cursor: pointer; +} +.item:hover:not(.disabled), +.item:focus-visible { + background: color-mix(in srgb, var(--on-surface) 8%, transparent); +} +.disabled { + color: var(--disabled-content); + cursor: default; +} +.icon { + flex: none; +} +.text { + display: flex; + flex-direction: column; + gap: 2px; +} +.reason { + font: var(--type-body-small); + letter-spacing: var(--tracking-body-small); + color: var(--on-surface-variant); +} diff --git a/web/src/features/accounts/OverflowMenu.tsx b/web/src/features/accounts/OverflowMenu.tsx new file mode 100644 index 0000000..26b63e4 --- /dev/null +++ b/web/src/features/accounts/OverflowMenu.tsx @@ -0,0 +1,125 @@ +import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react'; +import { useShortcut } from '../../app/shortcuts'; +import { Icon } from '../../design/Icon'; +import type { OutlineIconName } from '../../design/icons.generated'; +import styles from './OverflowMenu.module.css'; + +export interface MenuItem { + key: string; + label: string; + icon: OutlineIconName; + onSelect: () => void; + /** A disabled item stays focusable and reads its reason (S-5: "Ta bort konto" with `account_delete_blocked`). */ + disabledReason?: string; +} + +/** + * design-spec §3.11 `KvittMenu` for the web: an icon button opening a menu of actions. Arrow keys move between items, + * Esc and a click outside close it, and focus returns to the button. + */ +export function OverflowMenu({ label, items }: { label: string; items: readonly MenuItem[] }) { + const [open, setOpen] = useState(false); + const menuId = useId(); + const rootRef = useRef(null); + const buttonRef = useRef(null); + const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + + const close = (refocus: boolean) => { + setOpen(false); + if (refocus) buttonRef.current?.focus(); + }; + useShortcut( + 'Escape', + () => { + close(true); + }, + { enabled: open }, + ); + + useEffect(() => { + if (!open) return undefined; + itemRefs.current[0]?.focus(); + const onPointer = (event: MouseEvent) => { + if (event.target instanceof Node && rootRef.current?.contains(event.target) !== true) setOpen(false); + }; + document.addEventListener('mousedown', onPointer); + return () => { + document.removeEventListener('mousedown', onPointer); + }; + }, [open]); + + const move = (event: KeyboardEvent, index: number) => { + const count = items.length; + let next: number | null = null; + if (event.key === 'ArrowDown') next = (index + 1) % count; + else if (event.key === 'ArrowUp') next = (index - 1 + count) % count; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = count - 1; + else if (event.key === 'Tab') setOpen(false); + if (next === null) return; + event.preventDefault(); + itemRefs.current[next]?.focus(); + }; + + return ( +
+ + {open ? ( + + ) : null} +
+ ); +} diff --git a/web/src/features/accounts/PageStates.module.css b/web/src/features/accounts/PageStates.module.css new file mode 100644 index 0000000..211e1ac --- /dev/null +++ b/web/src/features/accounts/PageStates.module.css @@ -0,0 +1,62 @@ +.loading { + padding: 28px var(--s5); +} +.loadingInner { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s2); + text-align: center; +} +.spinnerTile { + display: grid; + place-items: center; + width: 56px; + height: 56px; + border-radius: var(--radius-category-tile); + background: var(--primary-container); + color: var(--on-primary-container); +} +.spinner { + animation: spin 1.2s linear infinite; +} +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } +} +@keyframes spin { + to { + transform: rotate(-360deg); + } +} +.loadingTitle { + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); +} +.muted { + font: var(--type-body-medium); + letter-spacing: var(--tracking-body-medium); + color: var(--on-surface-variant); +} +.failed { + gap: var(--s4); +} +.failedInner { + display: flex; + gap: var(--s3); + align-items: flex-start; +} +.warning { + flex: none; + color: var(--error); +} +.failedText { + display: flex; + flex-direction: column; + gap: var(--s1); +} +.failedTitle { + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); +} diff --git a/web/src/features/accounts/PageStates.tsx b/web/src/features/accounts/PageStates.tsx new file mode 100644 index 0000000..c837b5f --- /dev/null +++ b/web/src/features/accounts/PageStates.tsx @@ -0,0 +1,65 @@ +import { ButtonLink, Button } from '../../components/Button'; +import { Card } from '../../components/Card'; +import { EmptyState } from '../../components/EmptyState'; +import { Icon } from '../../design/Icon'; +import { useI18n } from '../../i18n/useI18n'; +import { paths } from '../../app/routes'; +import { writeErrorKey } from './accountWrites'; +import styles from './PageStates.module.css'; + +/** + * The first snapshot pull (SPEC §15.6 "loading"): design-spec §3.6 hero `Loading` content, announced politely. + * `compact` drops the body line (account pages). + */ +export function LoadingCard({ compact = false }: { compact?: boolean }) { + const { t } = useI18n(); + return ( + +
+ +

{t('home_loading')}

+ {compact ? null :

{t('home_loading_body')}

} +
+
+ ); +} + +/** A failed read of the snapshot with nothing to show yet (SPEC §15.6 "error, with retry"). */ +export function LoadFailedCard({ error, onRetry }: { error: unknown; onRetry: () => void }) { + const { t } = useI18n(); + return ( + +
+ +
+

{t('web_accounts_load_failed')}

+

{t(writeErrorKey(error))}

+
+
+
+ +
+
+ ); +} + +/** An account id that is not (or no longer) in the snapshot. */ +export function AccountNotFound() { + const { t } = useI18n(); + return ( + + {t('web_not_found_action')} + + } + /> + ); +} diff --git a/web/src/features/accounts/accountWrites.ts b/web/src/features/accounts/accountWrites.ts new file mode 100644 index 0000000..b67736c --- /dev/null +++ b/web/src/features/accounts/accountWrites.ts @@ -0,0 +1,75 @@ +/** + * Account writes (E-18 to E-22, SPEC §7). Each returns the changed row, which is applied to the snapshot at once and + * followed by a pull (SPEC §15.5). A failure throws ApiError and changes nothing; the web never queues a write. + */ +import { useCallback, useMemo } from 'react'; +import { ApiError, unwrap } from '../../api/client'; +import { useLedgerWriter } from '../../api/ledger'; +import type { Account } from '../../api/types'; +import { useApi } from '../../api/useApi'; +import type { StringKey } from '../../i18n/catalog'; + +export interface AccountFields { + name: string; + default_shares: { user_id: string; share_bp: number }[]; +} + +export function useAccountWrites() { + const api = useApi(); + const { apply, refresh } = useLedgerWriter(); + const applied = useCallback( + (account: Account): Account => { + apply({ accounts: [account] }); + void refresh(); + return account; + }, + [apply, refresh], + ); + return useMemo( + () => ({ + create: async (fields: AccountFields) => applied(await unwrap(api.POST('/accounts', { body: fields }))), + update: async (id: string, fields: Partial) => + applied( + await unwrap(api.PATCH('/accounts/{account_id}', { params: { path: { account_id: id } }, body: fields })), + ), + archive: async (id: string) => + applied(await unwrap(api.POST('/accounts/{account_id}/archive', { params: { path: { account_id: id } } }))), + unarchive: async (id: string) => + applied(await unwrap(api.POST('/accounts/{account_id}/unarchive', { params: { path: { account_id: id } } }))), + remove: async (id: string) => + applied(await unwrap(api.DELETE('/accounts/{account_id}', { params: { path: { account_id: id } } }))), + }), + [api, applied], + ); +} + +/** True when the request never reached the server: the page offers "Försök igen" (S-5, S-6). */ +export function isNetworkError(error: unknown): boolean { + return error instanceof ApiError && error.code === 'network'; +} + +/** The text of a failed account write (SPEC §14.3.2 codes; `name_taken` is shown on the name field instead). */ +export function writeErrorKey(error: unknown): StringKey { + if (!(error instanceof ApiError)) return 'error_server'; + switch (error.code) { + case 'network': + return 'common_could_not_reach_server'; + case 'account_not_empty': + return 'account_delete_blocked'; + case 'forbidden': + return 'error_forbidden'; + case 'not_found': + return 'error_not_found'; + case 'household_incomplete': + return 'error_household_incomplete'; + case 'rate_limited': + return 'error_rate_limited'; + case 'validation_failed': + case 'invalid_request': + return 'error_validation'; + case 'protocol_unsupported': + return 'error_app_too_old'; + default: + return 'error_server'; + } +} diff --git a/web/src/features/accounts/balance.ts b/web/src/features/accounts/balance.ts new file mode 100644 index 0000000..02b0852 --- /dev/null +++ b/web/src/features/accounts/balance.ts @@ -0,0 +1,44 @@ +/** The balance wording of SPEC §9.3 and design-spec §3.2 `BalanceWording`, from the viewer's side. */ +import type { I18n } from '../../i18n/I18nProvider'; +import type { AmountTone } from '../../components/Money'; +import { formatMoney } from '../../lib/money'; + +export type BalanceScope = 'global' | 'account'; + +export interface BalanceWording { + /** "Anna är skyldig dig" / "Du är skyldig Anna" / "Ni är kvitt" (global) / "Kvitt" (account). */ + wording: string; + /** The one-line sentence for screen readers: "Du är skyldig Anna 3 250,00 kr". */ + sentence: string; + tone: Extract; + /** The amount to show; null when settled (no amount). */ + amountOre: number | null; +} + +/** `netOre` is `net(viewer)`: positive, the partner owes the viewer; negative, the viewer owes the partner. */ +export function balanceWording( + netOre: number, + partnerName: string, + scope: BalanceScope, + i18n: Pick, +): BalanceWording { + const { t, language } = i18n; + if (netOre > 0) { + return { + wording: t('balance_partner_owes_you', partnerName), + sentence: t('balance_partner_owes_you_amount', partnerName, formatMoney(netOre, language)), + tone: 'owed', + amountOre: netOre, + }; + } + if (netOre < 0) { + return { + wording: t('balance_you_owe_partner', partnerName), + sentence: t('balance_you_owe_partner_amount', partnerName, formatMoney(-netOre, language)), + tone: 'owing', + amountOre: -netOre, + }; + } + const settled = t(scope === 'global' ? 'balance_all_settled' : 'balance_account_settled'); + return { wording: settled, sentence: settled, tone: 'settled', amountOre: null }; +} diff --git a/web/src/features/accounts/i18n/en.json b/web/src/features/accounts/i18n/en.json new file mode 100644 index 0000000..83c6b18 --- /dev/null +++ b/web/src/features/accounts/i18n/en.json @@ -0,0 +1,6 @@ +{ + "web_accounts_load_failed": "Could not get the household's expenses", + "web_accounts_split_presets": "Quick choices", + "web_accounts_saving": "Saving…", + "web_accounts_not_found_body": "The account may have been deleted on another device." +} diff --git a/web/src/features/accounts/i18n/sv.json b/web/src/features/accounts/i18n/sv.json new file mode 100644 index 0000000..800c778 --- /dev/null +++ b/web/src/features/accounts/i18n/sv.json @@ -0,0 +1,6 @@ +{ + "web_accounts_load_failed": "Kunde inte hämta hushållets utgifter", + "web_accounts_split_presets": "Snabbval", + "web_accounts_saving": "Sparar…", + "web_accounts_not_found_body": "Kontot kan ha tagits bort på en annan enhet." +} diff --git a/web/src/features/accounts/links.ts b/web/src/features/accounts/links.ts new file mode 100644 index 0000000..493e07f --- /dev/null +++ b/web/src/features/accounts/links.ts @@ -0,0 +1,21 @@ +/** + * Links to other plans' pages with this account preset. The query parameter `account` is proposed as D-144: W-5 + * (`/app/expenses/new?account=`) and W-7 (`/app/settle?account=`) read it; `routes.ts` has no builder for it. + */ +import { paths } from '../../app/routes'; + +export const ACCOUNT_PARAM = 'account'; + +function withAccount(path: string, accountId: string): string { + return `${path}?${ACCOUNT_PARAM}=${encodeURIComponent(accountId)}`; +} + +/** W-5 New expense with the account preset (S-5's FAB). */ +export function newExpenseFor(accountId: string): string { + return withAccount(paths.expenseNew, accountId); +} + +/** W-7 Settle up for one account ("Gör upp det här kontot"). */ +export function settleFor(accountId: string): string { + return withAccount(paths.settle, accountId); +} diff --git a/web/src/features/accounts/model.test.ts b/web/src/features/accounts/model.test.ts new file mode 100644 index 0000000..654554a --- /dev/null +++ b/web/src/features/accounts/model.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { applyPage, emptyLedger } from '../../api/ledger'; +import { translate, type FormatArg, type StringKey } from '../../i18n/catalog'; +import type { Language } from '../../i18n/language'; +import { ANNA_ID, JACOB, JACOB_ID } from '../../test/fixtures'; +import { balanceWording } from './balance'; +import { + accountDetail, + accountLedger, + codePoints, + defaultShares, + homeModel, + isDeletable, + orderedAccounts, + viewerShareBp, +} from './model'; +import { GOTLAND_ID, HUSET_ID, snapshot, STUGA, STUGA_ID, VARDAG_ID } from './testData'; + +const ledger = applyPage(emptyLedger(), snapshot({ accounts: undefined })); +const full = applyPage(emptyLedger(), snapshot()); + +function i18n(language: Language) { + return { language, t: (key: StringKey, ...args: FormatArg[]) => translate(language, key, args) }; +} + +describe('homeModel (S-4)', () => { + it('gives the global balance, archived included, and splits active from archived', () => { + const model = homeModel(full, JACOB_ID, '2026-10'); + expect(model?.globalNetOre).toBe(-87_620); + expect(model?.active.map((s) => [s.account.name, s.netOre, s.monthCount, s.colourIndex])).toEqual([ + ['Vardag', 62_380, 2, 1], + ['Huset', -160_000, 1, 2], + ]); + expect(model?.archived.map((s) => [s.account.name, s.netOre, s.monthCount, s.colourIndex])).toEqual([ + ['Gotland 2026', 10_000, 0, 3], + ]); + expect(model?.household.partner?.id).toBe(ANNA_ID); + }); + + it('is the mirror image for the partner (net(A) + net(B) = 0)', () => { + expect(homeModel(full, ANNA_ID, '2026-10')?.globalNetOre).toBe(87_620); + }); + + it('has no partner while only the admin exists, and no model for an unknown viewer', () => { + const alone = applyPage(emptyLedger(), snapshot({ users: [JACOB], accounts: [] })); + expect(homeModel(alone, JACOB_ID, '2026-10')?.household.partner).toBeNull(); + expect(homeModel(alone, 'nobody', '2026-10')).toBeNull(); + expect(ledger.accounts.size).toBe(0); + }); +}); + +describe('accountLedger (S-5)', () => { + it('interleaves expenses and settlements by date, groups by month with household spend', () => { + const groups = accountLedger(full, VARDAG_ID); + expect(groups.map((g) => [g.period, g.totalOre, g.rows.map((r) => `${r.kind}:${r.date}`)])).toEqual([ + ['2026-10', 164_760, ['expense:2026-10-16', 'settlement:2026-10-10', 'expense:2026-10-03']], + ['2026-09', 100_000, ['expense:2026-09-20']], + ]); + }); + + it("carries the settlement's allocation to this account (AC-11.7)", () => { + const row = accountLedger(full, HUSET_ID)[0]?.rows.find((r) => r.kind === 'settlement'); + expect(row?.kind === 'settlement' ? row.allocationOre : null).toBe(10_000); + }); + + it('orders the same date by created_at descending (AC-8.10)', () => { + const later = { created_at: '2026-10-17T09:00:00.000Z' }; + const page = snapshot(); + const expenses = page.expenses.map((e) => + e.description === 'Apoteket' ? { ...e, expense_date: '2026-10-16', ...later } : e, + ); + const groups = accountLedger(applyPage(emptyLedger(), { ...page, expenses }), VARDAG_ID); + expect(groups[0]?.rows.slice(0, 2).map((r) => (r.kind === 'expense' ? r.expense.description : r.kind))).toEqual([ + 'Apoteket', + 'ICA Maxi Lindhagen', + ]); + }); +}); + +describe('account rules', () => { + it('allows delete only without expenses, allocations and templates (AC-6.5)', () => { + const withStuga = applyPage(emptyLedger(), snapshot({ accounts: [STUGA] })); + expect(isDeletable(withStuga, STUGA_ID)).toBe(true); + expect(isDeletable(full, VARDAG_ID)).toBe(false); + expect(isDeletable(full, GOTLAND_ID)).toBe(false); + }); + + it('lists accounts by created_at (AC-6.6) and builds default shares sorted by user id', () => { + expect(orderedAccounts(full).map((a) => a.id)).toEqual([VARDAG_ID, HUSET_ID, GOTLAND_ID]); + expect(viewerShareBp(STUGA, JACOB_ID)).toBe(6000); + expect(viewerShareBp(STUGA, ANNA_ID)).toBe(4000); + expect(defaultShares(ANNA_ID, JACOB_ID, 4000)).toEqual([ + { user_id: JACOB_ID, share_bp: 6000 }, + { user_id: ANNA_ID, share_bp: 4000 }, + ]); + }); + + it('counts name length in code points after trimming (SPEC §9.6)', () => { + expect(codePoints(' Åre 🏔 ')).toBe(5); + }); + + it('gives no detail for a deleted or unknown account', () => { + const deleted = applyPage( + emptyLedger(), + snapshot({ accounts: [{ ...STUGA, deleted_at: '2026-10-01T00:00:00.000Z' }] }), + ); + expect(accountDetail(deleted, STUGA_ID, JACOB_ID)).toBeNull(); + expect(accountDetail(full, 'unknown', JACOB_ID)).toBeNull(); + expect(accountDetail(full, VARDAG_ID, JACOB_ID)?.netOre).toBe(62_380); + }); +}); + +describe('balanceWording (§9.3)', () => { + it('words each direction and settled, global and per account, in both languages', () => { + expect(balanceWording(62_380, 'Anna', 'account', i18n('sv'))).toMatchObject({ + wording: 'Anna är skyldig dig', + tone: 'owed', + amountOre: 62_380, + }); + const owing = balanceWording(-87_620, 'Anna', 'global', i18n('sv')); + expect(owing).toMatchObject({ wording: 'Du är skyldig Anna', tone: 'owing', amountOre: 87_620 }); + expect(owing.sentence).toBe('Du är skyldig Anna 876,20 kr'); + expect(balanceWording(0, 'Anna', 'global', i18n('sv'))).toMatchObject({ wording: 'Ni är kvitt', amountOre: null }); + expect(balanceWording(0, 'Anna', 'account', i18n('sv')).wording).toBe('Kvitt'); + expect(balanceWording(-1, 'Anna', 'global', i18n('en')).sentence).toBe('You owe Anna SEK 0.01'); + }); +}); diff --git a/web/src/features/accounts/model.ts b/web/src/features/accounts/model.ts new file mode 100644 index 0000000..188539b --- /dev/null +++ b/web/src/features/accounts/model.ts @@ -0,0 +1,223 @@ +/** + * Derived views of the ledger for Home (S-4), account detail (S-5) and the account editor (S-6). Pure functions over + * the sync snapshot (SPEC §15.5); balances come from W0's `ledgerBalances` (§9.3), never re-implemented here. + */ +import { householdUserIds, ledgerBalances, live, type Ledger } from '../../api/ledger'; +import type { Account, Expense, Settlement, User } from '../../api/types'; +import type { Balances } from '../../lib/balances'; +import { BP_TOTAL } from '../../lib/money'; + +/** SPEC §9.6: an account name is 1 to 60 code points after trimming. */ +export const ACCOUNT_NAME_MAX = 60; +/** design-spec §2.4: accounts use `--chart-cat-(position mod 8 + 1)` and `--account-header-n`. */ +export const ACCOUNT_COLOURS = 8; + +export interface Household { + viewer: User; + /** Null while the partner has not joined (S-3). */ + partner: User | null; + /** Both ids, sorted (avatar slot order). */ + userIds: string[]; +} + +/** The signed-in user and the partner, or null when the viewer is not (yet) in the ledger. */ +export function householdOf(ledger: Ledger, viewerId: string): Household | null { + const viewer = ledger.users.get(viewerId); + if (viewer === undefined) return null; + const partner = [...ledger.users.values()].find((u) => u.id !== viewerId) ?? null; + return { viewer, partner, userIds: householdUserIds(ledger) }; +} + +/** Non-deleted accounts in `created_at` order (AC-6.6), ties by id like the server. */ +export function orderedAccounts(ledger: Ledger): Account[] { + return live(ledger.accounts).sort((a, b) => + a.created_at === b.created_at ? (a.id < b.id ? -1 : 1) : a.created_at < b.created_at ? -1 : 1, + ); +} + +/** 1 to 8: the account's colour slot from its position among the non-deleted accounts. */ +export function colourIndexOf(ordered: readonly Account[], accountId: string): number { + const position = Math.max( + 0, + ordered.findIndex((a) => a.id === accountId), + ); + return (position % ACCOUNT_COLOURS) + 1; +} + +/** `net(viewer)` of one account (positive: the partner owes the viewer). */ +export function accountNet(balances: Balances, accountId: string, viewerId: string): number { + return balances.perAccount[accountId]?.[viewerId] ?? 0; +} + +/** `YYYY-MM` of a `YYYY-MM-DD` date. */ +export function periodOf(date: string): string { + return date.slice(0, 7); +} + +export interface AccountSummary { + account: Account; + colourIndex: number; + netOre: number; + /** Non-deleted expenses of the account dated in `period` (S-4 "11 utgifter i oktober"). */ + monthCount: number; + archived: boolean; +} + +export interface HomeModel { + household: Household; + /** `net(viewer)` over every non-deleted account, archived included (AC-10.5). */ + globalNetOre: number; + active: AccountSummary[]; + archived: AccountSummary[]; +} + +/** Everything S-4 shows, for the viewer, with this month's counts for `period` (`YYYY-MM`). */ +export function homeModel(ledger: Ledger, viewerId: string, period: string): HomeModel | null { + const household = householdOf(ledger, viewerId); + if (household === null) return null; + const balances = ledgerBalances(ledger); + const ordered = orderedAccounts(ledger); + const counts = new Map(); + for (const expense of live(ledger.expenses)) { + if (periodOf(expense.expense_date) !== period) continue; + counts.set(expense.account_id, (counts.get(expense.account_id) ?? 0) + 1); + } + const summaries = ordered.map((account, index): AccountSummary => ({ + account, + colourIndex: (index % ACCOUNT_COLOURS) + 1, + netOre: accountNet(balances, account.id, viewerId), + monthCount: counts.get(account.id) ?? 0, + archived: account.archived_at !== null, + })); + return { + household, + globalNetOre: balances.global[viewerId] ?? 0, + active: summaries.filter((s) => !s.archived), + archived: summaries.filter((s) => s.archived), + }; +} + +export type AccountRow = + | { kind: 'expense'; id: string; date: string; createdAt: string; expense: Expense } + | { + kind: 'settlement'; + id: string; + date: string; + createdAt: string; + settlement: Settlement; + /** The settlement's allocation to this account (AC-11.7). */ + allocationOre: number; + }; + +export interface MonthGroup { + /** `YYYY-MM`. */ + period: string; + /** Household spend: the sum of the month's expense amounts in this account (settlements excluded). */ + totalOre: number; + rows: AccountRow[]; +} + +/** AC-8.10 order: date descending, then `created_at` descending (then id, for a stable order). */ +function newestFirst(a: AccountRow, b: AccountRow): number { + if (a.date !== b.date) return a.date < b.date ? 1 : -1; + if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? 1 : -1; + return a.id < b.id ? 1 : -1; +} + +/** The account's expenses and settlements interleaved by date and grouped by month, newest first (S-5). */ +export function accountLedger(ledger: Ledger, accountId: string): MonthGroup[] { + const rows: AccountRow[] = []; + for (const expense of live(ledger.expenses)) { + if (expense.account_id !== accountId) continue; + rows.push({ kind: 'expense', id: expense.id, date: expense.expense_date, createdAt: expense.created_at, expense }); + } + for (const settlement of live(ledger.settlements)) { + const allocationOre = settlement.allocations + .filter((a) => a.account_id === accountId) + .reduce((sum, a) => sum + a.amount_ore, 0); + if (!settlement.allocations.some((a) => a.account_id === accountId)) continue; + rows.push({ + kind: 'settlement', + id: settlement.id, + date: settlement.settlement_date, + createdAt: settlement.created_at, + settlement, + allocationOre, + }); + } + rows.sort(newestFirst); + const groups: MonthGroup[] = []; + for (const row of rows) { + const period = periodOf(row.date); + let group = groups.at(-1); + if (group?.period !== period) { + group = { period, totalOre: 0, rows: [] }; + groups.push(group); + } + group.rows.push(row); + if (row.kind === 'expense') group.totalOre += row.expense.amount_ore; + } + return groups; +} + +/** + * AC-6.5: an account can be deleted only when no non-deleted expense, no allocation of a non-deleted settlement and no + * non-deleted recurring template references it (the server answers `account_not_empty` otherwise). + */ +export function isDeletable(ledger: Ledger, accountId: string): boolean { + if (live(ledger.expenses).some((e) => e.account_id === accountId)) return false; + if (live(ledger.settlements).some((s) => s.allocations.some((a) => a.account_id === accountId))) return false; + return !live(ledger.recurringTemplates).some((t) => t.account_id === accountId); +} + +/** The viewer's default share in bp (the partner's is the rest); 50/50 when the account lacks the viewer's row. */ +export function viewerShareBp(account: Account, viewerId: string): number { + return account.default_shares.find((s) => s.user_id === viewerId)?.share_bp ?? BP_TOTAL / 2; +} + +/** Default shares for a write (E-18, E-19), sorted by `user_id` like the wire object. */ +export function defaultShares( + viewerId: string, + partnerId: string, + viewerBp: number, +): { user_id: string; share_bp: number }[] { + return [ + { user_id: viewerId, share_bp: viewerBp }, + { user_id: partnerId, share_bp: BP_TOTAL - viewerBp }, + ].sort((a, b) => (a.user_id < b.user_id ? -1 : 1)); +} + +/** Length in code points after trimming (SPEC §9.6). */ +export function codePoints(text: string): number { + return Array.from(text.trim()).length; +} + +export interface AccountDetail { + account: Account; + viewer: User; + partner: User; + userIds: string[]; + colourIndex: number; + /** `net(viewer)` in this account. */ + netOre: number; + groups: MonthGroup[]; + deletable: boolean; +} + +/** Everything S-5 shows; null when the account is not in the snapshot (or deleted) or the household is incomplete. */ +export function accountDetail(ledger: Ledger, accountId: string, viewerId: string): AccountDetail | null { + const account = ledger.accounts.get(accountId); + if (account === undefined || account.deleted_at !== null) return null; + const household = householdOf(ledger, viewerId); + if (household?.partner == null) return null; + return { + account, + viewer: household.viewer, + partner: household.partner, + userIds: household.userIds, + colourIndex: colourIndexOf(orderedAccounts(ledger), accountId), + netOre: accountNet(ledgerBalances(ledger), accountId, viewerId), + groups: accountLedger(ledger, accountId), + deletable: isDeletable(ledger, accountId), + }; +} diff --git a/web/src/features/accounts/testData.ts b/web/src/features/accounts/testData.ts new file mode 100644 index 0000000..5044bc8 --- /dev/null +++ b/web/src/features/accounts/testData.ts @@ -0,0 +1,123 @@ +/** + * The household snapshot the W1 tests share (test-only; imported by `*.test.ts(x)` files). Jacob (admin, the viewer) + * and Anna; Vardag and Huset active, Gotland archived. Balances from Jacob's side: Vardag +623,80, Huset −1 600,00, + * Gotland +100,00, global −876,20 ("Du är skyldig Anna 876,20 kr"). + */ +import type { Account, Expense, SyncPullResponse, User } from '../../api/types'; +import { FakeServer, json } from '../../test/fakeServer'; +import { account, ANNA, ANNA_ID, expense, GROCERIES, JACOB, JACOB_ID, page, settlement } from '../../test/fixtures'; + +export const VARDAG_ID = '01929c5e-0000-7000-8000-0000000000c1'; +export const HUSET_ID = '01929c5e-0000-7000-8000-0000000000c2'; +export const GOTLAND_ID = '01929c5e-0000-7000-8000-0000000000c3'; +export const STUGA_ID = '01929c5e-0000-7000-8000-0000000000c4'; +export const NEW_ID = '01929c5e-0000-7000-8000-0000000000c9'; + +/** 2026-10-21 12:00 in Stockholm: "this month" is October 2026. */ +export const NOW = new Date('2026-10-21T10:00:00.000Z'); + +export function acct( + id: string, + name: string, + serverVersion: number, + options: { createdAt: string; archived?: boolean; jacobBp?: number }, +): Account { + const base = account(id, name, serverVersion); + const jacobBp = options.jacobBp ?? 5000; + return { + ...base, + created_at: options.createdAt, + archived_at: options.archived === true ? '2026-09-01T08:00:00.000Z' : null, + default_shares: [ + { user_id: JACOB_ID, share_bp: jacobBp }, + { user_id: ANNA_ID, share_bp: 10_000 - jacobBp }, + ], + }; +} + +export function exp( + id: string, + accountId: string, + date: string, + amountOre: number, + payer: string, + jacobShare: number, + serverVersion: number, + extra: Partial = {}, +): Expense { + return { + ...expense( + id, + accountId, + amountOre, + payer, + { [JACOB_ID]: jacobShare, [ANNA_ID]: amountOre - jacobShare }, + serverVersion, + ), + expense_date: date, + ...extra, + }; +} + +export const VARDAG = acct(VARDAG_ID, 'Vardag', 20, { createdAt: '2026-01-01T08:00:00.000Z' }); +export const HUSET = acct(HUSET_ID, 'Huset', 21, { createdAt: '2026-02-01T08:00:00.000Z' }); +export const GOTLAND = acct(GOTLAND_ID, 'Gotland 2026', 22, { createdAt: '2026-03-01T08:00:00.000Z', archived: true }); +export const STUGA = acct(STUGA_ID, 'Sommarstugan', 23, { createdAt: '2026-04-01T08:00:00.000Z', jacobBp: 6000 }); + +export const EXPENSES: Expense[] = [ + exp('01929c5e-0000-7000-8000-0000000000e1', VARDAG_ID, '2026-10-16', 124_760, JACOB_ID, 62_380, 30, { + description: 'ICA Maxi Lindhagen', + }), + exp('01929c5e-0000-7000-8000-0000000000e2', VARDAG_ID, '2026-10-03', 40_000, ANNA_ID, 20_000, 31, { + description: 'Apoteket', + }), + exp('01929c5e-0000-7000-8000-0000000000e3', VARDAG_ID, '2026-09-20', 100_000, JACOB_ID, 50_000, 32, { + description: 'Systembolaget', + }), + exp('01929c5e-0000-7000-8000-0000000000e4', HUSET_ID, '2026-10-05', 300_000, ANNA_ID, 150_000, 33, { + description: 'Elräkning', + }), + exp('01929c5e-0000-7000-8000-0000000000e5', GOTLAND_ID, '2026-07-01', 20_000, JACOB_ID, 10_000, 34, { + description: 'Färjan', + }), + exp('01929c5e-0000-7000-8000-0000000000e6', VARDAG_ID, '2026-10-12', 99_900, JACOB_ID, 0, 35, { + description: 'Borttagen', + deleted_at: '2026-10-13T08:00:00.000Z', + }), +]; + +export const SETTLEMENT = { + ...settlement( + '01929c5e-0000-7000-8000-0000000000f1', + ANNA_ID, + JACOB_ID, + { [VARDAG_ID]: 30_000, [HUSET_ID]: 10_000 }, + 40, + ), + settlement_date: '2026-10-10', +}; + +export function snapshot(rows: Partial = {}): SyncPullResponse { + return page(0, { + users: [JACOB, ANNA], + categories: [GROCERIES], + accounts: [VARDAG, HUSET, GOTLAND], + expenses: EXPENSES, + settlements: [SETTLEMENT], + ...rows, + }); +} + +/** A fake server whose `GET /sync` answers `pull` (the whole snapshot from cursor 0). */ +export function serverWith(pull: SyncPullResponse = snapshot()): FakeServer { + return new FakeServer().on('GET', '/sync', () => json(pull)); +} + +export function users(...list: User[]): Partial { + return { users: list }; +} + +/** An exact matcher for text with U+00A0 inside amounts: each space in `text` matches any whitespace. */ +export function loose(text: string): RegExp { + return new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '\\s')}$`); +} diff --git a/web/src/features/accounts/useToday.ts b/web/src/features/accounts/useToday.ts new file mode 100644 index 0000000..57eca2f --- /dev/null +++ b/web/src/features/accounts/useToday.ts @@ -0,0 +1,11 @@ +import { useNow } from '../../app/useNow'; +import { localDateTime, type LocalDate } from '../../lib/dates'; + +const MINUTE_MS = 60_000; + +/** Today's date in the browser's zone, refreshed every minute (list dates, month headers, this month's counts). */ +export function useToday(): LocalDate { + const now = useNow(MINUTE_MS); + const { year, month, day } = localDateTime(new Date(now)); + return { year, month, day }; +} diff --git a/web/src/features/accounts/useViewer.ts b/web/src/features/accounts/useViewer.ts new file mode 100644 index 0000000..79e5767 --- /dev/null +++ b/web/src/features/accounts/useViewer.ts @@ -0,0 +1,6 @@ +import { useSession } from '../../app/useSession'; + +/** The signed-in user's id; RequireAuth guarantees a session on every page of these features. */ +export function useViewerId(): string | null { + return useSession()?.userId ?? null; +} diff --git a/web/src/features/home/AccountCard.module.css b/web/src/features/home/AccountCard.module.css new file mode 100644 index 0000000..b17f8d5 --- /dev/null +++ b/web/src/features/home/AccountCard.module.css @@ -0,0 +1,81 @@ +.card { + display: flex; + flex-wrap: wrap; + align-items: center; + column-gap: 14px; + row-gap: var(--s1); + min-height: 76px; + padding: 14px var(--s4); + border-radius: var(--radius-account-card); + background: var(--card); + box-shadow: var(--elevation-card); + color: var(--on-surface); + text-decoration: none; +} +.card:hover { + background: color-mix(in srgb, var(--card) 94%, var(--on-surface)); +} +.archived { + opacity: 0.92; +} +.middle { + flex: 1 1 140px; + min-width: 0; + display: flex; + flex-direction: column; +} +.nameLine { + display: flex; + align-items: center; + gap: var(--s2); + min-width: 0; +} +.name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); +} +.badge { + display: inline-flex; + align-items: center; + gap: 2px; + flex: none; + padding: 1px var(--s2); + border-radius: var(--radius-full); + background: var(--surface-container-high); + color: var(--on-surface-variant); + font: var(--type-label-small); +} +.count { + font: var(--type-body-small); + letter-spacing: var(--tracking-body-small); + color: var(--on-surface-variant); +} +/* The balance column wraps under the name when it does not fit (long names, 7-digit amounts, large text). */ +.end { + flex: 0 1 auto; + margin-left: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + text-align: end; +} +.wording { + font: var(--type-label-medium); +} +.owed { + color: var(--owed); +} +.owing { + color: var(--owing); +} +.settled { + flex-direction: row; + align-items: center; + gap: var(--s1); + font: var(--type-title-medium); + color: var(--settled); +} diff --git a/web/src/features/home/AccountCard.tsx b/web/src/features/home/AccountCard.tsx new file mode 100644 index 0000000..c38ac42 --- /dev/null +++ b/web/src/features/home/AccountCard.tsx @@ -0,0 +1,55 @@ +import { Link } from 'react-router'; +import type { User } from '../../api/types'; +import { paths } from '../../app/routes'; +import { Money } from '../../components/Money'; +import { Icon } from '../../design/Icon'; +import { useI18n } from '../../i18n/useI18n'; +import { monthName } from '../../lib/dates'; +import { AccountTile } from '../accounts/AccountTile'; +import { balanceWording } from '../accounts/balance'; +import type { AccountSummary } from '../accounts/model'; +import styles from './AccountCard.module.css'; + +/** + * design-spec §3.4 `AccountCard` (S-4): tile, name with the archived badge, this month's expense count, and the + * account balance in the §9.3 wording ("Kvitt" with a check when zero). The whole card opens W-3. + */ +export function AccountCard({ summary, partner, month }: { summary: AccountSummary; partner: User; month: number }) { + const i18n = useI18n(); + const { t, tp, language } = i18n; + const { account, archived, netOre, monthCount, colourIndex } = summary; + const balance = balanceWording(netOre, partner.display_name, 'account', i18n); + const name = monthName(month, language); + const count = + monthCount === 0 + ? t('home_account_no_expenses_month', name) + : tp('home_account_expenses_month', monthCount, monthCount, name); + return ( + + + + + {account.name} + {archived ? ( + + + {t('common_archived')} + + ) : null} + + {count} + + {balance.amountOre === null ? ( + + + {balance.wording} + + ) : ( + + {balance.wording} + + + )} + + ); +} diff --git a/web/src/features/home/BalanceHero.module.css b/web/src/features/home/BalanceHero.module.css new file mode 100644 index 0000000..cf61d2b --- /dev/null +++ b/web/src/features/home/BalanceHero.module.css @@ -0,0 +1,45 @@ +.hero { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s1); + padding: var(--s4) var(--s5) var(--s5); + border-radius: var(--radius-extra-large); + background: var(--card); + box-shadow: var(--elevation-card); + color: var(--on-surface); + text-align: center; + overflow-wrap: anywhere; +} +.wording { + margin-top: var(--s2); + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); +} +.owed { + color: var(--owed); +} +.owing { + color: var(--owing); +} +.settle { + margin-top: 14px; +} +.settledCard { + padding-top: 0; + background: var(--settled-container); + color: var(--on-settled-container); + overflow: hidden; +} +.settledCard > svg:first-child { + margin: 0 calc(-1 * var(--s5)) var(--s2); + width: calc(100% + 2 * var(--s5)); +} +.settledTitle { + margin-top: var(--s2); + font: var(--type-headline-medium); +} +.settledBody { + font: var(--type-body-medium); + letter-spacing: var(--tracking-body-medium); +} diff --git a/web/src/features/home/BalanceHero.tsx b/web/src/features/home/BalanceHero.tsx new file mode 100644 index 0000000..1ae5bf4 --- /dev/null +++ b/web/src/features/home/BalanceHero.tsx @@ -0,0 +1,54 @@ +import type { User } from '../../api/types'; +import { paths } from '../../app/routes'; +import { ButtonLink } from '../../components/Button'; +import { Money } from '../../components/Money'; +import { Icon } from '../../design/Icon'; +import { useI18n } from '../../i18n/useI18n'; +import { balanceWording } from '../accounts/balance'; +import styles from './BalanceHero.module.css'; +import { Bunting, Seesaw } from './Seesaw'; + +/** + * design-spec §3.6 `BalanceHero` (S-4): the global balance from the viewer's side (§9.3, AC-10.1) on the seesaw, the + * amount, and "Gör upp" (to W-7) when non-zero; settled: the bunting card, "Ni är kvitt", no amount, no button. + */ +export function BalanceHero({ + viewer, + partner, + userIds, + netOre, +}: { + viewer: User; + partner: User; + userIds: readonly string[]; + netOre: number; +}) { + const i18n = useI18n(); + const { t } = i18n; + const balance = balanceWording(netOre, partner.display_name, 'global', i18n); + if (balance.amountOre === null) { + return ( +
+ + +

{balance.wording}

+

{t('balance_all_settled_body')}

+
+ ); + } + return ( +
+ +

+ {balance.sentence} + +

+ + } className={styles.settle}> + {t('home_settle_up')} + +
+ ); +} diff --git a/web/src/features/home/HomePage.module.css b/web/src/features/home/HomePage.module.css new file mode 100644 index 0000000..5bca1b5 --- /dev/null +++ b/web/src/features/home/HomePage.module.css @@ -0,0 +1,143 @@ +.page { + container-type: inline-size; + padding: 0 var(--gutter); +} +.grid { + display: flex; + flex-direction: column; + gap: var(--gap-section); +} +.heroColumn, +.accounts { + display: flex; + flex-direction: column; + gap: var(--gap-section); + min-width: 0; +} +/* Desktop: the balance beside the accounts. */ +@container (min-width: 760px) { + .grid { + display: grid; + grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); + align-items: start; + gap: var(--s6); + } + .heroColumn { + position: sticky; + top: var(--s4); + } +} +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s2); + min-height: var(--touch-target); +} +.sectionTitle { + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); +} +.list { + display: flex; + flex-direction: column; + gap: var(--s2); + margin: 0; + padding: 0; + list-style: none; +} +.collapsible { + display: flex; + align-items: center; + gap: var(--s3); + min-height: var(--row-min); + padding: 0 var(--s4); + border: none; + border-radius: var(--radius-large); + background: var(--surface-container-low); + color: var(--on-surface-variant); + font: var(--type-title-small); + letter-spacing: var(--tracking-title-small); + text-align: start; + cursor: pointer; +} +.collapsible:hover { + background: var(--surface-container); +} +.collapsibleLabel { + flex: 1; +} +.skeleton { + display: block; + height: 76px; + border-radius: var(--radius-account-card); + background: var(--surface-container-high); +} +.addLabel { + white-space: nowrap; +} +@media (max-width: 479px) { + .addLabel { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } +} +.emptyColumn { + display: flex; + flex-direction: column; + gap: var(--gap-section); + max-width: 640px; +} +.welcome { + flex-direction: row; + align-items: center; + gap: var(--s4); +} +.pair { + display: inline-flex; + flex: none; +} +.pairAvatar + .pairAvatar { + margin-left: -10px; + box-shadow: 0 0 0 2px var(--card); +} +.welcomeText { + display: flex; + flex-direction: column; +} +.welcomeTitle { + font: var(--type-title-medium); + letter-spacing: var(--tracking-title-medium); +} +.welcomeBody { + font: var(--type-body-medium); + letter-spacing: var(--tracking-body-medium); + color: var(--on-surface-variant); +} +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s3); + padding: var(--s8) var(--gutter); + text-align: center; +} +.pile { + display: inline-flex; +} +.pile > * + * { + margin-left: -12px; +} +.emptyTitle { + font: var(--type-headline-small); +} +.emptyBody { + max-width: 44ch; + font: var(--type-body-large); + letter-spacing: var(--tracking-body-large); + color: var(--on-surface-variant); +} diff --git a/web/src/features/home/HomePage.test.tsx b/web/src/features/home/HomePage.test.tsx new file mode 100644 index 0000000..2243323 --- /dev/null +++ b/web/src/features/home/HomePage.test.tsx @@ -0,0 +1,209 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Invite } from '../../api/types'; +import { errorResponse, json } from '../../test/fakeServer'; +import { ANNA, ANNA_ID, JACOB } from '../../test/fixtures'; +import { renderApp, TEST_SESSION } from '../../test/render'; +import { + acct, + exp, + EXPENSES, + GOTLAND_ID, + HUSET_ID, + loose, + NOW, + serverWith, + snapshot, + VARDAG, + VARDAG_ID, +} from '../accounts/testData'; +import { resetHomeSession } from './HomePage'; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + resetHomeSession(); +}); +afterEach(() => { + vi.useRealTimers(); +}); + +function topBar() { + const header = screen.getByRole('heading', { level: 1 }).closest('header'); + if (header === null) throw new Error('no top bar'); + return within(header); +} + +describe('Home (W-2)', () => { + it('shows the global balance, Settle up, active accounts and a collapsed archived section', async () => { + const user = userEvent.setup(); + renderApp('/', { server: serverWith() }); + const hero = await screen.findByRole('region', { name: loose('Du är skyldig Anna 876,20 kr') }); + expect(within(hero).getByText('876,20 kr')).toBeInTheDocument(); + expect(within(hero).getByRole('link', { name: 'Gör upp' })).toHaveAttribute('href', '/app/settle'); + + const accounts = screen.getByRole('region', { name: 'Konton' }); + expect(within(accounts).getByRole('link', { name: 'Nytt konto' })).toHaveAttribute('href', '/app/accounts/new'); + const cards = within(accounts).getAllByRole('link', { name: /Vardag|Huset|Gotland/ }); + expect(cards.map((c) => c.getAttribute('href'))).toEqual([ + `/app/accounts/${VARDAG_ID}`, + `/app/accounts/${HUSET_ID}`, + ]); + expect(cards[0]).toHaveTextContent('Vardag2 utgifter i oktoberAnna är skyldig dig623,80 kr'); + expect(cards[1]).toHaveTextContent('Huset1 utgift i oktoberDu är skyldig Anna1 600,00 kr'); + expect(topBar().getByRole('link', { name: 'Lägg till utgift' })).toHaveAttribute('href', '/app/expenses/new'); + + const archived = within(accounts).getByRole('button', { name: 'Arkiverade (1)' }); + expect(archived).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByText('Gotland 2026')).not.toBeInTheDocument(); + await user.click(archived); + expect(archived).toHaveAttribute('aria-expanded', 'true'); + const gotland = within(accounts).getByRole('link', { name: /Gotland 2026/ }); + expect(gotland).toHaveAttribute('href', `/app/accounts/${GOTLAND_ID}`); + expect(gotland).toHaveTextContent('Gotland 2026ArkiveratInga utgifter i oktoberAnna är skyldig dig100,00 kr'); + }); + + it('keeps the archived section open for the session', async () => { + const user = userEvent.setup(); + const first = renderApp('/', { server: serverWith() }); + await user.click(await screen.findByRole('button', { name: 'Arkiverade (1)' })); + first.unmount(); + renderApp('/', { server: serverWith() }); + expect(await screen.findByRole('button', { name: 'Arkiverade (1)' })).toHaveAttribute('aria-expanded', 'true'); + }); + + it('words a balance the partner owes', async () => { + const vardagOnly = EXPENSES.filter((e) => e.account_id === VARDAG_ID); + renderApp('/', { server: serverWith(snapshot({ accounts: [VARDAG], expenses: vardagOnly, settlements: [] })) }); + const hero = await screen.findByRole('region', { name: loose('Anna är skyldig dig 923,80 kr') }); + expect(within(hero).getByText('Anna är skyldig dig')).toBeInTheDocument(); + expect(within(hero).getByRole('link', { name: 'Gör upp' })).toBeInTheDocument(); + }); + + it('shows the settled hero without amount or Settle up, accounts as "Kvitt"', async () => { + renderApp('/', { server: serverWith(snapshot({ expenses: [], settlements: [] })) }); + const hero = await screen.findByRole('region', { name: 'Ni är kvitt' }); + expect(within(hero).getByText('Ingen är skyldig något just nu.')).toBeInTheDocument(); + expect(within(hero).queryByRole('link')).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Vardag/ })).toHaveTextContent('Inga utgifter i oktoberKvitt'); + }); + + it('shows the empty state when the household has no accounts', async () => { + renderApp('/', { server: serverWith(snapshot({ accounts: [], expenses: [], settlements: [] })) }); + expect(await screen.findByRole('heading', { name: 'Skapa ert första konto' })).toBeInTheDocument(); + expect(screen.getByText('Du och Anna är med')).toBeInTheDocument(); + expect(screen.getByText('Hushållet är klart. Nu behövs ett konto.')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Nytt konto' })).toHaveAttribute('href', '/app/accounts/new'); + expect(topBar().queryByRole('link', { name: 'Lägg till utgift' })).not.toBeInTheDocument(); + }); + + it('hides New expense when only archived accounts exist', async () => { + renderApp('/', { + server: serverWith(snapshot({ accounts: [{ ...VARDAG, archived_at: '2026-10-01T00:00:00.000Z' }] })), + }); + expect(await screen.findByRole('button', { name: 'Arkiverade (1)' })).toBeInTheDocument(); + expect(topBar().queryByRole('link', { name: 'Lägg till utgift' })).not.toBeInTheDocument(); + }); + + it("shows S-3's content to the admin while the partner has not joined, with the pending invite", async () => { + const invite: Invite = { + id: '01929c5e-0000-7000-8000-0000000000b1', + kind: 'partner', + target_user_id: null, + code: null, + link: null, + created_at: '2026-10-21T08:00:00.000Z', + expires_at: '2026-10-24T19:40:00.000Z', + }; + const server = serverWith(snapshot({ users: [JACOB], accounts: [], expenses: [], settlements: [] })).on( + 'GET', + '/invites', + () => json({ invites: [invite] }), + ); + renderApp('/', { server }); + expect(await screen.findByRole('heading', { name: 'Väntar på din partner' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Bjud in partner' })).toHaveAttribute('href', '/app/settings/invite'); + expect(await screen.findByText('Inbjudan skickad')).toBeInTheDocument(); + expect(screen.getByText(/^Inbjudan gäller till lör 24 okt\. \d\d:40$/)).toBeInTheDocument(); + expect(screen.getByText(/blir tillgängliga när din partner har gått med/)).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Nytt konto' })).not.toBeInTheDocument(); + expect(topBar().queryByRole('link', { name: 'Lägg till utgift' })).not.toBeInTheDocument(); + }); + + it('offers a retry when the pending invites cannot be read', async () => { + const server = serverWith(snapshot({ users: [JACOB], accounts: [], expenses: [], settlements: [] })).on( + 'GET', + '/invites', + () => { + throw new TypeError('Failed to fetch'); + }, + ); + renderApp('/', { server }); + const status = await screen.findByText('Kunde inte nå servern'); + expect(status.parentElement).toHaveTextContent('Försök igen'); + }); + + it('shows no invite action to a member waiting for the partner', async () => { + const server = serverWith(snapshot({ users: [ANNA], accounts: [], expenses: [], settlements: [] })); + renderApp('/', { server, session: { ...TEST_SESSION, userId: ANNA_ID } }); + expect(await screen.findByRole('heading', { name: 'Väntar på din partner' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Bjud in partner' })).not.toBeInTheDocument(); + expect(server.calls('GET', '/invites')).toHaveLength(0); + }); + + it('shows the loading state during the first pull', async () => { + const server = serverWith().on('GET', '/sync', () => new Promise(() => undefined)); + renderApp('/', { server }); + expect(await screen.findByText('Hämtar era utgifter…')).toBeInTheDocument(); + expect(screen.getByText(/Första synken hämtar allt/)).toBeInTheDocument(); + }); + + it('shows the error state with a retry that reads again', async () => { + const user = userEvent.setup(); + const server = serverWith().on('GET', '/sync', () => { + throw new TypeError('Failed to fetch'); + }); + renderApp('/', { server }); + const alert = await screen.findByRole('alert'); + expect(alert).toHaveTextContent('Kunde inte hämta hushållets utgifterKunde inte nå servern'); + server.on('GET', '/sync', () => json(snapshot())); + await user.click(screen.getByRole('button', { name: 'Försök igen' })); + expect(await screen.findByRole('region', { name: loose('Du är skyldig Anna 876,20 kr') })).toBeInTheDocument(); + }); + + it('shows a server error code as its text', async () => { + renderApp('/', { server: serverWith().on('GET', '/sync', () => errorResponse('forbidden', 403)) }); + expect(await screen.findByRole('alert')).toHaveTextContent('Du får inte göra det här'); + }); + + it('shows long names and 7-digit amounts in full', async () => { + const long = acct(VARDAG_ID, 'Sommarstugan i Österåker 2027 med bryggan och bastun', 20, { + createdAt: '2026-01-01T08:00:00.000Z', + }); + const big = exp( + '01929c5e-0000-7000-8000-0000000000e9', + VARDAG_ID, + '2026-10-20', + 246_913_578, + JACOB.id, + 123_456_789, + 30, + ); + renderApp('/', { server: serverWith(snapshot({ accounts: [long], expenses: [big], settlements: [] })) }); + expect( + await screen.findByRole('region', { name: loose('Anna är skyldig dig 1 234 567,89 kr') }), + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: /Sommarstugan i Österåker 2027 med bryggan och bastun/ }), + ).toHaveTextContent('1 234 567,89 kr'); + }); + + it('reads in English', async () => { + renderApp('/', { server: serverWith(), language: 'en' }); + expect(await screen.findByRole('region', { name: loose('You owe Anna SEK 876.20') })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Settle up' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Archived (1)' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Vardag/ })).toHaveTextContent('2 expenses in October'); + }); +}); diff --git a/web/src/features/home/HomePage.tsx b/web/src/features/home/HomePage.tsx index 2552e01..1af8fbd 100644 --- a/web/src/features/home/HomePage.tsx +++ b/web/src/features/home/HomePage.tsx @@ -1,8 +1,249 @@ -import { PlaceholderPage } from '../../app/PlaceholderPage'; +import { useCallback, useId, useState } from 'react'; +import type { Ledger } from '../../api/ledger'; +import { useLedger } from '../../api/ledger'; +import type { User } from '../../api/types'; +import { useUserPicture } from '../../api/pictures'; +import { paths } from '../../app/routes'; +import { TopBar } from '../../app/TopBar'; +import { Avatar, avatarSlot } from '../../components/Avatar'; +import { ButtonLink } from '../../components/Button'; +import { Card } from '../../components/Card'; +import { CategoryIcon } from '../../components/CategoryIcon'; +import { Icon } from '../../design/Icon'; import { useI18n } from '../../i18n/useI18n'; +import { formatDate } from '../../lib/dates'; +import { homeModel, type AccountSummary, type HomeModel } from '../accounts/model'; +import { LoadFailedCard, LoadingCard } from '../accounts/PageStates'; +import { useToday } from '../accounts/useToday'; +import { useViewerId } from '../accounts/useViewer'; +import { AccountCard } from './AccountCard'; +import { BalanceHero } from './BalanceHero'; +import styles from './HomePage.module.css'; +import { WaitingForPartner } from './WaitingForPartner'; -/** S-4 Home (W1). */ +/** S-4: the archived section is collapsed by default and its state is kept for the session (this page view). */ +let archivedExpandedInSession = false; + +/** For tests: forget the session's archived-section state. */ +export function resetHomeSession(): void { + archivedExpandedInSession = false; +} + +/** + * W-2 Home, /app/ (mirrors S-4; S-3's content while the partner has not joined). No sync pill, no update banner and + * no pull to refresh (SPEC §15.6): the shell's updated line stands in for the pill. + */ export default function HomePage() { const { t } = useI18n(); - return ; + const viewerId = useViewerId(); + const today = useToday(); + const period = formatDate(today).slice(0, 7); + const select = useCallback( + (ledger: Ledger) => (viewerId === null ? null : homeModel(ledger, viewerId, period)), + [viewerId, period], + ); + const query = useLedger(select); + const model = query.data ?? null; + const canAddExpense = model !== null && model.household.partner !== null && model.active.length > 0; + + let content; + if (query.data === undefined) { + content = query.isError ? ( + { + void query.refetch(); + }} + /> + ) : ( + + ); + } else if (model === null) { + content = ( + { + void query.refetch(); + }} + /> + ); + } else if (model.household.partner === null) { + content = ; + } else if (model.active.length === 0 && model.archived.length === 0) { + content = ( + + ); + } else { + content = ; + } + + return ( + <> + }> + {t('fab_add_expense')} + + ) : undefined + } + /> +
{content}
+ + ); +} + +function HomeLoaded({ model, partner, month }: { model: HomeModel; partner: User; month: number }) { + const { t } = useI18n(); + const headingId = useId(); + const { household } = model; + return ( +
+
+ +
+
+
+

+ {t('home_accounts')} +

+ }> + {t('home_new_account')} + +
+ {model.active.length === 0 ? null : } + {model.archived.length === 0 ? null : ( + + )} +
+
+ ); +} + +function AccountList({ + summaries, + partner, + month, + id, +}: { + summaries: readonly AccountSummary[]; + partner: User; + month: number; + id?: string; +}) { + return ( +
    + {summaries.map((summary) => ( +
  • + +
  • + ))} +
+ ); +} + +/** S-4 `CollapsibleRow` "Arkiverade (n)": collapsed by default, expanded in place to the archived accounts (AC-6.4). */ +function ArchivedSection({ + summaries, + partner, + month, +}: { + summaries: readonly AccountSummary[]; + partner: User; + month: number; +}) { + const { t } = useI18n(); + const listId = useId(); + const [expanded, setExpanded] = useState(archivedExpandedInSession); + return ( + <> + + {expanded ? : null} + + ); +} + +/** S-4-home-empty: the partner has joined and there are no accounts yet. */ +function HomeEmpty({ viewer, partner, userIds }: { viewer: User; partner: User; userIds: readonly string[] }) { + const { t } = useI18n(); + return ( +
+ + + + {t('home_empty_welcome', partner.display_name)} + {t('home_empty_subtitle')} + + +
+ +

{t('home_empty_title')}

+

{t('home_empty_body')}

+ }> + {t('home_new_account')} + +
+
+ ); +} + +function PersonAvatar({ user, userIds }: { user: User; userIds: readonly string[] }) { + const picture = useUserPicture(user.id, user.picture_version); + return ( + + ); +} + +/** S-4-home-loading: the hero's `Loading` content, the "Konton" header without its action, three skeleton cards. */ +function HomeLoading() { + const { t } = useI18n(); + return ( +
+
+ +
+
+
+

{t('home_accounts')}

+
+ +
+
+ ); } diff --git a/web/src/features/home/Seesaw.module.css b/web/src/features/home/Seesaw.module.css new file mode 100644 index 0000000..d5a37de --- /dev/null +++ b/web/src/features/home/Seesaw.module.css @@ -0,0 +1,70 @@ +.seesaw { + display: block; + width: 100%; + max-width: 320px; + height: auto; + margin: 0 auto; + overflow: visible; +} +.fulcrum { + fill: var(--outline-variant); +} +.owed { + fill: var(--owed); +} +.owing { + fill: var(--owing); +} +.settled { + fill: var(--settled); +} +.waiting { + fill: var(--outline); +} +.slot1 { + fill: var(--avatar-1); +} +.slot2 { + fill: var(--avatar-2); +} +.initials1, +.initials2 { + font-size: 20px; + font-weight: 600; + text-anchor: middle; + dominant-baseline: central; +} +.initials1 { + fill: var(--on-avatar-1); +} +.initials2 { + fill: var(--on-avatar-2); +} +.emptySeat { + fill: var(--surface-container-high); + stroke: var(--outline); + stroke-width: 2; + stroke-dasharray: 4 3; +} +.emptyIcon { + fill: var(--on-surface-variant); +} +.label { + font-size: 12px; + font-weight: 600; + fill: var(--on-surface-variant); + dominant-baseline: central; +} +.labelStart { + text-anchor: end; +} +.bunting { + display: block; + width: 100%; + height: 30px; +} +.string { + fill: none; + stroke: var(--outline); + stroke-width: 1.5; +} diff --git a/web/src/features/home/Seesaw.tsx b/web/src/features/home/Seesaw.tsx new file mode 100644 index 0000000..69c1dd3 --- /dev/null +++ b/web/src/features/home/Seesaw.tsx @@ -0,0 +1,170 @@ +import { useId } from 'react'; +import { useUserPicture } from '../../api/pictures'; +import type { User } from '../../api/types'; +import { avatarSlot, initials } from '../../components/Avatar'; +import { OUTLINE } from '../../design/icons.generated'; +import { useI18n } from '../../i18n/useI18n'; +import styles from './Seesaw.module.css'; + +/** Who sits lower: the person who owes (design-spec §3.6). `waiting` is S-3's empty seat. */ +export type SeesawTilt = 'owed' | 'owing' | 'settled' | 'waiting'; + +/** design-spec §3.6: 9° for a balance, 12° down on the viewer's side while waiting, level when settled. */ +const ANGLE: Record = { owed: 9, owing: -9, settled: 0, waiting: -12 }; +const WIDTH = 320; +const HEIGHT = 100; +const PIVOT_X = WIDTH / 2; +const PIVOT_Y = 72; +const ARM = 88; +const PLANK = 220; +const R = 23; +/** Labels beside the avatars are cut to this many characters (decorative; the names are said elsewhere). */ +const LABEL_MAX = 7; + +function short(label: string): string { + const chars = Array.from(label); + return chars.length <= LABEL_MAX ? label : `${chars.slice(0, LABEL_MAX - 1).join('')}…`; +} + +function Seat({ user, userIds, cx, cy }: { user: User; userIds: readonly string[]; cx: number; cy: number }) { + const clipId = useId(); + const picture = useUserPicture(user.id, user.picture_version); + const slot = avatarSlot(user.id, userIds); + return ( + + + {picture === null ? ( + + {initials(user.display_name)} + + ) : ( + <> + + + + + + )} + + ); +} + +function EmptySeat({ cx, cy }: { cx: number; cy: number }) { + const icon = R; + return ( + + + + + + + ); +} + +/** + * The decorative seesaw of design-spec §3.6: the viewer on the left, the partner (or S-3's empty seat) on the right; + * the side of the person who owes sits lower. Drawn in SVG so it scales down on narrow screens. + */ +export function Seesaw({ + viewer, + partner, + userIds, + tilt, +}: { + viewer: User; + partner: User | null; + userIds: readonly string[]; + tilt: SeesawTilt; +}) { + const { t } = useI18n(); + const radians = (ANGLE[tilt] * Math.PI) / 180; + const dx = ARM * Math.cos(radians); + const dy = ARM * Math.sin(radians); + const lift = 4 + R; + const left = { x: PIVOT_X - dx, y: PIVOT_Y - dy - lift }; + const right = { x: PIVOT_X + dx, y: PIVOT_Y + dy - lift }; + return ( + + ); +} + +/** The settled hero's bunting: a curved line with 14 pennants in category colours (design-spec §3.6). */ +const PENNANTS = [ + 'green', + 'orange', + 'yellow', + 'blue', + 'teal', + 'purple', + 'red', + 'pink', + 'lime', + 'orange', + 'green', + 'blue', + 'yellow', + 'pink', +] as const; + +export function Bunting() { + const top = 4; + const sag = 22; + const yAt = (t: number) => (1 - t) * (1 - t) * top + 2 * (1 - t) * t * sag + t * t * top; + return ( + + ); +} diff --git a/web/src/features/home/WaitingForPartner.module.css b/web/src/features/home/WaitingForPartner.module.css new file mode 100644 index 0000000..2572971 --- /dev/null +++ b/web/src/features/home/WaitingForPartner.module.css @@ -0,0 +1,57 @@ +.column { + display: flex; + flex-direction: column; + gap: var(--gap-section); + max-width: 560px; +} +.hero { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s3); + padding: 28px 22px var(--s6); + border-radius: var(--radius-extra-large); + background: var(--card); + box-shadow: var(--elevation-card); + text-align: center; +} +.title { + font: var(--type-headline-small); +} +.body { + font: var(--type-body-large); + letter-spacing: var(--tracking-body-large); + color: var(--on-surface-variant); +} +.invite { + display: flex; + align-items: center; + gap: 14px; + min-height: var(--row-min); + padding: var(--s3) var(--s4); + border-radius: var(--radius-group-outer); + background: var(--card); + box-shadow: var(--elevation-card); +} +.inviteIcon { + flex: none; + color: var(--on-surface-variant); +} +.inviteText { + display: flex; + flex-direction: column; +} +.inviteTitle { + font: var(--type-title-small); + letter-spacing: var(--tracking-title-small); +} +.inviteExpiry { + font: var(--type-body-medium); + letter-spacing: var(--tracking-body-medium); + color: var(--on-surface-variant); +} +.footer { + font: var(--type-body-medium); + letter-spacing: var(--tracking-body-medium); + color: var(--on-surface-variant); +} diff --git a/web/src/features/home/WaitingForPartner.tsx b/web/src/features/home/WaitingForPartner.tsx new file mode 100644 index 0000000..cb58fcc --- /dev/null +++ b/web/src/features/home/WaitingForPartner.tsx @@ -0,0 +1,84 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useId } from 'react'; +import { unwrap } from '../../api/client'; +import type { User } from '../../api/types'; +import { useApi } from '../../api/useApi'; +import { paths } from '../../app/routes'; +import { ButtonLink } from '../../components/Button'; +import { useToast } from '../../components/Toast'; +import { Icon } from '../../design/Icon'; +import { useI18n } from '../../i18n/useI18n'; +import { weekdayDateTime } from '../../lib/dates'; +import { useToday } from '../accounts/useToday'; +import { Seesaw } from './Seesaw'; +import styles from './WaitingForPartner.module.css'; + +export const INVITES_QUERY_KEY = ['invites'] as const; + +/** + * S-3's content inside Home while the partner has not joined (SPEC §15.1, §15.6 W-2; AC-5.1): the waiting hero, the + * pending partner invite's expiry (E-13, admin only) and "Bjud in partner" to W-18. No accounts are offered. + */ +export function WaitingForPartner({ viewer, userIds }: { viewer: User; userIds: readonly string[] }) { + const { t } = useI18n(); + const admin = viewer.role === 'admin'; + const titleId = useId(); + return ( +
+
+ +

+ {t('onboarding_waiting_title')} +

+

{t('onboarding_waiting_body')}

+ {admin ? ( + }> + {t('common_invite_partner')} + + ) : null} +
+ {admin ? : null} +

{t('onboarding_waiting_footer')}

+
+ ); +} + +function PendingInvite() { + const { t, language } = useI18n(); + const api = useApi(); + const toast = useToast(); + const today = useToday(); + const invites = useQuery({ + queryKey: INVITES_QUERY_KEY, + queryFn: ({ signal }) => unwrap(api.GET('/invites', { signal })), + }); + const { isError, refetch, errorUpdatedAt } = invites; + useEffect(() => { + if (!isError) return; + toast({ + message: t('common_could_not_reach_server'), + action: { + label: t('common_retry'), + run: () => { + void refetch(); + }, + }, + }); + }, [isError, errorUpdatedAt, refetch, toast, t]); + + const pending = invites.data?.invites + .filter((invite) => invite.kind === 'partner') + .sort((a, b) => (a.expires_at < b.expires_at ? 1 : -1))[0]; + if (pending === undefined) return null; + return ( +
+ + + {t('onboarding_waiting_invite_sent')} + + {t('onboarding_waiting_invite_expires', weekdayDateTime(new Date(pending.expires_at), today, language))} + + +
+ ); +}