From 934eee793ca618fc11c8d466cca2e404089872f1 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 21 Oct 2026 21:25:52 +0200 Subject: [PATCH] W1 web Home and accounts: W-2 Home, W-3 account detail, W-4 account editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home (mirrors S-4, S-3 while the partner has not joined): global balance hero with the seesaw and §9.3 wording, Settle up, active accounts with balance and this month's count, collapsed "Arkiverade (n)" kept for the session, empty, loading and error states, New expense when an active account exists. Account detail (S-5): header with default split, split bar, archived badge and balance; expenses and settlements interleaved by date, grouped by month with household spend (settlement rows carry the allocation, AC-11.7); overflow menu with Edit, Archive/Unarchive (undo snackbar, D-071) and Delete only when allowed (AC-6.5); not-found, loading and error states. Account editor (S-6): name (1-60 code points, name_taken on the field), two percent fields that always sum to 100 with two decimals, slider and presets, the "Av 100,00 kr" preview; discard dialog with a title and no body (D-080) on every navigation away; E-18/E-19 apply the returned row. The account preset for W-5 and W-7 is the query parameter `account` (proposed D-144). Tests over a shared snapshot fixture and the fake server. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../accounts/AccountBalanceHeader.module.css | 77 ++++ .../accounts/AccountBalanceHeader.tsx | 117 ++++++ .../accounts/AccountDetailPage.module.css | 53 +++ .../accounts/AccountDetailPage.test.tsx | 245 +++++++++++ .../features/accounts/AccountDetailPage.tsx | 329 ++++++++++++++- .../accounts/AccountEditorPage.module.css | 66 +++ .../accounts/AccountEditorPage.test.tsx | 197 +++++++++ .../features/accounts/AccountEditorPage.tsx | 388 +++++++++++++++++- .../features/accounts/AccountTile.module.css | 36 ++ web/src/features/accounts/AccountTile.tsx | 34 ++ .../features/accounts/OverflowMenu.module.css | 67 +++ web/src/features/accounts/OverflowMenu.tsx | 125 ++++++ .../features/accounts/PageStates.module.css | 62 +++ web/src/features/accounts/PageStates.tsx | 65 +++ web/src/features/accounts/accountWrites.ts | 75 ++++ web/src/features/accounts/balance.ts | 44 ++ web/src/features/accounts/i18n/en.json | 6 + web/src/features/accounts/i18n/sv.json | 6 + web/src/features/accounts/links.ts | 21 + web/src/features/accounts/model.test.ts | 127 ++++++ web/src/features/accounts/model.ts | 223 ++++++++++ web/src/features/accounts/testData.ts | 123 ++++++ web/src/features/accounts/useToday.ts | 11 + web/src/features/accounts/useViewer.ts | 6 + web/src/features/home/AccountCard.module.css | 81 ++++ web/src/features/home/AccountCard.tsx | 55 +++ web/src/features/home/BalanceHero.module.css | 45 ++ web/src/features/home/BalanceHero.tsx | 54 +++ web/src/features/home/HomePage.module.css | 143 +++++++ web/src/features/home/HomePage.test.tsx | 209 ++++++++++ web/src/features/home/HomePage.tsx | 247 ++++++++++- web/src/features/home/Seesaw.module.css | 70 ++++ web/src/features/home/Seesaw.tsx | 170 ++++++++ .../home/WaitingForPartner.module.css | 57 +++ web/src/features/home/WaitingForPartner.tsx | 84 ++++ 35 files changed, 3705 insertions(+), 13 deletions(-) create mode 100644 web/src/features/accounts/AccountBalanceHeader.module.css create mode 100644 web/src/features/accounts/AccountBalanceHeader.tsx create mode 100644 web/src/features/accounts/AccountDetailPage.module.css create mode 100644 web/src/features/accounts/AccountDetailPage.test.tsx create mode 100644 web/src/features/accounts/AccountEditorPage.module.css create mode 100644 web/src/features/accounts/AccountEditorPage.test.tsx create mode 100644 web/src/features/accounts/AccountTile.module.css create mode 100644 web/src/features/accounts/AccountTile.tsx create mode 100644 web/src/features/accounts/OverflowMenu.module.css create mode 100644 web/src/features/accounts/OverflowMenu.tsx create mode 100644 web/src/features/accounts/PageStates.module.css create mode 100644 web/src/features/accounts/PageStates.tsx create mode 100644 web/src/features/accounts/accountWrites.ts create mode 100644 web/src/features/accounts/balance.ts create mode 100644 web/src/features/accounts/i18n/en.json create mode 100644 web/src/features/accounts/i18n/sv.json create mode 100644 web/src/features/accounts/links.ts create mode 100644 web/src/features/accounts/model.test.ts create mode 100644 web/src/features/accounts/model.ts create mode 100644 web/src/features/accounts/testData.ts create mode 100644 web/src/features/accounts/useToday.ts create mode 100644 web/src/features/accounts/useViewer.ts create mode 100644 web/src/features/home/AccountCard.module.css create mode 100644 web/src/features/home/AccountCard.tsx create mode 100644 web/src/features/home/BalanceHero.module.css create mode 100644 web/src/features/home/BalanceHero.tsx create mode 100644 web/src/features/home/HomePage.module.css create mode 100644 web/src/features/home/HomePage.test.tsx create mode 100644 web/src/features/home/Seesaw.module.css create mode 100644 web/src/features/home/Seesaw.tsx create mode 100644 web/src/features/home/WaitingForPartner.module.css create mode 100644 web/src/features/home/WaitingForPartner.tsx 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))} + + +
+ ); +}