W1 web Home and accounts: W-2 Home, W-3 account detail, W-4 account editor
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<section
|
||||
className={styles.header}
|
||||
style={{ background: `var(--account-header-${colourIndex})` }}
|
||||
aria-label={account.name}
|
||||
>
|
||||
<div className={styles.top}>
|
||||
<AccountTile name={account.name} colourIndex={colourIndex} size={40} muted={archived} />
|
||||
<div className={styles.split}>
|
||||
<p className={styles.splitText}>
|
||||
<span className="visually-hidden">{t('account_default_split_spoken', '')}</span>
|
||||
{split}
|
||||
</p>
|
||||
<div className={styles.bar} aria-hidden="true">
|
||||
<span
|
||||
className={viewerSlot === 1 ? styles.slot1 : styles.slot2}
|
||||
style={{ flexGrow: viewerBp, flexBasis: 0 }}
|
||||
/>
|
||||
<span
|
||||
className={viewerSlot === 1 ? styles.slot2 : styles.slot1}
|
||||
style={{ flexGrow: BP_TOTAL - viewerBp, flexBasis: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{archived ? (
|
||||
<span className={styles.badge}>
|
||||
<Icon name="inventory_2" size={14} />
|
||||
{t('common_archived')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{balance.amountOre === null ? (
|
||||
<p className={styles.settled}>
|
||||
<Icon name="check_circle" filled size={28} />
|
||||
{balance.wording}
|
||||
</p>
|
||||
) : (
|
||||
<div className={`${styles.balance} ${styles[balance.tone]}`}>
|
||||
<span className="visually-hidden">{balance.sentence}</span>
|
||||
<p className={styles.wording} aria-hidden="true">
|
||||
{balance.wording}
|
||||
</p>
|
||||
<span aria-hidden="true">
|
||||
<Money ore={balance.amountOre} amountStyle="large" tone={balance.tone} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{archived || netOre !== 0 ? (
|
||||
<div className={styles.actions}>
|
||||
{archived ? (
|
||||
<Button
|
||||
variant="tonal"
|
||||
icon={<Icon name="unarchive" size={20} />}
|
||||
onClick={onUnarchive}
|
||||
disabled={unarchiving}
|
||||
aria-busy={unarchiving || undefined}
|
||||
>
|
||||
{t('account_unarchive')}
|
||||
</Button>
|
||||
) : null}
|
||||
{netOre !== 0 ? (
|
||||
<ButtonLink to={settleFor(account.id)} icon={<Icon name="handshake" filled size={20} />}>
|
||||
{t('account_settle_this')}
|
||||
</ButtonLink>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof userEvent.setup>) {
|
||||
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<Response>(() => 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',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 <PlaceholderPage title={t('web_account_detail_title')} back={paths.home} />;
|
||||
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 (
|
||||
<>
|
||||
<TopBar title={t('web_account_detail_title')} back={paths.home} />
|
||||
<div className={styles.body}>
|
||||
{ledger.isError ? (
|
||||
<LoadFailedCard
|
||||
error={ledger.error}
|
||||
onRetry={() => {
|
||||
void ledger.refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<LoadingCard compact />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (detail === null || viewerId === null) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title={t('web_account_detail_title')} back={paths.home} />
|
||||
<AccountNotFound />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Loaded ledger={data} detail={detail} viewerId={viewerId} />;
|
||||
}
|
||||
|
||||
function Loaded({
|
||||
ledger,
|
||||
detail,
|
||||
viewerId,
|
||||
}: {
|
||||
ledger: Ledger;
|
||||
detail: NonNullable<ReturnType<typeof accountDetail>>;
|
||||
viewerId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const today = useToday();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const writes = useAccountWrites();
|
||||
const [busy, setBusy] = useState<Busy>(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 (
|
||||
<>
|
||||
<TopBar
|
||||
title={account.name}
|
||||
back={paths.home}
|
||||
actions={
|
||||
<>
|
||||
{archived ? null : (
|
||||
<ButtonLink to={newExpenseFor(account.id)} icon={<Icon name="add" size={20} />}>
|
||||
<span className={styles.addLabel}>{t('fab_add_expense')}</span>
|
||||
</ButtonLink>
|
||||
)}
|
||||
<OverflowMenu label={t('common_more_options')} items={menu} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className={styles.page}>
|
||||
<div className={styles.body} aria-busy={busy !== null || undefined}>
|
||||
<div className={styles.headerColumn}>
|
||||
<AccountBalanceHeader
|
||||
account={account}
|
||||
colourIndex={detail.colourIndex}
|
||||
netOre={detail.netOre}
|
||||
viewer={detail.viewer}
|
||||
partner={detail.partner}
|
||||
userIds={detail.userIds}
|
||||
unarchiving={busy === 'unarchive'}
|
||||
onUnarchive={() => void unarchive()}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.ledger}>
|
||||
{detail.groups.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="receipt_long"
|
||||
title={t('account_empty_title')}
|
||||
body={t('account_empty_body', account.name)}
|
||||
/>
|
||||
) : (
|
||||
detail.groups.map((group) => (
|
||||
<Month key={group.period} group={group} ledger={ledger} viewerId={viewerId} today={today} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
open={confirmDelete}
|
||||
alert
|
||||
title={t('account_delete_confirm_title', account.name)}
|
||||
onClose={() => {
|
||||
if (busy !== 'delete') setConfirmDelete(false);
|
||||
}}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setConfirmDelete(false);
|
||||
}}
|
||||
>
|
||||
{t('common_cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={busy === 'delete'}
|
||||
aria-busy={busy === 'delete' || undefined}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
{t('common_delete')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{t('account_delete_confirm_body')}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<section className={styles.month} aria-label={heading}>
|
||||
<h2 className={styles.monthHeader}>{heading}</h2>
|
||||
<SegmentedGroup>
|
||||
{group.rows.map((row) => (
|
||||
<SegmentedItem key={`${row.kind}-${row.id}`}>
|
||||
<Row row={row} ledger={ledger} viewerId={viewerId} today={today} />
|
||||
</SegmentedItem>
|
||||
))}
|
||||
</SegmentedGroup>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<LedgerRow
|
||||
kind="expense"
|
||||
to={paths.expense(expense.id)}
|
||||
date={date}
|
||||
description={expense.description}
|
||||
amountOre={expense.amount_ore}
|
||||
effect={expenseEffect(viewerId, expense.payer_user_id, shares)}
|
||||
leading={
|
||||
<CategoryIcon
|
||||
icon={category?.icon ?? 'category'}
|
||||
color={category?.color ?? 'grey'}
|
||||
archived={category !== undefined && category.archived_at !== null}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<LedgerRow
|
||||
kind="settlement"
|
||||
to={paths.settlement(settlement.id)}
|
||||
date={date}
|
||||
title={title}
|
||||
accountCount={settlement.allocations.length}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Account, 'name' | 'default_shares'>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<PlaceholderPage title={t('account_editor_new')} back={paths.home} />
|
||||
) : (
|
||||
<PlaceholderPage title={t('account_editor_edit')} back={paths.account(id)} />
|
||||
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 (
|
||||
<>
|
||||
<TopBar title={title} back={back} />
|
||||
<div className={styles.body}>
|
||||
{ledger.isError ? (
|
||||
<LoadFailedCard
|
||||
error={ledger.error}
|
||||
onRetry={() => {
|
||||
void ledger.refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<LoadingCard compact />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<>
|
||||
<TopBar title={title} back={paths.home} />
|
||||
<AccountNotFound />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (household?.partner == null) {
|
||||
// AC-5.1: no accounts before the partner has joined.
|
||||
return (
|
||||
<>
|
||||
<TopBar title={title} back={back} />
|
||||
<EmptyState
|
||||
icon="person_add"
|
||||
title={t('error_household_incomplete')}
|
||||
action={
|
||||
<ButtonLink to={paths.home} variant="tonal">
|
||||
{t('web_not_found_action')}
|
||||
</ButtonLink>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EditorForm
|
||||
key={id ?? 'new'}
|
||||
title={title}
|
||||
back={back}
|
||||
account={account}
|
||||
viewer={household.viewer}
|
||||
partner={household.partner}
|
||||
userIds={household.userIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
};
|
||||
|
||||
const nameError = nameTaken ?? (tooLong ? tp('error_too_long', ACCOUNT_NAME_MAX) : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar
|
||||
title={title}
|
||||
back={back}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="text" onClick={goBack}>
|
||||
{t('common_cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={!valid || saving} aria-busy={saving || undefined}>
|
||||
{saving ? t('web_accounts_saving') : t('common_save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<form id={formId} className={styles.body} onSubmit={onSubmit} noValidate>
|
||||
<TextField
|
||||
label={t('common_name')}
|
||||
value={name}
|
||||
autoFocus={account === null}
|
||||
autoCapitalize="sentences"
|
||||
autoComplete="off"
|
||||
error={nameError}
|
||||
onChange={(value) => {
|
||||
setName(value);
|
||||
setNameTaken(null);
|
||||
}}
|
||||
/>
|
||||
<Card title={t('account_editor_default_split')} className={styles.splitCard}>
|
||||
<p className={styles.hint}>{t('account_editor_split_hint')}</p>
|
||||
<div className={styles.people}>
|
||||
<Person user={viewer} userIds={userIds} />
|
||||
<Person user={partner} userIds={userIds} />
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.slider}
|
||||
min={0}
|
||||
max={BP_TOTAL}
|
||||
step={SLIDER_STEP_BP}
|
||||
value={viewerBp ?? initialBp}
|
||||
aria-label={t('editor_ratio_slider', viewer.display_name)}
|
||||
aria-valuetext={
|
||||
viewerBp === null
|
||||
? undefined
|
||||
: t(
|
||||
'editor_ratio_spoken',
|
||||
viewer.display_name,
|
||||
basisPoints(viewerBp, language),
|
||||
partner.display_name,
|
||||
basisPoints(BP_TOTAL - viewerBp, language),
|
||||
)
|
||||
}
|
||||
onChange={(event) => {
|
||||
setRatio(Number(event.target.value));
|
||||
}}
|
||||
/>
|
||||
<div className={styles.fields}>
|
||||
<TextField
|
||||
label={viewer.display_name}
|
||||
value={viewerText}
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
inputClassName="amount"
|
||||
suffix={<span className={styles.unit}>{t('editor_percent_suffix')}</span>}
|
||||
onChange={(text) => {
|
||||
onPercent(text, setViewerText, setPartnerText);
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label={partner.display_name}
|
||||
value={partnerText}
|
||||
inputMode="decimal"
|
||||
autoComplete="off"
|
||||
inputClassName="amount"
|
||||
suffix={<span className={styles.unit}>{t('editor_percent_suffix')}</span>}
|
||||
onChange={(text) => {
|
||||
onPercent(text, setPartnerText, setViewerText);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.presets} role="group" aria-label={t('web_accounts_split_presets')}>
|
||||
{PRESETS_BP.map((bp) => (
|
||||
<Button
|
||||
key={bp}
|
||||
variant={viewerBp === bp ? 'tonal' : 'outlined'}
|
||||
aria-pressed={viewerBp === bp}
|
||||
aria-label={t(
|
||||
'editor_ratio_spoken',
|
||||
viewer.display_name,
|
||||
basisPoints(bp, language),
|
||||
partner.display_name,
|
||||
basisPoints(BP_TOTAL - bp, language),
|
||||
)}
|
||||
onClick={() => {
|
||||
setRatio(bp);
|
||||
}}
|
||||
>
|
||||
{t('common_ratio', basisPoints(bp, language), basisPoints(BP_TOTAL - bp, language))}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{/* Of 100,00 kr each share in öre equals its bp (10 000 öre × bp / 10 000), so no rounding applies. */}
|
||||
<p className={styles.preview} aria-live="polite">
|
||||
<Icon name="calculate" size={20} />
|
||||
<span>
|
||||
{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')),
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</Card>
|
||||
</form>
|
||||
<Dialog
|
||||
open={blocker.state === 'blocked'}
|
||||
title={t('common_discard_title')}
|
||||
onClose={() => {
|
||||
blocker.reset?.();
|
||||
}}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
blocker.reset?.();
|
||||
}}
|
||||
>
|
||||
{t('common_keep_editing')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
blocker.proceed?.();
|
||||
}}
|
||||
>
|
||||
{t('common_discard')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Person({ user, userIds }: { user: User; userIds: readonly string[] }) {
|
||||
const picture = useUserPicture(user.id, user.picture_version);
|
||||
return (
|
||||
<span className={styles.person}>
|
||||
<Avatar displayName={user.display_name} slot={avatarSlot(user.id, userIds)} size={32} pictureUrl={picture} />
|
||||
<span className={styles.personName}>{user.display_name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={[styles.tile, colour, muted ? styles.muted : undefined].filter(Boolean).join(' ')}
|
||||
style={{ width: size, height: size, fontSize: Math.round(size * 0.41) }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{tileLetter(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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<HTMLButtonElement>, 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 (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? menuId : undefined}
|
||||
onClick={() => {
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
<Icon name="more_vert" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div id={menuId} role="menu" aria-label={label} className={styles.menu}>
|
||||
{items.map((item, index) => {
|
||||
const disabled = item.disabledReason !== undefined;
|
||||
const reasonId = `${menuId}-${item.key}-reason`;
|
||||
const labelId = `${menuId}-${item.key}-label`;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
ref={(element) => {
|
||||
itemRefs.current[index] = element;
|
||||
}}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
aria-labelledby={labelId}
|
||||
aria-disabled={disabled ? true : undefined}
|
||||
aria-describedby={disabled ? reasonId : undefined}
|
||||
className={disabled ? `${styles.item} ${styles.disabled}` : styles.item}
|
||||
onKeyDown={(event) => {
|
||||
move(event, index);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
close(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
<Icon name={item.icon} size={22} className={styles.icon} />
|
||||
<span className={styles.text}>
|
||||
<span id={labelId}>{item.label}</span>
|
||||
{disabled ? (
|
||||
<span id={reasonId} className={styles.reason}>
|
||||
{item.disabledReason}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className={styles.loading}>
|
||||
<div role="status" className={styles.loadingInner}>
|
||||
<span className={styles.spinnerTile} aria-hidden="true">
|
||||
<Icon name="sync" size={28} className={styles.spinner} />
|
||||
</span>
|
||||
<p className={styles.loadingTitle}>{t('home_loading')}</p>
|
||||
{compact ? null : <p className={styles.muted}>{t('home_loading_body')}</p>}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Card className={styles.failed}>
|
||||
<div role="alert" className={styles.failedInner}>
|
||||
<Icon name="warning" filled size={24} className={styles.warning} />
|
||||
<div className={styles.failedText}>
|
||||
<h2 className={styles.failedTitle}>{t('web_accounts_load_failed')}</h2>
|
||||
<p className={styles.muted}>{t(writeErrorKey(error))}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="tonal" icon={<Icon name="refresh" size={20} />} onClick={onRetry}>
|
||||
{t('common_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** An account id that is not (or no longer) in the snapshot. */
|
||||
export function AccountNotFound() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<EmptyState
|
||||
icon="info"
|
||||
title={t('error_not_found')}
|
||||
body={t('web_accounts_not_found_body')}
|
||||
action={
|
||||
<ButtonLink to={paths.home} variant="tonal">
|
||||
{t('web_not_found_action')}
|
||||
</ButtonLink>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<AccountFields>) =>
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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<AmountTone, 'owed' | 'owing' | 'settled'>;
|
||||
/** 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<I18n, 't' | 'language'>,
|
||||
): 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 };
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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=<id>`) and W-7 (`/app/settle?account=<id>`) 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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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> = {},
|
||||
): 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> = {}): 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<SyncPullResponse> {
|
||||
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')}$`);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<Link to={paths.account(account.id)} className={archived ? `${styles.card} ${styles.archived}` : styles.card}>
|
||||
<AccountTile name={account.name} colourIndex={colourIndex} muted={archived} />
|
||||
<span className={styles.middle}>
|
||||
<span className={styles.nameLine}>
|
||||
<span className={styles.name}>{account.name}</span>
|
||||
{archived ? (
|
||||
<span className={styles.badge}>
|
||||
<Icon name="inventory_2" size={14} />
|
||||
{t('common_archived')}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={styles.count}>{count}</span>
|
||||
</span>
|
||||
{balance.amountOre === null ? (
|
||||
<span className={`${styles.end} ${styles.settled}`}>
|
||||
<Icon name="check_circle" filled size={20} />
|
||||
{balance.wording}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${styles.end} ${styles[balance.tone]}`}>
|
||||
<span className={styles.wording}>{balance.wording}</span>
|
||||
<Money ore={balance.amountOre} amountStyle="medium" tone={balance.tone} />
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className={`${styles.hero} ${styles.settledCard}`} aria-label={balance.sentence}>
|
||||
<Bunting />
|
||||
<Seesaw viewer={viewer} partner={partner} userIds={userIds} tilt="settled" />
|
||||
<h2 className={styles.settledTitle}>{balance.wording}</h2>
|
||||
<p className={styles.settledBody}>{t('balance_all_settled_body')}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section className={styles.hero} aria-label={balance.sentence}>
|
||||
<Seesaw viewer={viewer} partner={partner} userIds={userIds} tilt={balance.tone} />
|
||||
<h2 className={`${styles.wording} ${styles[balance.tone]}`}>
|
||||
<span className="visually-hidden">{balance.sentence}</span>
|
||||
<span aria-hidden="true">{balance.wording}</span>
|
||||
</h2>
|
||||
<span aria-hidden="true">
|
||||
<Money ore={balance.amountOre} amountStyle="hero" tone={balance.tone} />
|
||||
</span>
|
||||
<ButtonLink to={paths.settle} icon={<Icon name="handshake" filled size={20} />} className={styles.settle}>
|
||||
{t('home_settle_up')}
|
||||
</ButtonLink>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Response>(() => 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');
|
||||
});
|
||||
});
|
||||
@@ -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 <PlaceholderPage title={t('nav_home')} />;
|
||||
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 ? (
|
||||
<LoadFailedCard
|
||||
error={query.error}
|
||||
onRetry={() => {
|
||||
void query.refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<HomeLoading />
|
||||
);
|
||||
} else if (model === null) {
|
||||
content = (
|
||||
<LoadFailedCard
|
||||
error={null}
|
||||
onRetry={() => {
|
||||
void query.refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (model.household.partner === null) {
|
||||
content = <WaitingForPartner viewer={model.household.viewer} userIds={model.household.userIds} />;
|
||||
} else if (model.active.length === 0 && model.archived.length === 0) {
|
||||
content = (
|
||||
<HomeEmpty viewer={model.household.viewer} partner={model.household.partner} userIds={model.household.userIds} />
|
||||
);
|
||||
} else {
|
||||
content = <HomeLoaded model={model} partner={model.household.partner} month={today.month} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar
|
||||
title={t('nav_home')}
|
||||
actions={
|
||||
canAddExpense ? (
|
||||
<ButtonLink to={paths.expenseNew} icon={<Icon name="add" size={20} />}>
|
||||
<span className={styles.addLabel}>{t('fab_add_expense')}</span>
|
||||
</ButtonLink>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className={styles.page}>{content}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeLoaded({ model, partner, month }: { model: HomeModel; partner: User; month: number }) {
|
||||
const { t } = useI18n();
|
||||
const headingId = useId();
|
||||
const { household } = model;
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.heroColumn}>
|
||||
<BalanceHero
|
||||
viewer={household.viewer}
|
||||
partner={partner}
|
||||
userIds={household.userIds}
|
||||
netOre={model.globalNetOre}
|
||||
/>
|
||||
</div>
|
||||
<section className={styles.accounts} aria-labelledby={headingId}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 id={headingId} className={styles.sectionTitle}>
|
||||
{t('home_accounts')}
|
||||
</h2>
|
||||
<ButtonLink to={paths.accountNew} variant="text" icon={<Icon name="add" size={20} />}>
|
||||
{t('home_new_account')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
{model.active.length === 0 ? null : <AccountList summaries={model.active} partner={partner} month={month} />}
|
||||
{model.archived.length === 0 ? null : (
|
||||
<ArchivedSection summaries={model.archived} partner={partner} month={month} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountList({
|
||||
summaries,
|
||||
partner,
|
||||
month,
|
||||
id,
|
||||
}: {
|
||||
summaries: readonly AccountSummary[];
|
||||
partner: User;
|
||||
month: number;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<ul className={styles.list} id={id}>
|
||||
{summaries.map((summary) => (
|
||||
<li key={summary.account.id}>
|
||||
<AccountCard summary={summary} partner={partner} month={month} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.collapsible}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={expanded ? listId : undefined}
|
||||
onClick={() => {
|
||||
archivedExpandedInSession = !expanded;
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
<Icon name="inventory_2" size={20} />
|
||||
<span className={styles.collapsibleLabel}>{t('home_archived_section', summaries.length)}</span>
|
||||
<Icon name={expanded ? 'expand_less' : 'expand_more'} size={24} />
|
||||
</button>
|
||||
{expanded ? <AccountList id={listId} summaries={summaries} partner={partner} month={month} /> : 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 (
|
||||
<div className={styles.emptyColumn}>
|
||||
<Card className={styles.welcome}>
|
||||
<span className={styles.pair} aria-hidden="true">
|
||||
<PersonAvatar user={viewer} userIds={userIds} />
|
||||
<PersonAvatar user={partner} userIds={userIds} />
|
||||
</span>
|
||||
<span className={styles.welcomeText}>
|
||||
<span className={styles.welcomeTitle}>{t('home_empty_welcome', partner.display_name)}</span>
|
||||
<span className={styles.welcomeBody}>{t('home_empty_subtitle')}</span>
|
||||
</span>
|
||||
</Card>
|
||||
<div className={styles.empty}>
|
||||
<span className={styles.pile} aria-hidden="true">
|
||||
<CategoryIcon icon="shopping_cart" color="green" size={52} />
|
||||
<CategoryIcon icon="flight" color="blue" size={52} />
|
||||
<CategoryIcon icon="restaurant" color="orange" size={52} />
|
||||
</span>
|
||||
<h2 className={styles.emptyTitle}>{t('home_empty_title')}</h2>
|
||||
<p className={styles.emptyBody}>{t('home_empty_body')}</p>
|
||||
<ButtonLink to={paths.accountNew} size="medium" icon={<Icon name="add" />}>
|
||||
{t('home_new_account')}
|
||||
</ButtonLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonAvatar({ user, userIds }: { user: User; userIds: readonly string[] }) {
|
||||
const picture = useUserPicture(user.id, user.picture_version);
|
||||
return (
|
||||
<Avatar
|
||||
displayName={user.display_name}
|
||||
slot={avatarSlot(user.id, userIds)}
|
||||
size={44}
|
||||
pictureUrl={picture}
|
||||
className={styles.pairAvatar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** S-4-home-loading: the hero's `Loading` content, the "Konton" header without its action, three skeleton cards. */
|
||||
function HomeLoading() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.heroColumn}>
|
||||
<LoadingCard />
|
||||
</div>
|
||||
<section className={styles.accounts} aria-label={t('home_accounts')}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h2 className={styles.sectionTitle}>{t('home_accounts')}</h2>
|
||||
</div>
|
||||
<div className={styles.list} aria-hidden="true">
|
||||
<span className={styles.skeleton} />
|
||||
<span className={styles.skeleton} />
|
||||
<span className={styles.skeleton} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SeesawTilt, number> = { 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 (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={R} className={slot === 1 ? styles.slot1 : styles.slot2} />
|
||||
{picture === null ? (
|
||||
<text x={cx} y={cy} className={slot === 1 ? styles.initials1 : styles.initials2}>
|
||||
{initials(user.display_name)}
|
||||
</text>
|
||||
) : (
|
||||
<>
|
||||
<clipPath id={clipId}>
|
||||
<circle cx={cx} cy={cy} r={R} />
|
||||
</clipPath>
|
||||
<image
|
||||
href={picture}
|
||||
x={cx - R}
|
||||
y={cy - R}
|
||||
width={2 * R}
|
||||
height={2 * R}
|
||||
clipPath={`url(#${clipId})`}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySeat({ cx, cy }: { cx: number; cy: number }) {
|
||||
const icon = R;
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={R - 1} className={styles.emptySeat} />
|
||||
<svg x={cx - icon / 2} y={cy - icon / 2} width={icon} height={icon} viewBox="0 -960 960 960">
|
||||
<path d={OUTLINE.person_add} className={styles.emptyIcon} />
|
||||
</svg>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<svg
|
||||
className={styles.seesaw}
|
||||
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-tilt={tilt}
|
||||
>
|
||||
<polygon
|
||||
points={`${PIVOT_X},${PIVOT_Y + 2} ${PIVOT_X - 18},${PIVOT_Y + 24} ${PIVOT_X + 18},${PIVOT_Y + 24}`}
|
||||
className={styles.fulcrum}
|
||||
/>
|
||||
<rect
|
||||
x={PIVOT_X - PLANK / 2}
|
||||
y={PIVOT_Y - 4}
|
||||
width={PLANK}
|
||||
height={8}
|
||||
rx={4}
|
||||
className={styles[tilt]}
|
||||
transform={`rotate(${ANGLE[tilt]} ${PIVOT_X} ${PIVOT_Y})`}
|
||||
/>
|
||||
<Seat user={viewer} userIds={userIds} cx={left.x} cy={left.y} />
|
||||
{partner === null ? (
|
||||
<EmptySeat cx={right.x} cy={right.y} />
|
||||
) : (
|
||||
<Seat user={partner} userIds={userIds} cx={right.x} cy={right.y} />
|
||||
)}
|
||||
<text x={left.x - R - 6} y={left.y} className={`${styles.label} ${styles.labelStart}`}>
|
||||
{short(t('common_you'))}
|
||||
</text>
|
||||
<text x={right.x + R + 6} y={right.y} className={styles.label}>
|
||||
{short(partner === null ? t('common_partner') : partner.display_name)}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<svg className={styles.bunting} viewBox={`0 0 ${WIDTH} 30`} preserveAspectRatio="none" aria-hidden="true">
|
||||
<path d={`M0,${top} Q${PIVOT_X},${sag} ${WIDTH},${top}`} className={styles.string} />
|
||||
{PENNANTS.map((colour, index) => {
|
||||
const t = (index + 0.5) / PENNANTS.length;
|
||||
const x = t * WIDTH;
|
||||
const y = yAt(t);
|
||||
return (
|
||||
<polygon
|
||||
key={index}
|
||||
points={`${x - 7},${y} ${x + 7},${y} ${x},${y + 12}`}
|
||||
style={{ fill: `var(--cat-${colour})` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={styles.column}>
|
||||
<section className={styles.hero} aria-labelledby={titleId}>
|
||||
<Seesaw viewer={viewer} partner={null} userIds={userIds} tilt="waiting" />
|
||||
<h2 id={titleId} className={styles.title}>
|
||||
{t('onboarding_waiting_title')}
|
||||
</h2>
|
||||
<p className={styles.body}>{t('onboarding_waiting_body')}</p>
|
||||
{admin ? (
|
||||
<ButtonLink to={paths.settingsInvite} size="medium" icon={<Icon name="person_add" />}>
|
||||
{t('common_invite_partner')}
|
||||
</ButtonLink>
|
||||
) : null}
|
||||
</section>
|
||||
{admin ? <PendingInvite /> : null}
|
||||
<p className={styles.footer}>{t('onboarding_waiting_footer')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.invite}>
|
||||
<Icon name="schedule" size={24} className={styles.inviteIcon} />
|
||||
<span className={styles.inviteText}>
|
||||
<span className={styles.inviteTitle}>{t('onboarding_waiting_invite_sent')}</span>
|
||||
<span className={styles.inviteExpiry}>
|
||||
{t('onboarding_waiting_invite_expires', weekdayDateTime(new Date(pending.expires_at), today, language))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user