From e88233adf502bdd9a0ae3e59cc85d01129644c70 Mon Sep 17 00:00:00 2001 From: WMP Date: Tue, 8 Sep 2026 21:13:11 +0200 Subject: [PATCH 01/44] Make sortable headers keyboard accessible --- frontend/src/components/ui/SortIcon.tsx | 4 +- .../src/components/ui/SortableHeader.test.tsx | 44 ++++++++++++++++++- frontend/src/components/ui/SortableHeader.tsx | 17 +++++-- frontend/src/test/intl-harness.guard.test.ts | 1 - 4 files changed, 59 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ui/SortIcon.tsx b/frontend/src/components/ui/SortIcon.tsx index 45c48edbc7..ad27d60054 100644 --- a/frontend/src/components/ui/SortIcon.tsx +++ b/frontend/src/components/ui/SortIcon.tsx @@ -12,7 +12,7 @@ export function SortIcon({ sortDirection: 'asc' | 'desc'; }) { if (sortField !== field) { - return ↕; + return ; } - return {sortDirection === 'asc' ? '↑' : '↓'}; + return ; } diff --git a/frontend/src/components/ui/SortableHeader.test.tsx b/frontend/src/components/ui/SortableHeader.test.tsx index 391b2eebfe..9a98de740f 100644 --- a/frontend/src/components/ui/SortableHeader.test.tsx +++ b/frontend/src/components/ui/SortableHeader.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent } from '@/test/render'; import { SortableHeader } from './SortableHeader'; function renderHeader(props: Partial[0]> = {}) { @@ -45,9 +45,51 @@ describe('SortableHeader', () => { expect(screen.getByText('↕')).toBeInTheDocument(); }); + it('exposes only the active direction and hides the visual glyph', () => { + const { rerender } = renderHeader({ sortDirection: 'desc' }); + const header = screen.getByRole('columnheader', { name: 'Name' }); + expect(header).toHaveAttribute('aria-sort', 'descending'); + expect(screen.getByText('↓')).toHaveAttribute('aria-hidden', 'true'); + + rerender( + + + + + field="name" + sortField="amount" + sortDirection="asc" + onSort={vi.fn()} + > + Name + + + +
, + ); + expect(screen.getByRole('columnheader', { name: 'Name' })).toHaveAttribute('aria-sort', 'none'); + }); + it('calls onSort with this field when clicked', () => { const { onSort } = renderHeader(); fireEvent.click(screen.getByText('Name')); expect(onSort).toHaveBeenCalledWith('name'); }); + + it.each(['Enter', ' '])('sorts from the keyboard with %j', (key) => { + const { onSort } = renderHeader(); + const header = screen.getByRole('columnheader', { name: 'Name' }); + expect(header).toHaveAttribute('tabindex', '0'); + + fireEvent.keyDown(header, { key }); + + expect(onSort).toHaveBeenCalledOnce(); + expect(onSort).toHaveBeenCalledWith('name'); + }); + + it('ignores unrelated keys', () => { + const { onSort } = renderHeader(); + fireEvent.keyDown(screen.getByRole('columnheader', { name: 'Name' }), { key: 'ArrowDown' }); + expect(onSort).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/ui/SortableHeader.tsx b/frontend/src/components/ui/SortableHeader.tsx index 7099571b4f..7c3ac8b426 100644 --- a/frontend/src/components/ui/SortableHeader.tsx +++ b/frontend/src/components/ui/SortableHeader.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode } from 'react'; +import { type KeyboardEvent, ReactNode } from 'react'; import { SortIcon } from './SortIcon'; import type { SortDirection } from '@/hooks/useSortableTable'; @@ -29,14 +29,25 @@ export function SortableHeader({ }: SortableHeaderProps) { const justify = align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : ''; + const isActive = sortField === field; + const sort = () => onSort(field); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + sort(); + }; + return ( // `role="columnheader"` is the implicit role of a ``, restated so it // survives a table restyled for phones (a `display` other than table-cell // drops the implicit role); inert everywhere else. onSort(field)} - className={`cursor-pointer transition-colors motion-reduce:transition-none hover:bg-gray-100 dark:hover:bg-gray-700 select-none ${className}`} + tabIndex={0} + aria-sort={isActive ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} + onClick={sort} + onKeyDown={handleKeyDown} + className={`cursor-pointer transition-colors motion-reduce:transition-none hover:bg-gray-100 dark:hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-600 dark:focus-visible:outline-blue-400 select-none ${className}`} >
{children} diff --git a/frontend/src/test/intl-harness.guard.test.ts b/frontend/src/test/intl-harness.guard.test.ts index dc43f05ad1..cff2bb0846 100644 --- a/frontend/src/test/intl-harness.guard.test.ts +++ b/frontend/src/test/intl-harness.guard.test.ts @@ -101,7 +101,6 @@ const RTL_IMPORT_BASELINE = new Set([ '/src/components/ui/LoadingSpinner.test.tsx', '/src/components/ui/NumericInput.test.tsx', '/src/components/ui/Select.test.tsx', - '/src/components/ui/SortableHeader.test.tsx', '/src/components/ui/SummaryCard.test.tsx', '/src/hooks/useAnchorRect.test.ts', '/src/hooks/useBillsFilters.test.ts', From fc52ccd6866a405ef7b00b74cdf27cb10d115b5c Mon Sep 17 00:00:00 2001 From: WMP Date: Tue, 8 Sep 2026 21:24:43 +0200 Subject: [PATCH 02/44] Mark partial report table totals --- .../CreditUtilizationReport.mobileWrapped.test.tsx | 7 +++++++ .../components/reports/CreditUtilizationReport.tsx | 12 +++++++++--- .../CurrencyExposureReport.mobileWrapped.test.tsx | 8 +++++++- .../components/reports/CurrencyExposureReport.tsx | 7 ++++++- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/reports/CreditUtilizationReport.mobileWrapped.test.tsx b/frontend/src/components/reports/CreditUtilizationReport.mobileWrapped.test.tsx index d64d72dfc6..e360a95292 100644 --- a/frontend/src/components/reports/CreditUtilizationReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/CreditUtilizationReport.mobileWrapped.test.tsx @@ -259,6 +259,13 @@ describe('CreditUtilizationReport (phone wrapped table)', () => { } expect(footRow.textContent).toContain('Credit Limit'); expect(footRow.textContent).toContain('Utilization'); + // CHF could not be converted, so each money subtotal in the footer must + // carry the same visible and accessible partial marker as the summary. + for (const cell of [limit, used, available]) { + expect(cell.querySelector('[data-testid="partial-total"]')).toBeInTheDocument(); + expect(cell.querySelector('[data-testid="partial-total-marker"]')).toBeInTheDocument(); + expect(cell.textContent).toContain('partial total'); + } }); it('keeps the account name shrinkable and readable in full', async () => { diff --git a/frontend/src/components/reports/CreditUtilizationReport.tsx b/frontend/src/components/reports/CreditUtilizationReport.tsx index 7d065e16a3..39201ff850 100644 --- a/frontend/src/components/reports/CreditUtilizationReport.tsx +++ b/frontend/src/components/reports/CreditUtilizationReport.tsx @@ -705,15 +705,21 @@ export function CreditUtilizationReport() { {columns.limit.label} - {formatCurrency(totals.limit, displayCurrency)} + + {formatCurrency(totals.limit, displayCurrency)} + {columns.used.label} - {formatCurrency(totals.used, displayCurrency)} + + {formatCurrency(totals.used, displayCurrency)} + {columns.available.label} - {formatCurrency(totals.available, displayCurrency)} + + {formatCurrency(totals.available, displayCurrency)} + {columns.utilization.label} diff --git a/frontend/src/components/reports/CurrencyExposureReport.mobileWrapped.test.tsx b/frontend/src/components/reports/CurrencyExposureReport.mobileWrapped.test.tsx index 731e48458a..21899f8cc9 100644 --- a/frontend/src/components/reports/CurrencyExposureReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/CurrencyExposureReport.mobileWrapped.test.tsx @@ -76,7 +76,7 @@ vi.mock('@/lib/logger', () => ({ createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), })); -const holding = (id: string, currencyCode: string, marketValue: number) => ({ +const holding = (id: string, currencyCode: string, marketValue: number | null) => ({ id, accountId: 'acc-1', securityId: `s-${id}`, @@ -101,6 +101,9 @@ const HOLDINGS = [ holding('h-cad-1', 'CAD', 123456.78), holding('h-cad-2', 'CAD', 1000), holding('h-jpy', 'JPY', 98765.43), + // This position has no value, so it contributes no row but makes every + // portfolio-value subtotal partial. + holding('h-unpriced', 'GBP', null), ]; async function renderReport() { @@ -261,6 +264,9 @@ describe('CurrencyExposureReport (phone wrapped table)', () => { expect(footRow.textContent).toContain('CAD Value'); expect(footRow.textContent).toContain('% of Portfolio'); expect(footRow.textContent).toContain('Holdings'); + expect(converted.querySelector('[data-testid="partial-total"]')).toBeInTheDocument(); + expect(converted.querySelector('[data-testid="partial-total-marker"]')).toBeInTheDocument(); + expect(converted.textContent).toContain('partial total'); }); it('keeps each total announced against its own column, though two footer cells leave the DOM', async () => { diff --git a/frontend/src/components/reports/CurrencyExposureReport.tsx b/frontend/src/components/reports/CurrencyExposureReport.tsx index e2f2c06b5f..c80ff90066 100644 --- a/frontend/src/components/reports/CurrencyExposureReport.tsx +++ b/frontend/src/components/reports/CurrencyExposureReport.tsx @@ -707,7 +707,12 @@ export function CurrencyExposureReport() { {columns.convertedValue.label} - {formatCurrencyFull(totalPortfolioValue, defaultCurrency)} + + {formatCurrencyFull(totalPortfolioValue, defaultCurrency)} + {columns.percentage.label} From c67a4fe52a990c40c725d88b0be6c021b422e44a Mon Sep 17 00:00:00 2001 From: WMP Date: Tue, 8 Sep 2026 21:32:17 +0200 Subject: [PATCH 03/44] Make allocation rows keyboard operable --- ...ypeAllocationReport.mobileWrapped.test.tsx | 32 ++++++++++++------- .../reports/SecurityTypeAllocationReport.tsx | 21 ++++++------ 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.mobileWrapped.test.tsx b/frontend/src/components/reports/SecurityTypeAllocationReport.mobileWrapped.test.tsx index a19878477b..43cabe8935 100644 --- a/frontend/src/components/reports/SecurityTypeAllocationReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.mobileWrapped.test.tsx @@ -250,20 +250,30 @@ describe('SecurityTypeAllocationReport (phone wrapped table)', () => { expect(container.textContent).not.toContain(`VTI - ${LONG_NAME}`); }); - it('states no expansion the keyboard cannot reach', async () => { + it.each(['Enter', ' '])('operates a type row with %j and announces its expansion', async (key) => { const container = await renderReport(); - // The row is the expand control and `role="row"` would take an - // `aria-expanded`, but a `` is not focusable and this one carries a - // bare `onClick` with no key handler: announcing the state would promise a - // control a keyboard user cannot operate. Focusability and the state are - // one repair, and it is a behaviour change rather than a layout one -- - // this pins the pair so the attribute cannot arrive without the handling. const etfs = findTypeRow(container, 'ETFs')!; - const stated = etfs.getAttribute('aria-expanded') !== null; - const operable = - etfs.hasAttribute('tabindex') || etfs.getAttribute('role') === 'button'; - expect(stated).toBe(operable); + expect(etfs).toHaveAttribute('role', 'row'); + expect(etfs).toHaveAttribute('tabindex', '0'); + expect(etfs).toHaveAttribute('aria-expanded', 'false'); + expect(etfs.className).toContain('focus-visible:outline-2'); + expect(etfs.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); + + await act(async () => { fireEvent.keyDown(etfs, { key }); }); + + expect(findTypeRow(container, 'ETFs')).toHaveAttribute('aria-expanded', 'true'); + expect(childRows(container)).toHaveLength(1); + }); + + it('ignores unrelated keys on an expandable type row', async () => { + const container = await renderReport(); + const etfs = findTypeRow(container, 'ETFs')!; + + await act(async () => { fireEvent.keyDown(etfs, { key: 'ArrowDown' }); }); + + expect(etfs).toHaveAttribute('aria-expanded', 'false'); + expect(childRows(container)).toHaveLength(0); }); it('flips the chevron rotation class with the expansion', async () => { diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx index ab28af5719..e4a7b6c486 100644 --- a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx @@ -622,17 +622,15 @@ export function SecurityTypeAllocationReport() { ` is - not focusable and this one carries a bare `onClick` with no - key handler, so the state would announce a control a - keyboard cannot operate -- a stated dead end rather than - the silent one there is now. The two are one repair and it - is a behaviour change: make the row operable through the - repo's row-click convention (`useLongPress({ onClick })`), - then state the expansion. Reported, not done here. */ - className="grid grid-cols-2 items-start gap-x-3 gap-y-1.5 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer sm:table-row sm:p-0" - onClick={() => setExpandedType(expandedType === item.type ? null : item.type)} + tabIndex={0} + aria-expanded={expandedType === item.type} + className="grid grid-cols-2 items-start gap-x-3 gap-y-1.5 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-600 dark:focus-visible:outline-blue-400 cursor-pointer sm:table-row sm:p-0" + onClick={() => setExpandedType((current) => current === item.type ? null : item.type)} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + setExpandedType((current) => current === item.type ? null : item.type); + }} > {/* The identity; the `` around it stays the click target at every width. A type label is bounded in practice (five @@ -669,6 +667,7 @@ export function SecurityTypeAllocationReport() { /> {item.label} {n} : n); + const investmentLabel = t('form.tabs.investment'); return transaction.isInvestment ? ( {bound(transaction.investmentSecurity?.symbol - ? `${transaction.investmentAction || 'Investment'}: ${transaction.investmentSecurity.symbol}` - : transaction.investmentAction || 'Investment')} + ? `${transaction.investmentAction || investmentLabel}: ${transaction.investmentSecurity.symbol}` + : transaction.investmentAction || investmentLabel)} ) : transaction.isTransfer ? ( - {bound('Transfer')} + {bound(t('form.tabs.transfer'))} ) : transaction.isSplit ? ( s.category?.name || 'Uncategorized').join(', ')} > - {bound(<>Split ({transaction.splits?.length || 0}))} + {bound(t('list.splitBadge', { count: transaction.splits?.length || 0 }))} ) : transaction.category ? ( {formatDate(transaction.nextDueDate)} - + {formatDate(transaction.nextOverride.overrideDate)} diff --git a/frontend/src/i18n/messages/de/scheduledTransactions.json b/frontend/src/i18n/messages/de/scheduledTransactions.json index 13c8f71d7b..7bcf4ecfd8 100644 --- a/frontend/src/i18n/messages/de/scheduledTransactions.json +++ b/frontend/src/i18n/messages/de/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} geändert", "modifiedTitle": "{count, plural, one {# bevorstehende Ausführung geändert} other {# bevorstehende Ausführungen geändert}}", "modifiedAmountTitle": "Für nächste Ausführung geändert", + "modifiedDateTitle": "Datum für dieses Vorkommen geändert", + "splitBadge": "Buchungsteil ({count})", "autoPostBadge": "Ein", "autoPostTitle": "Wird automatisch bei Fälligkeit gebucht", "inactiveSuffix": " — Inaktiv", diff --git a/frontend/src/i18n/messages/en/scheduledTransactions.json b/frontend/src/i18n/messages/en/scheduledTransactions.json index 3d1e5633b7..8da4dec886 100644 --- a/frontend/src/i18n/messages/en/scheduledTransactions.json +++ b/frontend/src/i18n/messages/en/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} modified", "modifiedTitle": "{count, plural, one {# upcoming occurrence modified} other {# upcoming occurrences modified}}", "modifiedAmountTitle": "Modified for next occurrence", + "modifiedDateTitle": "Date modified for this occurrence", + "splitBadge": "Split ({count})", "autoPostBadge": "On", "autoPostTitle": "Auto-posts when due", "inactiveSuffix": " — Inactive", diff --git a/frontend/src/i18n/messages/es/scheduledTransactions.json b/frontend/src/i18n/messages/es/scheduledTransactions.json index 77ce1ed83f..64c2355364 100644 --- a/frontend/src/i18n/messages/es/scheduledTransactions.json +++ b/frontend/src/i18n/messages/es/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} modificadas", "modifiedTitle": "{count, plural, one {# ocurrencia próxima modificada} other {# ocurrencias próximas modificadas}}", "modifiedAmountTitle": "Modificado para la próxima ocurrencia", + "modifiedDateTitle": "Fecha modificada para esta ocurrencia", + "splitBadge": "División ({count})", "autoPostBadge": "Activo", "autoPostTitle": "Se registra automáticamente al vencer", "inactiveSuffix": " — Inactiva", diff --git a/frontend/src/i18n/messages/fr/scheduledTransactions.json b/frontend/src/i18n/messages/fr/scheduledTransactions.json index 50a0145fe7..7cf2e5f5c4 100644 --- a/frontend/src/i18n/messages/fr/scheduledTransactions.json +++ b/frontend/src/i18n/messages/fr/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} modifiée(s)", "modifiedTitle": "{count, plural, one {# occurrence à venir modifiée} other {# occurrences à venir modifiées}}", "modifiedAmountTitle": "Modifié pour la prochaine occurrence", + "modifiedDateTitle": "Date modifiée pour cette occurrence", + "splitBadge": "Répartition ({count})", "autoPostBadge": "Activé", "autoPostTitle": "Enregistrement automatique à l'échéance", "inactiveSuffix": " — Inactif", diff --git a/frontend/src/i18n/messages/hi/scheduledTransactions.json b/frontend/src/i18n/messages/hi/scheduledTransactions.json index b902772545..63dd8e31e8 100644 --- a/frontend/src/i18n/messages/hi/scheduledTransactions.json +++ b/frontend/src/i18n/messages/hi/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} संशोधित", "modifiedTitle": "{count, plural, one {# आगामी घटना संशोधित} other {# आगामी घटनाएँ संशोधित}}", "modifiedAmountTitle": "अगली घटना के लिए संशोधित", + "modifiedDateTitle": "इस आवृत्ति के लिए तारीख बदली गई", + "splitBadge": "विभाजन ({count})", "autoPostBadge": "चालू", "autoPostTitle": "देय होने पर स्वतः पोस्ट होता है", "inactiveSuffix": " — निष्क्रिय", diff --git a/frontend/src/i18n/messages/id/scheduledTransactions.json b/frontend/src/i18n/messages/id/scheduledTransactions.json index 42d89882ff..d219ffd673 100644 --- a/frontend/src/i18n/messages/id/scheduledTransactions.json +++ b/frontend/src/i18n/messages/id/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} dimodifikasi", "modifiedTitle": "{count, plural, one {# kemunculan mendatang dimodifikasi} other {# kemunculan mendatang dimodifikasi}}", "modifiedAmountTitle": "Dimodifikasi untuk kemunculan berikutnya", + "modifiedDateTitle": "Tanggal diubah untuk kejadian ini", + "splitBadge": "Bagi ({count})", "autoPostBadge": "Aktif", "autoPostTitle": "Diposting otomatis saat jatuh tempo", "inactiveSuffix": " — Tidak Aktif", diff --git a/frontend/src/i18n/messages/it/scheduledTransactions.json b/frontend/src/i18n/messages/it/scheduledTransactions.json index d6fbcc11a0..8134f10955 100644 --- a/frontend/src/i18n/messages/it/scheduledTransactions.json +++ b/frontend/src/i18n/messages/it/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} modificate", "modifiedTitle": "{count, plural, one {# occorrenza futura modificata} other {# occorrenze future modificate}}", "modifiedAmountTitle": "Modificato per la prossima occorrenza", + "modifiedDateTitle": "Data modificata per questa occorrenza", + "splitBadge": "Suddivisione ({count})", "autoPostBadge": "Attivo", "autoPostTitle": "Registrazione automatica alla scadenza", "inactiveSuffix": " — Inattiva", diff --git a/frontend/src/i18n/messages/ja/scheduledTransactions.json b/frontend/src/i18n/messages/ja/scheduledTransactions.json index 23f84a4d6c..9e2ca39a20 100644 --- a/frontend/src/i18n/messages/ja/scheduledTransactions.json +++ b/frontend/src/i18n/messages/ja/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count}件変更", "modifiedTitle": "{count, plural, one {今後の発生#件が変更されています} other {今後の発生#件が変更されています}}", "modifiedAmountTitle": "次回の発生分の金額が変更されています", + "modifiedDateTitle": "この回の予定日を変更済み", + "splitBadge": "分割 ({count})", "autoPostBadge": "オン", "autoPostTitle": "期日に自動投稿", "inactiveSuffix": " — 非アクティブ", diff --git a/frontend/src/i18n/messages/ko/scheduledTransactions.json b/frontend/src/i18n/messages/ko/scheduledTransactions.json index 421f5bd41e..5d65a1a4a1 100644 --- a/frontend/src/i18n/messages/ko/scheduledTransactions.json +++ b/frontend/src/i18n/messages/ko/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count}개 수정됨", "modifiedTitle": "{count, plural, one {# 건의 예정 발생이 수정됨} other {# 건의 예정 발생이 수정됨}}", "modifiedAmountTitle": "다음 발생에 대해 수정됨", + "modifiedDateTitle": "이 일정의 날짜가 변경됨", + "splitBadge": "분할 ({count})", "autoPostBadge": "켜짐", "autoPostTitle": "만기 시 자동 게시", "inactiveSuffix": " — 비활성", diff --git a/frontend/src/i18n/messages/nl/scheduledTransactions.json b/frontend/src/i18n/messages/nl/scheduledTransactions.json index 7c9cfb2b03..2140cc5963 100644 --- a/frontend/src/i18n/messages/nl/scheduledTransactions.json +++ b/frontend/src/i18n/messages/nl/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} gewijzigd", "modifiedTitle": "{count, plural, one {# aankomende herhaling gewijzigd} other {# aankomende herhalingen gewijzigd}}", "modifiedAmountTitle": "Gewijzigd voor volgende herhaling", + "modifiedDateTitle": "Datum gewijzigd voor deze gebeurtenis", + "splitBadge": "Splitsing ({count})", "autoPostBadge": "Aan", "autoPostTitle": "Wordt automatisch geboekt op vervaldatum", "inactiveSuffix": " — Inactief", diff --git a/frontend/src/i18n/messages/pl/scheduledTransactions.json b/frontend/src/i18n/messages/pl/scheduledTransactions.json index 9faea0cc55..3e46ee9106 100644 --- a/frontend/src/i18n/messages/pl/scheduledTransactions.json +++ b/frontend/src/i18n/messages/pl/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} zmodyfikowanych", "modifiedTitle": "{count, plural, one {# nadchodzące wystąpienie zmodyfikowane} few {# nadchodzące wystąpienia zmodyfikowane} many {# nadchodzących wystąpień zmodyfikowanych} other {# nadchodzących wystąpień zmodyfikowanych}}", "modifiedAmountTitle": "Zmodyfikowane dla następnego wystąpienia", + "modifiedDateTitle": "Data zmieniona dla tego wystąpienia", + "splitBadge": "Podział ({count})", "autoPostBadge": "Wł.", "autoPostTitle": "Księguje automatycznie w terminie", "inactiveSuffix": " — Nieaktywne", diff --git a/frontend/src/i18n/messages/pt-BR/scheduledTransactions.json b/frontend/src/i18n/messages/pt-BR/scheduledTransactions.json index 53da6dbbb9..fd032dd6ee 100644 --- a/frontend/src/i18n/messages/pt-BR/scheduledTransactions.json +++ b/frontend/src/i18n/messages/pt-BR/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} modificadas", "modifiedTitle": "{count, plural, one {# ocorrência futura modificada} other {# ocorrências futuras modificadas}}", "modifiedAmountTitle": "Modificado para a próxima ocorrência", + "modifiedDateTitle": "Data alterada para esta ocorrência", + "splitBadge": "Dividida ({count})", "autoPostBadge": "Ativo", "autoPostTitle": "Lançado automaticamente na data de vencimento", "inactiveSuffix": " — Inativo", diff --git a/frontend/src/i18n/messages/pt/scheduledTransactions.json b/frontend/src/i18n/messages/pt/scheduledTransactions.json index ab01891e93..9343defa1e 100644 --- a/frontend/src/i18n/messages/pt/scheduledTransactions.json +++ b/frontend/src/i18n/messages/pt/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} modificadas", "modifiedTitle": "{count, plural, one {# ocorrência futura modificada} other {# ocorrências futuras modificadas}}", "modifiedAmountTitle": "Modificado para a próxima ocorrência", + "modifiedDateTitle": "Data alterada para esta ocorrência", + "splitBadge": "Dividida ({count})", "autoPostBadge": "Ativo", "autoPostTitle": "Lançado automaticamente na data de vencimento", "inactiveSuffix": " — Inativo", diff --git a/frontend/src/i18n/messages/ru/scheduledTransactions.json b/frontend/src/i18n/messages/ru/scheduledTransactions.json index 0508dcfded..94e733bcc6 100644 --- a/frontend/src/i18n/messages/ru/scheduledTransactions.json +++ b/frontend/src/i18n/messages/ru/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} изменено", "modifiedTitle": "{count, plural, one {# предстоящее вхождение изменено} other {# предстоящих вхождений изменено}}", "modifiedAmountTitle": "Изменено для следующего вхождения", + "modifiedDateTitle": "Дата изменена для этого выполнения", + "splitBadge": "Разбивка ({count})", "autoPostBadge": "Вкл.", "autoPostTitle": "Автоматически проводится при наступлении срока", "inactiveSuffix": " — Неактивна", diff --git a/frontend/src/i18n/messages/tr/scheduledTransactions.json b/frontend/src/i18n/messages/tr/scheduledTransactions.json index d07549763f..debdc30794 100644 --- a/frontend/src/i18n/messages/tr/scheduledTransactions.json +++ b/frontend/src/i18n/messages/tr/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} değiştirildi", "modifiedTitle": "{count, plural, one {# yaklaşan tekrar değiştirildi} other {# yaklaşan tekrar değiştirildi}}", "modifiedAmountTitle": "Bir sonraki tekrar için değiştirildi", + "modifiedDateTitle": "Bu gerçekleşme için tarih değiştirildi", + "splitBadge": "Bölünmüş ({count})", "autoPostBadge": "Açık", "autoPostTitle": "Vadesi geldiğinde otomatik kaydedilir", "inactiveSuffix": " — Pasif", diff --git a/frontend/src/i18n/messages/uk/scheduledTransactions.json b/frontend/src/i18n/messages/uk/scheduledTransactions.json index d54d49e1e3..fb52d3c7ca 100644 --- a/frontend/src/i18n/messages/uk/scheduledTransactions.json +++ b/frontend/src/i18n/messages/uk/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} змінено", "modifiedTitle": "{count, plural, one {# майбутнє повторення змінено} other {# майбутніх повторень змінено}}", "modifiedAmountTitle": "Змінено для наступного повторення", + "modifiedDateTitle": "Дату змінено для цього виконання", + "splitBadge": "Розподілена ({count})", "autoPostBadge": "Увімк.", "autoPostTitle": "Автоматично проводиться в термін", "inactiveSuffix": " — Неактивний", diff --git a/frontend/src/i18n/messages/vi/scheduledTransactions.json b/frontend/src/i18n/messages/vi/scheduledTransactions.json index f0c3b88467..3c15b997fd 100644 --- a/frontend/src/i18n/messages/vi/scheduledTransactions.json +++ b/frontend/src/i18n/messages/vi/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} đã sửa", "modifiedTitle": "{count, plural, one {# lần xuất hiện sắp tới đã được sửa} other {# lần xuất hiện sắp tới đã được sửa}}", "modifiedAmountTitle": "Đã sửa cho lần xuất hiện tiếp theo", + "modifiedDateTitle": "Ngày đã được thay đổi cho lần này", + "splitBadge": "Phần chia ({count})", "autoPostBadge": "Bật", "autoPostTitle": "Tự động đăng khi đến hạn", "inactiveSuffix": " — Không hoạt động", diff --git a/frontend/src/i18n/messages/xx/scheduledTransactions.json b/frontend/src/i18n/messages/xx/scheduledTransactions.json index 8a61bbbdf3..16239b90ec 100644 --- a/frontend/src/i18n/messages/xx/scheduledTransactions.json +++ b/frontend/src/i18n/messages/xx/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "[XX-{count} modified-XX]", "modifiedTitle": "[XX-{count, plural, one {# upcoming occurrence modified} other {# upcoming occurrences modified}}-XX]", "modifiedAmountTitle": "[XX-Modified for next occurrence-XX]", + "modifiedDateTitle": "[XX-Date modified for this occurrence-XX]", + "splitBadge": "[XX-Split ({count})-XX]", "autoPostBadge": "[XX-On-XX]", "autoPostTitle": "[XX-Auto-posts when due-XX]", "inactiveSuffix": "[XX- — Inactive-XX]", diff --git a/frontend/src/i18n/messages/zh-CN/scheduledTransactions.json b/frontend/src/i18n/messages/zh-CN/scheduledTransactions.json index 50c8af600e..15692bfa5b 100644 --- a/frontend/src/i18n/messages/zh-CN/scheduledTransactions.json +++ b/frontend/src/i18n/messages/zh-CN/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "已修改 {count} 次", "modifiedTitle": "{count, plural, one {# 个即将到来的发生已修改} other {# 个即将到来的发生已修改}}", "modifiedAmountTitle": "下次发生时已修改", + "modifiedDateTitle": "已修改此次执行的日期", + "splitBadge": "拆分({count})", "autoPostBadge": "开启", "autoPostTitle": "到期自动过账", "inactiveSuffix": " — 非活跃", diff --git a/frontend/src/i18n/messages/zh-TW/scheduledTransactions.json b/frontend/src/i18n/messages/zh-TW/scheduledTransactions.json index b6e7ca3a5a..824b7c044e 100644 --- a/frontend/src/i18n/messages/zh-TW/scheduledTransactions.json +++ b/frontend/src/i18n/messages/zh-TW/scheduledTransactions.json @@ -22,6 +22,8 @@ "modifiedBadge": "{count} 次已修改", "modifiedTitle": "{count, plural, one {# 筆即將到來的付款已修改} other {# 筆即將到來的付款已修改}}", "modifiedAmountTitle": "下一次付款已修改", + "modifiedDateTitle": "已修改此次執行的日期", + "splitBadge": "分割({count})", "autoPostBadge": "開", "autoPostTitle": "到期自動過帳", "inactiveSuffix": " — 非活躍", From 8d50ffc7178faeb785dfcb820bc49c358ed8a6d7 Mon Sep 17 00:00:00 2001 From: WMP Date: Wed, 9 Sep 2026 02:20:24 +0200 Subject: [PATCH 05/44] Translate security type allocation labels --- ...ityTypeAllocationReport.i18n.guard.test.ts | 34 ++++++++++++++++++ ...ypeAllocationReport.mobileWrapped.test.tsx | 21 ++++++----- .../reports/SecurityTypeAllocationReport.tsx | 35 +++++++++---------- frontend/src/i18n/messages/de/reports.json | 1 + frontend/src/i18n/messages/en/reports.json | 1 + frontend/src/i18n/messages/es/reports.json | 1 + frontend/src/i18n/messages/fr/reports.json | 1 + frontend/src/i18n/messages/hi/reports.json | 1 + frontend/src/i18n/messages/id/reports.json | 1 + frontend/src/i18n/messages/it/reports.json | 1 + frontend/src/i18n/messages/ja/reports.json | 1 + frontend/src/i18n/messages/ko/reports.json | 1 + frontend/src/i18n/messages/nl/reports.json | 1 + frontend/src/i18n/messages/pl/reports.json | 1 + frontend/src/i18n/messages/pt-BR/reports.json | 1 + frontend/src/i18n/messages/pt/reports.json | 1 + frontend/src/i18n/messages/ru/reports.json | 1 + frontend/src/i18n/messages/tr/reports.json | 1 + frontend/src/i18n/messages/uk/reports.json | 1 + frontend/src/i18n/messages/vi/reports.json | 1 + frontend/src/i18n/messages/xx/reports.json | 1 + frontend/src/i18n/messages/zh-CN/reports.json | 1 + frontend/src/i18n/messages/zh-TW/reports.json | 1 + 23 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/reports/SecurityTypeAllocationReport.i18n.guard.test.ts diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.i18n.guard.test.ts b/frontend/src/components/reports/SecurityTypeAllocationReport.i18n.guard.test.ts new file mode 100644 index 0000000000..1b941214b0 --- /dev/null +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.i18n.guard.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +const sources = import.meta.glob('/src/components/reports/SecurityTypeAllocationReport.tsx', { + query: '?raw', + eager: true, + import: 'default', +}) as Record; + +describe('SecurityTypeAllocationReport translates type and quantity labels', () => { + it('scans the production component', () => { + expect(Object.keys(sources)).toEqual([ + '/src/components/reports/SecurityTypeAllocationReport.tsx', + ]); + }); + + it('gets known security type labels from the existing dashboard catalogue', () => { + const source = Object.values(sources)[0]; + + expect(source).not.toMatch(/const\s+TYPE_LABELS\b/); + expect(source).toContain("useTranslations('dashboard')"); + expect(source).toContain('tDashboard(`securityTypeAllocation.types.${type}`)'); + }); + + it('labels a child quantity as shares, not as the parent holding count', () => { + const source = Object.values(sources)[0]; + const quantityCell = source.match( + //, + )?.[0]; + + expect(quantityCell).toBeDefined(); + expect(quantityCell).toContain("t('securityTypeAllocation.colShares')"); + expect(quantityCell).not.toContain('columns.count.label'); + }); +}); diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.mobileWrapped.test.tsx b/frontend/src/components/reports/SecurityTypeAllocationReport.mobileWrapped.test.tsx index 43cabe8935..bd8851b596 100644 --- a/frontend/src/components/reports/SecurityTypeAllocationReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.mobileWrapped.test.tsx @@ -308,10 +308,10 @@ describe('SecurityTypeAllocationReport (phone wrapped table)', () => { // painted order would silently swap Total Value and % of Portfolio on // every desktop. expect(placements(child)).toEqual(['c1+2/r1', 'c2/r2', 'c1/r2', 'c2/r3']); - // The same claim as an ASSOCIATION rather than a placement string: each - // captioned child cell carries the caption of the column its DOM position - // puts it in from `sm` up. Reordering the cells to match the painted order - // fails here even if every placement string is still right. + // The same claim as an ASSOCIATION rather than a placement string. The + // quantity cell is the deliberate exception: the desktop column counts + // holdings on a type row, while this child value is a number of shares, so + // its phone caption names the value rather than borrowing the header. const columnHeader = Array.from(container.querySelectorAll('thead tr'))[1]; const headerLabels = Array.from(columnHeader.querySelectorAll('th')).map((th) => th.textContent?.replace(/[↑↓↕]/g, '').trim(), @@ -323,7 +323,7 @@ describe('SecurityTypeAllocationReport (phone wrapped table)', () => { null, headerLabels[1], headerLabels[2], - headerLabels[3], + 'Shares', ]); expect(child.className).toContain('grid grid-cols-2'); expect(child.className).toContain('sm:table-row'); @@ -353,12 +353,11 @@ describe('SecurityTypeAllocationReport (phone wrapped table)', () => { expect(identity.querySelector('span.sm\\:hidden')).toBeNull(); expect(share.textContent).toBe('% of Portfolio84.7%'); expect(value.textContent).toBe('Total ValueCAD 1234567.89'); - // The share count is captioned too, and the reason is placement rather - // than kind: it sits at `col-start-2 row-start-3`, directly under this - // row's money figure in the same track, size and alignment, so bare it - // reads as a second amount. The caption names the COLUMN, exactly as the - // desktop header above it does. - expect(quantity.textContent).toBe('Holdings1244.5678'); + // The share count is captioned too. It sits at `col-start-2 row-start-3`, + // directly under this row's money figure, so bare it reads as a second + // amount. It names shares rather than borrowing the Holdings header, which + // counts securities on the parent row. + expect(quantity.textContent).toBe('Shares1244.5678'); expect(quantity.querySelector('span.sm\\:hidden')).not.toBeNull(); for (const cell of [share, value, quantity]) { expect(cell.className).toContain('whitespace-nowrap'); diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx index e4a7b6c486..880a75106f 100644 --- a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx @@ -191,13 +191,11 @@ const TYPE_COLOURS: Record = { CASH: chartColors.axis, }; -const TYPE_LABELS: Record = { - STOCK: 'Stocks', - ETF: 'ETFs', - MUTUAL_FUND: 'Mutual Funds', - BOND: 'Bonds', - CASH: 'Cash', -}; +const KNOWN_SECURITY_TYPES = ['STOCK', 'ETF', 'MUTUAL_FUND', 'BOND', 'CASH'] as const; + +function isKnownSecurityType(type: string): type is (typeof KNOWN_SECURITY_TYPES)[number] { + return (KNOWN_SECURITY_TYPES as readonly string[]).includes(type); +} interface TypeAllocation { type: string; @@ -235,6 +233,7 @@ const ACCOUNTS_STORAGE_KEY = 'monize-reports-security-type-allocation-accounts'; export function SecurityTypeAllocationReport() { const t = useTranslations('reports'); + const tDashboard = useTranslations('dashboard'); const tCommon = useTranslations('common'); const { formatCurrencyCompact: formatCurrency, formatCurrency: formatCurrencyFull, formatPercent } = useNumberFormat(); const { defaultCurrency, convertToDefault } = useExchangeRates(); @@ -302,7 +301,9 @@ export function SecurityTypeAllocationReport() { return Array.from(typeMap.entries()) .map(([type, data]) => ({ type, - label: TYPE_LABELS[type] || type, + label: isKnownSecurityType(type) + ? tDashboard(`securityTypeAllocation.types.${type}`) + : type, totalValue: data.totalValue, percentage: totalValue > 0 ? (data.totalValue / totalValue) * 100 : 0, count: data.holdings.length, @@ -323,7 +324,7 @@ export function SecurityTypeAllocationReport() { }), })) .sort((a, b) => b.totalValue - a.totalValue); - }, [holdings, convertToDefault]); + }, [holdings, convertToDefault, tDashboard]); const totalPortfolioValue = useMemo( () => allocationData.reduce((sum, a) => sum + a.totalValue, 0), @@ -743,16 +744,14 @@ export function SecurityTypeAllocationReport() { ? '-' : formatPercent((value / totalPortfolioValue) * 100, 1)} - {/* A caption names the COLUMN its cell is in, not the kind - of the value, so this one is as true as the Holdings - header above it on a desktop and no truer: the column - holds a count of holdings on a type row and a count of - SHARES here. That conflation is pre-existing and - reported; bare, the cell would add a new one, since at - `col-start-2 row-start-3` it sits directly under this - row's money figure in the same track. */} + {/* This cell holds a share quantity rather than the type + row's count of holdings, so its phone caption names + that value directly instead of borrowing the desktop + column header. Bare, it would read as a second amount: + at `col-start-2 row-start-3` it sits directly under the + money figure in the same track. */} - {columns.count.label} + {t('securityTypeAllocation.colShares')} {h.quantity} diff --git a/frontend/src/i18n/messages/de/reports.json b/frontend/src/i18n/messages/de/reports.json index 3a9d5bf357..123a8198b9 100644 --- a/frontend/src/i18n/messages/de/reports.json +++ b/frontend/src/i18n/messages/de/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Anteile", "noData": "Keine Anlagebestände gefunden. Fügen Sie Wertpapiere hinzu, um die Anlageklassenaufschlüsselung zu sehen.", "totalPortfolio": "Gesamtportfolio", "assetTypes": "Anlageklassen", diff --git a/frontend/src/i18n/messages/en/reports.json b/frontend/src/i18n/messages/en/reports.json index 24d629fbe7..923d68ded0 100644 --- a/frontend/src/i18n/messages/en/reports.json +++ b/frontend/src/i18n/messages/en/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Shares", "noData": "No investment holdings found. Add securities to see the asset type breakdown.", "totalPortfolio": "Total Portfolio", "assetTypes": "Asset Types", diff --git a/frontend/src/i18n/messages/es/reports.json b/frontend/src/i18n/messages/es/reports.json index 31c620eeb9..ef1355a86f 100644 --- a/frontend/src/i18n/messages/es/reports.json +++ b/frontend/src/i18n/messages/es/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Acciones", "noData": "No se encontraron posiciones de inversión. Agrega valores para ver el desglose por tipo de activo.", "totalPortfolio": "Total de la cartera", "assetTypes": "Tipos de activos", diff --git a/frontend/src/i18n/messages/fr/reports.json b/frontend/src/i18n/messages/fr/reports.json index 9dbfd0f5fc..d2204a4f36 100644 --- a/frontend/src/i18n/messages/fr/reports.json +++ b/frontend/src/i18n/messages/fr/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Parts", "noData": "Aucune position d'investissement trouvée. Ajoutez des titres pour voir la ventilation par type d'actif.", "totalPortfolio": "Total du portefeuille", "assetTypes": "Types d'actifs", diff --git a/frontend/src/i18n/messages/hi/reports.json b/frontend/src/i18n/messages/hi/reports.json index 650a03115c..7a98732c3b 100644 --- a/frontend/src/i18n/messages/hi/reports.json +++ b/frontend/src/i18n/messages/hi/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "शेयर", "noData": "कोई निवेश होल्डिंग नहीं मिली। संपत्ति प्रकार विवरण देखने के लिए प्रतिभूतियाँ जोड़ें।", "totalPortfolio": "कुल पोर्टफोलियो", "assetTypes": "संपत्ति प्रकार", diff --git a/frontend/src/i18n/messages/id/reports.json b/frontend/src/i18n/messages/id/reports.json index cd6b1be9d4..471f556926 100644 --- a/frontend/src/i18n/messages/id/reports.json +++ b/frontend/src/i18n/messages/id/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Saham", "noData": "Tidak ada kepemilikan investasi ditemukan. Tambah keamanan dokumen untuk melihat rincian tipe aset.", "totalPortfolio": "Total Portofolio", "assetTypes": "Tipe Aset", diff --git a/frontend/src/i18n/messages/it/reports.json b/frontend/src/i18n/messages/it/reports.json index 51b0165845..2dfe474ddd 100644 --- a/frontend/src/i18n/messages/it/reports.json +++ b/frontend/src/i18n/messages/it/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Azioni", "noData": "Nessuna posizione in investimenti trovata. Aggiungi titoli per vedere la ripartizione per tipo di asset.", "totalPortfolio": "Portafoglio totale", "assetTypes": "Tipi di asset", diff --git a/frontend/src/i18n/messages/ja/reports.json b/frontend/src/i18n/messages/ja/reports.json index bb66988fa5..5c6041683c 100644 --- a/frontend/src/i18n/messages/ja/reports.json +++ b/frontend/src/i18n/messages/ja/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "株数", "noData": "投資保有銘柄が見つかりません。資産タイプ内訳を確認するには有価証券を追加してください。", "totalPortfolio": "ポートフォリオ合計", "assetTypes": "資産タイプ", diff --git a/frontend/src/i18n/messages/ko/reports.json b/frontend/src/i18n/messages/ko/reports.json index 73648dc910..79808b2957 100644 --- a/frontend/src/i18n/messages/ko/reports.json +++ b/frontend/src/i18n/messages/ko/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "수량", "noData": "투자 보유 항목을 찾을 수 없습니다. 자산 유형 분류를 보려면 유가증권을 추가하세요.", "totalPortfolio": "총 포트폴리오", "assetTypes": "자산 유형", diff --git a/frontend/src/i18n/messages/nl/reports.json b/frontend/src/i18n/messages/nl/reports.json index c38045e0cc..6a8b26123d 100644 --- a/frontend/src/i18n/messages/nl/reports.json +++ b/frontend/src/i18n/messages/nl/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Aandelen", "noData": "Geen beleggingsposities gevonden. Voeg waardepapieren toe om de activatypeuitsplitsing te zien.", "totalPortfolio": "Totale portefeuille", "assetTypes": "Activatypen", diff --git a/frontend/src/i18n/messages/pl/reports.json b/frontend/src/i18n/messages/pl/reports.json index 5959c42af7..82ad96e4f4 100644 --- a/frontend/src/i18n/messages/pl/reports.json +++ b/frontend/src/i18n/messages/pl/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Udziały", "noData": "Nie znaleziono pozycji inwestycyjnych. Dodaj papiery wartościowe, aby zobaczyć rozbicie według typu aktywów.", "totalPortfolio": "Cały portfel", "assetTypes": "Typy aktywów", diff --git a/frontend/src/i18n/messages/pt-BR/reports.json b/frontend/src/i18n/messages/pt-BR/reports.json index 87494c01e9..47b50aa8c2 100644 --- a/frontend/src/i18n/messages/pt-BR/reports.json +++ b/frontend/src/i18n/messages/pt-BR/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Ações", "noData": "Nenhuma posição de investimento encontrada. Adicione títulos para ver a análise por tipo de ativo.", "totalPortfolio": "Total do Portfólio", "assetTypes": "Tipos de Ativo", diff --git a/frontend/src/i18n/messages/pt/reports.json b/frontend/src/i18n/messages/pt/reports.json index def4f82081..3dc152a255 100644 --- a/frontend/src/i18n/messages/pt/reports.json +++ b/frontend/src/i18n/messages/pt/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Ações", "noData": "Nenhuma posição de investimento encontrada. Adicione títulos para ver a análise por tipo de ativo.", "totalPortfolio": "Total do Portefólio", "assetTypes": "Tipos de Ativo", diff --git a/frontend/src/i18n/messages/ru/reports.json b/frontend/src/i18n/messages/ru/reports.json index 755a278c42..f93081cae8 100644 --- a/frontend/src/i18n/messages/ru/reports.json +++ b/frontend/src/i18n/messages/ru/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Акции", "noData": "Инвестиционные активы не найдены. Добавьте ценные бумаги для просмотра разбивки по типам активов.", "totalPortfolio": "Всего портфель", "assetTypes": "Типы активов", diff --git a/frontend/src/i18n/messages/tr/reports.json b/frontend/src/i18n/messages/tr/reports.json index c69ace0cb1..f50e0e09bf 100644 --- a/frontend/src/i18n/messages/tr/reports.json +++ b/frontend/src/i18n/messages/tr/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Hisse", "noData": "Yatırım varlığı bulunamadı. Varlık türü dökümünü görmek için menkul kıymet ekleyin.", "totalPortfolio": "Toplam Portföy", "assetTypes": "Varlık Türleri", diff --git a/frontend/src/i18n/messages/uk/reports.json b/frontend/src/i18n/messages/uk/reports.json index ad0836820e..95ab80b035 100644 --- a/frontend/src/i18n/messages/uk/reports.json +++ b/frontend/src/i18n/messages/uk/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Акції", "noData": "Інвестиційних позицій не знайдено. Додайте цінні папери, щоб побачити розбивку за типами активів.", "totalPortfolio": "Усього портфеля", "assetTypes": "Типи активів", diff --git a/frontend/src/i18n/messages/vi/reports.json b/frontend/src/i18n/messages/vi/reports.json index 01636aded2..3ffb575ddb 100644 --- a/frontend/src/i18n/messages/vi/reports.json +++ b/frontend/src/i18n/messages/vi/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "Cổ phiếu", "noData": "Không tìm thấy vị thế đầu tư. Thêm chứng khoán để xem phân tích theo loại tài sản.", "totalPortfolio": "Tổng danh mục", "assetTypes": "Loại tài sản", diff --git a/frontend/src/i18n/messages/xx/reports.json b/frontend/src/i18n/messages/xx/reports.json index 4bd25e16d5..871ed1088a 100644 --- a/frontend/src/i18n/messages/xx/reports.json +++ b/frontend/src/i18n/messages/xx/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "[XX-Shares-XX]", "noData": "[XX-No investment holdings found. Add securities to see the asset type breakdown.-XX]", "totalPortfolio": "[XX-Total Portfolio-XX]", "assetTypes": "[XX-Asset Types-XX]", diff --git a/frontend/src/i18n/messages/zh-CN/reports.json b/frontend/src/i18n/messages/zh-CN/reports.json index 793d6cee9b..f0a7f66349 100644 --- a/frontend/src/i18n/messages/zh-CN/reports.json +++ b/frontend/src/i18n/messages/zh-CN/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "股数", "noData": "未找到投资持仓。添加证券以查看资产类型明细。", "totalPortfolio": "投资组合总计", "assetTypes": "资产类型", diff --git a/frontend/src/i18n/messages/zh-TW/reports.json b/frontend/src/i18n/messages/zh-TW/reports.json index eebd05daf7..43a81cc5c3 100644 --- a/frontend/src/i18n/messages/zh-TW/reports.json +++ b/frontend/src/i18n/messages/zh-TW/reports.json @@ -1405,6 +1405,7 @@ } }, "securityTypeAllocation": { + "colShares": "股數", "noData": "找不到投資持倉。請新增證券以查看資產類型明細。", "totalPortfolio": "投資組合總值", "assetTypes": "資產類型", From 8df0a106dd93f81e960690c35301359fb5f391f4 Mon Sep 17 00:00:00 2001 From: WMP Date: Wed, 9 Sep 2026 02:52:14 +0200 Subject: [PATCH 06/44] Localize recurring expense metadata --- .../built-in-reports.service.spec.ts | 8 +-- .../dto/recurring-expenses.dto.ts | 40 ++++++++++++- .../tax-recurring-reports.service.spec.ts | 27 ++++++--- .../tax-recurring-reports.service.ts | 13 ++-- .../RecurringExpensesWidget.test.tsx | 2 +- ...rringExpensesReport.mobileWrapped.test.tsx | 16 ++--- .../reports/RecurringExpensesReport.test.tsx | 44 ++++++++------ .../reports/RecurringExpensesReport.tsx | 59 ++++++++++++------- frontend/src/i18n/messages/de/reports.json | 8 +++ frontend/src/i18n/messages/en-GB/reports.json | 3 + frontend/src/i18n/messages/en/reports.json | 8 +++ frontend/src/i18n/messages/es/reports.json | 8 +++ frontend/src/i18n/messages/fr/reports.json | 8 +++ frontend/src/i18n/messages/hi/reports.json | 8 +++ frontend/src/i18n/messages/id/reports.json | 8 +++ frontend/src/i18n/messages/it/reports.json | 8 +++ frontend/src/i18n/messages/ja/reports.json | 8 +++ frontend/src/i18n/messages/ko/reports.json | 8 +++ frontend/src/i18n/messages/nl/reports.json | 8 +++ frontend/src/i18n/messages/pl/reports.json | 8 +++ frontend/src/i18n/messages/pt-BR/reports.json | 8 +++ frontend/src/i18n/messages/pt/reports.json | 8 +++ frontend/src/i18n/messages/ru/reports.json | 8 +++ frontend/src/i18n/messages/tr/reports.json | 8 +++ frontend/src/i18n/messages/uk/reports.json | 8 +++ frontend/src/i18n/messages/vi/reports.json | 8 +++ frontend/src/i18n/messages/xx/reports.json | 8 +++ frontend/src/i18n/messages/zh-CN/reports.json | 8 +++ frontend/src/i18n/messages/zh-TW/reports.json | 8 +++ .../types/built-in-reports.contract.test.ts | 33 +++++++++++ frontend/src/types/built-in-reports.ts | 14 ++++- 31 files changed, 349 insertions(+), 70 deletions(-) create mode 100644 frontend/src/types/built-in-reports.contract.test.ts diff --git a/backend/src/built-in-reports/built-in-reports.service.spec.ts b/backend/src/built-in-reports/built-in-reports.service.spec.ts index 84c4c09c70..79bbda63af 100644 --- a/backend/src/built-in-reports/built-in-reports.service.spec.ts +++ b/backend/src/built-in-reports/built-in-reports.service.spec.ts @@ -1383,7 +1383,7 @@ describe("BuiltInReportsService", () => { expect(result.data).toHaveLength(1); expect(result.data[0].payeeName).toBe("Netflix"); - expect(result.data[0].frequency).toBe("Monthly"); + expect(result.data[0].frequency).toBe("MONTHLY"); expect(result.data[0].totalAmount).toBe(90); expect(result.data[0].averageAmount).toBe(15); expect(result.data[0].occurrences).toBe(6); @@ -1429,9 +1429,9 @@ describe("BuiltInReportsService", () => { const biweekly = result.data.find((d) => d.payeeName === "Biweekly"); const occasional = result.data.find((d) => d.payeeName === "Occasional"); - expect(weekly?.frequency).toBe("Weekly"); - expect(biweekly?.frequency).toBe("Bi-weekly"); - expect(occasional?.frequency).toBe("Occasional"); + expect(weekly?.frequency).toBe("WEEKLY"); + expect(biweekly?.frequency).toBe("BIWEEKLY"); + expect(occasional?.frequency).toBe("OCCASIONAL"); }); it("merges multi-currency rows for the same payee", async () => { diff --git a/backend/src/built-in-reports/dto/recurring-expenses.dto.ts b/backend/src/built-in-reports/dto/recurring-expenses.dto.ts index 4fb22e356f..1e5bdfc3d3 100644 --- a/backend/src/built-in-reports/dto/recurring-expenses.dto.ts +++ b/backend/src/built-in-reports/dto/recurring-expenses.dto.ts @@ -1,21 +1,57 @@ +import { ApiProperty } from "@nestjs/swagger"; + +export const RECURRING_EXPENSE_FREQUENCIES = [ + "WEEKLY", + "BIWEEKLY", + "MONTHLY", + "OCCASIONAL", + "IRREGULAR", +] as const; + +export type RecurringExpenseFrequency = + (typeof RECURRING_EXPENSE_FREQUENCIES)[number]; + export class RecurringExpenseItem { + @ApiProperty() payeeName: string; + + @ApiProperty({ nullable: true }) payeeId: string | null; + + @ApiProperty() occurrences: number; + + @ApiProperty() totalAmount: number; + + @ApiProperty() averageAmount: number; + + @ApiProperty({ example: "2025-01-15" }) lastTransactionDate: string; - frequency: string; - categoryName: string; + + @ApiProperty({ enum: RECURRING_EXPENSE_FREQUENCIES }) + frequency: RecurringExpenseFrequency; + + @ApiProperty({ nullable: true }) + categoryName: string | null; } export class RecurringSummary { + @ApiProperty() totalRecurring: number; + + @ApiProperty() monthlyEstimate: number; + + @ApiProperty() uniquePayees: number; } export class RecurringExpensesResponse { + @ApiProperty({ type: [RecurringExpenseItem] }) data: RecurringExpenseItem[]; + + @ApiProperty({ type: RecurringSummary }) summary: RecurringSummary; } diff --git a/backend/src/built-in-reports/tax-recurring-reports.service.spec.ts b/backend/src/built-in-reports/tax-recurring-reports.service.spec.ts index fa41d02f6c..6596a1180c 100644 --- a/backend/src/built-in-reports/tax-recurring-reports.service.spec.ts +++ b/backend/src/built-in-reports/tax-recurring-reports.service.spec.ts @@ -479,7 +479,7 @@ describe("TaxRecurringReportsService", () => { expect(result.data[0].totalAmount).toBe(90); expect(result.data[0].averageAmount).toBe(15); expect(result.data[0].occurrences).toBe(6); - expect(result.data[0].frequency).toBe("Monthly"); + expect(result.data[0].frequency).toBe("MONTHLY"); }); it("determines frequency based on occurrence count", async () => { @@ -525,15 +525,26 @@ describe("TaxRecurringReportsService", () => { total_amount: "30.00", last_transaction_date: lastDate, }, + { + payee_id: "p-5", + payee_name_normalized: "irregular", + payee_name: "Irregular", + category_name: null, + currency_code: "USD", + occurrences: 2, + total_amount: "20.00", + last_transaction_date: lastDate, + }, ]); - const result = await service.getRecurringExpenses(mockUserId); + const result = await service.getRecurringExpenses(mockUserId, 2); const byName = new Map(result.data.map((d) => [d.payeeName, d])); - expect(byName.get("Weekly")!.frequency).toBe("Weekly"); - expect(byName.get("Biweekly")!.frequency).toBe("Bi-weekly"); - expect(byName.get("Monthly")!.frequency).toBe("Monthly"); - expect(byName.get("Occasional")!.frequency).toBe("Occasional"); + expect(byName.get("Weekly")!.frequency).toBe("WEEKLY"); + expect(byName.get("Biweekly")!.frequency).toBe("BIWEEKLY"); + expect(byName.get("Monthly")!.frequency).toBe("MONTHLY"); + expect(byName.get("Occasional")!.frequency).toBe("OCCASIONAL"); + expect(byName.get("Irregular")!.frequency).toBe("IRREGULAR"); }); it("merges payees with different currencies by normalized name", async () => { @@ -683,7 +694,7 @@ describe("TaxRecurringReportsService", () => { expect(result.data[0].averageAmount).toBe(11.111); }); - it("uses 'Uncategorized' when category_name is null", async () => { + it("keeps categoryName null when category_name is null", async () => { const lastDate = new Date(); scopedManager.query.mockResolvedValue([ { @@ -700,7 +711,7 @@ describe("TaxRecurringReportsService", () => { const result = await service.getRecurringExpenses(mockUserId); - expect(result.data[0].categoryName).toBe("Uncategorized"); + expect(result.data[0].categoryName).toBeNull(); }); it("calls currency service with correct user id", async () => { diff --git a/backend/src/built-in-reports/tax-recurring-reports.service.ts b/backend/src/built-in-reports/tax-recurring-reports.service.ts index cc2ab4d702..eb1ddacc71 100644 --- a/backend/src/built-in-reports/tax-recurring-reports.service.ts +++ b/backend/src/built-in-reports/tax-recurring-reports.service.ts @@ -11,6 +11,7 @@ import { BillPaymentItem, MonthlyBillTotal, } from "./dto"; +import type { RecurringExpenseFrequency } from "./dto/recurring-expenses.dto"; import { formatDateYMD } from "../common/date-utils"; import { roundMoney, sumMoney, toMoneyNumber } from "../common/round.util"; import { @@ -305,11 +306,11 @@ export class TaxRecurringReportsService { const totalAmount = row.totalAmount; const occurrences = row.occurrences; - let frequency = "Irregular"; - if (occurrences >= 24) frequency = "Weekly"; - else if (occurrences >= 12) frequency = "Bi-weekly"; - else if (occurrences >= 5) frequency = "Monthly"; - else if (occurrences >= 3) frequency = "Occasional"; + let frequency: RecurringExpenseFrequency = "IRREGULAR"; + if (occurrences >= 24) frequency = "WEEKLY"; + else if (occurrences >= 12) frequency = "BIWEEKLY"; + else if (occurrences >= 5) frequency = "MONTHLY"; + else if (occurrences >= 3) frequency = "OCCASIONAL"; return { payeeName: row.payeeName, @@ -319,7 +320,7 @@ export class TaxRecurringReportsService { averageAmount: roundMoney(totalAmount / occurrences), lastTransactionDate: formatDateYMD(row.lastTransactionDate), frequency, - categoryName: row.categoryName || "Uncategorized", + categoryName: row.categoryName, }; }, ); diff --git a/frontend/src/components/dashboard/RecurringExpensesWidget.test.tsx b/frontend/src/components/dashboard/RecurringExpensesWidget.test.tsx index dd892547e5..facf1f9546 100644 --- a/frontend/src/components/dashboard/RecurringExpensesWidget.test.tsx +++ b/frontend/src/components/dashboard/RecurringExpensesWidget.test.tsx @@ -40,7 +40,7 @@ describe('RecurringExpensesWidget', () => { it('fetches with the configured minimum occurrences and renders the estimate', async () => { getRecurringExpenses.mockResolvedValue({ data: [ - { payeeName: 'Netflix', payeeId: 'p1', occurrences: 6, totalAmount: 90, averageAmount: 15, lastTransactionDate: '2026-06-01', frequency: 'monthly', categoryName: 'Streaming' }, + { payeeName: 'Netflix', payeeId: 'p1', occurrences: 6, totalAmount: 90, averageAmount: 15, lastTransactionDate: '2026-06-01', frequency: 'MONTHLY', categoryName: 'Streaming' }, ], summary: { totalRecurring: 90, monthlyEstimate: 15, uniquePayees: 1 }, }); diff --git a/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx b/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx index d88a4cec80..0feb57ce63 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx @@ -71,11 +71,11 @@ vi.mock('@/lib/logger', () => ({ * re-sort and leave the rows in the order they were already in. * * Their figures are the SERVER's, not invented: `averageAmount` is - * `totalAmount / occurrences` and the frequency label is derived from the - * occurrence count (>= 24 Weekly, >= 12 Bi-weekly, >= 5 Monthly, >= 3 - * Occasional, else Irregular) in - * `backend/src/built-in-reports/tax-recurring-reports.service.ts`, which also - * substitutes the literal `Uncategorized` for a row with no category. + * `totalAmount / occurrences` and the frequency code is derived from the + * occurrence count (>= 24 WEEKLY, >= 12 BIWEEKLY, >= 5 MONTHLY, >= 3 + * OCCASIONAL, else IRREGULAR) in + * `backend/src/built-in-reports/tax-recurring-reports.service.ts`. The server + * keeps a missing category as null; the report localizes both structures. * * The second carries the absence the row has to render without navigating: no * payee id, which the recurring query produces for a transaction whose payee is @@ -87,7 +87,7 @@ const RESPONSE = { payeeId: 'p-water', payeeName: 'Water Utility', categoryName: 'Utilities', - frequency: 'Monthly', + frequency: 'MONTHLY', occurrences: 6, averageAmount: 50, totalAmount: 300, @@ -96,8 +96,8 @@ const RESPONSE = { { payeeId: null, payeeName: 'Zebra Market', - categoryName: 'Uncategorized', - frequency: 'Weekly', + categoryName: null, + frequency: 'WEEKLY', occurrences: 26, averageAmount: 25, totalAmount: 650, diff --git a/frontend/src/components/reports/RecurringExpensesReport.test.tsx b/frontend/src/components/reports/RecurringExpensesReport.test.tsx index 8a5106c5a0..3fabf17212 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.test.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.test.tsx @@ -47,7 +47,7 @@ vi.mock("recharts", () => ({
@@ -125,7 +125,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p-1", payeeName: "Netflix", categoryName: "Entertainment", - frequency: "Monthly", + frequency: "MONTHLY", occurrences: 6, averageAmount: 15.99, totalAmount: 95.94, @@ -167,7 +167,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p-1", payeeName: "Netflix", categoryName: "Entertainment", - frequency: "Monthly", + frequency: "MONTHLY", occurrences: 6, averageAmount: 15.99, totalAmount: 95.94, @@ -177,7 +177,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p-2", payeeName: "Gym", categoryName: "Health", - frequency: "Monthly", + frequency: "MONTHLY", occurrences: 6, averageAmount: 50.0, totalAmount: 300.0, @@ -206,7 +206,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p-1", payeeName: "Weekly Sub", categoryName: "Subscriptions", - frequency: "Weekly", + frequency: "WEEKLY", occurrences: 24, averageAmount: 5.0, totalAmount: 120.0, @@ -216,7 +216,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p-2", payeeName: "Bi-weekly Pay", categoryName: "Services", - frequency: "Bi-weekly", + frequency: "BIWEEKLY", occurrences: 12, averageAmount: 30.0, totalAmount: 360.0, @@ -225,8 +225,8 @@ describe("RecurringExpensesReport", () => { { payeeId: null, payeeName: "Quarterly Bill", - categoryName: "Utilities", - frequency: "Quarterly", + categoryName: null, + frequency: "OCCASIONAL", occurrences: 3, averageAmount: 100.0, totalAmount: 300.0, @@ -239,8 +239,11 @@ describe("RecurringExpensesReport", () => { await waitFor(() => { expect(screen.getByText("Weekly")).toBeInTheDocument(); }); - expect(screen.getByText("Bi-weekly")).toBeInTheDocument(); - expect(screen.getByText("Quarterly")).toBeInTheDocument(); + expect(screen.getByText("Every 2 Weeks")).toBeInTheDocument(); + expect(screen.getByText("Occasional")).toBeInTheDocument(); + expect(screen.getByText("Uncategorized")).toBeInTheDocument(); + expect(screen.getByText("Weekly")).toHaveClass("bg-purple-100"); + expect(screen.getByText("Occasional")).toHaveClass("bg-gray-100"); }); it("renders minimum occurrences selector", async () => { @@ -262,7 +265,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p-1", payeeName: "Netflix", categoryName: "Entertainment", - frequency: "Monthly", + frequency: "MONTHLY", occurrences: 6, averageAmount: 15.99, totalAmount: 95.94, @@ -290,7 +293,7 @@ describe("RecurringExpensesReport", () => { payeeId: null, payeeName: "Unknown Store", categoryName: "Shopping", - frequency: "Monthly", + frequency: "MONTHLY", occurrences: 4, averageAmount: 50, totalAmount: 200, @@ -311,8 +314,8 @@ describe("RecurringExpensesReport", () => { { payeeId: "p-1", payeeName: "Netflix", - categoryName: "Entertainment", - frequency: "Monthly", + categoryName: null, + frequency: "MONTHLY", occurrences: 6, averageAmount: 15.99, totalAmount: 95.94, @@ -329,6 +332,11 @@ describe("RecurringExpensesReport", () => { expect.any(Array), expect.any(Array), ); + expect(mockExportToCsv.mock.calls[0][2][0].slice(0, 3)).toEqual([ + "Netflix", + "Uncategorized", + "Monthly", + ]); }); it("changes min occurrences when selector changes", async () => { @@ -350,7 +358,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p-1", payeeName: "Netflix", categoryName: "Entertainment", - frequency: "Monthly", + frequency: "MONTHLY", occurrences: 6, averageAmount: 15.99, totalAmount: 95.94, @@ -380,7 +388,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p-1", payeeName: "Netflix", categoryName: "Entertainment", - frequency: "Monthly", + frequency: "MONTHLY", occurrences: 6, averageAmount: 15.99, totalAmount: 95.94, @@ -404,7 +412,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p1", payeeName: "Netflix", categoryName: "Entertainment", - frequency: "Monthly", + frequency: "MONTHLY", occurrences: 6, averageAmount: 15, totalAmount: 90, @@ -414,7 +422,7 @@ describe("RecurringExpensesReport", () => { payeeId: "p2", payeeName: "Spotify", categoryName: "Entertainment", - frequency: "Monthly", + frequency: "MONTHLY", occurrences: 6, averageAmount: 10, totalAmount: 60, diff --git a/frontend/src/components/reports/RecurringExpensesReport.tsx b/frontend/src/components/reports/RecurringExpensesReport.tsx index 9fd3feacc0..8bf597d55e 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useRef } from 'react'; +import { useCallback, useState, useMemo, useRef } from 'react'; import { useTranslations } from 'next-intl'; import { Skeleton } from '@/components/ui/LoadingSkeleton'; import { useRouter } from 'next/navigation'; @@ -13,7 +13,10 @@ import { } from 'recharts'; import { format } from 'date-fns'; import { builtInReportsApi } from '@/lib/built-in-reports'; -import { RecurringExpenseItem } from '@/types/built-in-reports'; +import { + RecurringExpenseItem, + RecurringExpenseFrequency, +} from '@/types/built-in-reports'; import { useNumberFormat } from '@/hooks/useNumberFormat'; import { chartSeriesColor } from '@/lib/chart-colors'; import { resolvePdfColor } from '@/components/reports/resolve-pdf-color'; @@ -162,6 +165,14 @@ const DATE_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px- /** Every caption in a wrapped cell is phone-only. */ const CAPTION_CLASS = 'sm:hidden'; +const FREQUENCY_BADGE_CLASS: Record = { + WEEKLY: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + BIWEEKLY: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + MONTHLY: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + OCCASIONAL: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400', + IRREGULAR: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400', +}; + export function RecurringExpensesReport() { const t = useTranslations('reports'); const router = useRouter(); @@ -178,6 +189,16 @@ export function RecurringExpensesReport() { [minOccurrences], ); + const frequencyLabel = useCallback( + (frequency: RecurringExpenseFrequency) => t(`recurringExpenses.frequency.${frequency}`), + [t], + ); + const categoryLabel = useCallback( + (categoryName: string | null) => + categoryName ?? t('recurringExpenses.categoryUncategorized'), + [t], + ); + const sortedExpenses = useMemo(() => { if (!recurringData) return []; const sorted = [...recurringData.data].sort((a, b) => { @@ -187,10 +208,10 @@ export function RecurringExpensesReport() { comparison = compareValues(a.payeeName, b.payeeName); break; case 'category': - comparison = compareValues(a.categoryName, b.categoryName); + comparison = compareValues(categoryLabel(a.categoryName), categoryLabel(b.categoryName)); break; case 'frequency': - comparison = compareValues(a.frequency, b.frequency); + comparison = compareValues(frequencyLabel(a.frequency), frequencyLabel(b.frequency)); break; case 'count': comparison = compareValues(a.occurrences, b.occurrences); @@ -208,7 +229,7 @@ export function RecurringExpensesReport() { return sortDirection === 'asc' ? comparison : -comparison; }); return sorted; - }, [recurringData, sortField, sortDirection]); + }, [categoryLabel, frequencyLabel, recurringData, sortField, sortDirection]); const chartData = useMemo(() => { if (!recurringData) return []; @@ -231,14 +252,14 @@ export function RecurringExpensesReport() { field: 'category', label: t('recurringExpenses.colCategory'), csvLabel: t('recurringExpenses.csvColCategory'), - csvValue: (e) => e.categoryName, + csvValue: (e) => categoryLabel(e.categoryName), }, frequency: { field: 'frequency', label: t('recurringExpenses.colFrequency'), align: 'center', csvLabel: t('recurringExpenses.csvColFrequency'), - csvValue: (e) => e.frequency, + csvValue: (e) => frequencyLabel(e.frequency), }, count: { field: 'count', @@ -335,7 +356,10 @@ export function RecurringExpensesReport() {

{data.payeeName}

- {t('recurringExpenses.tooltipTransactions', { count: data.occurrences, frequency: data.frequency })} + {t('recurringExpenses.tooltipTransactions', { + count: data.occurrences, + frequency: frequencyLabel(data.frequency), + })}

{t('recurringExpenses.tooltipTotal', { amount: formatCurrency(data.totalAmount) })} @@ -522,8 +546,9 @@ export function RecurringExpensesReport() { opportunity. So a future translation that outgrows the track spends the 12px column gap there rather than reopening the wrapper's sideways scroll on the right. It also lands where it - belongs: the server derives the frequency label FROM the - occurrence count, so the two sit one above the other. + belongs: the server derives the frequency code FROM the + occurrence count, and this component localizes it, so the two + sit one above the other. The frequency pill is its OWN column, not a badge inside the identity cell, so it cannot join the payee's line; it takes the @@ -604,7 +629,7 @@ export function RecurringExpensesReport() { role="cell" className="col-start-1 row-start-2 min-w-0 break-words p-0 text-sm text-gray-500 dark:text-gray-400 sm:table-cell sm:break-normal sm:px-4 sm:py-3" > - {expense.categoryName} + {categoryLabel(expense.categoryName)} {/* The pill is centred from `sm` up, as it is today; on a phone it starts at its track's left edge. */} @@ -612,16 +637,8 @@ export function RecurringExpensesReport() { role="cell" className="col-start-1 row-start-3 min-w-0 p-0 text-sm sm:table-cell sm:px-4 sm:py-3 sm:text-center" > - - {expense.frequency} + + {frequencyLabel(expense.frequency)} {/* The count is centred from `sm` up, as it is today; on a diff --git a/frontend/src/i18n/messages/de/reports.json b/frontend/src/i18n/messages/de/reports.json index 123a8198b9..f72549267f 100644 --- a/frontend/src/i18n/messages/de/reports.json +++ b/frontend/src/i18n/messages/de/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(in den letzten 6 Monaten)", "noRecurring": "Keine wiederkehrenden Ausgaben mit {count}+ Vorkommen in den letzten 6 Monaten gefunden.", "failedToLoad": "Wiederkehrende Ausgaben konnten nicht geladen werden.", + "categoryUncategorized": "Nicht kategorisiert", + "frequency": { + "WEEKLY": "Wöchentlich", + "BIWEEKLY": "Alle 2 Wochen", + "MONTHLY": "Monatlich", + "OCCASIONAL": "Gelegentlich", + "IRREGULAR": "Unregelmäßig" + }, "top10ChartTitle": "Top 10 wiederkehrende Ausgaben", "allRecurringTitle": "Alle wiederkehrenden Ausgaben", "tooltipTransactions": "{count} Buchungen – {frequency}", diff --git a/frontend/src/i18n/messages/en-GB/reports.json b/frontend/src/i18n/messages/en-GB/reports.json index 8c21f871cd..3515781c19 100644 --- a/frontend/src/i18n/messages/en-GB/reports.json +++ b/frontend/src/i18n/messages/en-GB/reports.json @@ -26,6 +26,9 @@ "filterBuilder": { "categoryUncategorized": "Uncategorised" }, + "recurringExpenses": { + "categoryUncategorized": "Uncategorised" + }, "uncategorizedTransactions": { "totalUncategorized": "Total Uncategorised", "uncategorizedExpenses": "Uncategorised Expenses", diff --git a/frontend/src/i18n/messages/en/reports.json b/frontend/src/i18n/messages/en/reports.json index 923d68ded0..2fcfdc9b7c 100644 --- a/frontend/src/i18n/messages/en/reports.json +++ b/frontend/src/i18n/messages/en/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(in last 6 months)", "noRecurring": "No recurring expenses found with {count}+ occurrences in the last 6 months.", "failedToLoad": "Failed to load recurring expenses data.", + "categoryUncategorized": "Uncategorized", + "frequency": { + "WEEKLY": "Weekly", + "BIWEEKLY": "Every 2 Weeks", + "MONTHLY": "Monthly", + "OCCASIONAL": "Occasional", + "IRREGULAR": "Irregular" + }, "top10ChartTitle": "Top 10 Recurring Expenses", "allRecurringTitle": "All Recurring Expenses", "tooltipTransactions": "{count} transactions - {frequency}", diff --git a/frontend/src/i18n/messages/es/reports.json b/frontend/src/i18n/messages/es/reports.json index ef1355a86f..9ce9a2838b 100644 --- a/frontend/src/i18n/messages/es/reports.json +++ b/frontend/src/i18n/messages/es/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(en los últimos 6 meses)", "noRecurring": "No se encontraron gastos recurrentes con {count}+ ocurrencias en los últimos 6 meses.", "failedToLoad": "Error al cargar los datos de gastos recurrentes.", + "categoryUncategorized": "Sin categorizar", + "frequency": { + "WEEKLY": "Semanal", + "BIWEEKLY": "Cada 2 semanas", + "MONTHLY": "Mensual", + "OCCASIONAL": "Ocasional", + "IRREGULAR": "Irregular" + }, "top10ChartTitle": "Top 10 gastos recurrentes", "allRecurringTitle": "Todos los gastos recurrentes", "tooltipTransactions": "{count} transacciones - {frequency}", diff --git a/frontend/src/i18n/messages/fr/reports.json b/frontend/src/i18n/messages/fr/reports.json index d2204a4f36..dd0d0c9442 100644 --- a/frontend/src/i18n/messages/fr/reports.json +++ b/frontend/src/i18n/messages/fr/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(dans les 6 derniers mois)", "noRecurring": "Aucune dépense récurrente trouvée avec {count}+ occurrences dans les 6 derniers mois.", "failedToLoad": "Impossible de charger les données des dépenses récurrentes.", + "categoryUncategorized": "Non catégorisé", + "frequency": { + "WEEKLY": "Hebdomadaire", + "BIWEEKLY": "Toutes les 2 semaines", + "MONTHLY": "Mensuel", + "OCCASIONAL": "Occasionnel", + "IRREGULAR": "Irrégulier" + }, "top10ChartTitle": "Top 10 des dépenses récurrentes", "allRecurringTitle": "Toutes les dépenses récurrentes", "tooltipTransactions": "{count} transactions - {frequency}", diff --git a/frontend/src/i18n/messages/hi/reports.json b/frontend/src/i18n/messages/hi/reports.json index 7a98732c3b..d9cc3919f5 100644 --- a/frontend/src/i18n/messages/hi/reports.json +++ b/frontend/src/i18n/messages/hi/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(पिछले 6 महीनों में)", "noRecurring": "पिछले 6 महीनों में {count}+ घटनाओं के साथ कोई आवर्ती व्यय नहीं मिला।", "failedToLoad": "आवर्ती व्यय डेटा लोड करने में विफल।", + "categoryUncategorized": "अवर्गीकृत", + "frequency": { + "WEEKLY": "साप्ताहिक", + "BIWEEKLY": "हर 2 सप्ताह", + "MONTHLY": "मासिक", + "OCCASIONAL": "कभी-कभी", + "IRREGULAR": "अनियमित" + }, "top10ChartTitle": "शीर्ष 10 आवर्ती व्यय", "allRecurringTitle": "सभी आवर्ती व्यय", "tooltipTransactions": "{count} लेनदेन - {frequency}", diff --git a/frontend/src/i18n/messages/id/reports.json b/frontend/src/i18n/messages/id/reports.json index 471f556926..65e7d24b6d 100644 --- a/frontend/src/i18n/messages/id/reports.json +++ b/frontend/src/i18n/messages/id/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(dalam 6 bulan terakhir)", "noRecurring": "Tidak ada pengeluaran berulang ditemukan dengan {count}+ kejadian dalam 6 bulan terakhir.", "failedToLoad": "Gagal memuat data pengeluaran berulang.", + "categoryUncategorized": "Belum Dikategorikan", + "frequency": { + "WEEKLY": "Mingguan", + "BIWEEKLY": "Setiap 2 Minggu", + "MONTHLY": "Bulanan", + "OCCASIONAL": "Sesekali", + "IRREGULAR": "Tidak teratur" + }, "top10ChartTitle": "10 Pengeluaran Berulang Teratas", "allRecurringTitle": "Semua Pengeluaran Berulang", "tooltipTransactions": "{count} transaksi - {frequency}", diff --git a/frontend/src/i18n/messages/it/reports.json b/frontend/src/i18n/messages/it/reports.json index 2dfe474ddd..a20ba60a9b 100644 --- a/frontend/src/i18n/messages/it/reports.json +++ b/frontend/src/i18n/messages/it/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(negli ultimi 6 mesi)", "noRecurring": "Nessuna spesa ricorrente trovata con {count}+ occorrenze negli ultimi 6 mesi.", "failedToLoad": "Caricamento dati spese ricorrenti non riuscito.", + "categoryUncategorized": "Senza categoria", + "frequency": { + "WEEKLY": "Settimanale", + "BIWEEKLY": "Ogni 2 settimane", + "MONTHLY": "Mensile", + "OCCASIONAL": "Occasionale", + "IRREGULAR": "Irregolare" + }, "top10ChartTitle": "Top 10 spese ricorrenti", "allRecurringTitle": "Tutte le spese ricorrenti", "tooltipTransactions": "{count} transazioni - {frequency}", diff --git a/frontend/src/i18n/messages/ja/reports.json b/frontend/src/i18n/messages/ja/reports.json index 5c6041683c..0dbc8261cb 100644 --- a/frontend/src/i18n/messages/ja/reports.json +++ b/frontend/src/i18n/messages/ja/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(過去6ヶ月間)", "noRecurring": "過去6ヶ月間に {count}+ 回発生した定期費用が見つかりません。", "failedToLoad": "定期費用データの読み込みに失敗しました。", + "categoryUncategorized": "未分類", + "frequency": { + "WEEKLY": "毎週", + "BIWEEKLY": "2週間ごと", + "MONTHLY": "毎月", + "OCCASIONAL": "時々", + "IRREGULAR": "不定期" + }, "top10ChartTitle": "上位10定期費用", "allRecurringTitle": "すべての定期費用", "tooltipTransactions": "{count} 件の取引 - {frequency}", diff --git a/frontend/src/i18n/messages/ko/reports.json b/frontend/src/i18n/messages/ko/reports.json index 79808b2957..3e5c14c429 100644 --- a/frontend/src/i18n/messages/ko/reports.json +++ b/frontend/src/i18n/messages/ko/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(지난 6개월)", "noRecurring": "지난 6개월 동안 {count}회 이상의 반복 지출을 찾을 수 없습니다.", "failedToLoad": "반복 지출 데이터를 불러오지 못했습니다.", + "categoryUncategorized": "미분류", + "frequency": { + "WEEKLY": "매주", + "BIWEEKLY": "2주마다", + "MONTHLY": "매월", + "OCCASIONAL": "가끔", + "IRREGULAR": "불규칙" + }, "top10ChartTitle": "상위 10개 반복 지출", "allRecurringTitle": "모든 반복 지출", "tooltipTransactions": "{count}건의 거래 - {frequency}", diff --git a/frontend/src/i18n/messages/nl/reports.json b/frontend/src/i18n/messages/nl/reports.json index 6a8b26123d..51480968d7 100644 --- a/frontend/src/i18n/messages/nl/reports.json +++ b/frontend/src/i18n/messages/nl/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(in de afgelopen 6 maanden)", "noRecurring": "Geen terugkerende uitgaven gevonden met {count}+ keer in de afgelopen 6 maanden.", "failedToLoad": "Gegevens terugkerende uitgaven konden niet worden geladen.", + "categoryUncategorized": "Niet gecategoriseerd", + "frequency": { + "WEEKLY": "Wekelijks", + "BIWEEKLY": "Elke 2 weken", + "MONTHLY": "Maandelijks", + "OCCASIONAL": "Incidenteel", + "IRREGULAR": "Onregelmatig" + }, "top10ChartTitle": "Top 10 terugkerende uitgaven", "allRecurringTitle": "Alle terugkerende uitgaven", "tooltipTransactions": "{count} transacties - {frequency}", diff --git a/frontend/src/i18n/messages/pl/reports.json b/frontend/src/i18n/messages/pl/reports.json index 82ad96e4f4..da0e8c8bb5 100644 --- a/frontend/src/i18n/messages/pl/reports.json +++ b/frontend/src/i18n/messages/pl/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(w ostatnich 6 miesiącach)", "noRecurring": "Nie znaleziono wydatków cyklicznych z co najmniej {count} wystąpieniami w ostatnich 6 miesiącach.", "failedToLoad": "Nie udało się załadować danych o wydatkach cyklicznych.", + "categoryUncategorized": "Bez kategorii", + "frequency": { + "WEEKLY": "Co tydzień", + "BIWEEKLY": "Co 2 tygodnie", + "MONTHLY": "Co miesiąc", + "OCCASIONAL": "Okazjonalnie", + "IRREGULAR": "Nieregularnie" + }, "top10ChartTitle": "10 największych wydatków cyklicznych", "allRecurringTitle": "Wszystkie wydatki cykliczne", "tooltipTransactions": "{count} transakcji - {frequency}", diff --git a/frontend/src/i18n/messages/pt-BR/reports.json b/frontend/src/i18n/messages/pt-BR/reports.json index 47b50aa8c2..7830030078 100644 --- a/frontend/src/i18n/messages/pt-BR/reports.json +++ b/frontend/src/i18n/messages/pt-BR/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(nos últimos 6 meses)", "noRecurring": "Nenhuma despesa recorrente encontrada com {count}+ ocorrências nos últimos 6 meses.", "failedToLoad": "Falha ao carregar os dados de despesas recorrentes.", + "categoryUncategorized": "Não Categorizado", + "frequency": { + "WEEKLY": "Semanal", + "BIWEEKLY": "A cada 2 Semanas", + "MONTHLY": "Mensal", + "OCCASIONAL": "Ocasional", + "IRREGULAR": "Irregular" + }, "top10ChartTitle": "Top 10 Despesas Recorrentes", "allRecurringTitle": "Todas as Despesas Recorrentes", "tooltipTransactions": "{count} transações - {frequency}", diff --git a/frontend/src/i18n/messages/pt/reports.json b/frontend/src/i18n/messages/pt/reports.json index 3dc152a255..d5d9f2ac12 100644 --- a/frontend/src/i18n/messages/pt/reports.json +++ b/frontend/src/i18n/messages/pt/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(nos últimos 6 meses)", "noRecurring": "Nenhuma despesa recorrente encontrada com {count}+ ocorrências nos últimos 6 meses.", "failedToLoad": "Falha ao carregar os dados de despesas recorrentes.", + "categoryUncategorized": "Não Categorizado", + "frequency": { + "WEEKLY": "Semanal", + "BIWEEKLY": "A cada 2 Semanas", + "MONTHLY": "Mensal", + "OCCASIONAL": "Ocasional", + "IRREGULAR": "Irregular" + }, "top10ChartTitle": "Top 10 Despesas Recorrentes", "allRecurringTitle": "Todas as Despesas Recorrentes", "tooltipTransactions": "{count} transações - {frequency}", diff --git a/frontend/src/i18n/messages/ru/reports.json b/frontend/src/i18n/messages/ru/reports.json index f93081cae8..69bd6d1ab8 100644 --- a/frontend/src/i18n/messages/ru/reports.json +++ b/frontend/src/i18n/messages/ru/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(за последние 6 месяцев)", "noRecurring": "Регулярные расходы с {count}+ вхождениями за последние 6 месяцев не найдены.", "failedToLoad": "Не удалось загрузить данные о регулярных расходах.", + "categoryUncategorized": "Без категории", + "frequency": { + "WEEKLY": "Еженедельно", + "BIWEEKLY": "Каждые 2 недели", + "MONTHLY": "Ежемесячно", + "OCCASIONAL": "Периодически", + "IRREGULAR": "Нерегулярно" + }, "top10ChartTitle": "Топ 10 регулярных расходов", "allRecurringTitle": "Все регулярные расходы", "tooltipTransactions": "{count} проводок — {frequency}", diff --git a/frontend/src/i18n/messages/tr/reports.json b/frontend/src/i18n/messages/tr/reports.json index f50e0e09bf..3739894af4 100644 --- a/frontend/src/i18n/messages/tr/reports.json +++ b/frontend/src/i18n/messages/tr/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(son 6 ayda)", "noRecurring": "Son 6 ayda {count}+ tekrarla tekrarlayan gider bulunamadı.", "failedToLoad": "Tekrarlayan gider verileri yüklenemedi.", + "categoryUncategorized": "Kategorisiz", + "frequency": { + "WEEKLY": "Haftalık", + "BIWEEKLY": "Her 2 Haftada Bir", + "MONTHLY": "Aylık", + "OCCASIONAL": "Ara sıra", + "IRREGULAR": "Düzensiz" + }, "top10ChartTitle": "En Çok Tekrarlayan 10 Gider", "allRecurringTitle": "Tüm Tekrarlayan Giderler", "tooltipTransactions": "{count} işlem - {frequency}", diff --git a/frontend/src/i18n/messages/uk/reports.json b/frontend/src/i18n/messages/uk/reports.json index 95ab80b035..421702272e 100644 --- a/frontend/src/i18n/messages/uk/reports.json +++ b/frontend/src/i18n/messages/uk/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(за останні 6 місяців)", "noRecurring": "Регулярних витрат з {count}+ повтореннями за останні 6 місяців не знайдено.", "failedToLoad": "Не вдалося завантажити дані про регулярні витрати.", + "categoryUncategorized": "Некатегоризовані", + "frequency": { + "WEEKLY": "Щотижня", + "BIWEEKLY": "Кожні 2 тижні", + "MONTHLY": "Щомісяця", + "OCCASIONAL": "Час від часу", + "IRREGULAR": "Нерегулярно" + }, "top10ChartTitle": "Топ 10 регулярних витрат", "allRecurringTitle": "Усі регулярні витрати", "tooltipTransactions": "{count} операцій — {frequency}", diff --git a/frontend/src/i18n/messages/vi/reports.json b/frontend/src/i18n/messages/vi/reports.json index 3ffb575ddb..302ffde6e3 100644 --- a/frontend/src/i18n/messages/vi/reports.json +++ b/frontend/src/i18n/messages/vi/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(trong 6 tháng qua)", "noRecurring": "Không tìm thấy chi phí định kỳ nào với {count}+ lần xuất hiện trong 6 tháng qua.", "failedToLoad": "Không thể tải dữ liệu chi phí định kỳ.", + "categoryUncategorized": "Chưa phân loại", + "frequency": { + "WEEKLY": "Hàng tuần", + "BIWEEKLY": "Mỗi 2 tuần", + "MONTHLY": "Hàng tháng", + "OCCASIONAL": "Thỉnh thoảng", + "IRREGULAR": "Không thường xuyên" + }, "top10ChartTitle": "Top 10 chi phí định kỳ", "allRecurringTitle": "Tất cả chi phí định kỳ", "tooltipTransactions": "{count} giao tác - {frequency}", diff --git a/frontend/src/i18n/messages/xx/reports.json b/frontend/src/i18n/messages/xx/reports.json index 871ed1088a..d379b47fb7 100644 --- a/frontend/src/i18n/messages/xx/reports.json +++ b/frontend/src/i18n/messages/xx/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "[XX-(in last 6 months)-XX]", "noRecurring": "[XX-No recurring expenses found with {count}+ occurrences in the last 6 months.-XX]", "failedToLoad": "[XX-Failed to load recurring expenses data.-XX]", + "categoryUncategorized": "[XX-Uncategorized-XX]", + "frequency": { + "WEEKLY": "[XX-Weekly-XX]", + "BIWEEKLY": "[XX-Every 2 Weeks-XX]", + "MONTHLY": "[XX-Monthly-XX]", + "OCCASIONAL": "[XX-Occasional-XX]", + "IRREGULAR": "[XX-Irregular-XX]" + }, "top10ChartTitle": "[XX-Top 10 Recurring Expenses-XX]", "allRecurringTitle": "[XX-All Recurring Expenses-XX]", "tooltipTransactions": "[XX-{count} transactions - {frequency}-XX]", diff --git a/frontend/src/i18n/messages/zh-CN/reports.json b/frontend/src/i18n/messages/zh-CN/reports.json index f0a7f66349..ea5442c3de 100644 --- a/frontend/src/i18n/messages/zh-CN/reports.json +++ b/frontend/src/i18n/messages/zh-CN/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(过去 6 个月内)", "noRecurring": "过去 6 个月内未找到 {count}+ 次发生的定期支出。", "failedToLoad": "加载定期支出数据失败。", + "categoryUncategorized": "未分类", + "frequency": { + "WEEKLY": "每周", + "BIWEEKLY": "每两周", + "MONTHLY": "每月", + "OCCASIONAL": "偶尔", + "IRREGULAR": "不定期" + }, "top10ChartTitle": "前 10 大定期支出", "allRecurringTitle": "所有定期支出", "tooltipTransactions": "{count} 笔交易 - {frequency}", diff --git a/frontend/src/i18n/messages/zh-TW/reports.json b/frontend/src/i18n/messages/zh-TW/reports.json index 43a81cc5c3..d6eac87c9b 100644 --- a/frontend/src/i18n/messages/zh-TW/reports.json +++ b/frontend/src/i18n/messages/zh-TW/reports.json @@ -1229,6 +1229,14 @@ "inLast6Months": "(過去 6 個月內)", "noRecurring": "過去 6 個月內未找到出現 {count} 次以上的固定支出。", "failedToLoad": "載入固定支出資料失敗。", + "categoryUncategorized": "未分類", + "frequency": { + "WEEKLY": "每週", + "BIWEEKLY": "每兩週", + "MONTHLY": "每月", + "OCCASIONAL": "偶爾", + "IRREGULAR": "不定期" + }, "top10ChartTitle": "前 10 大固定支出", "allRecurringTitle": "所有固定支出", "tooltipTransactions": "{count} 筆交易 - {frequency}", diff --git a/frontend/src/types/built-in-reports.contract.test.ts b/frontend/src/types/built-in-reports.contract.test.ts new file mode 100644 index 0000000000..365e3b0195 --- /dev/null +++ b/frontend/src/types/built-in-reports.contract.test.ts @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { RECURRING_EXPENSE_FREQUENCIES } from './built-in-reports'; + +const REPO_ROOT = join(__dirname, '..', '..', '..'); + +function backendDtoSource(): string { + return readFileSync( + join(REPO_ROOT, 'backend/src/built-in-reports/dto/recurring-expenses.dto.ts'), + 'utf8', + ); +} + +function backendFrequencyCodes(source: string): string[] { + const declaration = source.match( + /export const RECURRING_EXPENSE_FREQUENCIES\s*=\s*\[([\s\S]*?)\]\s*as const/, + ); + expect(declaration, 'backend recurring-expense frequency list not found').toBeTruthy(); + return [...declaration![1].matchAll(/"([A-Z]+)"/g)].map((match) => match[1]); +} + +describe('recurring-expenses API contract', () => { + it('keeps the frontend frequency union equal to the backend DTO enum', () => { + expect(backendFrequencyCodes(backendDtoSource())).toEqual([ + ...RECURRING_EXPENSE_FREQUENCIES, + ]); + }); + + it('keeps an absent category structural instead of substituting display copy', () => { + expect(backendDtoSource()).toMatch(/categoryName:\s*string\s*\|\s*null/); + }); +}); diff --git a/frontend/src/types/built-in-reports.ts b/frontend/src/types/built-in-reports.ts index 5061748113..c95ed7f464 100644 --- a/frontend/src/types/built-in-reports.ts +++ b/frontend/src/types/built-in-reports.ts @@ -198,6 +198,16 @@ export interface TaxSummaryResponse { } // Recurring expenses types +export const RECURRING_EXPENSE_FREQUENCIES = [ + 'WEEKLY', + 'BIWEEKLY', + 'MONTHLY', + 'OCCASIONAL', + 'IRREGULAR', +] as const; + +export type RecurringExpenseFrequency = (typeof RECURRING_EXPENSE_FREQUENCIES)[number]; + export interface RecurringExpenseItem { payeeName: string; payeeId: string | null; @@ -205,8 +215,8 @@ export interface RecurringExpenseItem { totalAmount: number; averageAmount: number; lastTransactionDate: string; - frequency: string; - categoryName: string; + frequency: RecurringExpenseFrequency; + categoryName: string | null; } export interface RecurringExpensesResponse { From 6fa548599e1f08b2e3491fabb2a302c3a8d10870 Mon Sep 17 00:00:00 2001 From: WMP Date: Wed, 9 Sep 2026 07:20:52 +0200 Subject: [PATCH 07/44] Honor date format in mobile reports --- ...aymentHistoryReport.mobileWrapped.test.tsx | 20 +++--- .../reports/BillPaymentHistoryReport.test.tsx | 70 +++++++++++++++---- .../reports/BillPaymentHistoryReport.tsx | 29 +++++--- ...rringExpensesReport.mobileWrapped.test.tsx | 14 ++-- .../reports/RecurringExpensesReport.test.tsx | 12 ++++ .../reports/RecurringExpensesReport.tsx | 25 +++---- ...dTransactionsReport.mobileWrapped.test.tsx | 16 ++--- .../UncategorizedTransactionsReport.test.tsx | 22 +++--- .../UncategorizedTransactionsReport.tsx | 16 ++--- 9 files changed, 146 insertions(+), 78 deletions(-) diff --git a/frontend/src/components/reports/BillPaymentHistoryReport.mobileWrapped.test.tsx b/frontend/src/components/reports/BillPaymentHistoryReport.mobileWrapped.test.tsx index 7f8e2f45a2..7f66a5d060 100644 --- a/frontend/src/components/reports/BillPaymentHistoryReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/BillPaymentHistoryReport.mobileWrapped.test.tsx @@ -42,6 +42,13 @@ vi.mock('@/hooks/useNumberFormat', async () => { }; }); +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (date: string) => `preferred-date:${date}`, + formatMonth: (month: string) => `preferred-month:${month}`, + }), +})); + const STABLE_RANGE = { start: '2024-01-01', end: '2025-01-01' }; vi.mock('@/hooks/useDateRange', () => ({ useDateRange: () => ({ @@ -52,13 +59,6 @@ vi.mock('@/hooks/useDateRange', () => ({ }), })); -// Spread the real module: `CellLabel` reads `cn` from here, and a bare factory -// blanks every other export of the module for the whole graph under test. -vi.mock('@/lib/utils', async (importActual) => ({ - ...(await importActual()), - parseLocalDate: (d: string) => new Date(d + 'T00:00:00'), -})); - vi.mock('@/components/ui/DateRangeSelector', () => ({ DateRangeSelector: () =>

, })); @@ -113,7 +113,7 @@ const RESPONSE = { lastPaymentDate: null, }, ], - monthlyTotals: [{ label: 'Jan 2025', total: 300 }], + monthlyTotals: [{ month: '2025-01', label: 'Jan 2025', total: 300 }], summary: { totalPaid: 900, monthlyAverage: 75, uniqueBills: 2, totalPayments: 15 }, }; @@ -206,9 +206,9 @@ describe('BillPaymentHistoryReport (phone wrapped rows)', () => { expect(rowText(row)).toContain('Payments3'); expect(rowText(row)).toContain('Average$50'); expect(rowText(row)).toContain('Total Paid$300'); - expect(rowText(row)).toContain('Last PaymentJun 15, 2024'); + expect(rowText(row)).toContain('Last Paymentpreferred-date:2024-06-15'); // The value really is its own node, not part of the caption's. - expect(screen.getByText('Jun 15, 2024')).toBeInTheDocument(); + expect(screen.getByText('preferred-date:2024-06-15')).toBeInTheDocument(); }); it('renders the no-payee fallback and the missing date inside the wrapped row', async () => { diff --git a/frontend/src/components/reports/BillPaymentHistoryReport.test.tsx b/frontend/src/components/reports/BillPaymentHistoryReport.test.tsx index d8ea01d2cd..c8dbe9781c 100644 --- a/frontend/src/components/reports/BillPaymentHistoryReport.test.tsx +++ b/frontend/src/components/reports/BillPaymentHistoryReport.test.tsx @@ -22,6 +22,11 @@ vi.mock('@/lib/csv-export', () => ({ exportToCsv: (...args: any[]) => mockExportToCsv(...args), })); +const mockExportToPdf = vi.fn().mockResolvedValue(undefined); +vi.mock('@/lib/pdf-export', () => ({ + exportToPdf: (...args: any[]) => mockExportToPdf(...args), +})); + vi.mock('@/hooks/useNumberFormat', async () => { const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); return { @@ -34,6 +39,14 @@ vi.mock('@/hooks/useNumberFormat', async () => { }), }; }); + +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (date: string) => `preferred-date:${date}`, + formatMonth: (month: string) => `preferred-month:${month}`, + }), +})); + const STABLE_RANGE = { start: '2024-01-01', end: '2025-01-01' }; vi.mock('@/hooks/useDateRange', () => ({ useDateRange: () => ({ @@ -44,21 +57,20 @@ vi.mock('@/hooks/useDateRange', () => ({ }), })); -// Spread the real module rather than replacing it: the by-bill table's phone -// captions render `CellLabel`, which reads `cn` from here, and a bare factory -// blanks every other export of the module for the whole graph under test. -vi.mock('@/lib/utils', async (importActual) => ({ - ...(await importActual()), - parseLocalDate: (d: string) => new Date(d + 'T00:00:00'), -})); - vi.mock('@/components/ui/DateRangeSelector', () => ({ DateRangeSelector: () =>
, })); vi.mock('recharts', () => ({ ResponsiveContainer: ({ children }: any) =>
{children}
, - BarChart: ({ children }: any) =>
{children}
, + BarChart: ({ children, data }: any) => ( +
entry.label).join(',')} + > + {children} +
+ ), Bar: () => null, XAxis: () => null, YAxis: () => null, @@ -119,7 +131,7 @@ describe('BillPaymentHistoryReport', () => { lastPaymentDate: '2025-01-01', }, ], - monthlyTotals: [{ label: 'Jan 2025', total: 1500 }], + monthlyTotals: [{ month: '2025-01', label: 'Jan 2025', total: 1500 }], summary: { totalPaid: 18000, monthlyAverage: 1500, uniqueBills: 1, totalPayments: 12 }, }); render(); @@ -128,6 +140,10 @@ describe('BillPaymentHistoryReport', () => { }); expect(screen.getByText('Monthly Average')).toBeInTheDocument(); expect(screen.getByText('Bills Paid')).toBeInTheDocument(); + expect(screen.getByTestId('bar-chart')).toHaveAttribute( + 'data-labels', + 'preferred-month:2025-01', + ); }); it('renders error state when the fetch fails', async () => { @@ -151,7 +167,7 @@ describe('BillPaymentHistoryReport', () => { lastPaymentDate: '2025-01-01', }, ], - monthlyTotals: [{ label: 'Jan 2025', total: 1500 }], + monthlyTotals: [{ month: '2025-01', label: 'Jan 2025', total: 1500 }], summary: { totalPaid: 18000, monthlyAverage: 1500, uniqueBills: 1, totalPayments: 12 }, }); render(); @@ -161,7 +177,7 @@ describe('BillPaymentHistoryReport', () => { expect(screen.getByText('Payment History by Bill')).toBeInTheDocument(); }); expect(screen.getByText('Rent')).toBeInTheDocument(); - expect(screen.getByText('Jan 1, 2025')).toBeInTheDocument(); + expect(screen.getByText('preferred-date:2025-01-01')).toBeInTheDocument(); }); it('shows No payee when payeeName is null', async () => { @@ -235,6 +251,36 @@ describe('BillPaymentHistoryReport', () => { expect.any(Array), expect.any(Array), ); + expect(mockExportToCsv.mock.calls[0][2][0][5]).toBe( + 'preferred-date:2025-01-01', + ); + }); + + it('exports preferred dates to PDF', async () => { + mockGetBillPaymentHistory.mockResolvedValue({ + billPayments: [ + { + scheduledTransactionId: 'st-1', + scheduledTransactionName: 'Rent', + payeeName: 'Landlord', + paymentCount: 12, + averagePayment: 1500, + totalPaid: 18000, + lastPaymentDate: '2025-01-01', + }, + ], + monthlyTotals: [], + summary: { totalPaid: 18000, monthlyAverage: 1500, uniqueBills: 1, totalPayments: 12 }, + }); + render(); + await waitFor(() => expect(screen.getByTestId('export-pdf')).toBeInTheDocument()); + await act(async () => { + fireEvent.click(screen.getByTestId('export-pdf')); + }); + await waitFor(() => expect(mockExportToPdf).toHaveBeenCalledTimes(1)); + expect(mockExportToPdf.mock.calls[0][0].tableData.rows[0][5]).toBe( + 'preferred-date:2025-01-01', + ); }); it('export does nothing when billData is null', async () => { diff --git a/frontend/src/components/reports/BillPaymentHistoryReport.tsx b/frontend/src/components/reports/BillPaymentHistoryReport.tsx index 8a11f00b42..d732b2cc28 100644 --- a/frontend/src/components/reports/BillPaymentHistoryReport.tsx +++ b/frontend/src/components/reports/BillPaymentHistoryReport.tsx @@ -12,11 +12,10 @@ import { Tooltip, ResponsiveContainer, } from 'recharts'; -import { format } from 'date-fns'; import { builtInReportsApi } from '@/lib/built-in-reports'; import { BillPaymentHistoryResponse } from '@/types/built-in-reports'; -import { parseLocalDate } from '@/lib/utils'; import { useNumberFormat } from '@/hooks/useNumberFormat'; +import { useDateFormat } from '@/hooks/useDateFormat'; import { useDateRange } from '@/hooks/useDateRange'; import { DateRangeSelector } from '@/components/ui/DateRangeSelector'; import { exportToCsv } from '@/lib/csv-export'; @@ -118,11 +117,11 @@ const PHONE_HEADER_CLASS = // being zero. The payment COUNT is bounded and trivial at 23px for `128`. const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; -// Last Payment is a WORD-shaped value, not a number: `format(..., 'MMM d, -// yyyy')` renders `Sep 5, 2026` (72px at `text-xs`), or `-` where the bill has -// never been paid. It resolves to the same rendering as a figure cell today -- -// including the nowrap, because a date is one label and breaking it after `Sep` -// reads as two values -- and it is spelled out rather than aliased to +// Last Payment is a WORD-shaped value, not a number. `useDateFormat` keeps the +// user's full-date preference; its longest preset is the same width class as +// the old `Sep 5, 2026` label, or `-` where the bill has never been paid. It +// resolves to the same rendering as a figure cell today -- including the +// nowrap, because a date is one label -- and it is spelled out rather than aliased to // `MONEY_CELL` deliberately: the two hold the same string for different // reasons, and an alias would carry a money-driven edit (dropping the nowrap // because a formatter stopped grouping, widening the type for a longer figure) @@ -139,6 +138,7 @@ export function BillPaymentHistoryReport() { const t = useTranslations('reports'); const router = useRouter(); const { formatCurrencyCompact: formatCurrency, formatCurrencyAxis } = useNumberFormat(); + const { formatDate, formatMonth } = useDateFormat(); const chartRef = useRef(null); const { dateRange, setDateRange, resolvedRange } = useDateRange({ defaultRange: '1y', alignment: 'day' }); const [viewType, setViewType] = useState<'overview' | 'byBill'>('overview'); @@ -158,6 +158,15 @@ export function BillPaymentHistoryReport() { [rangeStart, rangeEnd], ); + const chartData = useMemo( + () => + (billData?.monthlyTotals ?? []).map((entry) => ({ + ...entry, + label: formatMonth(entry.month), + })), + [billData, formatMonth], + ); + const sortedBillPayments = useMemo(() => { if (!billData) return []; const sorted = [...billData.billPayments]; @@ -219,7 +228,7 @@ export function BillPaymentHistoryReport() { bp.paymentCount, bp.averagePayment, bp.totalPaid, - bp.lastPaymentDate ? format(parseLocalDate(bp.lastPaymentDate), 'yyyy-MM-dd') : '', + bp.lastPaymentDate ? formatDate(bp.lastPaymentDate) : '', ]); return { headers, rows }; }; @@ -367,7 +376,7 @@ export function BillPaymentHistoryReport() {
- + @@ -550,7 +559,7 @@ export function BillPaymentHistoryReport() { > {columns.lastPayment.label} {bp.lastPaymentDate - ? format(parseLocalDate(bp.lastPaymentDate), 'MMM d, yyyy') + ? formatDate(bp.lastPaymentDate) : '-'} diff --git a/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx b/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx index 0feb57ce63..1eb703ed41 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx @@ -1,6 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent, act } from '@/test/render'; -import { format } from 'date-fns'; import { RecurringExpensesReport } from './RecurringExpensesReport'; /** @@ -33,6 +32,13 @@ vi.mock('@/hooks/useNumberFormat', async () => { }; }); +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (date: string) => `preferred-date:${date}`, + formatDateWithoutYear: (date: string) => `preferred-short-date:${date}`, + }), +})); + vi.mock('recharts', () => ({ ResponsiveContainer: ({ children }: any) =>
{children}
, PieChart: ({ children }: any) =>
{children}
, @@ -126,8 +132,6 @@ const EXPECTED_LABELS = [ * deliberately unchanged here -- this expectation follows the component so the * test asserts the LAYOUT (the caption beside the value) rather than the parse. */ -const lastPaid = (iso: string) => format(new Date(iso), 'MMM d'); - const stripGlyph = (text: string | null | undefined) => (text ?? '').replace(/[↑↓↕]/g, '').trim(); const rowText = (row: Element | null | undefined) => row?.textContent ?? ''; @@ -210,7 +214,7 @@ describe('RecurringExpensesReport (phone wrapped rows)', () => { expect(rowText(row)).toContain('Count6'); expect(rowText(row)).toContain('Avg Amount$50'); expect(rowText(row)).toContain('6-Mo Total$300'); - expect(rowText(row)).toContain(`Last Paid${lastPaid('2024-06-15')}`); + expect(rowText(row)).toContain('Last Paidpreferred-short-date:2024-06-15'); // The value really is its own node, not part of the caption's. expect(screen.getByText('$300')).toBeInTheDocument(); @@ -464,7 +468,7 @@ describe('RecurringExpensesReport (phone wrapped rows)', () => { 6, 50, 300, - format(new Date('2024-06-15'), 'yyyy-MM-dd'), + 'preferred-date:2024-06-15', ]); expect(rows[1][0]).toBe('Zebra Market'); }); diff --git a/frontend/src/components/reports/RecurringExpensesReport.test.tsx b/frontend/src/components/reports/RecurringExpensesReport.test.tsx index 3fabf17212..5d947159de 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.test.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.test.tsx @@ -18,6 +18,14 @@ vi.mock('@/hooks/useNumberFormat', async () => { }), }; }); + +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (date: string) => `preferred-date:${date}`, + formatDateWithoutYear: (date: string) => `preferred-short-date:${date}`, + }), +})); + vi.mock("recharts", () => ({ ResponsiveContainer: ({ children }: any) => (
{children}
@@ -337,6 +345,9 @@ describe("RecurringExpensesReport", () => { "Uncategorized", "Monthly", ]); + expect(mockExportToCsv.mock.calls[0][2][0][6]).toBe( + "preferred-date:2025-01-15", + ); }); it("changes min occurrences when selector changes", async () => { @@ -379,6 +390,7 @@ describe("RecurringExpensesReport", () => { expect(arg.summaryCards).toHaveLength(3); expect(arg.chartLegend[0].label).toContain("Netflix"); expect(arg.tableData.rows[0][0]).toBe("Netflix"); + expect(arg.tableData.rows[0][6]).toBe("preferred-date:2025-01-15"); }); it("renders the custom tooltip and navigates on pie slice click", async () => { diff --git a/frontend/src/components/reports/RecurringExpensesReport.tsx b/frontend/src/components/reports/RecurringExpensesReport.tsx index 8bf597d55e..2029a13a80 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.tsx @@ -11,13 +11,13 @@ import { ResponsiveContainer, Tooltip, } from 'recharts'; -import { format } from 'date-fns'; import { builtInReportsApi } from '@/lib/built-in-reports'; import { RecurringExpenseItem, RecurringExpenseFrequency, } from '@/types/built-in-reports'; import { useNumberFormat } from '@/hooks/useNumberFormat'; +import { useDateFormat } from '@/hooks/useDateFormat'; import { chartSeriesColor } from '@/lib/chart-colors'; import { resolvePdfColor } from '@/components/reports/resolve-pdf-color'; import { exportToCsv } from '@/lib/csv-export'; @@ -152,11 +152,11 @@ const PHONE_HEADER_CLASS = // for seven columns, is what this box can hold. const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; -// Last Paid is a WORD-shaped value, not a number: `format(..., 'MMM d')` -// renders `Sep 5` (34px at `text-xs`). It resolves to the same rendering as a -// figure cell today -- including the nowrap, because a date is one label and -// breaking it after `Sep` reads as two values -- and it is spelled out rather -// than aliased to `MONEY_CELL` deliberately: the two hold the same string for +// Last Paid is a WORD-shaped value, not a number. `formatDateWithoutYear` +// preserves the user's day/month order while keeping the narrow no-year shape +// of the old `Sep 5` label. It resolves to the same rendering as a figure cell +// today -- including the nowrap, because a date is one label -- and it is +// spelled out rather than aliased to `MONEY_CELL` deliberately: the two hold the same string for // different reasons, and an alias would carry a money-driven edit (dropping the // nowrap because a formatter stopped grouping, widening the type for a longer // figure) silently onto the date. @@ -177,6 +177,7 @@ export function RecurringExpensesReport() { const t = useTranslations('reports'); const router = useRouter(); const { formatCurrencyCompact: formatCurrency } = useNumberFormat(); + const { formatDate, formatDateWithoutYear } = useDateFormat(); const chartRef = useRef(null); const [minOccurrences, setMinOccurrences] = useState(3); const { sortField, sortDirection, handleSort } = useSortableTable( @@ -287,15 +288,7 @@ export function RecurringExpensesReport() { label: t('recurringExpenses.colLastPaid'), align: 'right', csvLabel: t('recurringExpenses.csvColLastPaid'), - // The export's own date format, unchanged. Both this and the cell's - // `MMM d` parse the server's `YYYY-MM-DD` through `new Date(...)`, which - // reads it as UTC midnight and then formats it LOCALLY: a negative - // offset pushes it back into the previous day, so every reader WEST of - // Greenwich sees the date before the one the server sent. `parseLocalDate` - // (`@/lib/utils`) is what the sibling report tables use for exactly this. - // The defect is pre-existing on both paths and is reported rather than - // fixed inside a layout change, so that neither hides the other. - csvValue: (e) => format(new Date(e.lastTransactionDate), 'yyyy-MM-dd'), + csvValue: (e) => formatDate(e.lastTransactionDate), }, }; @@ -671,7 +664,7 @@ export function RecurringExpensesReport() { className={`col-start-2 row-start-3 text-gray-500 dark:text-gray-400 ${DATE_CELL}`} > {columns.lastPaid.label} - {format(new Date(expense.lastTransactionDate), 'MMM d')} + {formatDateWithoutYear(expense.lastTransactionDate)} ))} diff --git a/frontend/src/components/reports/UncategorizedTransactionsReport.mobileWrapped.test.tsx b/frontend/src/components/reports/UncategorizedTransactionsReport.mobileWrapped.test.tsx index bc9e75ac13..d796af1ec9 100644 --- a/frontend/src/components/reports/UncategorizedTransactionsReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/UncategorizedTransactionsReport.mobileWrapped.test.tsx @@ -32,6 +32,12 @@ vi.mock('@/hooks/useNumberFormat', async () => { }; }); +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (date: string) => `preferred-date:${date}`, + }), +})); + const stableResolvedRange = { start: "2025-01-01", end: "2025-03-31" }; vi.mock("@/hooks/useDateRange", () => ({ @@ -43,14 +49,6 @@ vi.mock("@/hooks/useDateRange", () => ({ }), })); -// Spread the real module rather than replacing it: the phone captions render -// `CellLabel`, which reads `cn` from here, and a bare factory blanks every other -// export of the module for the whole graph under test. -vi.mock("@/lib/utils", async (importActual) => ({ - ...(await importActual()), - parseLocalDate: (d: string) => new Date(d + "T00:00:00"), -})); - vi.mock("@/components/ui/DateRangeSelector", () => ({ DateRangeSelector: () =>
, })); @@ -214,7 +212,7 @@ describe("UncategorizedTransactionsReport (phone wrapped rows)", () => { const row = txRow(container, "Corner Store")!; // Each caption sits immediately beside the value it names, as its own text // node, so a `getByText` on the value still matches the value node. - expect(row.textContent).toContain("DateMar 20, 2025"); + expect(row.textContent).toContain("Datepreferred-date:2025-03-20"); expect(row.textContent).toContain("AccountAlpha Savings"); expect(row.textContent).toContain("Amount$200.00"); // Scoped to the row: the income summary card above prints the same diff --git a/frontend/src/components/reports/UncategorizedTransactionsReport.test.tsx b/frontend/src/components/reports/UncategorizedTransactionsReport.test.tsx index 578f5ad2f2..d17dfe361b 100644 --- a/frontend/src/components/reports/UncategorizedTransactionsReport.test.tsx +++ b/frontend/src/components/reports/UncategorizedTransactionsReport.test.tsx @@ -41,6 +41,13 @@ vi.mock('@/hooks/useNumberFormat', async () => { }), }; }); + +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (date: string) => `preferred-date:${date}`, + }), +})); + const stableResolvedRange = { start: "2025-01-01", end: "2025-03-31" }; vi.mock("@/hooks/useDateRange", () => ({ @@ -52,14 +59,6 @@ vi.mock("@/hooks/useDateRange", () => ({ }), })); -// Spread the real module rather than replacing it: the table's phone captions -// render `CellLabel`, which reads `cn` from here, and a bare factory blanks -// every other export of the module for the whole graph under test. -vi.mock("@/lib/utils", async (importActual) => ({ - ...(await importActual()), - parseLocalDate: (d: string) => new Date(d + "T00:00:00"), -})); - vi.mock("@/components/ui/DateRangeSelector", () => ({ DateRangeSelector: () =>
, })); @@ -159,6 +158,7 @@ describe("UncategorizedTransactionsReport", () => { expect(screen.getByText("Unknown Store")).toBeInTheDocument(); }); expect(screen.getByText("Total Uncategorized")).toBeInTheDocument(); + expect(screen.getByText("preferred-date:2025-02-15")).toBeInTheDocument(); }); it("renders summary cards", async () => { @@ -568,6 +568,9 @@ describe("UncategorizedTransactionsReport", () => { expect.arrayContaining(["Date", "Payee", "Description", "Account", "Amount"]), expect.any(Array), ); + expect(mockExportToCsv.mock.calls[0][2][0][0]).toBe( + "preferred-date:2025-02-15", + ); }); it("exports PDF with the current transaction data", async () => { @@ -611,5 +614,8 @@ describe("UncategorizedTransactionsReport", () => { }), }), ); + expect(mockExportToPdf.mock.calls[0][0].tableData.rows[0][0]).toBe( + "preferred-date:2025-02-15", + ); }); }); diff --git a/frontend/src/components/reports/UncategorizedTransactionsReport.tsx b/frontend/src/components/reports/UncategorizedTransactionsReport.tsx index bea7b50ce6..6a96453fb5 100644 --- a/frontend/src/components/reports/UncategorizedTransactionsReport.tsx +++ b/frontend/src/components/reports/UncategorizedTransactionsReport.tsx @@ -5,11 +5,10 @@ import { useTranslations } from 'next-intl'; import { gainLossColor } from '@/lib/format'; import { Skeleton } from '@/components/ui/LoadingSkeleton'; import { useRouter } from 'next/navigation'; -import { format } from 'date-fns'; import { builtInReportsApi } from '@/lib/built-in-reports'; import { UncategorizedTransactionItem } from '@/types/built-in-reports'; -import { parseLocalDate } from '@/lib/utils'; import { useNumberFormat } from '@/hooks/useNumberFormat'; +import { useDateFormat } from '@/hooks/useDateFormat'; import { useDateRange } from '@/hooks/useDateRange'; import { DateRangeSelector } from '@/components/ui/DateRangeSelector'; import { exportToCsv } from '@/lib/csv-export'; @@ -120,10 +119,10 @@ const PHONE_HEADER_CLASS = // card. const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; -// The date is a fixed-shape label, not a number: `format(..., 'MMM d, yyyy')` -// renders `Dec 25, 2025` (72px at `text-xs`). It keeps the `whitespace-nowrap` -// it wears today, because a date is one label and breaking it after `Dec` reads -// as two values, and it is spelled out rather than aliased to `MONEY_CELL` +// The date is a fixed-shape label, not a number. `useDateFormat` keeps the +// user's full-date preference; its longest preset is the same width class as +// the old `Dec 25, 2025` label. It keeps the `whitespace-nowrap` it wears today, +// because a date is one label, and it is spelled out rather than aliased to `MONEY_CELL` // deliberately: the two hold nearly the same string for different reasons, and // an alias would carry a money-driven edit (a wider type for a longer figure) // silently onto the date. The one difference is the alignment -- this table's @@ -139,6 +138,7 @@ export function UncategorizedTransactionsReport() { const t = useTranslations('reports'); const router = useRouter(); const { formatCurrency } = useNumberFormat(); + const { formatDate } = useDateFormat(); const { dateRange, setDateRange, resolvedRange, isValid } = useDateRange({ defaultRange: '3m', alignment: 'day' }); const { sortField, sortDirection, handleSort } = useSortableTable( 'reports.uncategorized-transactions.sort', @@ -229,7 +229,7 @@ export function UncategorizedTransactionsReport() { t('uncategorizedTransactions.csvColAmount'), ]; const rows = filteredAndSortedTransactions.map((tx) => [ - format(parseLocalDate(tx.transactionDate), 'yyyy-MM-dd'), + formatDate(tx.transactionDate), tx.payeeName || t('uncategorizedTransactions.unknownPayee'), tx.description || '', tx.accountName || t('uncategorizedTransactions.unknownAccount'), @@ -529,7 +529,7 @@ export function UncategorizedTransactionsReport() { className={`col-start-2 row-start-2 text-gray-900 dark:text-gray-100 ${DATE_CELL}`} > {columns.date.label} - {format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy')} + {formatDate(tx.transactionDate)} Date: Wed, 9 Sep 2026 07:53:16 +0200 Subject: [PATCH 08/44] Carry report currency with uncategorized totals --- .../built-in-reports.service.spec.ts | 11 ++-- .../data-quality-reports.service.spec.ts | 26 ++++---- .../data-quality-reports.service.ts | 2 + .../dto/uncategorized-transactions.dto.ts | 6 ++ ...dTransactionsReport.mobileWrapped.test.tsx | 7 +- .../UncategorizedTransactionsReport.test.tsx | 66 ++++++++++++++++++- .../UncategorizedTransactionsReport.tsx | 11 ++-- .../types/built-in-reports.contract.test.ts | 34 ++++++++++ frontend/src/types/built-in-reports.ts | 2 + 9 files changed, 138 insertions(+), 27 deletions(-) diff --git a/backend/src/built-in-reports/built-in-reports.service.spec.ts b/backend/src/built-in-reports/built-in-reports.service.spec.ts index 79bbda63af..4ed0137bda 100644 --- a/backend/src/built-in-reports/built-in-reports.service.spec.ts +++ b/backend/src/built-in-reports/built-in-reports.service.spec.ts @@ -1675,7 +1675,7 @@ describe("BuiltInReportsService", () => { expect(result.summary.totalCount).toBe(0); }); - it("returns uncategorized transactions with converted amounts", async () => { + it("returns uncategorized transactions in the report currency", async () => { scopedManager.query .mockResolvedValueOnce([ { @@ -1707,13 +1707,13 @@ describe("BuiltInReportsService", () => { ); expect(result.transactions).toHaveLength(1); - // EUR->USD rate 1.1, so -50 EUR = -55 USD expect(result.transactions[0].amount).toBeCloseTo(-55, 5); + expect(result.transactions[0].currencyCode).toBe("USD"); expect(result.transactions[0].payeeName).toBe("Unknown Shop"); expect(result.transactions[0].accountId).toBe("acc-1"); }); - it("calculates summary totals across multiple currencies", async () => { + it("converts summary totals across multiple currencies", async () => { scopedManager.query.mockResolvedValueOnce([]).mockResolvedValueOnce([ { currency_code: "USD", @@ -1741,11 +1741,10 @@ describe("BuiltInReportsService", () => { expect(result.summary.totalCount).toBe(7); expect(result.summary.expenseCount).toBe(4); - // USD: 300 + EUR: 100 * 1.1 = 410 - expect(result.summary.expenseTotal).toBe(410); expect(result.summary.incomeCount).toBe(3); - // USD: 500 + EUR: 200 * 1.1 = 720 + expect(result.summary.expenseTotal).toBe(410); expect(result.summary.incomeTotal).toBe(720); + expect(result.summary.currencyCode).toBe("USD"); }); it("passes limit parameter to the query", async () => { diff --git a/backend/src/built-in-reports/data-quality-reports.service.spec.ts b/backend/src/built-in-reports/data-quality-reports.service.spec.ts index fa49b9ca19..8bcb4f60d5 100644 --- a/backend/src/built-in-reports/data-quality-reports.service.spec.ts +++ b/backend/src/built-in-reports/data-quality-reports.service.spec.ts @@ -78,6 +78,7 @@ describe("DataQualityReportsService", () => { expenseTotal: 0, incomeCount: 0, incomeTotal: 0, + currencyCode: "USD", }); }); @@ -124,6 +125,7 @@ describe("DataQualityReportsService", () => { expect(result.transactions).toHaveLength(2); expect(result.transactions[0].id).toBe("tx-1"); expect(result.transactions[0].amount).toBe(-50); + expect(result.transactions[0].currencyCode).toBe("USD"); expect(result.transactions[0].payeeName).toBe("Coffee Shop"); expect(result.transactions[0].description).toBe("Morning coffee"); expect(result.transactions[0].accountName).toBe("Checking"); @@ -134,14 +136,11 @@ describe("DataQualityReportsService", () => { expect(result.transactions[1].accountId).toBe("acc-2"); }); - it("calculates summary from multiple currency rows", async () => { + it("converts summary currency groups into one explicitly denominated total", async () => { currencyService.convertAmount.mockImplementation( - (amount: number, fromCurrency: string) => { - if (fromCurrency === "EUR") return amount * 1.1; - return amount; - }, + (amount: number, fromCurrency: string) => + fromCurrency === "EUR" ? amount * 1.1 : amount, ); - scopedManager.query.mockResolvedValueOnce([]); scopedManager.query.mockResolvedValueOnce([ { @@ -170,21 +169,19 @@ describe("DataQualityReportsService", () => { expect(result.summary.totalCount).toBe(8); expect(result.summary.expenseCount).toBe(5); - // 300 USD + 200 EUR * 1.1 = 300 + 220 = 520 - expect(result.summary.expenseTotal).toBe(520); expect(result.summary.incomeCount).toBe(3); - // 1000 USD + 500 EUR * 1.1 = 1000 + 550 = 1550 + expect(result.summary.expenseTotal).toBe(520); expect(result.summary.incomeTotal).toBe(1550); + expect(result.summary.currencyCode).toBe("USD"); }); - it("converts transaction amounts from foreign currencies", async () => { + it("returns a transaction amount in the report currency", async () => { currencyService.convertAmount.mockImplementation( (amount: number, fromCurrency: string) => { if (fromCurrency === "EUR") return amount * 1.1; return amount; }, ); - scopedManager.query.mockResolvedValueOnce([ { id: "tx-1", @@ -205,8 +202,8 @@ describe("DataQualityReportsService", () => { "2025-12-31", ); - // -100 EUR * 1.1 = -110 (use toBeCloseTo for floating point) expect(result.transactions[0].amount).toBeCloseTo(-110, 2); + expect(result.transactions[0].currencyCode).toBe("USD"); }); it("includes startDate filter when provided", async () => { @@ -317,11 +314,11 @@ describe("DataQualityReportsService", () => { expect(result.transactions[0].transactionDate).toBe("2025-03-15"); }); - it("calls currency service with correct user id", async () => { + it("uses the user's default as the report currency", async () => { scopedManager.query.mockResolvedValueOnce([]); scopedManager.query.mockResolvedValueOnce([]); - await service.getUncategorizedTransactions( + const result = await service.getUncategorizedTransactions( mockUserId, "2025-01-01", "2025-12-31", @@ -331,6 +328,7 @@ describe("DataQualityReportsService", () => { mockUserId, ); expect(currencyService.buildRateMap).toHaveBeenCalledWith("USD"); + expect(result.summary.currencyCode).toBe("USD"); }); it("handles both startDate and limit parameters together", async () => { diff --git a/backend/src/built-in-reports/data-quality-reports.service.ts b/backend/src/built-in-reports/data-quality-reports.service.ts index c521fa6e19..ce54535d46 100644 --- a/backend/src/built-in-reports/data-quality-reports.service.ts +++ b/backend/src/built-in-reports/data-quality-reports.service.ts @@ -120,6 +120,7 @@ export class DataQualityReportsService { defaultCurrency, rateMap, ), + currencyCode: defaultCurrency, payeeName: row.payee_name, description: row.description, accountName: row.account_name, @@ -216,6 +217,7 @@ export class DataQualityReportsService { expenseTotal: roundMoney(expenseTotal), incomeCount, incomeTotal: roundMoney(incomeTotal), + currencyCode: defaultCurrency, }, }; } diff --git a/backend/src/built-in-reports/dto/uncategorized-transactions.dto.ts b/backend/src/built-in-reports/dto/uncategorized-transactions.dto.ts index 68e4afe106..dc0fa95dc4 100644 --- a/backend/src/built-in-reports/dto/uncategorized-transactions.dto.ts +++ b/backend/src/built-in-reports/dto/uncategorized-transactions.dto.ts @@ -10,6 +10,9 @@ export class UncategorizedTransactionItem { @ApiProperty() amount: number; + @ApiProperty() + currencyCode: string; + @ApiProperty({ nullable: true }) payeeName: string | null; @@ -38,6 +41,9 @@ export class UncategorizedTransactionsSummary { @ApiProperty() incomeTotal: number; + + @ApiProperty() + currencyCode: string; } export class UncategorizedTransactionsResponse { diff --git a/frontend/src/components/reports/UncategorizedTransactionsReport.mobileWrapped.test.tsx b/frontend/src/components/reports/UncategorizedTransactionsReport.mobileWrapped.test.tsx index d796af1ec9..69ca7e13b7 100644 --- a/frontend/src/components/reports/UncategorizedTransactionsReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/UncategorizedTransactionsReport.mobileWrapped.test.tsx @@ -25,7 +25,8 @@ vi.mock('@/hooks/useNumberFormat', async () => { return { useNumberFormat: () => ({ ...numberFormatMockDefaults(), - formatCurrency: (n: number) => `$${n.toFixed(2)}`, + formatCurrency: (n: number, currencyCode = 'CAD') => + currencyCode === 'CAD' ? `$${n.toFixed(2)}` : `${currencyCode} ${n.toFixed(2)}`, formatCurrencyCompact: (n: number) => `$${n.toFixed(0)}`, defaultCurrency: "CAD", }), @@ -89,6 +90,7 @@ const RESPONSE = { accountName: "Zeta Chequing", accountId: "acc-z", amount: -123456.78, + currencyCode: "CAD", }, { id: "tx-late", @@ -98,6 +100,7 @@ const RESPONSE = { accountName: "Alpha Savings", accountId: "acc-a", amount: 200, + currencyCode: "CAD", }, { id: "tx-unknown", @@ -107,6 +110,7 @@ const RESPONSE = { accountName: null, accountId: "acc-u", amount: -75, + currencyCode: "CAD", }, ], summary: { @@ -115,6 +119,7 @@ const RESPONSE = { expenseTotal: 123531.78, incomeCount: 1, incomeTotal: 200, + currencyCode: "CAD", }, }; diff --git a/frontend/src/components/reports/UncategorizedTransactionsReport.test.tsx b/frontend/src/components/reports/UncategorizedTransactionsReport.test.tsx index d17dfe361b..b2f7cb1f73 100644 --- a/frontend/src/components/reports/UncategorizedTransactionsReport.test.tsx +++ b/frontend/src/components/reports/UncategorizedTransactionsReport.test.tsx @@ -35,7 +35,8 @@ vi.mock('@/hooks/useNumberFormat', async () => { return { useNumberFormat: () => ({ ...numberFormatMockDefaults(), - formatCurrency: (n: number) => `$${n.toFixed(2)}`, + formatCurrency: (n: number, currencyCode = 'CAD') => + currencyCode === 'CAD' ? `$${n.toFixed(2)}` : `${currencyCode} ${n.toFixed(2)}`, formatCurrencyCompact: (n: number) => `$${n.toFixed(0)}`, defaultCurrency: "CAD", }), @@ -180,6 +181,63 @@ describe("UncategorizedTransactionsReport", () => { expect(screen.getByText("Uncategorized Income")).toBeInTheDocument(); }); + it("formats rows, summary totals, CSV, and PDF in the response currency", async () => { + mockGetUncategorizedTransactions.mockResolvedValue({ + transactions: [ + { + id: "tx-first", + transactionDate: "2025-02-15", + payeeName: "First Store", + description: "", + accountName: "Primary Account", + accountId: "acc-primary", + amount: -50, + currencyCode: "EUR", + }, + { + id: "tx-eur", + transactionDate: "2025-02-16", + payeeName: "Euro Store", + description: "", + accountName: "EUR Account", + accountId: "acc-eur", + amount: 200, + currencyCode: "EUR", + }, + ], + summary: { + totalCount: 2, + expenseCount: 1, + expenseTotal: 50, + incomeCount: 1, + incomeTotal: 200, + currencyCode: "EUR", + }, + }); + + render(); + + await waitFor(() => expect(screen.getByText("Euro Store")).toBeInTheDocument()); + expect(screen.getAllByText("EUR -50.00")).toHaveLength(1); + expect(screen.getAllByText("EUR 200.00")).toHaveLength(2); + expect(screen.getByText("EUR 50.00")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("export-csv")); + expect(mockExportToCsv.mock.calls[0][2].map((row: unknown[]) => row[4])).toEqual([ + "EUR 200.00", + "EUR -50.00", + ]); + + await act(async () => { + fireEvent.click(screen.getByTestId("export-pdf")); + }); + await waitFor(() => expect(mockExportToPdf).toHaveBeenCalledTimes(1)); + expect(mockExportToPdf.mock.calls[0][0].tableData.rows.map((row: unknown[]) => row[4])).toEqual([ + "EUR 200.00", + "EUR -50.00", + ]); + }); + it("filters transactions by expense type", async () => { mockGetUncategorizedTransactions.mockResolvedValue({ transactions: [ @@ -548,6 +606,7 @@ describe("UncategorizedTransactionsReport", () => { accountName: "Chequing", accountId: "acc-1", amount: -50, + currencyCode: "EUR", }, ], summary: { @@ -571,6 +630,7 @@ describe("UncategorizedTransactionsReport", () => { expect(mockExportToCsv.mock.calls[0][2][0][0]).toBe( "preferred-date:2025-02-15", ); + expect(mockExportToCsv.mock.calls[0][2][0][4]).toBe("EUR -50.00"); }); it("exports PDF with the current transaction data", async () => { @@ -584,6 +644,7 @@ describe("UncategorizedTransactionsReport", () => { accountName: null, accountId: "acc-1", amount: -50, + currencyCode: "EUR", }, ], summary: { @@ -617,5 +678,8 @@ describe("UncategorizedTransactionsReport", () => { expect(mockExportToPdf.mock.calls[0][0].tableData.rows[0][0]).toBe( "preferred-date:2025-02-15", ); + expect(mockExportToPdf.mock.calls[0][0].tableData.rows[0][4]).toBe( + "EUR -50.00", + ); }); }); diff --git a/frontend/src/components/reports/UncategorizedTransactionsReport.tsx b/frontend/src/components/reports/UncategorizedTransactionsReport.tsx index 6a96453fb5..4c1da336ea 100644 --- a/frontend/src/components/reports/UncategorizedTransactionsReport.tsx +++ b/frontend/src/components/reports/UncategorizedTransactionsReport.tsx @@ -137,7 +137,7 @@ const CAPTION_CLASS = 'sm:hidden'; export function UncategorizedTransactionsReport() { const t = useTranslations('reports'); const router = useRouter(); - const { formatCurrency } = useNumberFormat(); + const { formatCurrency, defaultCurrency } = useNumberFormat(); const { formatDate } = useDateFormat(); const { dateRange, setDateRange, resolvedRange, isValid } = useDateRange({ defaultRange: '3m', alignment: 'day' }); const { sortField, sortDirection, handleSort } = useSortableTable( @@ -233,7 +233,7 @@ export function UncategorizedTransactionsReport() { tx.payeeName || t('uncategorizedTransactions.unknownPayee'), tx.description || '', tx.accountName || t('uncategorizedTransactions.unknownAccount'), - tx.amount, + formatCurrency(tx.amount, tx.currencyCode), ]); return { headers, rows }; }; @@ -275,6 +275,7 @@ export function UncategorizedTransactionsReport() { expenseTotal: 0, incomeCount: 0, incomeTotal: 0, + currencyCode: defaultCurrency, }; return ( @@ -293,7 +294,7 @@ export function UncategorizedTransactionsReport() { {summary.expenseCount}
- {formatCurrency(summary.expenseTotal)} + {formatCurrency(summary.expenseTotal, summary.currencyCode)}
@@ -302,7 +303,7 @@ export function UncategorizedTransactionsReport() { {summary.incomeCount}
- {formatCurrency(summary.incomeTotal)} + {formatCurrency(summary.incomeTotal, summary.currencyCode)}
@@ -556,7 +557,7 @@ export function UncategorizedTransactionsReport() { className={`col-start-2 row-start-1 font-medium ${gainLossColor(tx.amount)} ${MONEY_CELL}`} > {columns.amount.label} - {formatCurrency(tx.amount)} + {formatCurrency(tx.amount, tx.currencyCode)} ))} diff --git a/frontend/src/types/built-in-reports.contract.test.ts b/frontend/src/types/built-in-reports.contract.test.ts index 365e3b0195..6d9abb0a4b 100644 --- a/frontend/src/types/built-in-reports.contract.test.ts +++ b/frontend/src/types/built-in-reports.contract.test.ts @@ -12,6 +12,17 @@ function backendDtoSource(): string { ); } +function uncategorizedBackendDtoSource(): string { + return readFileSync( + join(REPO_ROOT, 'backend/src/built-in-reports/dto/uncategorized-transactions.dto.ts'), + 'utf8', + ); +} + +function frontendTypesSource(): string { + return readFileSync(join(__dirname, 'built-in-reports.ts'), 'utf8'); +} + function backendFrequencyCodes(source: string): string[] { const declaration = source.match( /export const RECURRING_EXPENSE_FREQUENCIES\s*=\s*\[([\s\S]*?)\]\s*as const/, @@ -31,3 +42,26 @@ describe('recurring-expenses API contract', () => { expect(backendDtoSource()).toMatch(/categoryName:\s*string\s*\|\s*null/); }); }); + +describe('uncategorized-transactions money contract', () => { + it('carries a currency code beside every transaction amount in both layers', () => { + expect(uncategorizedBackendDtoSource()).toMatch( + /class UncategorizedTransactionItem\s*\{[^}]*amount:\s*number;[^}]*currencyCode:\s*string;/, + ); + expect(frontendTypesSource()).toMatch( + /interface UncategorizedTransactionItem\s*\{[^}]*amount:\s*number;[^}]*currencyCode:\s*string;/, + ); + }); + + it('couples every converted summary amount to its destination currency', () => { + const backend = uncategorizedBackendDtoSource(); + const frontend = frontendTypesSource(); + + expect(backend).toMatch( + /class UncategorizedTransactionsSummary\s*\{[^}]*expenseTotal:\s*number;[^}]*incomeTotal:\s*number;[^}]*currencyCode:\s*string;/, + ); + expect(frontend).toMatch( + /interface UncategorizedTransactionsResponse\s*\{[^}]*summary:\s*\{[^}]*expenseTotal:\s*number;[^}]*incomeTotal:\s*number;[^}]*currencyCode:\s*string;/, + ); + }); +}); diff --git a/frontend/src/types/built-in-reports.ts b/frontend/src/types/built-in-reports.ts index c95ed7f464..ccc93c5d73 100644 --- a/frontend/src/types/built-in-reports.ts +++ b/frontend/src/types/built-in-reports.ts @@ -261,6 +261,7 @@ export interface UncategorizedTransactionItem { id: string; transactionDate: string; amount: number; + currencyCode: string; payeeName: string | null; description: string | null; accountName: string | null; @@ -275,6 +276,7 @@ export interface UncategorizedTransactionsResponse { expenseTotal: number; incomeCount: number; incomeTotal: number; + currencyCode: string; }; } From 381f515591e168f1ff1a60e2e6904c8a0d52a21e Mon Sep 17 00:00:00 2001 From: WMP Date: Wed, 9 Sep 2026 08:20:26 +0200 Subject: [PATCH 09/44] Localize budget trend month labels --- .../budgets/budget-reports.service.spec.ts | 10 +++- backend/src/budgets/budget-reports.service.ts | 6 +- .../budget-trend-reports.service.spec.ts | 11 +++- .../budgets/budget-trend-reports.service.ts | 39 ++++-------- frontend/src/app/budgets/[id]/page.tsx | 3 +- ...BudgetCategoryTrend.mobileWrapped.test.tsx | 8 ++- .../budgets/BudgetCategoryTrend.test.tsx | 60 ++++++++++++++++--- .../budgets/BudgetCategoryTrend.tsx | 24 +++++--- .../components/budgets/BudgetDashboard.tsx | 10 +--- .../budgets/BudgetTrendChart.test.tsx | 31 ++++++---- .../components/budgets/BudgetTrendChart.tsx | 25 ++++---- .../reports/BudgetTrendReport.test.tsx | 15 +++-- .../components/reports/BudgetTrendReport.tsx | 8 ++- ...udgetVsActualReport.mobileWrapped.test.tsx | 50 +++++++--------- .../reports/BudgetVsActualReport.test.tsx | 34 ++++++++--- .../reports/BudgetVsActualReport.tsx | 24 ++++---- ...ryPerformanceReport.mobileWrapped.test.tsx | 2 +- .../CategoryPerformanceReport.test.tsx | 2 +- .../src/types/budget-trend.contract.test.ts | 42 +++++++++++++ frontend/src/types/budget.ts | 4 +- 20 files changed, 262 insertions(+), 146 deletions(-) create mode 100644 frontend/src/types/budget-trend.contract.test.ts diff --git a/backend/src/budgets/budget-reports.service.spec.ts b/backend/src/budgets/budget-reports.service.spec.ts index f89e180c13..8c13578285 100644 --- a/backend/src/budgets/budget-reports.service.spec.ts +++ b/backend/src/budgets/budget-reports.service.spec.ts @@ -305,11 +305,11 @@ describe("BudgetReportsService", () => { const result = await service.getTrend("user-1", "budget-1", 6); expect(result).toHaveLength(2); - expect(result[0].month).toBe("Dec 2025"); + expect(result[0].monthKey).toBe("2025-12"); expect(result[0].budgeted).toBe(800); expect(result[0].actual).toBe(750); expect(result[0].variance).toBe(-50); - expect(result[1].month).toBe("Jan 2026"); + expect(result[1].monthKey).toBe("2026-01"); expect(result[1].actual).toBe(820); expect(result[1].variance).toBe(20); }); @@ -353,7 +353,7 @@ describe("BudgetReportsService", () => { const result = await service.getTrend("user-1", "budget-1", 6); expect(result).toHaveLength(2); - expect(result[1].month).toBe("Feb 2026"); + expect(result[1].monthKey).toBe("2026-02"); expect(result[1].actual).toBe(450); expect(result[1].budgeted).toBe(800); }); @@ -366,6 +366,9 @@ describe("BudgetReportsService", () => { expect(result).toHaveLength(3); expect(budgetsService.findOne).toHaveBeenCalledWith("user-1", "budget-1"); + expect( + result.every((point) => /^\d{4}-\d{2}$/.test(point.monthKey)), + ).toBe(true); }); it("should return empty array when no categories and no periods", async () => { @@ -438,6 +441,7 @@ describe("BudgetReportsService", () => { expect(result[0].categoryId).toBe("cat-1"); expect(result[0].categoryName).toBe("Groceries"); expect(result[0].data).toHaveLength(1); + expect(result[0].data[0].monthKey).toBe("2026-01"); expect(result[0].data[0].budgeted).toBe(500); expect(result[0].data[0].actual).toBe(420); expect(result[0].data[0].variance).toBe(-80); diff --git a/backend/src/budgets/budget-reports.service.ts b/backend/src/budgets/budget-reports.service.ts index 366ce1e9f8..46298a4736 100644 --- a/backend/src/budgets/budget-reports.service.ts +++ b/backend/src/budgets/budget-reports.service.ts @@ -10,7 +10,7 @@ import { } from "./budget-date.utils"; export interface BudgetTrendPoint { - month: string; + monthKey: string; budgeted: number; actual: number; variance: number; @@ -18,7 +18,7 @@ export interface BudgetTrendPoint { } export interface CategoryTrendPoint { - month: string; + monthKey: string; categoryId: string; categoryName: string; budgeted: number; @@ -31,7 +31,7 @@ export interface CategoryTrendSeries { categoryId: string; categoryName: string; data: Array<{ - month: string; + monthKey: string; budgeted: number; actual: number; variance: number; diff --git a/backend/src/budgets/budget-trend-reports.service.spec.ts b/backend/src/budgets/budget-trend-reports.service.spec.ts index e32a43e1e6..3e42c75d9c 100644 --- a/backend/src/budgets/budget-trend-reports.service.spec.ts +++ b/backend/src/budgets/budget-trend-reports.service.spec.ts @@ -312,11 +312,11 @@ describe("BudgetTrendReportsService", () => { const result = await service.getTrend("user-1", "budget-1", 6); expect(result).toHaveLength(2); - expect(result[0].month).toBe("Dec 2025"); + expect(result[0].monthKey).toBe("2025-12"); expect(result[0].budgeted).toBe(800); expect(result[0].actual).toBe(750); expect(result[0].variance).toBe(-50); - expect(result[1].month).toBe("Jan 2026"); + expect(result[1].monthKey).toBe("2026-01"); expect(result[1].actual).toBe(820); expect(result[1].variance).toBe(20); }); @@ -360,7 +360,7 @@ describe("BudgetTrendReportsService", () => { const result = await service.getTrend("user-1", "budget-1", 6); expect(result).toHaveLength(2); - expect(result[1].month).toBe("Feb 2026"); + expect(result[1].monthKey).toBe("2026-02"); expect(result[1].actual).toBe(450); expect(result[1].budgeted).toBe(800); }); @@ -404,6 +404,9 @@ describe("BudgetTrendReportsService", () => { expect(result).toHaveLength(3); expect(budgetsService.findOne).toHaveBeenCalledWith("user-1", "budget-1"); + expect( + result.every((point) => /^\d{4}-\d{2}$/.test(point.monthKey)), + ).toBe(true); }); it("should return empty when no categories and no transfers in live mode", async () => { @@ -743,6 +746,7 @@ describe("BudgetTrendReportsService", () => { expect(result).toHaveLength(1); expect(result[0].categoryId).toBe("cat-1"); expect(result[0].categoryName).toBe("Groceries"); + expect(result[0].data[0].monthKey).toBe("2026-01"); expect(result[0].data[0].budgeted).toBe(500); expect(result[0].data[0].actual).toBe(420); }); @@ -975,6 +979,7 @@ describe("BudgetTrendReportsService", () => { // Current month for cat-1: 350 + 50 = 400 const cat1CurrentMonth = cat1!.data[cat1!.data.length - 1]; + expect(cat1CurrentMonth.monthKey).toBe(monthKey); expect(cat1CurrentMonth.actual).toBe(400); expect(cat1CurrentMonth.budgeted).toBe(500); expect(cat1CurrentMonth.variance).toBe(-100); diff --git a/backend/src/budgets/budget-trend-reports.service.ts b/backend/src/budgets/budget-trend-reports.service.ts index fa60094f77..38399da714 100644 --- a/backend/src/budgets/budget-trend-reports.service.ts +++ b/backend/src/budgets/budget-trend-reports.service.ts @@ -3,7 +3,7 @@ import { DataSource } from "typeorm"; import { withScopedDb } from "../common/db/scoped-db"; import { Budget } from "./entities/budget.entity"; import { BudgetPeriod, PeriodStatus } from "./entities/budget-period.entity"; -import { getMonthEndYMD } from "../common/date-utils"; +import { formatMonthKey, getMonthEndYMD } from "../common/date-utils"; import { Transaction } from "../transactions/entities/transaction.entity"; import { TransactionSplit } from "../transactions/entities/transaction-split.entity"; import { BudgetsService } from "./budgets.service"; @@ -19,21 +19,6 @@ import { SPLIT_TRANSFER_AMOUNT, } from "./budget-spending.util"; -const MONTH_NAMES = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - @Injectable() export class BudgetTrendReportsService { private readonly logger = new Logger(BudgetTrendReportsService.name); @@ -64,7 +49,7 @@ export class BudgetTrendReportsService { budgeted > 0 ? roundToDecimals((actual / budgeted) * 100, 2) : 0; return { - month: this.formatPeriodMonth(period.periodStart), + monthKey: this.formatPeriodMonthKey(period.periodStart), budgeted: roundMoney(budgeted), actual: roundMoney(actual), variance: roundMoney(variance), @@ -88,7 +73,7 @@ export class BudgetTrendReportsService { : 0; result.push({ - month: this.formatPeriodMonth(currentPeriod.periodStart), + monthKey: this.formatPeriodMonthKey(currentPeriod.periodStart), budgeted: roundMoney(budgeted), actual: roundMoney(currentActuals), variance: roundMoney(variance), @@ -129,7 +114,7 @@ export class BudgetTrendReportsService { const seriesMap = new Map(); for (const period of periods) { - const periodMonth = this.formatPeriodMonth(period.periodStart); + const periodMonthKey = this.formatPeriodMonthKey(period.periodStart); const cats = period.periodCategories || []; for (const pc of cats) { @@ -181,7 +166,7 @@ export class BudgetTrendReportsService { budgeted > 0 ? roundToDecimals((actual / budgeted) * 100, 2) : 0; seriesMap.get(catId)!.data.push({ - month: periodMonth, + monthKey: periodMonthKey, budgeted: roundMoney(budgeted), actual: roundMoney(actual), variance: roundMoney(variance), @@ -470,8 +455,7 @@ export class BudgetTrendReportsService { const d = new Date(today.getFullYear(), today.getMonth() - i, 1); const year = d.getFullYear(); const month = d.getMonth(); - const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`; - const monthLabel = `${MONTH_NAMES[month].substring(0, 3)} ${year}`; + const monthKey = formatMonthKey(year, month + 1); const budgeted = Number(bc.amount) || 0; const actual = actualMap.get(catId)?.get(monthKey) || 0; @@ -480,7 +464,7 @@ export class BudgetTrendReportsService { budgeted > 0 ? roundToDecimals((actual / budgeted) * 100, 2) : 0; data.push({ - month: monthLabel, + monthKey, budgeted: roundMoney(budgeted), actual: roundMoney(actual), variance: roundMoney(variance), @@ -660,8 +644,7 @@ export class BudgetTrendReportsService { const d = new Date(today.getFullYear(), today.getMonth() - i, 1); const year = d.getFullYear(); const month = d.getMonth(); - const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`; - const monthLabel = `${MONTH_NAMES[month].substring(0, 3)} ${year}`; + const monthKey = formatMonthKey(year, month + 1); const actual = actualByMonth.get(monthKey) || 0; const variance = actual - totalBudgeted; @@ -671,7 +654,7 @@ export class BudgetTrendReportsService { : 0; result.push({ - month: monthLabel, + monthKey, budgeted: roundMoney(totalBudgeted), actual: roundMoney(actual), variance: roundMoney(variance), @@ -682,10 +665,10 @@ export class BudgetTrendReportsService { return result; } - private formatPeriodMonth(periodStart: string): string { + private formatPeriodMonthKey(periodStart: string): string { const parts = periodStart.split("-"); const year = parseInt(parts[0], 10); const month = parseInt(parts[1], 10); - return `${MONTH_NAMES[month - 1].substring(0, 3)} ${year}`; + return formatMonthKey(year, month); } } diff --git a/frontend/src/app/budgets/[id]/page.tsx b/frontend/src/app/budgets/[id]/page.tsx index 8b186806b6..423c818866 100644 --- a/frontend/src/app/budgets/[id]/page.tsx +++ b/frontend/src/app/budgets/[id]/page.tsx @@ -24,6 +24,7 @@ import type { BudgetSummary, BudgetVelocity, BudgetPeriod, + BudgetTrendPoint, } from '@/types/budget'; import type { ScheduledTransaction } from '@/types/scheduled-transaction'; @@ -75,7 +76,7 @@ function BudgetDetailContent() { Array<{ date: string; amount: number }> >([]); const [trendData, setTrendData] = useState< - Array<{ month: string; budgeted: number; actual: number }> + BudgetTrendPoint[] >([]); const [selectedPeriodId, setSelectedPeriodId] = useState(null); const [selectedPeriod, setSelectedPeriod] = useState(null); diff --git a/frontend/src/components/budgets/BudgetCategoryTrend.mobileWrapped.test.tsx b/frontend/src/components/budgets/BudgetCategoryTrend.mobileWrapped.test.tsx index a7f02a3086..fcb7fe03a8 100644 --- a/frontend/src/components/budgets/BudgetCategoryTrend.mobileWrapped.test.tsx +++ b/frontend/src/components/budgets/BudgetCategoryTrend.mobileWrapped.test.tsx @@ -3,6 +3,10 @@ import { render, screen, fireEvent, act } from '@/test/render'; import { BudgetCategoryTrend } from './BudgetCategoryTrend'; import type { CategoryTrendSeries } from '@/types/budget'; +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ formatMonth: (monthKey: string) => monthKey }), +})); + /** * The phone layout of the Category Trends card's per-category averages table. * @@ -57,12 +61,12 @@ const DATA: CategoryTrendSeries[] = [ { categoryId: 'cat-1', categoryName: LONG_NAME, - data: [{ month: 'Jan 2026', budgeted: 1234567, actual: 1439000, variance: 204433, percentUsed: 117 }], + data: [{ monthKey: '2026-01', budgeted: 1234567, actual: 1439000, variance: 204433, percentUsed: 117 }], }, { categoryId: 'cat-2', categoryName: UNBREAKABLE_NAME, - data: [{ month: 'Jan 2026', budgeted: 123456, actual: 98765, variance: -24691, percentUsed: 80 }], + data: [{ monthKey: '2026-01', budgeted: 123456, actual: 98765, variance: -24691, percentUsed: 80 }], }, { // A series with no points: both averages are a known zero, not unknown. diff --git a/frontend/src/components/budgets/BudgetCategoryTrend.test.tsx b/frontend/src/components/budgets/BudgetCategoryTrend.test.tsx index fbab220941..f5bccd8771 100644 --- a/frontend/src/components/budgets/BudgetCategoryTrend.test.tsx +++ b/frontend/src/components/budgets/BudgetCategoryTrend.test.tsx @@ -3,16 +3,37 @@ import { render, screen, fireEvent } from '@/test/render'; import { BudgetCategoryTrend } from './BudgetCategoryTrend'; import type { CategoryTrendSeries } from '@/types/budget'; +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ formatMonth: (monthKey: string) => `localized:${monthKey}` }), +})); + // Mock recharts vi.mock('recharts', () => ({ - LineChart: ({ children }: { children: React.ReactNode }) => ( -
{children}
+ LineChart: ({ children, data }: { children: React.ReactNode; data: Array<{ monthKey: string }> }) => ( +
point.monthKey).join(',')}> + {children} +
), Line: ({ name }: { name: string }) =>
, - XAxis: () =>
, + XAxis: ({ dataKey, tickFormatter }: any) => ( +
{dataKey}:{tickFormatter('2026-01')}
+ ), YAxis: () =>
, CartesianGrid: () =>
, - Tooltip: () =>
, + Tooltip: ({ content }: any) => { + if (!content) return
; + const Content = content.type; + return ( +
+ +
+ ); + }, Legend: () =>
, ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -26,16 +47,16 @@ const mockData: CategoryTrendSeries[] = [ categoryId: 'cat-1', categoryName: 'Groceries', data: [ - { month: 'Jan 2026', budgeted: 500, actual: 420, variance: -80, percentUsed: 84 }, - { month: 'Feb 2026', budgeted: 500, actual: 530, variance: 30, percentUsed: 106 }, + { monthKey: '2026-01', budgeted: 500, actual: 420, variance: -80, percentUsed: 84 }, + { monthKey: '2026-02', budgeted: 500, actual: 530, variance: 30, percentUsed: 106 }, ], }, { categoryId: 'cat-2', categoryName: 'Dining', data: [ - { month: 'Jan 2026', budgeted: 300, actual: 250, variance: -50, percentUsed: 83.33 }, - { month: 'Feb 2026', budgeted: 300, actual: 310, variance: 10, percentUsed: 103.33 }, + { monthKey: '2026-01', budgeted: 300, actual: 250, variance: -50, percentUsed: 83.33 }, + { monthKey: '2026-02', budgeted: 300, actual: 310, variance: 10, percentUsed: 103.33 }, ], }, ]; @@ -56,6 +77,27 @@ describe('BudgetCategoryTrend', () => { render(); expect(screen.getByTestId('category-trend-chart')).toBeInTheDocument(); expect(screen.getByTestId('line-chart')).toBeInTheDocument(); + expect(screen.getByTestId('x-axis')).toHaveTextContent('monthKey:localized:2026-01'); + expect(screen.getByTestId('tooltip')).toHaveTextContent('localized:2026-02'); + }); + + it('groups and orders chart points by the structural month key', () => { + const reverseLocalizedOrder: CategoryTrendSeries[] = [ + { + categoryId: 'cat-1', + categoryName: 'Groceries', + data: [mockData[0].data[1], mockData[0].data[0]], + }, + ]; + + render( + , + ); + + expect(screen.getByTestId('line-chart')).toHaveAttribute( + 'data-month-keys', + '2026-01,2026-02', + ); }); it('draws no chart legend: the toggle pills are the legend', () => { @@ -123,7 +165,7 @@ describe('BudgetCategoryTrend', () => { categoryId: 'cat-1', categoryName: 'Dining', data: [ - { month: 'Jan 2026', budgeted: 200, actual: 350, variance: 150, percentUsed: 175 }, + { monthKey: '2026-01', budgeted: 200, actual: 350, variance: 150, percentUsed: 175 }, ], }, ]; diff --git a/frontend/src/components/budgets/BudgetCategoryTrend.tsx b/frontend/src/components/budgets/BudgetCategoryTrend.tsx index edcf9a2a99..a6ae601e1c 100644 --- a/frontend/src/components/budgets/BudgetCategoryTrend.tsx +++ b/frontend/src/components/budgets/BudgetCategoryTrend.tsx @@ -14,6 +14,7 @@ import { import { chartSeriesColor } from '@/lib/chart-colors'; import { CellLabel } from '@/components/ui/Table'; import type { CategoryTrendSeries } from '@/types/budget'; +import { useDateFormat } from '@/hooks/useDateFormat'; interface BudgetCategoryTrendProps { data: CategoryTrendSeries[]; @@ -60,6 +61,7 @@ function CategoryTrendTooltip({ payload, label, formatCurrency, + formatMonth, }: { active?: boolean; payload?: Array<{ @@ -70,13 +72,14 @@ function CategoryTrendTooltip({ }>; label?: string; formatCurrency: (amount: number) => string; + formatMonth: (monthKey: string) => string; }) { if (!active || !payload || payload.length === 0) return null; return (

- {label} + {formatMonth(String(label))}

{payload.map((entry) => (
(); for (const series of data) { for (const point of series.data) { - monthSet.add(point.month); + monthSet.add(point.monthKey); } } - const months = Array.from(monthSet); + const months = Array.from(monthSet).sort(); // Build chart data: one entry per month with each category as a field - return months.map((month) => { - const entry: Record = { month }; + return months.map((monthKey) => { + const entry: Record = { monthKey }; for (const series of data) { if (!selectedCategories.has(series.categoryId)) continue; - const point = series.data.find((p) => p.month === month); + const point = series.data.find((p) => p.monthKey === monthKey); entry[series.categoryId] = point?.actual ?? 0; } return entry; @@ -196,9 +200,10 @@ export function BudgetCategoryTrend({ + } /> {/* No ``: the toggle pills above the chart already name diff --git a/frontend/src/components/budgets/BudgetDashboard.tsx b/frontend/src/components/budgets/BudgetDashboard.tsx index 3aac954c56..94ad22e258 100644 --- a/frontend/src/components/budgets/BudgetDashboard.tsx +++ b/frontend/src/components/budgets/BudgetDashboard.tsx @@ -12,7 +12,7 @@ import { BudgetZeroBasedBar } from './BudgetZeroBasedBar'; import { Budget503020Summary } from './Budget503020Summary'; import { BudgetScenarioPlanner } from './BudgetScenarioPlanner'; import { STRATEGY_LABELS, STRATEGY_DESCRIPTIONS } from './utils/budget-labels'; -import type { BudgetSummary, BudgetVelocity } from '@/types/budget'; +import type { BudgetSummary, BudgetTrendPoint, BudgetVelocity } from '@/types/budget'; import type { ScheduledTransaction } from '@/types/scheduled-transaction'; interface DailySpending { @@ -20,18 +20,12 @@ interface DailySpending { amount: number; } -interface TrendDataPoint { - month: string; - budgeted: number; - actual: number; -} - interface BudgetDashboardProps { summary: BudgetSummary; velocity: BudgetVelocity; scheduledTransactions: ScheduledTransaction[]; dailySpending: DailySpending[]; - trendData: TrendDataPoint[]; + trendData: BudgetTrendPoint[]; healthScore: number; /** * The budget's own currency: what `formatCurrency` labels a bare amount with, diff --git a/frontend/src/components/budgets/BudgetTrendChart.test.tsx b/frontend/src/components/budgets/BudgetTrendChart.test.tsx index ba1c4e5a0b..e1b893145f 100644 --- a/frontend/src/components/budgets/BudgetTrendChart.test.tsx +++ b/frontend/src/components/budgets/BudgetTrendChart.test.tsx @@ -2,13 +2,19 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@/test/render'; import { BudgetTrendChart } from './BudgetTrendChart'; +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ formatMonth: (monthKey: string) => `localized:${monthKey}` }), +})); + // Mock recharts to avoid rendering actual SVGs in tests vi.mock('recharts', () => ({ LineChart: ({ children }: { children: React.ReactNode }) => (
{children}
), Line: ({ name }: { name: string }) =>
, - XAxis: () =>
, + XAxis: ({ dataKey, tickFormatter }: any) => ( +
{dataKey}:{tickFormatter('2025-09')}
+ ), YAxis: () =>
, CartesianGrid: () =>
, Tooltip: ({ content }: any) => { @@ -24,8 +30,9 @@ vi.mock('recharts', () => ({ @@ -43,12 +50,12 @@ vi.mock('recharts', () => ({ const mockFormat = (amount: number) => `$${amount.toFixed(2)}`; const mockData = [ - { month: 'Sep', budgeted: 5000, actual: 4800 }, - { month: 'Oct', budgeted: 5000, actual: 5200 }, - { month: 'Nov', budgeted: 5200, actual: 5100 }, - { month: 'Dec', budgeted: 5200, actual: 6000 }, - { month: 'Jan', budgeted: 5200, actual: 4900 }, - { month: 'Feb', budgeted: 5200, actual: 3100 }, + { monthKey: '2025-09', budgeted: 5000, actual: 4800, variance: -200, percentUsed: 96 }, + { monthKey: '2025-10', budgeted: 5000, actual: 5200, variance: 200, percentUsed: 104 }, + { monthKey: '2025-11', budgeted: 5200, actual: 5100, variance: -100, percentUsed: 98.08 }, + { monthKey: '2025-12', budgeted: 5200, actual: 6000, variance: 800, percentUsed: 115.38 }, + { monthKey: '2026-01', budgeted: 5200, actual: 4900, variance: -300, percentUsed: 94.23 }, + { monthKey: '2026-02', budgeted: 5200, actual: 3100, variance: -2100, percentUsed: 59.62 }, ]; describe('BudgetTrendChart', () => { @@ -64,6 +71,7 @@ describe('BudgetTrendChart', () => { expect(screen.getByTestId('line-chart')).toBeInTheDocument(); expect(screen.getByTestId('line-Budgeted')).toBeInTheDocument(); expect(screen.getByTestId('line-Actual')).toBeInTheDocument(); + expect(screen.getByTestId('x-axis')).toHaveTextContent('monthKey:localized:2025-09'); }); it('shows empty state when no data', () => { @@ -85,8 +93,8 @@ describe('BudgetTrendChart', () => { // Tooltip mock renders content component with active=true and payload expect(screen.getByTestId('tooltip')).toBeInTheDocument(); - // The tooltip should show the label "Sep" and the formatted values - expect(screen.getByText('Sep')).toBeInTheDocument(); + // The tooltip should localize the structural key and format the values. + expect(screen.getByText('localized:2025-09')).toBeInTheDocument(); expect(screen.getByText(/Budgeted.*\$5000\.00/)).toBeInTheDocument(); expect(screen.getByText(/Actual.*\$4800\.00/)).toBeInTheDocument(); }); @@ -94,7 +102,7 @@ describe('BudgetTrendChart', () => { it('renders with single data point', () => { render( , ); @@ -111,4 +119,3 @@ describe('BudgetTrendChart', () => { expect(screen.getByText('Budget vs Actual Trend')).toBeInTheDocument(); }); }); - diff --git a/frontend/src/components/budgets/BudgetTrendChart.tsx b/frontend/src/components/budgets/BudgetTrendChart.tsx index 709c45fcc9..93dee5c14d 100644 --- a/frontend/src/components/budgets/BudgetTrendChart.tsx +++ b/frontend/src/components/budgets/BudgetTrendChart.tsx @@ -12,15 +12,11 @@ import { ResponsiveContainer, } from 'recharts'; import { chartColors } from '@/lib/chart-colors'; - -interface TrendDataPoint { - month: string; - budgeted: number; - actual: number; -} +import { useDateFormat } from '@/hooks/useDateFormat'; +import type { BudgetTrendPoint } from '@/types/budget'; interface BudgetTrendChartProps { - data: TrendDataPoint[]; + data: BudgetTrendPoint[]; formatCurrency: (amount: number) => string; } @@ -31,6 +27,7 @@ function CustomTooltip({ formatCurrency, budgetedLabel, actualLabel, + formatMonth, }: { active?: boolean; payload?: Array<{ value: number; dataKey: string; color: string }>; @@ -38,13 +35,14 @@ function CustomTooltip({ formatCurrency: (amount: number) => string; budgetedLabel: string; actualLabel: string; + formatMonth: (monthKey: string) => string; }) { if (!active || !payload || payload.length === 0) return null; return (

- {label} + {formatMonth(String(label))}

{payload.map((entry) => (

+ } /> diff --git a/frontend/src/components/reports/BudgetTrendReport.test.tsx b/frontend/src/components/reports/BudgetTrendReport.test.tsx index d9b8d84d2c..4127e8a163 100644 --- a/frontend/src/components/reports/BudgetTrendReport.test.tsx +++ b/frontend/src/components/reports/BudgetTrendReport.test.tsx @@ -26,6 +26,9 @@ vi.mock('@/hooks/useNumberFormat', async () => { }), }; }); +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ formatMonth: (monthKey: string) => `localized:${monthKey}` }), +})); vi.mock('@/lib/logger', () => ({ createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), })); @@ -38,7 +41,9 @@ vi.mock('recharts', () => ({ ResponsiveContainer: ({ children }: any) =>

{children}
, LineChart: ({ children }: any) =>
{children}
, Line: () => null, - XAxis: () => null, + XAxis: ({ dataKey, tickFormatter }: any) => ( +
{tickFormatter?.('2025-02')}
+ ), YAxis: () => null, CartesianGrid: () => null, Legend: () => null, @@ -46,7 +51,7 @@ vi.mock('recharts', () => ({ const C = content; if (!C) return null; const samples = [ - { active: true, payload: [{ dataKey: 'budgeted', name: 'Budgeted', color: 'var(--chart-primary)', value: 1000 }, { dataKey: 'actual', name: 'Actual', color: 'var(--chart-income)', value: 900 }], label: 'tip-x' }, + { active: true, payload: [{ dataKey: 'budgeted', name: 'Budgeted', color: 'var(--chart-primary)', value: 1000 }, { dataKey: 'actual', name: 'Actual', color: 'var(--chart-income)', value: 900 }], label: '2025-02' }, { active: false, payload: [], label: '' }, { active: true, payload: [], label: 'empty' }, ]; @@ -58,11 +63,11 @@ const makeBudget = (overrides: Partial = {}): Budget => ({ id: 'b-1', name: 'Default', isActive: true, ...overrides } as Budget); const makePoint = ( - month: string, + monthKey: string, budgeted: number, actual: number, ): BudgetTrendPoint => ({ - month, + monthKey, budgeted, actual, variance: actual - budgeted, @@ -131,6 +136,7 @@ describe('BudgetTrendReport', () => { await waitFor(() => { expect(screen.getByText('Improving')).toBeInTheDocument(); }); + expect(screen.getByTestId('x-axis-monthKey')).toHaveTextContent('localized:2025-02'); expect(screen.getByText('Avg Budgeted')).toBeInTheDocument(); }); @@ -181,5 +187,6 @@ describe('BudgetTrendReport', () => { const arg = mockExportToPdf.mock.calls[0][0]; expect(arg.title).toBe('Budget Trend'); expect(arg.tableData.headers).toContain('Month'); + expect(arg.tableData.rows[0][0]).toBe('localized:2025-01'); }); }); diff --git a/frontend/src/components/reports/BudgetTrendReport.tsx b/frontend/src/components/reports/BudgetTrendReport.tsx index 80d65987a0..9732dbb2ec 100644 --- a/frontend/src/components/reports/BudgetTrendReport.tsx +++ b/frontend/src/components/reports/BudgetTrendReport.tsx @@ -14,6 +14,7 @@ import { } from 'recharts'; import { budgetsApi } from '@/lib/budgets'; import { useNumberFormat } from '@/hooks/useNumberFormat'; +import { useDateFormat } from '@/hooks/useDateFormat'; import { useTranslations } from 'next-intl'; import { useReportData } from '@/hooks/useReportData'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; @@ -25,6 +26,7 @@ import { resolvePdfColor } from '@/components/reports/resolve-pdf-color'; export function BudgetTrendReport() { const t = useTranslations('reports'); const { formatCurrencyCompact: formatCurrency, formatPercentTrimmed } = useNumberFormat(); + const { formatMonth } = useDateFormat(); const chartRef = useRef(null); const [selectedBudgetIdState, setSelectedBudgetId] = useState(''); const [months, setMonths] = useState(12); @@ -71,7 +73,7 @@ export function BudgetTrendReport() { const { exportToPdf } = await import('@/lib/pdf-export'); const headers = [t('budgetTrend.colMonth'), t('budgetTrend.colBudgeted'), t('budgetTrend.colActual'), t('budgetTrend.colPercentUsed')]; const rows = trendData.map((point) => [ - point.month, + formatMonth(point.monthKey), formatCurrency(point.budgeted), formatCurrency(point.actual), `${formatPercentTrimmed(point.percentUsed)}`, @@ -154,14 +156,14 @@ export function BudgetTrendReport() { - + formatCurrency(v)} tick={{ fontSize: 12 }} /> { if (!active || !payload || payload.length === 0) return null; return (
-

{label}

+

{formatMonth(String(label))}

{payload.map((entry) => (

{entry.name}: {formatCurrency(entry.value as number)} diff --git a/frontend/src/components/reports/BudgetVsActualReport.mobileWrapped.test.tsx b/frontend/src/components/reports/BudgetVsActualReport.mobileWrapped.test.tsx index adbbcd1d93..8ea2c1aca6 100644 --- a/frontend/src/components/reports/BudgetVsActualReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/BudgetVsActualReport.mobileWrapped.test.tsx @@ -55,6 +55,9 @@ vi.mock('@/hooks/useNumberFormat', async () => { }), }; }); +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ formatMonth: (monthKey: string) => `month:${monthKey}` }), +})); vi.mock('@/lib/logger', () => ({ createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), @@ -84,8 +87,8 @@ vi.mock('recharts', () => ({ Tooltip: () => null, })); -const makePoint = (month: string, budgeted: number, actual: number): BudgetTrendPoint => ({ - month, +const makePoint = (monthKey: string, budgeted: number, actual: number): BudgetTrendPoint => ({ + monthKey, budgeted, actual, variance: actual - budgeted, @@ -96,23 +99,12 @@ const makePoint = (month: string, budgeted: number, actual: number): BudgetTrend // over budget (a positive variance, red, and the `+` prefix) and one under // (negative, green), plus a month at exactly 100%. // -// The month labels are the shape the API really sends -- a three-letter -// English month and a four-digit year, from `formatPeriodMonth` in -// `backend/src/budgets/budget-trend-reports.service.ts`, not an ISO -// `YYYY-MM`. That matters here rather than being pedantry: the phone layout -// gives the month an `auto` grid track precisely because that label is bounded -// and short, so a fixture in another format would not exercise the assumption -// the whole track budget rests on. -// -// The three are chosen so their chronological and alphabetical orders agree. -// `compareValues` sorts this column as a STRING, which for these labels is -// alphabetical by English month name -- a real, pre-existing defect (a -// 12-month trend sorts Apr, Aug, Dec, Feb, ...) that this layout change -// neither introduces nor fixes; the fixture simply does not lean on it. +// The API sends canonical `YYYY-MM` keys. The report localizes them only when +// it renders a label, while sorting and row identity continue to use the key. const POINTS: BudgetTrendPoint[] = [ - makePoint('Jan 2025', 1234567, 1439000), // over budget - makePoint('Jun 2025', 123456, 98765), // under budget - makePoint('Nov 2025', 200000, 200000), // exactly on budget: 100%, variance 0 + makePoint('2025-01', 1234567, 1439000), // over budget + makePoint('2025-06', 123456, 98765), // under budget + makePoint('2025-11', 200000, 200000), // exactly on budget: 100%, variance 0 ]; async function renderTable() { @@ -142,7 +134,7 @@ describe('BudgetVsActualReport (phone wrapped summary table)', () => { it('captions every figure inside the row so a phone needs no column header', async () => { const container = await renderTable(); - const row = findRow(container, 'Jan 2025'); + const row = findRow(container, 'month:2025-01'); expect(row).toBeDefined(); // Each caption sits immediately beside the value it names, as its own text // node, so a `getByText` on the value still matches the value node. @@ -153,7 +145,7 @@ describe('BudgetVsActualReport (phone wrapped summary table)', () => { // The month is the row's identity, not one of its figures, so it carries // no caption -- it is the first thing on the line and names itself. const monthCell = row?.querySelector('td'); - expect(monthCell?.textContent).toBe('Jan 2025'); + expect(monthCell?.textContent).toBe('month:2025-01'); expect(monthCell?.querySelector('span')).toBeNull(); // Captions reuse the table's own column keys: no new catalogue string. for (const caption of ['Month', 'Budgeted', 'Actual', 'Variance', '% Used']) { @@ -367,7 +359,7 @@ describe('BudgetVsActualReport (phone wrapped summary table)', () => { Array.from(container.querySelectorAll('tbody tr')).map( (r) => r.querySelector('td')?.textContent, ); - expect(monthOrder()).toEqual(['Jan 2025', 'Jun 2025', 'Nov 2025']); + expect(monthOrder()).toEqual(['month:2025-01', 'month:2025-06', 'month:2025-11']); // "Variance" in the phone strip: the fourth of the five controls in the // first header row. Addressed by position because the label also appears @@ -379,7 +371,7 @@ describe('BudgetVsActualReport (phone wrapped summary table)', () => { fireEvent.click(phoneVariance); }); // Ascending by variance puts June's -24,691 first, then November's 0. - expect(monthOrder()).toEqual(['Jun 2025', 'Nov 2025', 'Jan 2025']); + expect(monthOrder()).toEqual(['month:2025-06', 'month:2025-11', 'month:2025-01']); }); it('keeps the sign colouring and the + prefix the column used, in the wrapped cell', async () => { @@ -388,14 +380,14 @@ describe('BudgetVsActualReport (phone wrapped summary table)', () => { const varianceOf = (month: string) => findRow(container, month)?.querySelector('.col-start-2.row-start-1'); // Over budget is red and prefixed; under budget is green and is not. - expect(varianceOf('Jan 2025')?.className).toContain('text-red-600'); - expect(varianceOf('Jan 2025')?.textContent).toContain('+$204433'); - expect(varianceOf('Jun 2025')?.className).toContain('text-green-600'); - expect(varianceOf('Jun 2025')?.textContent).toContain('$-24691'); + expect(varianceOf('month:2025-01')?.className).toContain('text-red-600'); + expect(varianceOf('month:2025-01')?.textContent).toContain('+$204433'); + expect(varianceOf('month:2025-06')?.className).toContain('text-green-600'); + expect(varianceOf('month:2025-06')?.textContent).toContain('$-24691'); // Exactly on budget is not "over": zero takes the green branch and no // prefix, which is the behaviour the column has today. - expect(varianceOf('Nov 2025')?.className).toContain('text-green-600'); - expect(varianceOf('Nov 2025')?.textContent).not.toContain('+'); + expect(varianceOf('month:2025-11')?.className).toContain('text-green-600'); + expect(varianceOf('month:2025-11')?.textContent).not.toContain('+'); }); it('exports the columns the screen shows, in the screen’s order', async () => { @@ -421,7 +413,7 @@ describe('BudgetVsActualReport (phone wrapped summary table)', () => { ).map((th) => th.textContent?.replace(/[↑↓↕]/g, '').trim()); expect(headers).toEqual(columnLabels); - const screenRow = findRow(container, 'Jan 2025')!; + const screenRow = findRow(container, 'month:2025-01')!; const screenCells = Array.from(screenRow.querySelectorAll('td')).map((td) => { const caption = td.querySelector('span')?.textContent ?? ''; return (td.textContent ?? '').slice(caption.length); diff --git a/frontend/src/components/reports/BudgetVsActualReport.test.tsx b/frontend/src/components/reports/BudgetVsActualReport.test.tsx index 1580d5b89d..9bb5d05454 100644 --- a/frontend/src/components/reports/BudgetVsActualReport.test.tsx +++ b/frontend/src/components/reports/BudgetVsActualReport.test.tsx @@ -28,6 +28,13 @@ vi.mock('@/hooks/useNumberFormat', async () => { }), }; }); +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatMonth: (monthKey: string) => + ({ '2025-01': 'Zulu month', '2025-02': 'Alpha month' })[monthKey as '2025-01' | '2025-02'] ?? + `localized:${monthKey}`, + }), +})); vi.mock('@/lib/logger', () => ({ createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), })); @@ -48,7 +55,9 @@ vi.mock('recharts', () => ({ Bar: () => null, LineChart: ({ children }: any) =>

{children}
, Line: () => null, - XAxis: () => null, + XAxis: ({ dataKey, tickFormatter }: any) => ( +
{tickFormatter?.('2025-02')}
+ ), YAxis: () => null, CartesianGrid: () => null, Legend: () => null, @@ -56,9 +65,9 @@ vi.mock('recharts', () => ({ const C = content; if (!C) return null; const samples = [ - { active: true, payload: [{ dataKey: 'budgeted', name: 'Budgeted', color: 'var(--chart-primary)', value: 1000 }, { dataKey: 'actual', name: 'Actual', color: 'var(--chart-income)', value: 1100 }], label: 'tip-1' }, - { active: true, payload: [{ value: 100 }], label: 'tip-2' }, - { active: true, payload: [{ value: -50 }], label: 'tip-3' }, + { active: true, payload: [{ dataKey: 'budgeted', name: 'Budgeted', color: 'var(--chart-primary)', value: 1000 }, { dataKey: 'actual', name: 'Actual', color: 'var(--chart-income)', value: 1100 }], label: '2025-01' }, + { active: true, payload: [{ value: 100 }], label: '2025-02' }, + { active: true, payload: [{ value: -50 }], label: '2025-03' }, { active: false, payload: [], label: '' }, { active: true, payload: null, label: 'no payload' }, ]; @@ -70,11 +79,11 @@ const makeBudget = (overrides: Partial = {}): Budget => ({ id: 'b-1', name: 'Default', isActive: true, ...overrides } as Budget); const makePoint = ( - month: string, + monthKey: string, budgeted: number, actual: number, ): BudgetTrendPoint => ({ - month, + monthKey, budgeted, actual, variance: actual - budgeted, @@ -145,9 +154,17 @@ describe('BudgetVsActualReport', () => { mockGetCategoryTrend.mockResolvedValue([]); await renderReport(); await waitFor(() => { - expect(screen.getByText('2025-01')).toBeInTheDocument(); + expect(screen.getAllByText('Zulu month').length).toBeGreaterThan(0); }); - expect(screen.getByText('2025-02')).toBeInTheDocument(); + expect(screen.getAllByText('Alpha month').length).toBeGreaterThan(0); + expect(screen.queryByText('2025-01')).not.toBeInTheDocument(); + expect(screen.getAllByTestId('x-axis-monthKey')).toHaveLength(2); + const renderedMonths = Array.from(document.querySelectorAll('tbody tr')).map( + (row) => row.querySelector('td')?.textContent, + ); + // Localized labels sort in the opposite order. The table must still use + // the structural key, so January remains before February. + expect(renderedMonths).toEqual(['Zulu month', 'Alpha month']); }); it('toggles to By Category view', async () => { @@ -208,5 +225,6 @@ describe('BudgetVsActualReport', () => { await act(async () => { fireEvent.click(exportBtn); }); await waitFor(() => expect(mockExportToPdf).toHaveBeenCalled()); expect(mockExportToPdf.mock.calls[0][0].title).toBe('Budget vs Actual'); + expect(mockExportToPdf.mock.calls[0][0].tableData.rows[0][0]).toBe('Zulu month'); }); }); diff --git a/frontend/src/components/reports/BudgetVsActualReport.tsx b/frontend/src/components/reports/BudgetVsActualReport.tsx index 66bfdab744..b996f71b1d 100644 --- a/frontend/src/components/reports/BudgetVsActualReport.tsx +++ b/frontend/src/components/reports/BudgetVsActualReport.tsx @@ -17,6 +17,7 @@ import { import { budgetsApi } from '@/lib/budgets'; import type { BudgetTrendPoint, CategoryTrendSeries } from '@/types/budget'; import { useNumberFormat } from '@/hooks/useNumberFormat'; +import { useDateFormat } from '@/hooks/useDateFormat'; import { useTranslations } from 'next-intl'; import { useReportData } from '@/hooks/useReportData'; import { BudgetCategoryTrend } from '@/components/budgets/BudgetCategoryTrend'; @@ -114,9 +115,9 @@ const PHONE_HEADER_CLASS = // // The two money tracks are `minmax(0,1fr)` beside an `auto` identity track, // NOT three equal thirds, and that is what makes the figures fit. The month is -// the only bounded thing in the row -- the server sends a three-letter English -// month and a four-digit year (`formatPeriodMonth`) -- and its cell carries no -// caption, so an `auto` track costs it the 61px it actually uses instead of a +// the only bounded thing in the row -- `formatMonth` renders its canonical +// `YYYY-MM` key in the reader's configured date format -- and its cell carries +// no caption, so an `auto` track costs only the label it actually uses instead of a // third of the width, and hands the difference to the figures. The resolved // tracks, read off `getComputedStyle` rather than divided out: 61/93/93 at // 320px and 61/128/128 at 390px, against 83 and 106 on three equal thirds. @@ -175,6 +176,7 @@ const CAPTION_CLASS = 'sm:hidden'; export function BudgetVsActualReport() { const t = useTranslations('reports'); const { formatCurrencyCompact: formatCurrency, formatPercentTrimmed } = useNumberFormat(); + const { formatMonth } = useDateFormat(); const [selectedBudgetIdState, setSelectedBudgetId] = useState(''); const [months, setMonths] = useState(6); const [viewMode, setViewMode] = useState<'overview' | 'categories'>('overview'); @@ -235,7 +237,7 @@ export function BudgetVsActualReport() { let comparison = 0; switch (sortField) { case 'month': - comparison = compareValues(a.month, b.month); + comparison = compareValues(a.monthKey, b.monthKey); break; case 'budgeted': comparison = compareValues(a.budgeted, b.budgeted); @@ -261,7 +263,7 @@ export function BudgetVsActualReport() { month: { field: 'month', label: t('budgetVsActual.colMonth'), - value: (point) => point.month, + value: (point) => formatMonth(point.monthKey), }, budgeted: { field: 'budgeted', @@ -412,14 +414,14 @@ export function BudgetVsActualReport() { - + formatCurrency(v)} tick={{ fontSize: 12 }} /> { if (!active || !payload || payload.length === 0) return null; return (
-

{label}

+

{formatMonth(String(label))}

{payload.map((entry, idx) => (

{entry.name}: {formatCurrency(entry.value as number)} @@ -443,7 +445,7 @@ export function BudgetVsActualReport() { - + formatCurrency(v)} tick={{ fontSize: 12 }} /> { @@ -451,7 +453,7 @@ export function BudgetVsActualReport() { const variance = payload[0]?.value as number; return (

-

{label}

+

{formatMonth(String(label))}

0 ? 'text-red-500' : 'text-green-500'}`}> {t('budgetVsActual.tooltipVariance')} {variance > 0 ? '+' : ''}{formatCurrency(variance)}

@@ -551,11 +553,11 @@ export function BudgetVsActualReport() { {sortedTrendData.map((point) => ( - {point.month} + {formatMonth(point.monthKey)} {columns.budgeted.label} {formatCurrency(point.budgeted)} diff --git a/frontend/src/components/reports/CategoryPerformanceReport.mobileWrapped.test.tsx b/frontend/src/components/reports/CategoryPerformanceReport.mobileWrapped.test.tsx index 845bf4212e..1cf2765a49 100644 --- a/frontend/src/components/reports/CategoryPerformanceReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/CategoryPerformanceReport.mobileWrapped.test.tsx @@ -95,7 +95,7 @@ const makeSeries = ( categoryId: id, categoryName: name, data: budgeted.map((b, i) => ({ - month: `2025-${String(i + 1).padStart(2, '0')}`, + monthKey: `2025-${String(i + 1).padStart(2, '0')}`, budgeted: b, actual: actual[i], variance: actual[i] - b, diff --git a/frontend/src/components/reports/CategoryPerformanceReport.test.tsx b/frontend/src/components/reports/CategoryPerformanceReport.test.tsx index 5e641920f0..a1cc9c1c28 100644 --- a/frontend/src/components/reports/CategoryPerformanceReport.test.tsx +++ b/frontend/src/components/reports/CategoryPerformanceReport.test.tsx @@ -55,7 +55,7 @@ const makeSeries = ( categoryId: id, categoryName: name, data: points.map((p, idx) => ({ - month: `2025-0${idx + 1}`, + monthKey: `2025-0${idx + 1}`, budgeted: p.budgeted, actual: p.actual, variance: p.actual - p.budgeted, diff --git a/frontend/src/types/budget-trend.contract.test.ts b/frontend/src/types/budget-trend.contract.test.ts new file mode 100644 index 0000000000..3254d3c5da --- /dev/null +++ b/frontend/src/types/budget-trend.contract.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = join(__dirname, '..', '..', '..'); + +const backendTypes = readFileSync( + join(REPO_ROOT, 'backend/src/budgets/budget-reports.service.ts'), + 'utf8', +); +const frontendTypes = readFileSync(join(__dirname, 'budget.ts'), 'utf8'); + +function interfaceBody(source: string, name: string): string { + const match = source.match( + new RegExp(`export interface ${name}\\s*\\{([\\s\\S]*?)\\n\\}`), + ); + expect(match, `${name} declaration not found`).toBeTruthy(); + return match![1]; +} + +describe('budget trend month API contract', () => { + it('uses the same structural YYYY-MM field for overview trend points', () => { + for (const source of [backendTypes, frontendTypes]) { + const body = interfaceBody(source, 'BudgetTrendPoint'); + expect(body).toMatch(/monthKey:\s*string;/); + expect(body).not.toMatch(/\bmonth:\s*string;/); + } + }); + + it('uses the same structural field for category trend points', () => { + const backendPoint = interfaceBody(backendTypes, 'CategoryTrendPoint'); + const frontendPoint = interfaceBody(frontendTypes, 'CategoryTrendDataPoint'); + + for (const body of [backendPoint, frontendPoint]) { + expect(body).toMatch(/monthKey:\s*string;/); + expect(body).not.toMatch(/\bmonth:\s*string;/); + } + expect(interfaceBody(backendTypes, 'CategoryTrendSeries')).toMatch( + /data:\s*Array<\{[\s\S]*?monthKey:\s*string;/, + ); + }); +}); diff --git a/frontend/src/types/budget.ts b/frontend/src/types/budget.ts index ed4eef2b49..04bd06fc85 100644 --- a/frontend/src/types/budget.ts +++ b/frontend/src/types/budget.ts @@ -298,7 +298,7 @@ export interface BudgetVelocity { // --- Report Types --- export interface BudgetTrendPoint { - month: string; + monthKey: string; budgeted: number; actual: number; variance: number; @@ -306,7 +306,7 @@ export interface BudgetTrendPoint { } export interface CategoryTrendDataPoint { - month: string; + monthKey: string; budgeted: number; actual: number; variance: number; From fb3f779a741f4d4c8a1856a047e50b8bec084173 Mon Sep 17 00:00:00 2001 From: WMP Date: Wed, 9 Sep 2026 16:59:52 +0200 Subject: [PATCH 10/44] Share mobile table chrome classes --- .../budgets/BudgetCategoryTrend.tsx | 4 +- .../reports/BillPaymentHistoryReport.tsx | 12 ++---- .../reports/BudgetHealthScoreReport.tsx | 12 ++---- .../reports/BudgetVsActualReport.tsx | 12 ++---- .../reports/CategoryPerformanceReport.tsx | 12 ++---- .../reports/CreditUtilizationReport.tsx | 7 +-- .../reports/CurrencyExposureReport.tsx | 7 +-- .../reports/FlexGroupAnalysisReport.tsx | 12 ++---- .../reports/IncomeVsExpensesReport.tsx | 5 +-- .../InvestmentTransactionHistoryReport.tsx | 4 +- ...nvestmentTransactionHistoryReportParts.tsx | 13 ++---- .../reports/RecurringExpensesReport.tsx | 13 ++---- .../components/reports/SavingsRateReport.tsx | 13 ++---- .../reports/SectorWeightingsReport.tsx | 7 +-- .../reports/SecurityTypeAllocationReport.tsx | 7 +-- .../UncategorizedTransactionsReport.tsx | 13 ++---- frontend/src/components/ui/Table.test.tsx | 43 ++++++++++++++++++- frontend/src/components/ui/Table.tsx | 7 +++ 18 files changed, 83 insertions(+), 120 deletions(-) diff --git a/frontend/src/components/budgets/BudgetCategoryTrend.tsx b/frontend/src/components/budgets/BudgetCategoryTrend.tsx index a6ae601e1c..35f0b1716b 100644 --- a/frontend/src/components/budgets/BudgetCategoryTrend.tsx +++ b/frontend/src/components/budgets/BudgetCategoryTrend.tsx @@ -12,7 +12,7 @@ import { ResponsiveContainer, } from 'recharts'; import { chartSeriesColor } from '@/lib/chart-colors'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel } from '@/components/ui/Table'; import type { CategoryTrendSeries } from '@/types/budget'; import { useDateFormat } from '@/hooks/useDateFormat'; @@ -54,8 +54,6 @@ const CELL_PADDING = 'sm:py-2 sm:pr-4'; const LAST_CELL_PADDING = 'sm:py-2'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - function CategoryTrendTooltip({ active, payload, diff --git a/frontend/src/components/reports/BillPaymentHistoryReport.tsx b/frontend/src/components/reports/BillPaymentHistoryReport.tsx index d732b2cc28..9f359a18dc 100644 --- a/frontend/src/components/reports/BillPaymentHistoryReport.tsx +++ b/frontend/src/components/reports/BillPaymentHistoryReport.tsx @@ -21,7 +21,7 @@ import { DateRangeSelector } from '@/components/ui/DateRangeSelector'; import { exportToCsv } from '@/lib/csv-export'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useTranslations } from 'next-intl'; import { useReportData } from '@/hooks/useReportData'; @@ -69,18 +69,14 @@ const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray // the chip's own fill is a shade off the header band it sits on (this table's // `` keeps its `bg-gray-50` / `dark:bg-gray-900/50`, so the strip is on // that band rather than on the card, as it is on the sibling tables whose card -// has no header band). (The class is kept identical to those siblings; the -// copies are one of the duplications the converted-table consolidation pass -// folds into one home -- `components/ui/` is not this change's to edit.) +// has no header band). The shared `PHONE_HEADER_CLASS` keeps those controls +// identical across the reports. // // Five chips wrap to three lines at 320px in `en`/`pl`/`ru`/`id` (114px), four // in `de` (148px) and five in the pseudo-locale (182px) above the first row. // That is a measured cost, not a reason to drop a control: `reports.bill- // payment-history.sort` persists any of the five, so a field with no control // anywhere would leave a phone POINTING at a sort with no pointer back. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // A value cell inside a wrapped row: no padding of its own below `sm` and this // table's own `px-4 py-3` from `sm` up. Smaller type on phones so an // eight-figure compact amount still fits half the width. @@ -132,8 +128,6 @@ const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px const DATE_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - export function BillPaymentHistoryReport() { const t = useTranslations('reports'); const router = useRouter(); diff --git a/frontend/src/components/reports/BudgetHealthScoreReport.tsx b/frontend/src/components/reports/BudgetHealthScoreReport.tsx index 0646bb4976..c385bb440d 100644 --- a/frontend/src/components/reports/BudgetHealthScoreReport.tsx +++ b/frontend/src/components/reports/BudgetHealthScoreReport.tsx @@ -7,7 +7,7 @@ import { BudgetHealthGauge } from '@/components/budgets/BudgetHealthGauge'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportError } from '@/components/reports/ReportError'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { useTranslations } from 'next-intl'; import { useReportData } from '@/hooks/useReportData'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; @@ -113,12 +113,8 @@ const cellPadding = (col: SortColumn) => (col.last ? 'sm:py-2' : 'sm:py-2 sm:pr- // each data row is a grid -- so every control is left-aligned and self-naming. // The border is what says "tappable" here: there is no hover on a touch screen, // and the strip sits directly on the card, whose background this already is -- -// so the border is the whole of the affordance. (The class is kept identical to -// the sibling report tables that ship this strip; the copies are one of the -// duplications the converted-table consolidation pass folds into one home.) -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - +// so the border is the whole of the affordance. The shared +// `PHONE_HEADER_CLASS` keeps this strip identical to its sibling reports. // A figure cell (`% used`, `Score impact`) inside a wrapped row: no padding of // its own below `sm` and this table's own from `sm` up, which each cell adds // through `cellPadding` so "which column is last" stays decided in one place. @@ -166,8 +162,6 @@ const FIGURE_CELL = 'p-0 text-right text-xs font-medium whitespace-nowrap sm:table-cell sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - export function BudgetHealthScoreReport() { const t = useTranslations('reports'); const { formatPercentTrimmed } = useNumberFormat(); diff --git a/frontend/src/components/reports/BudgetVsActualReport.tsx b/frontend/src/components/reports/BudgetVsActualReport.tsx index b996f71b1d..43ec557239 100644 --- a/frontend/src/components/reports/BudgetVsActualReport.tsx +++ b/frontend/src/components/reports/BudgetVsActualReport.tsx @@ -24,7 +24,7 @@ import { BudgetCategoryTrend } from '@/components/budgets/BudgetCategoryTrend'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportError } from '@/components/reports/ReportError'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { chartColors } from '@/lib/chart-colors'; @@ -92,12 +92,8 @@ const cellPadding = (col: SortColumn) => (col.last ? 'sm:py-2' : 'sm:py-2 sm:pr- // each data row is a grid -- so every control is left-aligned and self-naming. // The border is what says "tappable" here: there is no hover on a touch screen, // and the strip sits directly on the card, whose background this already is -- -// so the border is the whole of the affordance. (The class is kept identical to -// the sibling report tables that ship this strip; the copies are one of the -// duplications the converted-table consolidation pass folds into one home.) -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - +// so the border is the whole of the affordance. The shared +// `PHONE_HEADER_CLASS` keeps this strip identical to its sibling reports. // A money (or percent) cell inside a wrapped row: no padding of its own below // `sm` and this table's own from `sm` up, which each cell adds through // `cellPadding` so "which column is last" stays decided in one place. Smaller @@ -171,8 +167,6 @@ const PHONE_HEADER_CLASS = const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - export function BudgetVsActualReport() { const t = useTranslations('reports'); const { formatCurrencyCompact: formatCurrency, formatPercentTrimmed } = useNumberFormat(); diff --git a/frontend/src/components/reports/CategoryPerformanceReport.tsx b/frontend/src/components/reports/CategoryPerformanceReport.tsx index 0d4bb668df..1975ed2bdc 100644 --- a/frontend/src/components/reports/CategoryPerformanceReport.tsx +++ b/frontend/src/components/reports/CategoryPerformanceReport.tsx @@ -9,7 +9,7 @@ import { useReportData } from '@/hooks/useReportData'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportError } from '@/components/reports/ReportError'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; import { useTranslations } from 'next-intl'; @@ -117,9 +117,8 @@ const cellPadding = (col: SortColumn) => (col.last ? 'sm:py-2.5' : 'sm:py-2.5 sm // each data row is a grid -- so every control is left-aligned and self-naming. // The border is what says "tappable" here: there is no hover on a touch screen, // and the strip sits directly on the card, whose background this already is -- -// so the border is the whole of the affordance. (The class is kept identical to -// the sibling report tables that ship this strip; the copies are one of the -// duplications the converted-table consolidation pass folds into one home.) +// so the border is the whole of the affordance. The shared +// `PHONE_HEADER_CLASS` keeps this strip identical to its sibling reports. // // This is the WIDEST strip of the converted family: eight fields, against the // five and six the sibling reports carry. In a long-caption locale that is @@ -130,9 +129,6 @@ const cellPadding = (col: SortColumn) => (col.last ? 'sm:py-2.5' : 'sm:py-2.5 sm // `SortableHeader`'s pre-existing gap for a keyboard or switch user (a `` // with an `onClick` and no `tabIndex`, `role` or key handler), which is shared // by every report table and is a separate fix. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // A figure cell inside a wrapped row: no padding of its own below `sm` and // this table's own from `sm` up, which each cell adds through `cellPadding` so // "which column is last" stays decided in one place. Smaller type on phones so @@ -218,8 +214,6 @@ const FIGURE_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:t const WORD_CELL = 'p-0 text-right text-xs sm:table-cell sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - export function CategoryPerformanceReport() { const t = useTranslations('reports'); const { formatCurrencyCompact: formatCurrency, formatPercentTrimmed } = useNumberFormat(); diff --git a/frontend/src/components/reports/CreditUtilizationReport.tsx b/frontend/src/components/reports/CreditUtilizationReport.tsx index 39201ff850..94ce701cd5 100644 --- a/frontend/src/components/reports/CreditUtilizationReport.tsx +++ b/frontend/src/components/reports/CreditUtilizationReport.tsx @@ -29,7 +29,7 @@ import { usePersistedAccountFilter } from '@/hooks/usePersistedAccountFilter'; import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMultiSelect'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { PartialTotal } from '@/components/ui/PartialTotal'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { ReportError } from '@/components/reports/ReportError'; @@ -99,9 +99,6 @@ const HEADER_CLASS = // The border and card background are what say "tappable": there is no hover on // a touch screen, and without them the strip reads as another row of the // captions the cells below carry. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // A figure cell inside a wrapped card: no padding of its own below `sm` (the // row supplies it and the grid does the spacing), the table cell's own padding // from `sm` up. Smaller type on phones. The colour stays on each cell, because @@ -141,8 +138,6 @@ const FIGURE_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - /** One slice of the total-utilization donut: drawn vs available credit. */ interface TotalUtilizationSlice { diff --git a/frontend/src/components/reports/CurrencyExposureReport.tsx b/frontend/src/components/reports/CurrencyExposureReport.tsx index c80ff90066..c9c0b9ce73 100644 --- a/frontend/src/components/reports/CurrencyExposureReport.tsx +++ b/frontend/src/components/reports/CurrencyExposureReport.tsx @@ -19,7 +19,7 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMultiSelect'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { PartialTotal } from '@/components/ui/PartialTotal'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; @@ -98,9 +98,6 @@ const HEADER_CLASS = // The border and card background are what say "tappable": there is no hover on // a touch screen, and without them the strip reads as another row of the // captions the cells below carry. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // A figure cell inside a wrapped card: no padding of its own below `sm` (the // row supplies it and the grid does the spacing), the table cell's own padding // from `sm` up. Smaller type on phones. The colour stays on each cell, because @@ -153,8 +150,6 @@ const FIGURE_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - interface CurrencyAllocation { currency: string; nativeValue: number; diff --git a/frontend/src/components/reports/FlexGroupAnalysisReport.tsx b/frontend/src/components/reports/FlexGroupAnalysisReport.tsx index b50f10feb9..1236318e7e 100644 --- a/frontend/src/components/reports/FlexGroupAnalysisReport.tsx +++ b/frontend/src/components/reports/FlexGroupAnalysisReport.tsx @@ -21,7 +21,7 @@ import { useReportData } from '@/hooks/useReportData'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportError } from '@/components/reports/ReportError'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; import { chartColors } from '@/lib/chart-colors'; @@ -107,9 +107,8 @@ const cellPadding = (col: SortColumn) => (col.last ? 'sm:py-2' : 'sm:py-2 sm:pr- // each data row is a grid -- so every control is left-aligned and self-naming. // The border is what says "tappable" here: there is no hover on a touch screen, // and the strip sits directly on the card, whose background this already is -- -// so the border is the whole of the affordance. (The class is kept identical to -// the sibling report tables that ship this strip; the copies are one of the -// duplications the converted-table consolidation pass folds into one home.) +// so the border is the whole of the affordance. The shared +// `PHONE_HEADER_CLASS` keeps this strip identical to its sibling reports. // // This table is drawn once per flex group, so a page with N groups shows N // strips -- one above each group's own rows, where a reader who has scrolled @@ -117,9 +116,6 @@ const cellPadding = (col: SortColumn) => (col.last ? 'sm:py-2' : 'sm:py-2 sm:pr- // pair this report holds, exactly as the N column header rows already do on // desktop: sorting from any group re-sorts every group. Nothing in the strip // or in `SortableHeader` carries a DOM id, so N copies collide over nothing. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // A money (or percent) cell inside a wrapped row: no padding of its own below // `sm` and this table's own from `sm` up, which each cell adds through // `cellPadding` so "which column is last" stays decided in one place. Smaller @@ -200,8 +196,6 @@ const PHONE_HEADER_CLASS = const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - export function FlexGroupAnalysisReport() { const t = useTranslations('reports'); diff --git a/frontend/src/components/reports/IncomeVsExpensesReport.tsx b/frontend/src/components/reports/IncomeVsExpensesReport.tsx index 5021203e53..41227c8dff 100644 --- a/frontend/src/components/reports/IncomeVsExpensesReport.tsx +++ b/frontend/src/components/reports/IncomeVsExpensesReport.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo, useRef } from "react"; -import { CellLabel } from "@/components/ui/Table"; +import { CellLabel, PHONE_HEADER_CLASS } from "@/components/ui/Table"; import { Skeleton } from '@/components/ui/LoadingSkeleton'; import { useRouter } from "next/navigation"; import { @@ -55,9 +55,6 @@ const HEADER_CLASS = // The border and card background are what say "tappable": there is no hover on // a touch screen, and without them the strip reads as another row of the // captions the cells below carry. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // A money cell inside a wrapped card: no padding of its own below `sm` (the row // supplies it and the grid does the spacing), the table cell's own padding from // `sm` up. Smaller type on phones so a six-figure amount still fits a diff --git a/frontend/src/components/reports/InvestmentTransactionHistoryReport.tsx b/frontend/src/components/reports/InvestmentTransactionHistoryReport.tsx index e6cbf69ac2..e1319e9157 100644 --- a/frontend/src/components/reports/InvestmentTransactionHistoryReport.tsx +++ b/frontend/src/components/reports/InvestmentTransactionHistoryReport.tsx @@ -20,7 +20,7 @@ import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { ReportError } from '@/components/reports/ReportError'; import { exportToCsv } from '@/lib/csv-export'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { PartialTotal } from '@/components/ui/PartialTotal'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; @@ -28,11 +28,9 @@ import { useTranslations } from 'next-intl'; import { useMainAccountName } from '@/hooks/useMainAccountName'; import { ACTION_COLORS, - CAPTION_CLASS, DATE_CELL, HEADER_CLASS, MONEY_CELL, - PHONE_HEADER_CLASS, type InvestmentTxSortField, type SortColumn, type SortColumnsByField, diff --git a/frontend/src/components/reports/InvestmentTransactionHistoryReportParts.tsx b/frontend/src/components/reports/InvestmentTransactionHistoryReportParts.tsx index ff2a39c1af..9bcb660b15 100644 --- a/frontend/src/components/reports/InvestmentTransactionHistoryReportParts.tsx +++ b/frontend/src/components/reports/InvestmentTransactionHistoryReportParts.tsx @@ -73,10 +73,9 @@ export const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:te // each data row is a grid -- so every control is left-aligned and self-naming. // The border is what says "tappable": there is no hover on a touch screen, and // the chip's own fill is a shade off the header band it sits on (this table's -// `` keeps its `bg-gray-50` / `dark:bg-gray-900/50`). The class is kept -// identical to the sibling report tables that ship this strip; the copies are -// one of the duplications the converted-table consolidation pass folds into one -// home -- `components/ui/` is not this change's to edit. +// `` keeps its `bg-gray-50` / `dark:bg-gray-900/50`). The shared +// `PHONE_HEADER_CLASS` in `components/ui/Table.tsx` keeps this strip identical +// to its sibling reports. // // Seven chips. Measured on the Chromium replica at 320px they wrap to four // lines in `en`/`pl`/`de`, five in `ru`/`id` and seven in the pseudo-locale @@ -86,9 +85,6 @@ export const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:te // with no control anywhere would leave a phone POINTING at a sort with no // pointer back -- and Account is exactly that field today, offered by a column // header that no phone and no tablet can see. -export const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // A figure cell inside a wrapped row: no padding of its own below `sm` and this // table's own `px-4 py-3` from `sm` up. Smaller type on phones so a seven-figure // 2dp amount still fits half the width. @@ -146,9 +142,6 @@ export const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cel // and the desktop is untouched. export const DATE_CELL = 'p-0 text-xs whitespace-nowrap max-sm:text-right sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; -/** Every caption in a wrapped cell is phone-only. */ -export const CAPTION_CLASS = 'sm:hidden'; - export const ACTION_COLORS: Record = { BUY: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', SELL: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', diff --git a/frontend/src/components/reports/RecurringExpensesReport.tsx b/frontend/src/components/reports/RecurringExpensesReport.tsx index 2029a13a80..a10a9dbca3 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.tsx @@ -23,7 +23,7 @@ import { resolvePdfColor } from '@/components/reports/resolve-pdf-color'; import { exportToCsv } from '@/lib/csv-export'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; import { ReportError } from '@/components/reports/ReportError'; @@ -85,10 +85,8 @@ const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray // each data row is a grid -- so every control is left-aligned and self-naming. // The border is what says "tappable": there is no hover on a touch screen, and // the chip's own fill is a shade off the header band it sits on (this table's -// `` keeps its `bg-gray-50` / `dark:bg-gray-900/50`). The class is kept -// identical to the sibling report tables that ship this strip; the copies are -// one of the duplications the converted-table consolidation pass folds into one -// home -- `components/ui/` is not this change's to edit. +// `` keeps its `bg-gray-50` / `dark:bg-gray-900/50`). The shared +// `PHONE_HEADER_CLASS` keeps this strip identical to its sibling reports. // // Seven chips is the second-widest strip of the converted family: measured on // the Chromium replica at 320px they wrap to four lines in `en`/`pl` (148px), @@ -97,9 +95,6 @@ const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray // cost, not a reason to drop a control: `reports.recurring-expenses.sort` // persists any of the seven, so a field with no control anywhere would leave a // phone POINTING at a sort with no pointer back. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // A value cell inside a wrapped row: no padding of its own below `sm` and this // table's own `px-4 py-3` from `sm` up. Smaller type on phones so a // seven-figure compact amount still fits half the width. @@ -163,8 +158,6 @@ const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px const DATE_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - const FREQUENCY_BADGE_CLASS: Record = { WEEKLY: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', BIWEEKLY: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', diff --git a/frontend/src/components/reports/SavingsRateReport.tsx b/frontend/src/components/reports/SavingsRateReport.tsx index 9e53fee795..9883e2732e 100644 --- a/frontend/src/components/reports/SavingsRateReport.tsx +++ b/frontend/src/components/reports/SavingsRateReport.tsx @@ -21,7 +21,7 @@ import type { Budget, SavingsRatePoint } from '@/types/budget'; import { useNumberFormat } from '@/hooks/useNumberFormat'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; import { ReportError } from '@/components/reports/ReportError'; @@ -65,13 +65,8 @@ const cellPadding = (col: SortColumn) => (col.last ? 'sm:py-2' : 'sm:py-2 sm:pr- // each data row is a grid -- so every control is left-aligned and self-naming. // The border is what says "tappable" here: there is no hover on a touch screen, // and the strip sits directly on the card, whose background this already is -- -// so the border is the whole of the affordance. (The class is kept identical to -// the two sibling report tables that ship this strip, where the background does -// separate the chip from a tinted header; the three copies are one of the -// duplications the converted-table consolidation pass folds into one home.) -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - +// so the border is the whole of the affordance. The shared +// `PHONE_HEADER_CLASS` keeps this strip identical to its sibling reports. // A money (or rate) cell inside a wrapped row: no padding of its own below // `sm` and this table's own from `sm` up, which each cell adds through // `cellPadding` so "which column is last" stays decided in one place. Smaller @@ -109,8 +104,6 @@ const PHONE_HEADER_CLASS = const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - export function SavingsRateReport() { const t = useTranslations('reports'); const { formatCurrencyCompact: formatCurrency, formatPercent, formatPercentTrimmed } = useNumberFormat(); diff --git a/frontend/src/components/reports/SectorWeightingsReport.tsx b/frontend/src/components/reports/SectorWeightingsReport.tsx index e9c017c502..71667fa89d 100644 --- a/frontend/src/components/reports/SectorWeightingsReport.tsx +++ b/frontend/src/components/reports/SectorWeightingsReport.tsx @@ -26,7 +26,7 @@ import { MultiSelect } from '@/components/ui/MultiSelect'; import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMultiSelect'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; @@ -76,9 +76,6 @@ const HEADER_CLASS = // The border and card background are what say "tappable": there is no hover on // a touch screen, and without them the strip reads as another row of the // captions the cells below carry. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // Where each column sits on the phone grid, written ONCE. The table has three // row shapes -- a sector row, the optional unclassified row and the totals // footer -- and all three place their cells from this record, so a reader @@ -157,8 +154,6 @@ const IDENTITY_CELL = `${CELL_PLACEMENT.sector} min-w-0 p-0 text-sm sm:table-cell sm:px-4 sm:py-3`; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - function CustomTooltip({ active, payload, formatCurrencyFull, defaultCurrency, labelDirect, labelEtf, labelTotal }: { active?: boolean; payload?: Array<{ payload: { sector: string; direct: number; etf: number; total: number; percentage: number } }>; diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx index 880a75106f..4ed0be4ad7 100644 --- a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx @@ -25,7 +25,7 @@ import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMult import { resolvePdfColor } from '@/components/reports/resolve-pdf-color'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { PartialTotal } from '@/components/ui/PartialTotal'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; @@ -73,9 +73,6 @@ const HEADER_CLASS = // each data row is a grid -- so every control is left-aligned and self-naming. // The border and card background are what say "tappable": there is no hover on a // touch screen, and without them the strip reads as one more row of captions. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // Where each column sits on the phone grid for an ASSET TYPE row and for the // totals footer, written once: those two shapes are 1x1 over the same four // columns, so the footer takes the type row's placement verbatim and a reader @@ -181,8 +178,6 @@ const CHILD_IDENTITY_CELL = `${CHILD_CELL_PLACEMENT.label} min-w-0 p-0 pl-8 text-sm break-words sm:table-cell sm:px-4 sm:py-2 sm:pl-10 sm:break-normal`; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - const TYPE_COLOURS: Record = { STOCK: CHART_SERIES[0], ETF: CHART_SERIES[1], diff --git a/frontend/src/components/reports/UncategorizedTransactionsReport.tsx b/frontend/src/components/reports/UncategorizedTransactionsReport.tsx index 4c1da336ea..632980a986 100644 --- a/frontend/src/components/reports/UncategorizedTransactionsReport.tsx +++ b/frontend/src/components/reports/UncategorizedTransactionsReport.tsx @@ -14,7 +14,7 @@ import { DateRangeSelector } from '@/components/ui/DateRangeSelector'; import { exportToCsv } from '@/lib/csv-export'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; import { ReportError } from '@/components/reports/ReportError'; @@ -60,10 +60,8 @@ const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray // each data row is a grid -- so every control is left-aligned and self-naming. // The border is what says "tappable": there is no hover on a touch screen, and // the chip's own fill is a shade off the header band it sits on (this table's -// `` keeps its `bg-gray-50` / `dark:bg-gray-900/50`). The class is kept -// identical to the sibling report tables that ship this strip; the copies are -// one of the duplications the converted-table consolidation pass folds into one -// home -- `components/ui/` is not this change's to edit. +// `` keeps its `bg-gray-50` / `dark:bg-gray-900/50`). The shared +// `PHONE_HEADER_CLASS` keeps this strip identical to its sibling reports. // // Four chips, one of them a COMPOUND label -- which is why a low chip count // says little about the strip's height here. Measured on the Chromium replica @@ -74,9 +72,6 @@ const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray // `reports.uncategorized-transactions.sort` persists any of the four, so a // field with no control anywhere would leave a phone POINTING at a sort with no // pointer back. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - // The amount cell inside a wrapped row: no padding of its own below `sm` and // this table's own `px-4 py-3` from `sm` up. Smaller type on phones so a // six-figure 2dp amount still fits half the width. @@ -132,8 +127,6 @@ const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px const DATE_CELL = 'p-0 text-xs whitespace-nowrap max-sm:text-right sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; /** Every caption in a wrapped cell is phone-only. */ -const CAPTION_CLASS = 'sm:hidden'; - export function UncategorizedTransactionsReport() { const t = useTranslations('reports'); const router = useRouter(); diff --git a/frontend/src/components/ui/Table.test.tsx b/frontend/src/components/ui/Table.test.tsx index 8d463ac88b..85b8364d55 100644 --- a/frontend/src/components/ui/Table.test.tsx +++ b/frontend/src/components/ui/Table.test.tsx @@ -1,6 +1,28 @@ import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; import { render, screen } from '@/test/render'; -import { Th, Td, CellLabel, TABLE_CLASS, TABLE_BODY_CLASS, TH_CLASS, TD_CLASS } from './Table'; +import { + Th, + Td, + CellLabel, + TABLE_CLASS, + TABLE_BODY_CLASS, + TH_CLASS, + TD_CLASS, + PHONE_HEADER_CLASS, + CAPTION_CLASS, +} from './Table'; + +const COMPONENTS_ROOT = join(__dirname, '..'); + +function componentSourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return componentSourceFiles(path); + return /\.tsx?$/.test(entry.name) ? [path] : []; + }); +} function renderCell(cell: React.ReactNode) { return render( @@ -33,6 +55,25 @@ describe('CellLabel', () => { }); describe('Table chrome', () => { + it('owns the shared phone header and caption breakpoint classes', () => { + expect(PHONE_HEADER_CLASS).toContain('uppercase'); + expect(PHONE_HEADER_CLASS).toContain('dark:bg-gray-800'); + expect(CAPTION_CLASS).toBe('sm:hidden'); + }); + + it('keeps the shared phone table constants in one source file', () => { + const declarations = componentSourceFiles(COMPONENTS_ROOT).flatMap((path) => { + const source = readFileSync(path, 'utf8'); + return [...source.matchAll(/^(?:export\s+)?const\s+(PHONE_HEADER_CLASS|CAPTION_CLASS)\s*=/gm)] + .map((match) => `${relative(COMPONENTS_ROOT, path)}:${match[1]}`); + }); + + expect(declarations).toEqual([ + 'ui/Table.tsx:PHONE_HEADER_CLASS', + 'ui/Table.tsx:CAPTION_CLASS', + ]); + }); + it('rules rows on the gray ramp, so the colour themes re-skin them', () => { for (const value of [TABLE_CLASS, TABLE_BODY_CLASS, TH_CLASS, TD_CLASS]) { expect(value).not.toMatch(/#[0-9a-f]{3,6}/i); diff --git a/frontend/src/components/ui/Table.tsx b/frontend/src/components/ui/Table.tsx index 94ec00e830..bc3ee8845d 100644 --- a/frontend/src/components/ui/Table.tsx +++ b/frontend/src/components/ui/Table.tsx @@ -36,6 +36,13 @@ export const TH_CLASS = /** The ordinary body cell. */ export const TD_CLASS = 'px-4 py-3 text-sm text-gray-900 dark:text-gray-100'; +/** Compact sortable-column control shown above wrapped table rows on phones. */ +export const PHONE_HEADER_CLASS = + 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; + +/** A wrapped cell's caption is replaced by the real column header from `sm`. */ +export const CAPTION_CLASS = 'sm:hidden'; + /** * The per-cell caption a wide table's value carries on a phone. * From 3bd9a6c3059b5a22d33167f7af533e11e4fb0172 Mon Sep 17 00:00:00 2001 From: WMP Date: Wed, 9 Sep 2026 17:08:28 +0200 Subject: [PATCH 11/44] Share sortable table column types --- .../reports/BillPaymentHistoryReport.tsx | 15 ++++++--------- .../reports/BudgetHealthScoreReport.tsx | 14 ++++++-------- .../components/reports/BudgetVsActualReport.tsx | 14 ++++++-------- .../reports/CategoryPerformanceReport.tsx | 14 ++++++-------- .../reports/CreditUtilizationReport.tsx | 15 ++++++--------- .../components/reports/CurrencyExposureReport.tsx | 14 ++++++-------- .../reports/FlexGroupAnalysisReport.tsx | 14 ++++++-------- .../components/reports/IncomeVsExpensesReport.tsx | 8 ++------ .../InvestmentTransactionHistoryReportParts.tsx | 14 ++++++-------- .../reports/RecurringExpensesReport.tsx | 14 ++++++-------- .../src/components/reports/SavingsRateReport.tsx | 7 ++----- .../components/reports/SectorWeightingsReport.tsx | 15 ++++++--------- .../reports/SecurityTypeAllocationReport.tsx | 15 ++++++--------- .../reports/UncategorizedTransactionsReport.tsx | 15 ++++++--------- frontend/src/components/ui/Table.tsx | 15 +++++++++++++++ 15 files changed, 91 insertions(+), 112 deletions(-) diff --git a/frontend/src/components/reports/BillPaymentHistoryReport.tsx b/frontend/src/components/reports/BillPaymentHistoryReport.tsx index 9f359a18dc..3af43e2432 100644 --- a/frontend/src/components/reports/BillPaymentHistoryReport.tsx +++ b/frontend/src/components/reports/BillPaymentHistoryReport.tsx @@ -22,6 +22,10 @@ import { exportToCsv } from '@/lib/csv-export'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useTranslations } from 'next-intl'; import { useReportData } from '@/hooks/useReportData'; @@ -36,12 +40,7 @@ type BillSortField = 'bill' | 'count' | 'average' | 'total' | 'lastPayment'; * phone sort strip -- so the two can never list different fields, and each * value cell takes its phone caption from the same entry as its header. */ -interface SortColumn { - field: BillSortField; - label: string; - /** How the column header aligns from `sm` up; the cells restate it. */ - align?: 'right' | 'center'; -} +type SortColumn = TableSortColumn; /** * The record the two header rows are built from, keyed by sort field. @@ -55,9 +54,7 @@ interface SortColumn { * none of which a test comparing header LABELS can see, because the labels * stay right. Here it is a compile error instead. */ -type SortColumnsByField = { - [K in BillSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; diff --git a/frontend/src/components/reports/BudgetHealthScoreReport.tsx b/frontend/src/components/reports/BudgetHealthScoreReport.tsx index c385bb440d..f975025953 100644 --- a/frontend/src/components/reports/BudgetHealthScoreReport.tsx +++ b/frontend/src/components/reports/BudgetHealthScoreReport.tsx @@ -8,6 +8,10 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportError } from '@/components/reports/ReportError'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useTranslations } from 'next-intl'; import { useReportData } from '@/hooks/useReportData'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; @@ -46,9 +50,7 @@ type CategoryImpactSortField = 'category' | 'group' | 'percentUsed' | 'impact'; * and rendered by BOTH header rows -- the column header row (from `sm` up) and * the phone sort strip -- so the two can never list different fields. */ -interface SortColumn { - field: CategoryImpactSortField; - label: string; +interface SortColumn extends TableSortColumn { /** * This column's cell, as text -- rendered by the `` AND by the PDF * export, which also takes its headings from this record. So the export @@ -64,8 +66,6 @@ interface SortColumn { * (the group pill), never a second derivation of the value. */ value: (cat: HealthScoreCategoryDetail) => string; - /** The two figure columns are right-aligned in the column header row. */ - align?: 'right'; /** The two text columns state today's explicit left alignment. */ headerAlign?: 'left'; /** @@ -89,9 +89,7 @@ interface SortColumn { * column would be unsortable -- none of which a test comparing header LABELS * can see, because the labels stay right. Here it is a compile error instead. */ -type SortColumnsByField = { - [K in CategoryImpactSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged: the two text columns are explicitly // left-aligned and the last column drops its right padding. diff --git a/frontend/src/components/reports/BudgetVsActualReport.tsx b/frontend/src/components/reports/BudgetVsActualReport.tsx index 43ec557239..1c4cb03f5a 100644 --- a/frontend/src/components/reports/BudgetVsActualReport.tsx +++ b/frontend/src/components/reports/BudgetVsActualReport.tsx @@ -25,6 +25,10 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportError } from '@/components/reports/ReportError'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { chartColors } from '@/lib/chart-colors'; @@ -35,9 +39,7 @@ type BudgetTrendSortField = 'month' | 'budgeted' | 'actual' | 'variance' | 'perc * rendered by BOTH header rows -- the column header row (from `sm` up) and the * phone sort strip -- so the two can never list different fields. */ -interface SortColumn { - field: BudgetTrendSortField; - label: string; +interface SortColumn extends TableSortColumn { /** * This column's cell, as text. The PDF export builds its headings AND its * row cells from the same ordered record the table renders, so the export @@ -46,8 +48,6 @@ interface SortColumn { * the new headings. */ value: (point: BudgetTrendPoint) => string; - /** Money and percent columns are right-aligned in the column header row. */ - align?: 'right'; /** * The last column carries no right padding, exactly as it does today. This * flag is the ONE place that is decided: the header cell and the body cell @@ -69,9 +69,7 @@ interface SortColumn { * none of which a test comparing header LABELS can see, because the labels * stay right. Here it is a compile error instead. */ -type SortColumnsByField = { - [K in BudgetTrendSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // An over-budget variance is prefixed; nothing else is. Written once because // the wrapped cell, the desktop cell and the PDF export all state it. diff --git a/frontend/src/components/reports/CategoryPerformanceReport.tsx b/frontend/src/components/reports/CategoryPerformanceReport.tsx index 1975ed2bdc..a599d8b2df 100644 --- a/frontend/src/components/reports/CategoryPerformanceReport.tsx +++ b/frontend/src/components/reports/CategoryPerformanceReport.tsx @@ -10,6 +10,10 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportError } from '@/components/reports/ReportError'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; import { useTranslations } from 'next-intl'; @@ -62,9 +66,7 @@ const varianceColor = (totalVariance: number) => * by BOTH header rows -- the column header row (from `sm` up) and the phone * sort strip -- so the two can never list different fields. */ -interface SortColumn { - field: CategoryPerformanceSortField; - label: string; +interface SortColumn extends TableSortColumn { /** * This column's cell, as text. The PDF export builds its headings AND its * row cells from the same ordered record the table renders, so the export @@ -73,8 +75,6 @@ interface SortColumn { * the new headings. */ value: (row: CategoryPerformanceRow) => string; - /** How the column header and its cells align from `sm` up. */ - align?: 'right' | 'center'; /** * The last column carries no right padding, exactly as it does today. This * flag is the ONE place that is decided: the header cell and the body cell @@ -96,9 +96,7 @@ interface SortColumn { * would be unsortable -- none of which a test comparing header LABELS can see, * because the labels stay right. Here it is a compile error instead. */ -type SortColumnsByField = { - [K in CategoryPerformanceSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. const headerClass = (col: SortColumn) => diff --git a/frontend/src/components/reports/CreditUtilizationReport.tsx b/frontend/src/components/reports/CreditUtilizationReport.tsx index 94ce701cd5..dbce70bbd8 100644 --- a/frontend/src/components/reports/CreditUtilizationReport.tsx +++ b/frontend/src/components/reports/CreditUtilizationReport.tsx @@ -30,6 +30,10 @@ import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMult import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { PartialTotal } from '@/components/ui/PartialTotal'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { ReportError } from '@/components/reports/ReportError'; @@ -58,12 +62,7 @@ type CreditUtilizationSortField = * never list different fields, and adding a member to the union fails `tsc` * rather than stranding a phone with no control for it. */ -interface SortColumn { - field: CreditUtilizationSortField; - label: string; - /** Money and percent columns are right-aligned in the column header row. */ - align?: 'right'; -} +type SortColumn = TableSortColumn; /** * The record the two header rows are built from, keyed by sort field. @@ -77,9 +76,7 @@ interface SortColumn { * and a test comparing header LABELS cannot see any of it, because the labels * stay right. Here it is a compile error instead. */ -type SortColumnsByField = { - [K in CreditUtilizationSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Utilization thresholds drive the bar colour: low (green), moderate (amber), // high (red). 30% / 75% mirror the common "keep utilization under 30%" guidance. diff --git a/frontend/src/components/reports/CurrencyExposureReport.tsx b/frontend/src/components/reports/CurrencyExposureReport.tsx index c9c0b9ce73..83ddc3b6c5 100644 --- a/frontend/src/components/reports/CurrencyExposureReport.tsx +++ b/frontend/src/components/reports/CurrencyExposureReport.tsx @@ -20,6 +20,10 @@ import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMult import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { PartialTotal } from '@/components/ui/PartialTotal'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; @@ -63,13 +67,9 @@ const FALLBACK_COLOURS = [CHART_SERIES[8], CHART_SERIES[9]]; * Deriving only the headings would relabel the exported columns while leaving * the values in the old order -- a silently mislabelled export. */ -interface SortColumn { - field: CurrencyExposureSortField; - label: string; +interface SortColumn extends TableSortColumn { /** The cell's text, rendered on screen and written to the PDF. */ value: (item: CurrencyAllocation) => string; - /** Money, rate, percent and count columns are right-aligned on desktop. */ - align?: 'right'; } /** @@ -84,9 +84,7 @@ interface SortColumn { * unsortable -- and a test comparing header LABELS cannot see any of it, * because the labels stay right. Here it is a compile error instead. */ -type SortColumnsByField = { - [K in CurrencyExposureSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. const HEADER_CLASS = diff --git a/frontend/src/components/reports/FlexGroupAnalysisReport.tsx b/frontend/src/components/reports/FlexGroupAnalysisReport.tsx index 1236318e7e..4e71232bea 100644 --- a/frontend/src/components/reports/FlexGroupAnalysisReport.tsx +++ b/frontend/src/components/reports/FlexGroupAnalysisReport.tsx @@ -22,6 +22,10 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportError } from '@/components/reports/ReportError'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; import { chartColors } from '@/lib/chart-colors'; @@ -52,9 +56,7 @@ const categoryRemaining = (cat: FlexGroupCategory) => cat.budgeted - cat.spent; * (from `sm` up) and the phone sort strip -- so the two can never list * different fields. */ -interface SortColumn { - field: FlexGroupSortField; - label: string; +interface SortColumn extends TableSortColumn { /** * This column's cell, as text. The PDF export builds its headings AND its * row cells from the same ordered record the table renders, so the export @@ -65,8 +67,6 @@ interface SortColumn { * prepended to both the headings and every row, so the pairing still holds.) */ value: (cat: FlexGroupCategory) => string; - /** The four figure columns are right-aligned in the column header row. */ - align?: 'right'; /** * The last column carries no right padding, exactly as it does today. This * flag is the ONE place that is decided: the header cell and the body cell @@ -88,9 +88,7 @@ interface SortColumn { * which a test comparing header LABELS can see, because the labels stay right. * Here it is a compile error instead. */ -type SortColumnsByField = { - [K in FlexGroupSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. const headerClass = (col: SortColumn) => diff --git a/frontend/src/components/reports/IncomeVsExpensesReport.tsx b/frontend/src/components/reports/IncomeVsExpensesReport.tsx index 41227c8dff..c8f675857e 100644 --- a/frontend/src/components/reports/IncomeVsExpensesReport.tsx +++ b/frontend/src/components/reports/IncomeVsExpensesReport.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useRef } from "react"; import { CellLabel, PHONE_HEADER_CLASS } from "@/components/ui/Table"; +import type { SortColumn as TableSortColumn } from '@/components/ui/Table'; import { Skeleton } from '@/components/ui/LoadingSkeleton'; import { useRouter } from "next/navigation"; import { @@ -39,12 +40,7 @@ type IncomeVsExpensesSortField = 'name' | 'income' | 'expenses' | 'savings' | 's * rendered by BOTH header rows -- the column header row (from `sm` up) and the * phone sort strip -- so the two can never list different fields. */ -interface SortColumn { - field: IncomeVsExpensesSortField; - label: string; - /** Money columns are right-aligned in the column header row. */ - align?: 'right'; -} +type SortColumn = TableSortColumn; const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; diff --git a/frontend/src/components/reports/InvestmentTransactionHistoryReportParts.tsx b/frontend/src/components/reports/InvestmentTransactionHistoryReportParts.tsx index 9bcb660b15..92c250ce32 100644 --- a/frontend/src/components/reports/InvestmentTransactionHistoryReportParts.tsx +++ b/frontend/src/components/reports/InvestmentTransactionHistoryReportParts.tsx @@ -7,6 +7,10 @@ * baselines are keyed per file. */ import { InvestmentAction, InvestmentTransaction } from '@/types/investment'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; export type InvestmentTxSortField = 'date' | 'action' | 'security' | 'account' | 'quantity' | 'price' | 'total'; @@ -17,11 +21,7 @@ export type InvestmentTxSortField = 'date' | 'action' | 'security' | 'account' | * value cell takes its phone caption from the same entry as its header, and the * CSV / PDF export builds its headings from that same ordered record. */ -export interface SortColumn { - field: InvestmentTxSortField; - label: string; - /** How the column header aligns from `sm` up; the cells restate it. */ - align?: 'right' | 'center'; +export interface SortColumn extends TableSortColumn { /** * The tier this column belongs to, spelled for BOTH of its halves here so * they cannot drift -- a header that returns at one breakpoint over values @@ -61,9 +61,7 @@ export interface SortColumn { * comparing header LABELS can see, because the labels stay right. Here it is a * compile error instead. */ -export type SortColumnsByField = { - [K in InvestmentTxSortField]: SortColumn & { field: K }; -}; +export type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. export const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; diff --git a/frontend/src/components/reports/RecurringExpensesReport.tsx b/frontend/src/components/reports/RecurringExpensesReport.tsx index a10a9dbca3..99ae0c4e5f 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.tsx @@ -24,6 +24,10 @@ import { exportToCsv } from '@/lib/csv-export'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; import { ReportError } from '@/components/reports/ReportError'; @@ -37,11 +41,7 @@ type RecurringSortField = 'payee' | 'category' | 'frequency' | 'count' | 'averag * cell takes its phone caption from the same entry as its header, and the CSV / * PDF export builds its headings and its cells from that same ordered record. */ -interface SortColumn { - field: RecurringSortField; - label: string; - /** How the column header aligns from `sm` up; the cells restate it. */ - align?: 'right' | 'center'; +interface SortColumn extends TableSortColumn { /** * This column's export heading and cell. They are separate from `label` * because the catalogue has always carried a second set of keys for the @@ -73,9 +73,7 @@ interface SortColumn { * -- none of which a test comparing header LABELS can see, because the labels * stay right. Here it is a compile error instead. */ -type SortColumnsByField = { - [K in RecurringSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; diff --git a/frontend/src/components/reports/SavingsRateReport.tsx b/frontend/src/components/reports/SavingsRateReport.tsx index 9883e2732e..a6a801711e 100644 --- a/frontend/src/components/reports/SavingsRateReport.tsx +++ b/frontend/src/components/reports/SavingsRateReport.tsx @@ -22,6 +22,7 @@ import { useNumberFormat } from '@/hooks/useNumberFormat'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { SortColumn as TableSortColumn } from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; import { ReportError } from '@/components/reports/ReportError'; @@ -36,11 +37,7 @@ type SavingsRateSortField = 'month' | 'income' | 'expenses' | 'savings' | 'rate' * once and rendered by BOTH header rows -- the column header row (from `sm` * up) and the phone sort strip -- so the two can never list different fields. */ -interface SortColumn { - field: SavingsRateSortField; - label: string; - /** Money columns are right-aligned in the column header row. */ - align?: 'right'; +interface SortColumn extends TableSortColumn { /** * The last column carries no right padding, exactly as it does today. This * flag is the ONE place that is decided: the header cell and the body cell diff --git a/frontend/src/components/reports/SectorWeightingsReport.tsx b/frontend/src/components/reports/SectorWeightingsReport.tsx index 71667fa89d..32e2e6e678 100644 --- a/frontend/src/components/reports/SectorWeightingsReport.tsx +++ b/frontend/src/components/reports/SectorWeightingsReport.tsx @@ -27,6 +27,10 @@ import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMult import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; @@ -43,12 +47,7 @@ type SectorSortField = 'sector' | 'direct' | 'etf' | 'total' | 'percentage'; * different fields, and adding a member to the union fails `tsc` rather than * stranding a phone with no control for it. */ -interface SortColumn { - field: SectorSortField; - label: string; - /** Money and percent columns are right-aligned on desktop. */ - align?: 'right'; -} +type SortColumn = TableSortColumn; /** * The record the two header rows are built from, keyed by sort field. @@ -62,9 +61,7 @@ interface SortColumn { * unsortable -- and a test comparing header LABELS cannot see any of it, * because the labels stay right. Here it is a compile error instead. */ -type SortColumnsByField = { - [K in SectorSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. const HEADER_CLASS = diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx index 4ed0be4ad7..b39c4cd2da 100644 --- a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx @@ -26,6 +26,10 @@ import { resolvePdfColor } from '@/components/reports/resolve-pdf-color'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { PartialTotal } from '@/components/ui/PartialTotal'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; @@ -44,12 +48,7 @@ type SecurityTypeSortField = 'label' | 'totalValue' | 'percentage' | 'count'; * different fields, and a new union member fails `tsc` rather than stranding a * phone with no control for it. */ -interface SortColumn { - field: SecurityTypeSortField; - label: string; - /** Money, percent and count columns are right-aligned on desktop. */ - align?: 'right'; -} +type SortColumn = TableSortColumn; /** * The record the two header rows are built from, each key tied to its entry's @@ -60,9 +59,7 @@ interface SortColumn { * share, and "Holdings" unsortable -- none of which a test comparing header * LABELS can see, because the labels stay right. Here it is a compile error. */ -type SortColumnsByField = { - [K in SecurityTypeSortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. const HEADER_CLASS = diff --git a/frontend/src/components/reports/UncategorizedTransactionsReport.tsx b/frontend/src/components/reports/UncategorizedTransactionsReport.tsx index 632980a986..5b39ba241f 100644 --- a/frontend/src/components/reports/UncategorizedTransactionsReport.tsx +++ b/frontend/src/components/reports/UncategorizedTransactionsReport.tsx @@ -15,6 +15,10 @@ import { exportToCsv } from '@/lib/csv-export'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; import { ReportError } from '@/components/reports/ReportError'; @@ -29,12 +33,7 @@ type SortField = 'date' | 'amount' | 'payee' | 'account'; * captioned value cell takes its phone caption from the same entry as its * header. */ -interface SortColumn { - field: SortField; - label: string; - /** How the column header aligns from `sm` up; the cells restate it. */ - align?: 'right' | 'center'; -} +type SortColumn = TableSortColumn; /** * The record the two header rows are built from, keyed by sort field. @@ -48,9 +47,7 @@ interface SortColumn { * which a test comparing header LABELS can see, because the labels stay right. * Here it is a compile error instead. */ -type SortColumnsByField = { - [K in SortField]: SortColumn & { field: K }; -}; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; diff --git a/frontend/src/components/ui/Table.tsx b/frontend/src/components/ui/Table.tsx index bc3ee8845d..96d2b428a6 100644 --- a/frontend/src/components/ui/Table.tsx +++ b/frontend/src/components/ui/Table.tsx @@ -43,6 +43,21 @@ export const PHONE_HEADER_CLASS = /** A wrapped cell's caption is replaced by the real column header from `sm`. */ export const CAPTION_CLASS = 'sm:hidden'; +/** Shared structural fields for a sortable data-table column. */ +export interface SortColumn< + Field extends string, + Alignment extends 'right' | 'center' = 'right' | 'center', +> { + field: Field; + label: string; + align?: Alignment; +} + +/** A complete column record whose key must equal the entry's `field`. */ +export type SortColumnsByField = { + [K in Field]: Column & { field: K }; +}; + /** * The per-cell caption a wide table's value carries on a phone. * From e024ca042a04f3c6ab48471e66a265c8b995e5fc Mon Sep 17 00:00:00 2001 From: WMP Date: Wed, 9 Sep 2026 17:28:25 +0200 Subject: [PATCH 12/44] Wrap realized gains tables on phones --- ...RealizedGainsReport.mobileWrapped.test.tsx | 250 ++++++++++++++++ .../reports/RealizedGainsReport.test.tsx | 6 + .../reports/RealizedGainsReport.tsx | 281 ++++++++++-------- 3 files changed, 407 insertions(+), 130 deletions(-) create mode 100644 frontend/src/components/reports/RealizedGainsReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/RealizedGainsReport.mobileWrapped.test.tsx b/frontend/src/components/reports/RealizedGainsReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..6b8078f611 --- /dev/null +++ b/frontend/src/components/reports/RealizedGainsReport.mobileWrapped.test.tsx @@ -0,0 +1,250 @@ +import { act, fireEvent, render, screen, waitFor } from '@/test/render'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RealizedGainsReport } from './RealizedGainsReport'; + +const mockGetRealizedGains = vi.fn(); +const mockGetInvestmentAccounts = vi.fn(); + +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ + ...numberFormatMockDefaults(), + formatCurrency: (value: number) => `MONEY<${value}>`, + formatCurrencyAxis: (value: number) => `AXIS<${value}>`, + formatNumber: (value: number) => `COUNT<${value}>`, + formatPercent: (value: number) => `PERCENT<${value}>`, + formatShareQuantity: (value: number) => `SHARES<${value}>`, + }), + }; +}); + +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (value: string) => `DATE<${value}>`, + }), +})); + +vi.mock('@/hooks/useExchangeRates', () => ({ + useExchangeRates: () => ({ + convertToDefault: (amount: number) => amount, + defaultCurrency: 'CAD', + }), +})); + +vi.mock('@/hooks/useDateRange', () => ({ + useDateRange: () => ({ + dateRange: '1y', + setDateRange: vi.fn(), + resolvedRange: { start: '2025-01-01', end: '2026-01-01' }, + isValid: true, + }), +})); + +vi.mock('@/components/ui/DateRangeSelector', () => ({ + DateRangeSelector: () =>
, +})); + +vi.mock('@/components/ui/ExportDropdown', () => ({ + ExportDropdown: () =>
, +})); + +vi.mock('@/components/reports/ReportAccountMultiSelect', () => ({ + ReportAccountMultiSelect: () =>
, +})); + +vi.mock('@/components/reports/RefreshPricesButton', () => ({ + RefreshPricesButton: () => , +})); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + BarChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + Bar: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, +})); + +vi.mock('@/lib/investments', () => ({ + investmentsApi: { + getRealizedGains: (...args: unknown[]) => mockGetRealizedGains(...args), + getInvestmentAccounts: (...args: unknown[]) => mockGetInvestmentAccounts(...args), + }, +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }), +})); + +const ENTRIES = [ + { + transactionId: 'sell-1', + transactionDate: '2025-06-15', + accountId: 'acc-1', + accountName: 'Brokerage', + accountCurrencyCode: 'CAD', + securityId: 'sec-1', + symbol: 'LONG', + securityName: 'A deliberately long security name that must wrap without a clamp', + securityCurrencyCode: 'CAD', + quantity: 12.3456789, + price: 100.5, + commission: 0, + proceeds: 1240, + costBasis: 1000, + realizedGain: 240, + }, + { + transactionId: 'sell-2', + transactionDate: '2025-07-20', + accountId: 'acc-1', + accountName: 'Brokerage', + accountCurrencyCode: 'CAD', + securityId: 'sec-1', + symbol: 'LONG', + securityName: 'A deliberately long security name that must wrap without a clamp', + securityCurrencyCode: 'CAD', + quantity: 2, + price: 80, + commission: 0, + proceeds: 160, + costBasis: 200, + realizedGain: -40, + }, +]; + +function placement(cell: Element): string { + const column = [...cell.classList].find((name) => name.startsWith('col-start-')); + const row = [...cell.classList].find((name) => name.startsWith('row-start-')); + return `${column}/${row}`; +} + +async function renderTables() { + mockGetRealizedGains.mockResolvedValue(ENTRIES); + mockGetInvestmentAccounts.mockResolvedValue([]); + const view = render(); + const tableButton = await screen.findByTitle('Table'); + await act(async () => { + fireEvent.click(tableButton); + }); + await waitFor(() => expect(view.container.querySelectorAll('table')).toHaveLength(2)); + return { + ...view, + securityTable: view.container.querySelectorAll('table')[0], + sellsTable: view.container.querySelectorAll('table')[1], + }; +} + +describe('RealizedGainsReport mobile wrapped tables', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + }); + + it('wraps the security summary and its total into three explicit phone lines', async () => { + const { securityTable } = await renderTables(); + + expect(securityTable).toHaveAttribute('role', 'table'); + expect(securityTable.className).toContain('block'); + expect(securityTable.className).toContain('sm:table'); + expect(securityTable.querySelector('thead')).toHaveAttribute('role', 'rowgroup'); + expect(securityTable.querySelector('tbody')).toHaveAttribute('role', 'rowgroup'); + expect(securityTable.querySelector('tfoot')).toHaveAttribute('role', 'rowgroup'); + + const row = securityTable.querySelector('tbody tr'); + expect(row).toHaveAttribute('role', 'row'); + expect(row?.className).toContain('grid-cols-2'); + expect(row?.className).toContain('sm:table-row'); + const cells = Array.from(row?.querySelectorAll('td') ?? []); + expect(cells).toHaveLength(5); + expect(cells.map(placement)).toEqual([ + 'col-start-1/row-start-1', + 'col-start-2/row-start-1', + 'col-start-1/row-start-2', + 'col-start-2/row-start-2', + 'col-start-1/row-start-3', + ]); + expect(cells[4].className).toContain('col-span-2'); + expect(cells.every((cell) => cell.getAttribute('role') === 'cell')).toBe(true); + expect(cells[0].querySelector('span')).toBeNull(); + expect(cells.slice(1).map((cell) => cell.querySelector('span')?.textContent)).toEqual([ + 'Trades', + 'Proceeds', + 'Cost Basis', + 'Gain/Loss', + ]); + expect(cells[0].textContent).toContain('A deliberately long security name'); + expect(cells[0].querySelector('.break-words')).toBeInTheDocument(); + expect(cells[1]).toHaveTextContent('COUNT<2>'); + + const totalCells = Array.from(securityTable.querySelectorAll('tfoot td')); + expect(totalCells.map(placement)).toEqual(cells.map(placement)); + expect(totalCells[1]).toHaveTextContent('COUNT<2>'); + expect(totalCells[4].className).toContain('col-span-2'); + }); + + it('wraps every sell transaction and uses preference-aware date and number formatters', async () => { + const { sellsTable } = await renderTables(); + const row = sellsTable.querySelector('tbody tr'); + expect(row).toHaveAttribute('role', 'row'); + expect(row?.className).toContain('grid-cols-2'); + expect(row?.className).toContain('sm:table-row'); + const cells = Array.from(row?.querySelectorAll('td') ?? []); + expect(cells).toHaveLength(5); + expect(cells.map(placement)).toEqual([ + 'col-start-1/row-start-1', + 'col-start-2/row-start-1', + 'col-start-1/row-start-2', + 'col-start-2/row-start-2', + 'col-start-1/row-start-3', + ]); + expect(cells[4].className).toContain('col-span-2'); + expect(cells.every((cell) => cell.getAttribute('role') === 'cell')).toBe(true); + expect(cells.map((cell) => cell.querySelector('span')?.textContent ?? null)).toEqual([ + 'Date', + null, + 'Shares', + 'Price', + 'Proceeds', + ]); + expect(cells[0]).toHaveTextContent('DATE<2025-07-20>'); + expect(cells[2]).toHaveTextContent('SHARES<2>'); + expect(cells[3]).toHaveTextContent('MONEY<80>'); + expect(cells[4]).toHaveTextContent('MONEY<160>'); + }); + + it('keeps all sort controls in accessible phone strips and restores the desktop table', async () => { + const { securityTable, sellsTable } = await renderTables(); + + for (const table of [securityTable, sellsTable]) { + const headerRows = table.querySelectorAll('thead tr'); + expect(headerRows).toHaveLength(2); + expect(headerRows[0].className).toContain('sm:hidden'); + expect(headerRows[1].className).toContain('hidden'); + expect(headerRows[1].className).toContain('sm:table-row'); + expect(headerRows[0].querySelectorAll('th')).toHaveLength(5); + expect(headerRows[1].querySelectorAll('th')).toHaveLength(5); + expect(table.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(table.querySelector('tbody')?.className).toContain('sm:table-row-group'); + for (const cell of table.querySelectorAll('tbody td')) { + expect(cell.className).toContain('sm:table-cell'); + } + } + + const activeSecuritySort = securityTable.querySelector( + 'thead tr.sm\\:hidden th[aria-sort="descending"]', + ); + const activeSellSort = sellsTable.querySelector( + 'thead tr.sm\\:hidden th[aria-sort="descending"]', + ); + expect(activeSecuritySort).toHaveTextContent('Gain/Loss'); + expect(activeSellSort).toHaveTextContent('Date'); + }); +}); diff --git a/frontend/src/components/reports/RealizedGainsReport.test.tsx b/frontend/src/components/reports/RealizedGainsReport.test.tsx index 8b51744145..47d950dd40 100644 --- a/frontend/src/components/reports/RealizedGainsReport.test.tsx +++ b/frontend/src/components/reports/RealizedGainsReport.test.tsx @@ -37,6 +37,12 @@ vi.mock('@/hooks/useDateRange', () => { }; }); +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (value: string) => value, + }), +})); + vi.mock('@/lib/utils', () => ({ parseLocalDate: (d: string) => new Date(d + 'T00:00:00'), cn: (...inputs: any[]) => inputs.flat(Infinity).filter(Boolean).join(' '), diff --git a/frontend/src/components/reports/RealizedGainsReport.tsx b/frontend/src/components/reports/RealizedGainsReport.tsx index c685d8e9b5..58926c779c 100644 --- a/frontend/src/components/reports/RealizedGainsReport.tsx +++ b/frontend/src/components/reports/RealizedGainsReport.tsx @@ -31,7 +31,15 @@ import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMult import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { exportToCsv } from '@/lib/csv-export'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { + CAPTION_CLASS, + CellLabel, + PHONE_HEADER_CLASS, + type SortColumn, + type SortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; +import { useDateFormat } from '@/hooks/useDateFormat'; import { createLogger } from '@/lib/logger'; const logger = createLogger('RealizedGainsReport'); @@ -39,6 +47,11 @@ const logger = createLogger('RealizedGainsReport'); type SecurityGainsSortField = 'symbol' | 'transactionCount' | 'totalProceeds' | 'totalCostBasis' | 'realizedGain'; type SellTransactionsSortField = 'date' | 'symbol' | 'quantity' | 'price' | 'proceeds'; +const HEADER_CLASS = + 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; +const FIGURE_CELL = + 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; + function CustomTooltip({ active, payload, fmtValue }: { active?: boolean; payload?: Array<{ value: number; payload: { symbol: string } }>; @@ -71,7 +84,14 @@ const ACCOUNTS_STORAGE_KEY = 'monize-reports-realized-gains-accounts'; export function RealizedGainsReport() { const t = useTranslations('reports'); const tCommon = useTranslations('common'); - const { formatCurrency: formatCurrencyFull, formatCurrencyAxis, formatPercent } = useNumberFormat(); + const { + formatCurrency: formatCurrencyFull, + formatCurrencyAxis, + formatNumber, + formatPercent, + formatShareQuantity, + } = useNumberFormat(); + const { formatDate } = useDateFormat(); const { defaultCurrency, convertToDefault } = useExchangeRates(); const [accounts, setAccounts] = useState([]); // Persisted so the report opens on the accounts the user last chose. @@ -91,6 +111,30 @@ export function RealizedGainsReport() { { field: 'date', direction: 'desc' }, ); + const securityGainColumns: SortColumnsByField< + SecurityGainsSortField, + SortColumn + > = { + symbol: { field: 'symbol', label: t('realizedGains.colSecurity') }, + transactionCount: { field: 'transactionCount', label: t('realizedGains.colTrades'), align: 'right' }, + totalProceeds: { field: 'totalProceeds', label: t('realizedGains.colProceeds'), align: 'right' }, + totalCostBasis: { field: 'totalCostBasis', label: t('realizedGains.colCostBasis'), align: 'right' }, + realizedGain: { field: 'realizedGain', label: t('realizedGains.colGainLoss'), align: 'right' }, + }; + const securityGainSortColumns = Object.values(securityGainColumns); + + const sellTransactionColumns: SortColumnsByField< + SellTransactionsSortField, + SortColumn + > = { + date: { field: 'date', label: t('realizedGains.colDate') }, + symbol: { field: 'symbol', label: t('realizedGains.colSecurity') }, + quantity: { field: 'quantity', label: t('realizedGains.colShares'), align: 'right' }, + price: { field: 'price', label: t('realizedGains.colPrice'), align: 'right' }, + proceeds: { field: 'proceeds', label: t('realizedGains.colProceeds'), align: 'right' }, + }; + const sellTransactionSortColumns = Object.values(sellTransactionColumns); + const selectedAccount = isSingleAccount ? accounts.find((a) => a.id === selectedAccountIds[0]) : undefined; @@ -466,101 +510,91 @@ export function RealizedGainsReport() {
- - - - - field="symbol" - sortField={securityGainsSort.sortField} - sortDirection={securityGainsSort.sortDirection} - onSort={securityGainsSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colSecurity')} - - - field="transactionCount" - sortField={securityGainsSort.sortField} - sortDirection={securityGainsSort.sortDirection} - onSort={securityGainsSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colTrades')} - - - field="totalProceeds" - sortField={securityGainsSort.sortField} - sortDirection={securityGainsSort.sortDirection} - onSort={securityGainsSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colProceeds')} - - - field="totalCostBasis" - sortField={securityGainsSort.sortField} - sortDirection={securityGainsSort.sortDirection} - onSort={securityGainsSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colCostBasis')} - - - field="realizedGain" - sortField={securityGainsSort.sortField} - sortDirection={securityGainsSort.sortDirection} - onSort={securityGainsSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colGainLoss')} - +
+ + + {securityGainSortColumns.map((column) => ( + + key={column.field} + field={column.field} + sortField={securityGainsSort.sortField} + sortDirection={securityGainsSort.sortDirection} + onSort={securityGainsSort.handleSort} + className={PHONE_HEADER_CLASS} + > + {column.label} + + ))} + + + {securityGainSortColumns.map((column) => ( + + key={column.field} + field={column.field} + sortField={securityGainsSort.sortField} + sortDirection={securityGainsSort.sortDirection} + onSort={securityGainsSort.handleSort} + align={column.align} + className={HEADER_CLASS} + > + {column.label} + + ))} - + {sortedSecurityGains.map((sg) => ( - - + - - - - ))} - - - + + - - - - @@ -579,77 +613,64 @@ export function RealizedGainsReport() {
-
+
{sg.symbol}
-
+
{sg.name}
- {sg.transactionCount} + + {securityGainColumns.transactionCount.label} + {formatNumber(sg.transactionCount, 0)} + + {securityGainColumns.totalProceeds.label} {fmtValue(sg.totalProceeds)} + + {securityGainColumns.totalCostBasis.label} {fmtValue(sg.totalCostBasis)} + + {securityGainColumns.realizedGain.label} {sg.realizedGain >= 0 ? '+' : ''}{fmtValue(sg.realizedGain)}
+
{t('realizedGains.total')} - {totals.totalTransactions} + + {securityGainColumns.transactionCount.label} + {formatNumber(totals.totalTransactions, 0)} + + {securityGainColumns.totalProceeds.label} {fmtValue(totals.totalProceeds)} + + {securityGainColumns.totalCostBasis.label} {fmtValue(totals.totalCostBasis)} + + {securityGainColumns.realizedGain.label} {totals.totalGain >= 0 ? '+' : ''}{fmtValue(totals.totalGain)}
- - - - field="date" - sortField={sellTransactionsSort.sortField} - sortDirection={sellTransactionsSort.sortDirection} - onSort={sellTransactionsSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colDate')} - - - field="symbol" - sortField={sellTransactionsSort.sortField} - sortDirection={sellTransactionsSort.sortDirection} - onSort={sellTransactionsSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colSecurity')} - - - field="quantity" - sortField={sellTransactionsSort.sortField} - sortDirection={sellTransactionsSort.sortDirection} - onSort={sellTransactionsSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colShares')} - - - field="price" - sortField={sellTransactionsSort.sortField} - sortDirection={sellTransactionsSort.sortDirection} - onSort={sellTransactionsSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colPrice')} - - - field="proceeds" - sortField={sellTransactionsSort.sortField} - sortDirection={sellTransactionsSort.sortDirection} - onSort={sellTransactionsSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('realizedGains.colProceeds')} - +
+ + + {sellTransactionSortColumns.map((column) => ( + + key={column.field} + field={column.field} + sortField={sellTransactionsSort.sortField} + sortDirection={sellTransactionsSort.sortDirection} + onSort={sellTransactionsSort.handleSort} + className={PHONE_HEADER_CLASS} + > + {column.label} + + ))} + + + {sellTransactionSortColumns.map((column) => ( + + key={column.field} + field={column.field} + sortField={sellTransactionsSort.sortField} + sortDirection={sellTransactionsSort.sortDirection} + onSort={sellTransactionsSort.handleSort} + align={column.align} + className={HEADER_CLASS} + > + {column.label} + + ))} - + {sortedEntries.map((entry) => ( - - + - - - -
- {format(parseLocalDate(entry.transactionDate), 'MMM d, yyyy')} +
+ {sellTransactionColumns.date.label} + {formatDate(entry.transactionDate)} +
{entry.symbol || 'N/A'}
- {entry.quantity.toFixed(4)} + + {sellTransactionColumns.quantity.label} + {formatShareQuantity(entry.quantity)} + + {sellTransactionColumns.price.label} {fmtValue(entry.price)} + + {sellTransactionColumns.proceeds.label} {(() => { const proceeds = toDisplay(entry.proceeds, entry.accountCurrencyCode); return proceeds === null From 6b80df36ef779af6b2685f89865e8dfaa1a301a6 Mon Sep 17 00:00:00 2001 From: WMP Date: Wed, 9 Sep 2026 17:43:13 +0200 Subject: [PATCH 13/44] Wrap investment performance table on phones --- ...ntPerformanceReport.mobileWrapped.test.tsx | 237 ++++++++++++++++++ .../reports/InvestmentPerformanceReport.tsx | 216 +++++++++------- 2 files changed, 358 insertions(+), 95 deletions(-) create mode 100644 frontend/src/components/reports/InvestmentPerformanceReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/InvestmentPerformanceReport.mobileWrapped.test.tsx b/frontend/src/components/reports/InvestmentPerformanceReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..7ca146963c --- /dev/null +++ b/frontend/src/components/reports/InvestmentPerformanceReport.mobileWrapped.test.tsx @@ -0,0 +1,237 @@ +import { act, fireEvent, render, waitFor } from '@/test/render'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { InvestmentPerformanceReport } from './InvestmentPerformanceReport'; + +const mockGetPortfolioSummary = vi.fn(); +const mockGetInvestmentAccounts = vi.fn(); + +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ + ...numberFormatMockDefaults(), + formatCurrency: (value: number, currency = 'CAD') => `${currency}<${value}>`, + formatPercent: (value: number) => `PLAIN<${value}>`, + formatShareQuantity: (value: number) => `SHARES<${value}>`, + formatSignedPercent: (value: number) => `SIGNED<${value}>`, + }), + }; +}); + +vi.mock('@/hooks/useExchangeRates', () => ({ + useExchangeRates: () => ({ defaultCurrency: 'CAD' }), +})); + +vi.mock('@/hooks/usePersistedAccountFilter', () => { + const filterState = [[], vi.fn(), vi.fn()] as const; + return { usePersistedAccountFilter: () => filterState }; +}); + +vi.mock('@/hooks/useMainAccountName', () => ({ + useMainAccountName: () => (name: string) => name, +})); + +vi.mock('@/components/reports/ReportAccountMultiSelect', () => ({ + ReportAccountMultiSelect: () =>
, +})); + +vi.mock('@/components/reports/RefreshPricesButton', () => ({ + RefreshPricesButton: () => , +})); + +vi.mock('@/components/ui/ExportDropdown', () => ({ + ExportDropdown: () =>
, +})); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + PieChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + Pie: ({ children }: { children: React.ReactNode }) =>
{children}
, + Cell: () => null, + Tooltip: () => null, + Legend: () => null, +})); + +vi.mock('@/lib/investments', () => ({ + investmentsApi: { + getPortfolioSummary: (...args: unknown[]) => mockGetPortfolioSummary(...args), + getInvestmentAccounts: (...args: unknown[]) => mockGetInvestmentAccounts(...args), + }, +})); + +const PORTFOLIO = { + holdings: [ + { + id: 'holding-1', + securityId: 'security-1', + accountId: 'account-1', + symbol: 'LONG', + name: 'A deliberately long holding name that wraps without a clamp', + quantity: 12.3456789, + averageCost: 80, + currentPrice: 100, + marketValue: 1234.56, + costBasis: 987.65, + costBasisAccountCurrency: 987.65, + gainLoss: 246.91, + gainLossPercent: 25, + currencyCode: 'CAD', + }, + { + id: 'holding-2', + securityId: 'security-1', + accountId: 'account-2', + symbol: 'LONG', + name: 'A deliberately long holding name that wraps without a clamp', + quantity: 2, + averageCost: 90, + currentPrice: 100, + marketValue: 200, + costBasis: 180, + costBasisAccountCurrency: 180, + gainLoss: 20, + gainLossPercent: 11.11, + currencyCode: 'CAD', + }, + ], + holdingsByAccount: [], + allocation: [], + totalPortfolioValue: 1434.56, + totalCostBasis: 1167.65, + totalGainLoss: 266.91, + totalGainLossPercent: 22.86, +}; + +const EXPECTED_PLACEMENT = [ + 'col-start-1/row-start-1', + 'col-start-1/row-start-2', + 'col-start-2/row-start-2', + 'col-start-1/row-start-3', + 'col-start-2/row-start-1', + 'col-start-2/row-start-3', + 'col-start-1/row-start-4', +]; + +function placement(cell: Element): string { + const column = [...cell.classList].find((name) => name.startsWith('col-start-')); + const row = [...cell.classList].find((name) => name.startsWith('row-start-')); + return `${column}/${row}`; +} + +async function renderReport() { + mockGetPortfolioSummary.mockResolvedValue(PORTFOLIO); + mockGetInvestmentAccounts.mockResolvedValue([ + { id: 'account-1', name: 'First account', currencyCode: 'CAD' }, + { id: 'account-2', name: 'Second account', currencyCode: 'CAD' }, + ]); + const view = render(); + const table = await waitFor(() => { + const result = view.container.querySelector('table'); + expect(result).toBeInTheDocument(); + return result!; + }); + return { ...view, table }; +} + +describe('InvestmentPerformanceReport mobile wrapped table', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + }); + + it('wraps every aggregated holding into four explicit phone lines', async () => { + const { table } = await renderReport(); + + expect(table).toHaveAttribute('role', 'table'); + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + expect(table.querySelector('thead')).toHaveAttribute('role', 'rowgroup'); + expect(table.querySelector('tbody')).toHaveAttribute('role', 'rowgroup'); + expect(table.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(table.querySelector('tbody')?.className).toContain('sm:table-row-group'); + + const row = table.querySelector('tbody tr'); + expect(row).toHaveAttribute('role', 'row'); + expect(row?.className).toContain('grid-cols-2'); + expect(row?.className).toContain('sm:table-row'); + const cells = Array.from(row?.querySelectorAll('td') ?? []); + expect(cells.map(placement)).toEqual(EXPECTED_PLACEMENT); + expect(cells[6].className).toContain('col-span-2'); + expect(cells.every((cell) => cell.getAttribute('role') === 'cell')).toBe(true); + expect(cells[0].querySelector('span.sm\\:hidden')).toBeNull(); + expect(cells.slice(1).map((cell) => cell.querySelector('span')?.textContent)).toEqual([ + 'Shares', + 'Avg Cost', + 'Current Price', + 'Market Value', + 'Gain/Loss', + 'Return', + ]); + expect(cells[0].querySelector('.break-words')).toHaveTextContent( + 'A deliberately long holding name that wraps without a clamp', + ); + expect(cells[1]).toHaveTextContent('SHARES<14.3456789>'); + for (const cell of cells) expect(cell.className).toContain('sm:table-cell'); + }); + + it.each(['Enter', ' '])('expands a holding with %s and wraps each account row', async (key) => { + const { table } = await renderReport(); + const holdingRow = table.querySelector('tbody tr')!; + expect(holdingRow).toHaveAttribute('tabindex', '0'); + expect(holdingRow).toHaveAttribute('aria-expanded', 'false'); + expect(holdingRow.className).toContain('focus-visible:outline-2'); + expect(holdingRow.querySelector('svg')).toHaveAttribute('aria-hidden', 'true'); + + await act(async () => { + fireEvent.keyDown(holdingRow, { key }); + }); + + expect(table.querySelector('tbody tr')).toHaveAttribute('aria-expanded', 'true'); + const rows = table.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(3); + const childCells = Array.from(rows[1].querySelectorAll('td')); + expect(rows[1]).toHaveAttribute('role', 'row'); + expect(rows[1].className).toContain('grid-cols-2'); + expect(rows[1].className).toContain('sm:table-row'); + expect(childCells.map(placement)).toEqual(EXPECTED_PLACEMENT); + expect(childCells[6].className).toContain('col-span-2'); + expect(childCells.every((cell) => cell.getAttribute('role') === 'cell')).toBe(true); + expect(childCells.map((cell) => cell.querySelector('span')?.textContent ?? null)).toEqual([ + null, + 'Shares', + 'Avg Cost', + 'Current Price', + 'Market Value', + 'Gain/Loss', + 'Return', + ]); + expect(childCells[0]).toHaveTextContent('First account'); + expect(childCells[1]).toHaveTextContent('SHARES<12.3456789>'); + }); + + it('keeps all seven accessible sort controls on phone and desktop', async () => { + const { table } = await renderReport(); + const headerRows = table.querySelectorAll('thead tr'); + expect(headerRows).toHaveLength(2); + expect(headerRows[0].className).toContain('sm:hidden'); + expect(headerRows[1].className).toContain('hidden'); + expect(headerRows[1].className).toContain('sm:table-row'); + expect(headerRows[0].querySelectorAll('th')).toHaveLength(7); + expect(headerRows[1].querySelectorAll('th')).toHaveLength(7); + const activePhoneSort = headerRows[0].querySelector('th[aria-sort="descending"]'); + expect(activePhoneSort).toHaveTextContent('Market Value'); + + const names = Array.from(headerRows[0].querySelectorAll('th')).map((header) => + header.textContent?.replace(/[↕↑↓]/g, '').trim(), + ); + expect(names).toEqual([ + 'Security', + 'Shares', + 'Avg Cost', + 'Current Price', + 'Market Value', + 'Gain/Loss', + 'Return', + ]); + }); +}); diff --git a/frontend/src/components/reports/InvestmentPerformanceReport.tsx b/frontend/src/components/reports/InvestmentPerformanceReport.tsx index 3ed2ed30de..ec4e0a51eb 100644 --- a/frontend/src/components/reports/InvestmentPerformanceReport.tsx +++ b/frontend/src/components/reports/InvestmentPerformanceReport.tsx @@ -21,6 +21,13 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMultiSelect'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { + CAPTION_CLASS, + CellLabel, + PHONE_HEADER_CLASS, + type SortColumn, + type SortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; import { usePersistedAccountFilter } from '@/hooks/usePersistedAccountFilter'; @@ -31,12 +38,34 @@ import { useMainAccountName } from '@/hooks/useMainAccountName'; type HoldingsSortField = 'symbol' | 'quantity' | 'averageCost' | 'currentPrice' | 'marketValue' | 'gainLoss' | 'gainLossPercent'; +const HEADER_CLASS = + 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; +const FIGURE_CELL = + 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; +const CHILD_FIGURE_CELL = + 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-2 sm:text-sm'; + +const CELL_PLACEMENT: Record = { + symbol: 'col-start-1 row-start-1', + quantity: 'col-start-1 row-start-2', + averageCost: 'col-start-2 row-start-2', + currentPrice: 'col-start-1 row-start-3', + marketValue: 'col-start-2 row-start-1', + gainLoss: 'col-start-2 row-start-3', + gainLossPercent: 'col-start-1 col-span-2 row-start-4', +}; + const ACCOUNTS_STORAGE_KEY = 'monize-reports-investment-performance-accounts'; export function InvestmentPerformanceReport() { const t = useTranslations('reports'); const mainAccountName = useMainAccountName(); - const { formatCurrency: formatCurrencyFull, formatSignedPercent, formatPercent: formatPlainPercent } = useNumberFormat(); + const { + formatCurrency: formatCurrencyFull, + formatPercent: formatPlainPercent, + formatShareQuantity, + formatSignedPercent, + } = useNumberFormat(); const { defaultCurrency } = useExchangeRates(); const chartRef = useRef(null); // Persisted so the report opens on the accounts the user last chose. @@ -52,6 +81,20 @@ export function InvestmentPerformanceReport() { { field: 'marketValue', direction: 'desc' }, ); + const columns: SortColumnsByField< + HoldingsSortField, + SortColumn + > = { + symbol: { field: 'symbol', label: t('investmentPerformance.colSecurity') }, + quantity: { field: 'quantity', label: t('investmentPerformance.colShares'), align: 'right' }, + averageCost: { field: 'averageCost', label: t('investmentPerformance.colAvgCost'), align: 'right' }, + currentPrice: { field: 'currentPrice', label: t('investmentPerformance.colCurrentPrice'), align: 'right' }, + marketValue: { field: 'marketValue', label: t('investmentPerformance.colMarketValue'), align: 'right' }, + gainLoss: { field: 'gainLoss', label: t('investmentPerformance.colGainLoss'), align: 'right' }, + gainLossPercent: { field: 'gainLossPercent', label: t('investmentPerformance.colReturn'), align: 'right' }, + }; + const sortColumns = Object.values(columns); + const { data: response, isLoading, error, reload } = useReportData( async () => { const [portfolioData, accountsData] = await Promise.all([ @@ -212,7 +255,7 @@ export function InvestmentPerformanceReport() { const headers = [t('investmentPerformance.colSecurity'), t('investmentPerformance.colShares'), t('investmentPerformance.colAvgCost'), t('investmentPerformance.colCurrentPrice'), t('investmentPerformance.colMarketValue'), t('investmentPerformance.colGainLoss'), t('investmentPerformance.colReturn')]; const rows = aggregatedHoldings.map((h) => [ `${h.symbol} - ${h.name}`, - h.quantity.toFixed(4), + formatShareQuantity(h.quantity), fmtHolding(h.averageCost, h.currencyCode), fmtHolding(h.currentPrice, h.currencyCode), fmtHolding(h.marketValue, h.currencyCode), @@ -382,97 +425,63 @@ export function InvestmentPerformanceReport() {
- - - - - field="symbol" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('investmentPerformance.colSecurity')} - - - field="quantity" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('investmentPerformance.colShares')} - - - field="averageCost" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('investmentPerformance.colAvgCost')} - - - field="currentPrice" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('investmentPerformance.colCurrentPrice')} - - - field="marketValue" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('investmentPerformance.colMarketValue')} - - - field="gainLoss" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('investmentPerformance.colGainLoss')} - - - field="gainLossPercent" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('investmentPerformance.colReturn')} - +
+ + + {sortColumns.map((column) => ( + + key={column.field} + field={column.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + className={PHONE_HEADER_CLASS} + > + {column.label} + + ))} + + + {sortColumns.map((column) => ( + + key={column.field} + field={column.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + align={column.align} + className={HEADER_CLASS} + > + {column.label} + + ))} - + {aggregatedHoldings.map((holding) => { const isExpandable = holding.accountBreakdowns.length > 1; const isExpanded = expandedSecurityId === holding.securityId; return ( setExpandedSecurityId(isExpanded ? null : holding.securityId) : undefined} + onKeyDown={isExpandable ? (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + setExpandedSecurityId(isExpanded ? null : holding.securityId); + } : undefined} > - - - - - - - {isExpanded && holding.accountBreakdowns.map((sub) => ( - - + - - - - - - From 4a0f5d5b770fea4b1c6eac7275233a24225b61a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 17:32:24 +0000 Subject: [PATCH 14/44] Sort Budget Health Score's Group column by the displayed label The Category sort compared the raw group enum (NEED/WANT/SAVING/null), so a localized reader saw rows ordered by the English enum rather than by the labels on screen, and the null group sorted to the bottom instead of beside its Uncategorized label. Sort on getGroupLabel, memoized so the order re-derives on a locale change. A regression test asserts the Uncategorized row precedes Want when sorting Group ascending. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- .../reports/BudgetHealthScoreReport.test.tsx | 27 +++++++++++++++++++ .../reports/BudgetHealthScoreReport.tsx | 15 +++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/reports/BudgetHealthScoreReport.test.tsx b/frontend/src/components/reports/BudgetHealthScoreReport.test.tsx index 95b5e8fc19..294f9b85f8 100644 --- a/frontend/src/components/reports/BudgetHealthScoreReport.test.tsx +++ b/frontend/src/components/reports/BudgetHealthScoreReport.test.tsx @@ -183,4 +183,31 @@ describe('BudgetHealthScoreReport', () => { await waitFor(() => expect(mockExportToPdf).toHaveBeenCalled()); expect(mockExportToPdf.mock.calls[0][0].summaryCards[0].color).toBe('#dc2626'); }); + + it('sorts the Group column by the displayed label, not the raw enum', async () => { + mockGetAll.mockResolvedValue([makeBudget()]); + mockGetHealthScore.mockResolvedValue(makeScore()); + const { container } = await renderReport(); + await waitFor(() => expect(screen.getByText('Groceries')).toBeInTheDocument()); + + // Click the Group column header (any of the sort controls carrying the + // label sorts the same field). + const groupHeader = Array.from( + container.querySelectorAll('[role="columnheader"]'), + ).find((el) => el.textContent?.includes('Group')) as HTMLElement; + await act(async () => { fireEvent.click(groupHeader); }); + + // Ascending by label: Need (Groceries), Saving (Savings), Uncategorized + // (Misc), Want (Dining). Sorting on the raw enum instead sends the null + // group to the bottom (its enum is null, which sorts last), so Misc came + // after Dining -- the defect this asserts against. + const bodyText = Array.from( + container.querySelector('tbody')!.querySelectorAll('tr'), + ).map((tr) => tr.textContent ?? ''); + const miscIdx = bodyText.findIndex((tRow) => tRow.includes('Misc')); + const diningIdx = bodyText.findIndex((tRow) => tRow.includes('Dining')); + expect(miscIdx).toBeGreaterThanOrEqual(0); + expect(diningIdx).toBeGreaterThanOrEqual(0); + expect(miscIdx).toBeLessThan(diningIdx); + }); }); diff --git a/frontend/src/components/reports/BudgetHealthScoreReport.tsx b/frontend/src/components/reports/BudgetHealthScoreReport.tsx index f975025953..72faeeced9 100644 --- a/frontend/src/components/reports/BudgetHealthScoreReport.tsx +++ b/frontend/src/components/reports/BudgetHealthScoreReport.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useRef } from 'react'; +import { useState, useMemo, useCallback, useRef } from 'react'; import { Skeleton } from '@/components/ui/LoadingSkeleton'; import { budgetsApi } from '@/lib/budgets'; import { BudgetHealthGauge } from '@/components/budgets/BudgetHealthGauge'; @@ -165,12 +165,12 @@ export function BudgetHealthScoreReport() { const { formatPercentTrimmed } = useNumberFormat(); const chartRef = useRef(null); - const getGroupLabel = (group: string | null): string => { + const getGroupLabel = useCallback((group: string | null): string => { if (group === 'NEED') return t('budgetHealthScore.groupNeed'); if (group === 'WANT') return t('budgetHealthScore.groupWant'); if (group === 'SAVING') return t('budgetHealthScore.groupSaving'); return t('budgetHealthScore.groupUncategorized'); - }; + }, [t]); const [selectedBudgetIdState, setSelectedBudgetId] = useState(''); const { sortField, sortDirection, handleSort } = useSortableTable( 'reports.budget-health-score.categoryImpact.sort', @@ -223,7 +223,12 @@ export function BudgetHealthScoreReport() { comparison = compareValues(a.categoryName, b.categoryName); break; case 'group': - comparison = compareValues(a.categoryGroup, b.categoryGroup); + // Sort by the label the row DISPLAYS, not the raw enum: the enum is + // English (`NEED`/`WANT`/`SAVING`), so ordering on it puts a + // localized reader's rows in an order unrelated to what they see, and + // a null group sorts as an empty string rather than beside its + // "Uncategorized" label. + comparison = compareValues(getGroupLabel(a.categoryGroup), getGroupLabel(b.categoryGroup)); break; case 'percentUsed': comparison = compareValues(a.percentUsed, b.percentUsed); @@ -235,7 +240,7 @@ export function BudgetHealthScoreReport() { return sortDirection === 'asc' ? comparison : -comparison; }); return sorted; - }, [healthScore, sortField, sortDirection]); + }, [healthScore, sortField, sortDirection, getGroupLabel]); // The four sortable columns, keyed by field so the record is exhaustive and // each entry must name its own key (see `SortColumnsByField`). From 5e0481181c52ce4a47e4bb703cee48ff1ebd0b1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 17:32:24 +0000 Subject: [PATCH 15/44] Guard the centralized mobile-table chrome constants PHONE_HEADER_CLASS and CAPTION_CLASS were centralized into components/ui/Table.tsx, but nothing stopped a report re-declaring them locally and drifting again (three copies had already lost a tracking token before they were shared). Add a source scan that fails on a local const re-declaration of either, outside Table.tsx. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- frontend/src/test/ui-conventions.test.ts | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/src/test/ui-conventions.test.ts b/frontend/src/test/ui-conventions.test.ts index 293d64776e..84b226e26c 100644 --- a/frontend/src/test/ui-conventions.test.ts +++ b/frontend/src/test/ui-conventions.test.ts @@ -310,6 +310,34 @@ describe("a scrollbar you need is not hidden", () => { }); }); +describe("the mobile-table chrome constants live once in Table.tsx", () => { + /** + * The phone sort-strip control class and the phone-only caption class are + * identical across every wrapped report, so they are exported from + * components/ui/Table.tsx and imported -- three copies had already drifted (a + * lost tracking token) before they were centralized. A local const + * re-declaration is that drift starting again. The per-row figure and header + * cell classes stay per-report deliberately, because their track budgets + * genuinely differ, so they are not policed here. + */ + const LOCAL_DECL = /\bconst\s+(PHONE_HEADER_CLASS|CAPTION_CLASS)\s*=/; + const HOME = "/src/components/ui/Table.tsx"; + + it("no file re-declares the shared chrome classes locally", () => { + const offenders: string[] = []; + for (const [path, content] of productionSources()) { + if (path === HOME) continue; + withoutComments(content) + .split("\n") + .forEach((line, i) => { + const match = line.match(LOCAL_DECL); + if (match) offenders.push(`${path}:${i + 1} re-declares ${match[1]}`); + }); + } + expect(offenders).toEqual([]); + }); +}); + describe("chart colours come from the theme tokens", () => { /** * `src/lib/chart-colors.ts` exposes `var(--chart-*)` strings so a chart From 600c0a4787247fc34ccfa739c361d3893e563521 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 17:58:47 +0000 Subject: [PATCH 16/44] Wrap Spending by Category table on phones Convert the table view of SpendingByCategoryReport to the phone-card layout (mechanism A): below `sm` the table, its row groups and each row become `block`/`grid` while staying an ordinary table from `sm` up, so nothing scrolls sideways on a phone. Layout: each row wraps into a two-track, two-line grid card. The category name (unbounded, so it wraps unclamped with `break-words sm:break-normal`) takes the whole of line 1; the amount and the % of Total share line 2, each captioned with `CellLabel` reusing the existing header key. Money cells carry `whitespace-nowrap` so a space-grouping locale cannot break a figure. Explicit `col-start`/`row-start` and restated `role` table/rowgroup/row/cell/columnheader put back the semantics a `display` restyle strips. The sortable header becomes a phone-only strip of the same SortableHeader chips, rendered (with the desktop header row) from one exhaustive mapped-type record so the two can never list different fields. The footer wraps the same way; every column has a total, so no cell leaves the DOM and none owes an `aria-colindex`. Rows stay clickable, as today. The `sm`-and-up output is unchanged apart from the phone-only additions and `whitespace-nowrap` on the figure cells. No new i18n keys. Adds SpendingByCategoryReport.mobileWrapped.test.tsx (pins placement, roles, captions, header control count, the phone sort strip, the footer, and row clickability; fails on the pre-conversion markup) and updates the existing test's header-click to address the column header row by position. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...ingByCategoryReport.mobileWrapped.test.tsx | 374 ++++++++++++++++++ .../reports/SpendingByCategoryReport.test.tsx | 11 +- .../reports/SpendingByCategoryReport.tsx | 226 +++++++++-- 3 files changed, 564 insertions(+), 47 deletions(-) create mode 100644 frontend/src/components/reports/SpendingByCategoryReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/SpendingByCategoryReport.mobileWrapped.test.tsx b/frontend/src/components/reports/SpendingByCategoryReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..754b226880 --- /dev/null +++ b/frontend/src/components/reports/SpendingByCategoryReport.mobileWrapped.test.tsx @@ -0,0 +1,374 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, act } from '@/test/render'; +import { SpendingByCategoryReport } from './SpendingByCategoryReport'; + +/** + * The phone layout of the Spending by Category data table. + * + * The table is ONE tree restyled by CSS (mechanism A): below `sm` each row wraps + * into a two-track, two-line grid card and the column header row is hidden, from + * `sm` up it is the ordinary table. jsdom applies no media queries, so both + * header rows and every phone caption are in the DOM here at all times -- which + * is what lets these assertions read the phone markup without emulating a + * viewport, and why the sort controls are addressed by position rather than by + * label (each label matches the phone strip, the column header row, and a + * caption). + * + * This report defaults to the pie view, so every case switches to the table + * view first (the table only mounts there). + */ + +const mockPush = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush }), +})); + +// Spread the shared defaults so a new formatter call in the component cannot +// crash this suite; the compact currency formatter (0dp, grouped) is what the +// table's amount cells really use. +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ ...numberFormatMockDefaults() }), + }; +}); + +let mockIsValid = true; +vi.mock('@/hooks/useDateRange', () => { + const resolvedRange = { start: '2025-01-01', end: '2025-03-31' }; + return { + useDateRange: () => ({ + dateRange: '3m', + setDateRange: vi.fn(), + startDate: '', + setStartDate: vi.fn(), + endDate: '', + setEndDate: vi.fn(), + resolvedRange, + get isValid() { + return mockIsValid; + }, + }), + }; +}); + +vi.mock('@/lib/chart-colours', () => ({ + CHART_COLOURS: ['#3b82f6', '#ef4444', '#22c55e', '#f97316'], +})); + +vi.mock('@/components/ui/DateRangeSelector', () => ({ + DateRangeSelector: () =>
, +})); + +vi.mock('@/components/ui/ChartViewToggle', () => ({ + ChartViewToggle: ({ onChange }: any) => ( +
+ +
+ ), +})); + +vi.mock('@/components/ui/ExportDropdown', () => ({ + ExportDropdown: () =>
, +})); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: any) =>
{children}
, + PieChart: ({ children }: any) =>
{children}
, + Pie: () => null, + Cell: () => null, + Tooltip: () => null, + BarChart: ({ children }: any) =>
{children}
, + Bar: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, +})); + +const mockGetSpendingByCategory = vi.fn(); +vi.mock('@/lib/built-in-reports', () => ({ + builtInReportsApi: { + getSpendingByCategory: (...args: any[]) => mockGetSpendingByCategory(...args), + }, +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})); + +// Value order and name order differ (Zebra/Apple/Mango), so a sort click that +// changes the visible order is observable. Mango carries no id, so its row is +// the non-clickable case. +const DATA = [ + { categoryId: 'c-zebra', categoryName: 'Zebra', total: 500, color: '#ff0000' }, + { categoryId: 'c-apple', categoryName: 'Apple', total: 300, color: '' }, + { categoryId: '', categoryName: 'Mango', total: 200, color: '' }, +]; + +async function renderReport() { + mockGetSpendingByCategory.mockResolvedValue({ data: DATA, totalSpending: 1000 }); + let container!: HTMLElement; + await act(async () => { + ({ container } = render()); + }); + await waitFor(() => expect(screen.getByTestId('toggle-table')).toBeInTheDocument()); + await act(async () => { + fireEvent.click(screen.getByTestId('toggle-table')); + }); + await waitFor(() => expect(container.querySelector('table')).toBeInTheDocument()); + return container; +} + +const rowText = (row: Element | null | undefined) => row?.textContent ?? ''; + +const findRow = (container: Element, name: string) => + Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.querySelector('td')?.textContent?.includes(name), + ); + +/** `c/r` for a cell, read off its explicit grid placement. */ +const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const line = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + return `c${col}/r${line}`; +}; + +describe('SpendingByCategoryReport (phone wrapped table)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPush.mockClear(); + mockIsValid = true; + window.localStorage.clear(); + }); + + it('captions every figure inside the row so a phone needs no column header', async () => { + const container = await renderReport(); + + const row = findRow(container, 'Zebra'); + expect(row).toBeDefined(); + for (const caption of ['Amount', '% of Total']) { + expect(rowText(row)).toContain(caption); + } + // Each caption sits immediately beside the value it names, as its own text + // node, so a value read still matches the value node. + expect(rowText(row)).toContain('Amount$500'); + expect(rowText(row)).toContain('% of Total50.0%'); + // The identity (the category name) is self-describing and carries no caption. + const identity = row!.querySelector('td')!; + expect(identity.textContent).toBe('Zebra'); + expect(rowText(identity)).not.toContain('Category'); + }); + + it('places every cell on the phone grid explicitly, and never wraps a figure', async () => { + const container = await renderReport(); + + const rows = Array.from(container.querySelectorAll('tbody tr')); + expect(rows).toHaveLength(3); + for (const row of rows) { + const cells = Array.from(row.querySelectorAll('td')); + expect(cells).toHaveLength(3); + for (const cell of cells) { + // Auto-flow placement is not deterministic, so each cell states its own + // column and line. + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + } + // The two figure cells (amount and share) never wrap and are right + // aligned; the identity cell is neither. + const figures = cells.filter((c) => c.className.includes('whitespace-nowrap')); + expect(figures).toHaveLength(2); + for (const cell of figures) { + expect(cell.className).toContain('text-right'); + } + } + }); + + it('wraps each row onto two lines: the category, then the amount and the share', async () => { + const container = await renderReport(); + + for (const row of Array.from(container.querySelectorAll('tbody tr'))) { + const [category, amount, share] = Array.from(row.querySelectorAll('td')); + expect(row.className).toContain('grid-cols-[minmax(0,1fr)_minmax(0,1fr)]'); + // The category takes the whole of line 1, the amount and the share split + // line 2 beneath it. + expect(category.className).toContain('col-span-2'); + expect(placement(category)).toBe('c1/r1'); + expect(placement(amount)).toBe('c1/r2'); + expect(placement(share)).toBe('c2/r2'); + // Nothing is placed on a third line. + for (const cell of [category, amount, share]) { + expect(cell.className).not.toMatch(/\brow-start-3\b/); + } + } + }); + + it('gives the totals row the data row placement, every cell captioned', async () => { + const container = await renderReport(); + + const footRow = container.querySelector('tfoot tr')!; + expect(footRow.className).toContain('grid-cols-[minmax(0,1fr)_minmax(0,1fr)]'); + const [total, amount, share] = Array.from(footRow.querySelectorAll('td')); + expect(total.textContent).toBe('Total'); + expect(placement(total)).toBe('c1/r1'); + expect(placement(amount)).toBe('c1/r2'); + expect(placement(share)).toBe('c2/r2'); + + // Every column has a total, so no footer cell leaves the DOM and none owes + // an `aria-colindex`. + for (const cell of Array.from(footRow.querySelectorAll('td'))) { + expect(cell.getAttribute('aria-colindex')).toBeNull(); + } + + // The totals are the largest figures on the table and carry their captions + // like any other cell, so a phone reader is not left with two bare numbers. + for (const cell of [amount, share]) { + expect(cell.className).toContain('font-bold'); + expect(cell.className).toContain('whitespace-nowrap'); + } + expect(footRow.textContent).toContain('Amount$1,000'); + expect(footRow.textContent).toContain('% of Total100%'); + }); + + it('keeps the identity a colour dot beside an unclamped, uncaptioned name', async () => { + const container = await renderReport(); + + const identity = findRow(container, 'Zebra')!.querySelector('td')!; + const inner = identity.querySelector('div')!; + expect(inner.className).toBe('flex items-center gap-2'); + // A category name is unbounded, so it wraps rather than clamps or truncates. + const name = inner.querySelector('span')!; + expect(name.className).toContain('break-words'); + expect(name.className).toContain('sm:break-normal'); + expect(identity.className).not.toContain('line-clamp'); + expect(identity.className).not.toContain('truncate'); + expect(identity.textContent).toBe('Zebra'); + expect(inner.querySelector('div')!.className).toContain('rounded-full'); + }); + + it('keeps the row a table row from sm up and a grid below it', async () => { + const container = await renderReport(); + + const table = container.querySelector('table'); + expect(table?.className).toContain('block'); + expect(table?.className).toContain('sm:table'); + expect(container.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(container.querySelector('tbody')?.className).toContain('sm:table-row-group'); + expect(container.querySelector('tfoot')?.className).toContain('sm:table-footer-group'); + const row = container.querySelector('tbody tr'); + expect(row?.className).toContain('grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)]'); + expect(row?.className).toContain('sm:table-row'); + expect(table?.parentElement?.className).toContain('overflow-x-auto'); + }); + + it('restores the table semantics a phone restyle strips', async () => { + const container = await renderReport(); + + const table = container.querySelector('table'); + expect(table?.getAttribute('role')).toBe('table'); + for (const group of ['thead', 'tbody', 'tfoot']) { + expect(container.querySelector(group)?.getAttribute('role')).toBe('rowgroup'); + } + for (const row of Array.from(container.querySelectorAll('table tr'))) { + expect(row.getAttribute('role')).toBe('row'); + } + // Three data rows of three cells plus a three-cell footer. + const cells = Array.from(container.querySelectorAll('table td')); + expect(cells.length).toBe(12); + for (const cell of cells) { + expect(cell.getAttribute('role')).toBe('cell'); + } + // `SortableHeader` restates `columnheader` on the `
` a `grid grid-cols-2 ... sm:table-row`; every `
+
-
+
{holding.symbol}
-
+
{holding.name} {isExpandable && ( @@ -483,6 +492,7 @@ export function InvestmentPerformanceReport() {
{isExpandable && (
- {holding.quantity.toFixed(4)} + + {columns.quantity.label} + {formatShareQuantity(holding.quantity)} + + {columns.averageCost.label} {fmtHolding(holding.averageCost, holding.currencyCode)} + + {columns.currentPrice.label} {fmtHolding(holding.currentPrice, holding.currencyCode)} + + {columns.marketValue.label} {fmtHolding(holding.marketValue, holding.currencyCode)} = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}> + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'} ${FIGURE_CELL}`}> + {columns.gainLoss.label} {fmtHolding(holding.gainLoss, holding.currencyCode)} = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}> + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'} ${FIGURE_CELL}`}> + {columns.gainLossPercent.label} {holding.gainLossPercent !== null ? formatPercent(holding.gainLossPercent) : t('investmentPerformance.na')}
+
{accountNameById.get(sub.accountId) || t('investmentPerformance.unknownAccount')} - {sub.quantity.toFixed(4)} + + {columns.quantity.label} + {formatShareQuantity(sub.quantity)} + + {columns.averageCost.label} {fmtHolding(sub.averageCost, sub.currencyCode)} + + {columns.currentPrice.label} {fmtHolding(sub.currentPrice, sub.currencyCode)} + + {columns.marketValue.label} {fmtHolding(sub.marketValue, sub.currencyCode)} = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}> + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'} ${CHILD_FIGURE_CELL}`}> + {columns.gainLoss.label} {fmtHolding(sub.gainLoss, sub.currencyCode)} = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}> + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'} ${CHILD_FIGURE_CELL}`}> + {columns.gainLossPercent.label} {sub.gainLossPercent !== null ? formatPercent(sub.gainLossPercent) : t('investmentPerformance.na')}
` it renders, so both + // header rows carry it. + for (const th of Array.from(container.querySelectorAll('table th'))) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + }); + + it('offers the same three sort controls on phones as in the column header', async () => { + const container = await renderReport(); + + const headerRows = Array.from(container.querySelectorAll('thead tr')); + expect(headerRows).toHaveLength(2); + const [phoneRow, columnRow] = headerRows; + // Exactly one of the two is displayed at any width. + expect(phoneRow.className).toContain('sm:hidden'); + expect(columnRow.className).toContain('hidden'); + expect(columnRow.className).toContain('sm:table-row'); + + const labelsOf = (row: Element) => + Array.from(row.querySelectorAll('th')).map((th) => + th.textContent?.replace(/[↑↓↕]/g, '').trim(), + ); + expect(labelsOf(phoneRow)).toEqual(['Category', 'Amount', '% of Total']); + // Both rows are rendered from one list, so they cannot list different fields. + expect(labelsOf(columnRow)).toEqual(labelsOf(phoneRow)); + // No column is stranded without a control: every header row carries exactly + // as many controls as a data row has cells. + const cellsPerRow = + container.querySelectorAll('tbody tr td').length / + container.querySelectorAll('tbody tr').length; + expect(phoneRow.querySelectorAll('th')).toHaveLength(cellsPerRow); + expect(columnRow.querySelectorAll('th')).toHaveLength(cellsPerRow); + }); + + it('sorts from the phone strip, not only from the column header', async () => { + const container = await renderReport(); + + const nameOrder = () => + Array.from(container.querySelectorAll('tbody tr')).map( + (r) => r.querySelector('td')?.textContent, + ); + // The stored default is the amount, descending. + expect(nameOrder()).toEqual(['Zebra', 'Apple', 'Mango']); + + // "Category" in the PHONE strip -- identified by the class that hides it + // from `sm` up, so this cannot silently fall through to the column header. + // Within it the control is the first of three, addressed by position because + // the label also appears in the column header and every caption. + const phoneStrip = Array.from(container.querySelectorAll('thead tr')).find((r) => + r.className.includes('sm:hidden'), + ); + expect(phoneStrip).toBeDefined(); + await act(async () => { + fireEvent.click(phoneStrip!.querySelectorAll('th')[0]); + }); + expect(nameOrder()).toEqual(['Apple', 'Mango', 'Zebra']); + + // A second tap reverses it. + await act(async () => { + fireEvent.click(phoneStrip!.querySelectorAll('th')[0]); + }); + expect(nameOrder()).toEqual(['Zebra', 'Mango', 'Apple']); + }); + + it('keeps the row clickable, as it is today', async () => { + const container = await renderReport(); + + // A row that names a category navigates to its transactions. + const zebra = findRow(container, 'Zebra')!; + expect(zebra.className).toContain('cursor-pointer'); + await act(async () => { + fireEvent.click(zebra); + }); + expect(mockPush).toHaveBeenCalledWith( + '/transactions?categoryId=c-zebra&startDate=2025-01-01&endDate=2025-03-31', + ); + + // A row with no category id is inert and carries no pointer cue. + mockPush.mockClear(); + const mango = findRow(container, 'Mango')!; + expect(mango.className).not.toContain('cursor-pointer'); + await act(async () => { + fireEvent.click(mango); + }); + expect(mockPush).not.toHaveBeenCalled(); + }); + + it('leaves the controls outside the table alone', async () => { + await renderReport(); + + expect(screen.getByTestId('date-range-selector')).toBeInTheDocument(); + expect(screen.getByTestId('chart-view-toggle')).toBeInTheDocument(); + expect(screen.getByTestId('export-dropdown')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/reports/SpendingByCategoryReport.test.tsx b/frontend/src/components/reports/SpendingByCategoryReport.test.tsx index 3b05228e9a..5b8cd80443 100644 --- a/frontend/src/components/reports/SpendingByCategoryReport.test.tsx +++ b/frontend/src/components/reports/SpendingByCategoryReport.test.tsx @@ -417,10 +417,13 @@ describe("SpendingByCategoryReport", () => { expect(screen.getByTestId("toggle-table")).toBeInTheDocument(); }); fireEvent.click(screen.getByTestId("toggle-table")); - // Click each sort header to exercise comparators (default desc by value). - const categoryHeader = screen.getByText("Category"); - const amountHeader = screen.getByText("Amount"); - const pctHeader = screen.getByText("% of Total"); + // Each header label now appears in three places (the phone sort strip, the + // column header row and the cell captions), so address the column header + // row by position rather than by label. + const columnHeader = document.querySelectorAll("table thead tr")[1]; + const [categoryHeader, amountHeader, pctHeader] = Array.from( + columnHeader.querySelectorAll("th"), + ); fireEvent.click(categoryHeader); // sort by name fireEvent.click(categoryHeader); // toggle desc fireEvent.click(pctHeader); diff --git a/frontend/src/components/reports/SpendingByCategoryReport.tsx b/frontend/src/components/reports/SpendingByCategoryReport.tsx index 99e119fc7e..2934c834fe 100644 --- a/frontend/src/components/reports/SpendingByCategoryReport.tsx +++ b/frontend/src/components/reports/SpendingByCategoryReport.tsx @@ -28,6 +28,7 @@ import { DateRangeSelector } from '@/components/ui/DateRangeSelector'; import { ChartViewToggle } from '@/components/ui/ChartViewToggle'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { CellLabel } from '@/components/ui/Table'; import { ChartTooltipPanel } from '@/components/reports/ChartTooltip'; import { ReportError } from '@/components/reports/ReportError'; import { exportToCsv } from '@/lib/csv-export'; @@ -37,6 +38,81 @@ type SpendingCategorySortField = 'name' | 'value' | 'percentage'; type ChartDataItem = ChartDatum & { id: string; colour: string }; +/** + * One column of the data table. The three are declared once, as a record over + * the sort field union, and rendered by BOTH header rows -- the column header + * row (from `sm` up) and the phone sort strip -- so the two can never list + * different fields, and adding a member to the union fails `tsc` rather than + * stranding a phone with no control for it. The labels double as the phone + * captions, so a value reads under exactly the label its column header uses. + */ +interface SortColumn { + field: SpendingCategorySortField; + label: string; + /** The amount and percentage columns are right-aligned on desktop. */ + align?: 'right'; +} + +/** + * The record the two header rows are built from, keyed by sort field. + * + * The key is tied to the entry's own `field`, which a plain + * `Record` does not do: that forces an + * entry to EXIST for every member of the union but lets it name a different + * one, so `value: { field: 'name', label: colAmount }` type-checks. Both header + * rows would then render two controls keyed `name` (a duplicate React key), + * tapping "Amount" would sort by Category, and "Amount" would be unsortable -- + * and a test comparing header LABELS cannot see any of it, because the labels + * stay right. Here it is a compile error instead. + */ +type SortColumnsByField = { + [K in SpendingCategorySortField]: SortColumn & { field: K }; +}; + +// Today's header cell, unchanged. This report's SortableHeader is not +// upper-tracked, so the local class matches the pre-conversion markup exactly +// (no `tracking-wider`); the `sm`-and-up header is byte-for-byte today's. +const HEADER_CLASS = + 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; + +// The same sort controls in the phone strip: a wrapped row of compact chips. +// Column alignment means nothing there -- the column header row is hidden and +// each data row is a grid -- so every control is left-aligned and self-naming. +// The border and card background are what say "tappable": there is no hover on +// a touch screen, and without them the strip reads as another row of the +// captions the cells below carry. +const PHONE_HEADER_CLASS = + 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; + +// A wrapped cell's caption is replaced by the real column header from `sm` up. +const CAPTION_CLASS = 'sm:hidden'; + +// A figure cell inside a wrapped card: no padding of its own below `sm` (the +// row supplies it and the grid does the spacing), the table cell's own padding +// from `sm` up. Smaller type on phones. `whitespace-nowrap` is the one property +// here that is NOT phone-only -- the single respect in which the `sm`-and-up +// cell differs from today's -- so a locale that groups thousands with a space +// cannot break a figure in the middle of a number. The amount uses the compact +// formatter, so the two figures share line 2 comfortably at 320px. +const FIGURE_CELL = + 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; + +// The row's phone grid: two equal tracks. The category takes the whole of line +// 1 (it is an unbounded name and wraps), the amount and the share split line 2 +// -- the amount under the left half, the share under the right. Shared by the +// data rows and the totals footer so a reader finds each figure in the same +// corner of every card. Inert from `sm` up, where each row is a table row again. +const ROW_GRID = + 'grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-start gap-x-3 gap-y-1.5 px-4 py-3'; + +// The identity cell (the coloured dot and the category name), the same box in +// the data rows and the footer. `min-w-0` lets the name shrink in its track; +// the name itself wraps unclamped below `sm` (`break-words`) and takes today's +// `break-normal` back from `sm` up. Keeps `text-sm` on phones -- a category +// name is prose, not a figure. +const IDENTITY_CELL = + 'col-start-1 col-span-2 row-start-1 min-w-0 p-0 text-sm sm:table-cell sm:px-4 sm:py-3'; + export function SpendingByCategoryReport() { const t = useTranslations('reports'); const router = useRouter(); @@ -106,6 +182,24 @@ export function SpendingByCategoryReport() { return sorted; }, [chartData, sortField, sortDirection, totalExpenses]); + // Exhaustive over the sort field union, so a new field is a compile error + // rather than a column with no control in either header -- and each entry + // must name its own key (see `SortColumnsByField`). These labels are also the + // phone captions, so a value reads under exactly the label its column header + // uses. + const columns: SortColumnsByField = { + name: { field: 'name', label: t('spendingByCategory.colCategory') }, + value: { field: 'value', label: t('spendingByCategory.colAmount'), align: 'right' }, + percentage: { field: 'percentage', label: t('spendingByCategory.colPctOfTotal'), align: 'right' }, + }; + + // Their order, rendered by BOTH header rows and matched by the cells' DOM + // order. DERIVED from the record rather than re-listed, so a field added to + // the union cannot ship with no sort control in either header. The record's + // declaration order IS the column order, and it is today's: category, amount, + // share. + const sortColumns: readonly SortColumn[] = Object.values(columns); + const handleExportPdf = async () => { const { exportToPdf } = await import('@/lib/pdf-export'); @@ -209,73 +303,119 @@ export function SpendingByCategoryReport() {

) : viewType === 'table' ? ( <> + {/* Data Table + + Below `sm` the table becomes a block and each row wraps into a + two-track grid so all three columns fit a phone without a + horizontal scroll, on two lines: the category takes line 1 (it + is an unbounded name and wraps freely), the amount and the + portfolio share share line 2. Nothing is dropped -- the card + carries all three columns -- and the row stays what it is today: + hovering, and clickable when it names a category. From `sm` up it + is the ordinary table. The sort controls survive as their own + phone-only header row, because the column header row that carries + them on desktop is hidden there. + + Restyling `display` below `sm` strips the implicit table + semantics, so the explicit ARIA roles put them back (inert from + `sm` up). Every row exposes all three cells at every width, so no + cell leaves the DOM and no `aria-colindex` is owed. The + `CellLabel` captions name each figure's column for a sighted + phone reader, who has no column header to look up. */}
- - - - - field="name" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('spendingByCategory.colCategory')} - - - field="value" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('spendingByCategory.colAmount')} - - - field="percentage" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('spendingByCategory.colPctOfTotal')} - + {/* Explicit roles: restyling `display` below `sm` strips the + implicit table semantics, and these put them back (inert from + `sm` up). */} +
+ + {/* Phone sort strip: the same three controls, wrapped. */} + + {sortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + className={PHONE_HEADER_CLASS} + > + {col.label} + + ))} + + + {sortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + align={col.align} + className={HEADER_CLASS} + > + {col.label} + + ))} - + {sortedTableData.map((item) => { const percentage = totalExpenses > 0 ? (item.value / totalExpenses) * 100 : 0; return ( item.id && handleCategoryClick(item.id)} > - - - ); })} - - - - + {/* The totals are the largest figures on the table, so this + row wraps exactly the way a data row does -- the same two + tracks and the same placement, each figure captioned -- + with "Total" standing in for the category in the identity + track. Every column has a total, so no cell leaves the DOM + below `sm` and the footer stays a full three-cell row at + every width. */} + + + - +
+ {/* The identity. A category name is unbounded, so it + takes the whole of line 1 and wraps unclamped + (`break-words`), taking today's wrapping back from + `sm` up. */} +
- {item.name} + {item.name}
+ {/* The amount is the headline: the left of line 2. */} + + {columns.value.label} {formatCurrency(item.value)} + {/* The share ends line 2, under the amount it is a share + of. Its value is bounded (`100.0%`) but its caption is + not, so it takes the same track as the amount rather + than an `auto` one sized by the caption. */} + + {columns.percentage.label} {formatPercent(percentage, 1)}
{t('spendingByCategory.total')} +
+ {t('spendingByCategory.total')} + + {columns.value.label} {formatCurrency(totalExpenses)} 100% + {columns.percentage.label} + 100% +
From 0874f5130b79c309a92df8bea3f0b25b183e50da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 17:59:23 +0000 Subject: [PATCH 17/44] Wrap Income by Source table on phones Convert the Income by Source report table to the phone-card layout used across the other reports (mechanism A, CSS single tree). Below `sm` the table becomes a block, each row a two-column grid: the source name beside its amount on line 1, the % of Total beneath the amount on line 2; from `sm` up the output is the ordinary table, identical to today (the one added property is `whitespace-nowrap` on the figure cells). - `thead`/`tbody`/`tfoot` go `block` below `sm` (`sm:table-*-group`); every `
`/`` carries an explicit `col-start`/`row-start` and restated ARIA roles (`table`/`rowgroup`/`row`/`cell`), since restyling `display` strips the implicit table semantics. - The three sortable columns are one exhaustive mapped-type record whose `Object.values` render BOTH the desktop column header row and a phone-only sort strip, so neither can strand a field. - Each figure cell carries a phone-only `CellLabel` caption reusing the existing column header key (colAmount, colPercentOfTotal); the source name and its colour dot are self-describing and carry none. The name wraps unclamped (`break-words sm:break-normal`) in a `min-w-0` track. - Rows stay clickable for real categories (opens that category's transactions) and inert for the uncategorised row, exactly as today. - Footer keeps all three columns at every width, so no `aria-colindex` is owed. Follows the worktree's existing pattern: local `SortColumn` / `SortColumnsByField` / `HEADER_CLASS` / `PHONE_HEADER_CLASS` / `CAPTION_CLASS` / `FIGURE_CELL` declarations, importing only `CellLabel` from Table.tsx (Table.tsx here exports only that of the wrapped-table chrome). No new i18n keys. Adds IncomeBySourceReport.mobileWrapped.test.tsx pinning cell placement, restated roles, caption-to-column association, header control count, the phone sort strip listing every field, footer placement, and the clickable/inert row contract. It fails on the pre-conversion component. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...ncomeBySourceReport.mobileWrapped.test.tsx | 342 ++++++++++++++++++ .../reports/IncomeBySourceReport.tsx | 245 ++++++++++--- 2 files changed, 543 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/reports/IncomeBySourceReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/IncomeBySourceReport.mobileWrapped.test.tsx b/frontend/src/components/reports/IncomeBySourceReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..858d98fda5 --- /dev/null +++ b/frontend/src/components/reports/IncomeBySourceReport.mobileWrapped.test.tsx @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, act } from '@/test/render'; +import { IncomeBySourceReport } from './IncomeBySourceReport'; + +/** + * The phone layout of the Income by Source data table. + * + * The table is ONE tree restyled by CSS (mechanism A): below `sm` the rows wrap + * into a two-column, two-line grid and the column header row is hidden, from + * `sm` up it is the ordinary table. jsdom applies no media queries, so both + * header rows and every phone caption are in the DOM here at all times -- which + * is exactly what lets these assertions read the phone markup without emulating + * a viewport, and why the sort controls have to be addressed by position rather + * than by label (each label matches the phone strip, the column header row, and + * a caption). + * + * The report opens on its pie view, so each case switches to the Table view + * first -- the table is only mounted there. + */ + +const mockPush = vi.fn(); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mockPush, replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/reports/income-by-source', + useSearchParams: () => new URLSearchParams(), + useParams: () => ({ reportId: 'income-by-source' }), +})); + +vi.mock('@/lib/pdf-export', () => ({ + exportToPdf: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ + ...numberFormatMockDefaults(), + formatCurrencyCompact: (n: number) => `$${n.toFixed(0)}`, + formatPercent: (n: number, d: number) => `${n.toFixed(d)}%`, + }), + }; +}); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: any) =>
{children}
, + PieChart: ({ children }: any) =>
{children}
, + Pie: () => null, + Cell: () => null, + Tooltip: () => null, + BarChart: ({ children }: any) =>
{children}
, + Bar: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, +})); + +const mockGetIncomeBySource = vi.fn(); +vi.mock('@/lib/built-in-reports', () => ({ + builtInReportsApi: { + getIncomeBySource: (...args: any[]) => mockGetIncomeBySource(...args), + }, +})); + +// Sorted by value descending (the stored default), so the row order is Salary, +// Interest Income, then the uncategorised row -- which carries no category id +// and so is deliberately NOT clickable. +const RESPONSE = { + data: [ + { categoryId: 'c-salary', categoryName: 'Salary', color: '#111111', total: 5000 }, + { categoryId: 'c-interest', categoryName: 'Interest Income', color: null, total: 1200 }, + { categoryId: null, categoryName: 'Uncategorized', color: '#222222', total: 300 }, + ], + totalIncome: 6500, +}; + +async function renderReport() { + mockGetIncomeBySource.mockResolvedValue(RESPONSE); + let container!: HTMLElement; + await act(async () => { + ({ container } = render()); + }); + // Switch from the default pie view to the table view. + await act(async () => { + fireEvent.click(screen.getByTitle('Table')); + }); + await waitFor(() => expect(container.querySelector('table')).toBeInTheDocument()); + return container; +} + +const rowText = (row: Element | null | undefined) => row?.textContent ?? ''; + +const findRow = (container: Element, name: string) => + Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.querySelector('td')?.textContent?.includes(name), + ); + +/** `c/r` for a cell, read off its explicit grid placement. */ +const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const line = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + return `c${col}/r${line}`; +}; + +describe('IncomeBySourceReport (phone wrapped table)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPush.mockClear(); + window.localStorage.clear(); + }); + + it('captions every figure inside the row so a phone needs no column header', async () => { + const container = await renderReport(); + + const row = findRow(container, 'Salary'); + expect(row).toBeDefined(); + for (const caption of ['Amount', '% of Total']) { + expect(rowText(row)).toContain(caption); + } + // Each caption sits immediately beside the value it names, as its own text + // node, so a `getByText` on the value still matches the value node. + expect(rowText(row)).toContain('Amount$5000'); + expect(rowText(row)).toContain('% of Total76.9%'); + // The identity carries NO caption -- a source name is self-describing. + const identity = row!.querySelector('td')!; + expect(identity.textContent).toBe('Salary'); + expect(identity.querySelector('span[class*="sm:hidden"]')).toBeNull(); + }); + + it('places every cell on the phone grid explicitly, and never wraps a figure', async () => { + const container = await renderReport(); + + // Auto-flow placement is not deterministic once a cell is added or made + // conditional, so each cell states its own column and line. + const rows = Array.from(container.querySelectorAll('tbody tr')); + expect(rows).toHaveLength(3); + for (const row of rows) { + const cells = Array.from(row.querySelectorAll('td')); + expect(cells).toHaveLength(3); + for (const cell of cells) { + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + } + // The two figure cells (amount and share) never wrap, and each is + // right-aligned. Right alignment is presentation, not containment. + const figures = cells.filter((c) => c.className.includes('whitespace-nowrap')); + expect(figures).toHaveLength(2); + for (const cell of figures) { + expect(cell.className).toContain('text-right'); + } + } + }); + + it('wraps each row onto two lines: source beside amount, then share beneath the amount', async () => { + const container = await renderReport(); + + for (const row of Array.from(container.querySelectorAll('tbody tr'))) { + const [name, value, pct] = Array.from(row.querySelectorAll('td')); + expect(row.className).toContain('grid grid-cols-2'); + expect(placement(name)).toBe('c1/r1'); + expect(placement(value)).toBe('c2/r1'); + expect(placement(pct)).toBe('c2/r2'); + // Nothing is placed on a third line. + for (const cell of [name, value, pct]) { + expect(cell.className).not.toMatch(/\brow-start-3\b/); + } + } + }); + + it('keeps the identity as an unclamped, break-words name beside its colour dot', async () => { + const container = await renderReport(); + + const identity = findRow(container, 'Salary')!.querySelector('td')!; + // The name wraps unclamped: a clamp would cut a trailing marker before the + // tail of the name, and no width assertion sees it. + expect(identity.className).not.toContain('line-clamp'); + expect(identity.className).not.toContain('truncate'); + expect(identity.className).toContain('min-w-0'); + const span = identity.querySelector('span')!; + expect(span.className).toContain('break-words'); + expect(span.className).toContain('sm:break-normal'); + expect(span.textContent).toBe('Salary'); + // The colour dot is exactly today's. + expect(identity.querySelector('div > div')!.className).toContain('rounded-full'); + }); + + it('gives the totals row the source-row placement, all three cells captioned', async () => { + const container = await renderReport(); + + const footRow = container.querySelector('tfoot tr')!; + expect(footRow.className).toContain('grid grid-cols-2'); + const [total, value, pct] = Array.from(footRow.querySelectorAll('td')); + expect(total.textContent).toBe('Total'); + expect(placement(total)).toBe('c1/r1'); + expect(placement(value)).toBe('c2/r1'); + expect(placement(pct)).toBe('c2/r2'); + + // Every column has a total, so no footer cell leaves the DOM below `sm` and + // none owes an `aria-colindex`. + const footCells = Array.from(footRow.querySelectorAll('td')); + expect(footCells).toHaveLength(3); + for (const cell of footCells) { + expect(cell.getAttribute('aria-colindex')).toBeNull(); + } + + // The totals carry their captions like any other cell. + for (const cell of [value, pct]) { + expect(cell.className).toContain('font-bold'); + expect(cell.className).toContain('whitespace-nowrap'); + } + expect(footRow.textContent).toContain('Amount$6500'); + expect(footRow.textContent).toContain('% of Total'); + expect(footRow.textContent).toContain('100%'); + }); + + it('keeps the row a table row from sm up and a grid below it', async () => { + const container = await renderReport(); + + const table = container.querySelector('table'); + expect(table?.className).toContain('block'); + expect(table?.className).toContain('sm:table'); + expect(container.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(container.querySelector('tbody')?.className).toContain('sm:table-row-group'); + expect(container.querySelector('tfoot')?.className).toContain('sm:table-footer-group'); + const row = container.querySelector('tbody tr'); + expect(row?.className).toContain('grid grid-cols-2'); + expect(row?.className).toContain('sm:table-row'); + // The wrapper still scrolls horizontally, which is what the table needs from + // `sm` up on a narrow desktop window. + expect(table?.parentElement?.className).toContain('overflow-x-auto'); + }); + + it('restores the table semantics a phone restyle strips', async () => { + const container = await renderReport(); + + const table = container.querySelector('table'); + expect(table?.getAttribute('role')).toBe('table'); + for (const group of ['thead', 'tbody', 'tfoot']) { + expect(container.querySelector(group)?.getAttribute('role')).toBe('rowgroup'); + } + for (const row of Array.from(container.querySelectorAll('table tr'))) { + expect(row.getAttribute('role')).toBe('row'); + } + // EVERY `
` -- three data rows plus the footer, nine in all. + const cells = Array.from(container.querySelectorAll('table td')); + expect(cells.length).toBe(12); + for (const cell of cells) { + expect(cell.getAttribute('role')).toBe('cell'); + } + // `SortableHeader` restates `columnheader` on the `` it renders, so both + // header rows already carry it. + for (const th of Array.from(container.querySelectorAll('table th'))) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + }); + + it('offers the same three sort controls on phones as in the column header', async () => { + const container = await renderReport(); + + const headerRows = Array.from(container.querySelectorAll('thead tr')); + expect(headerRows).toHaveLength(2); + const [phoneRow, columnRow] = headerRows; + // Exactly one of the two is displayed at any width. + expect(phoneRow.className).toContain('sm:hidden'); + expect(columnRow.className).toContain('hidden'); + expect(columnRow.className).toContain('sm:table-row'); + + const labelsOf = (row: Element) => + Array.from(row.querySelectorAll('th')).map((th) => + th.textContent?.replace(/[↑↓↕]/g, '').trim(), + ); + expect(labelsOf(phoneRow)).toEqual(['Source', 'Amount', '% of Total']); + // Both rows are rendered from one list, so they cannot list different + // fields. + expect(labelsOf(columnRow)).toEqual(labelsOf(phoneRow)); + // No column is stranded without a control: every header row carries exactly + // as many controls as a data row has cells. + const cellsPerRow = + container.querySelectorAll('tbody tr td').length / + container.querySelectorAll('tbody tr').length; + expect(phoneRow.querySelectorAll('th')).toHaveLength(cellsPerRow); + expect(columnRow.querySelectorAll('th')).toHaveLength(cellsPerRow); + }); + + it('sorts from the phone strip, not only from the column header', async () => { + const container = await renderReport(); + + const nameOrder = () => + Array.from(container.querySelectorAll('tbody tr')).map( + (r) => r.querySelector('td')?.textContent, + ); + // The stored default is the amount, descending. + expect(nameOrder()).toEqual(['Salary', 'Interest Income', 'Uncategorized']); + + // "Source" in the PHONE strip -- the header row that survives below `sm`, + // identified by the class that hides it from `sm` up rather than by its + // position. Within it the control is the first of three. + const phoneStrip = Array.from(container.querySelectorAll('thead tr')).find((r) => + r.className.includes('sm:hidden'), + ); + expect(phoneStrip).toBeDefined(); + await act(async () => { + fireEvent.click(phoneStrip!.querySelectorAll('th')[0]); + }); + // Ascending by name. + expect(nameOrder()).toEqual(['Interest Income', 'Salary', 'Uncategorized']); + + // A second tap reverses it. + await act(async () => { + fireEvent.click(phoneStrip!.querySelectorAll('th')[0]); + }); + expect(nameOrder()).toEqual(['Uncategorized', 'Salary', 'Interest Income']); + }); + + it('keeps a categorised row clickable and an uncategorised one inert', async () => { + const container = await renderReport(); + + const salary = findRow(container, 'Salary')!; + expect(salary.className).toContain('cursor-pointer'); + await act(async () => { + fireEvent.click(salary); + }); + expect(mockPush).toHaveBeenCalledTimes(1); + expect(mockPush.mock.calls[0][0]).toContain('categoryId=c-salary'); + + // The uncategorised row carries no category id, so it is not a pointer + // target and navigates nowhere. + const uncategorised = findRow(container, 'Uncategorized')!; + expect(uncategorised.className).not.toContain('cursor-pointer'); + await act(async () => { + fireEvent.click(uncategorised); + }); + expect(mockPush).toHaveBeenCalledTimes(1); + }); + + it('leaves the surfaces outside the table alone', async () => { + await renderReport(); + + // The controls card and its export dropdown are not part of the conversion. + expect(screen.getByTitle('Table')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/reports/IncomeBySourceReport.tsx b/frontend/src/components/reports/IncomeBySourceReport.tsx index 2084166e1c..144ad731e2 100644 --- a/frontend/src/components/reports/IncomeBySourceReport.tsx +++ b/frontend/src/components/reports/IncomeBySourceReport.tsx @@ -25,6 +25,7 @@ import { DateRangeSelector } from '@/components/ui/DateRangeSelector'; import { ChartViewToggle } from '@/components/ui/ChartViewToggle'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { CellLabel } from '@/components/ui/Table'; import { ChartTooltipPanel } from '@/components/reports/ChartTooltip'; import { ReportError } from '@/components/reports/ReportError'; import { CHART_COLOURS_INCOME } from '@/lib/chart-colours'; @@ -37,6 +38,88 @@ type IncomeSourceSortField = 'name' | 'value' | 'percentage'; type ChartDataItem = ChartDatum & { id: string; colour: string }; +/** + * One column of the data table. The three are declared once, as a record over + * the sort field union, and rendered by BOTH header rows -- the column header + * row (from `sm` up) and the phone sort strip -- so the two can never list + * different fields, and a new union member fails `tsc` rather than stranding a + * phone with no control for it. + */ +interface SortColumn { + field: IncomeSourceSortField; + label: string; + /** The amount and the share columns are right-aligned on desktop. */ + align?: 'right'; +} + +/** + * The record the two header rows are built from, each key tied to its entry's + * own `field`. A plain `Record` forces an + * entry to EXIST for every union member but lets it name a different one, so + * `percentage: { field: 'value', label: colPercent }` would type-check: two + * controls keyed `value` (a duplicate React key), "% of Total" sorting by + * amount, and "% of Total" unsortable -- none of which a test comparing header + * LABELS can see, because the labels stay right. Here it is a compile error. + */ +type SortColumnsByField = { + [K in IncomeSourceSortField]: SortColumn & { field: K }; +}; + +// Today's header cell, unchanged (this report's header carries no +// `tracking-wider`, so neither does this constant -- the `sm`-and-up output +// stays identical to today). +const HEADER_CLASS = + 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; + +// The same sort controls in the phone strip: a wrapped row of compact chips. +// Column alignment means nothing there -- the column header row is hidden and +// each data row is a grid -- so every control is left-aligned and self-naming. +// The border and card background are what say "tappable": there is no hover on a +// touch screen, and without them the strip reads as one more row of captions. +const PHONE_HEADER_CLASS = + 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; + +// Every caption in a wrapped cell is phone-only. +const CAPTION_CLASS = 'sm:hidden'; + +// Where each column sits on the phone grid for a SOURCE row and for the totals +// footer, written once: both shapes are 1x1 over the same three columns, so the +// footer takes the source row's placement verbatim and a reader finds the share +// in the same corner of both. Line 1 is the identity beside the amount (the +// figure the row is read for), line 2 the share beneath the amount it is a +// share OF. Auto-flow would place them by DOM order and silently re-flow the +// moment a cell became conditional; these are inert from `sm` up. +const CELL_PLACEMENT: Record = { + name: 'col-start-1 row-start-1', + value: 'col-start-2 row-start-1', + percentage: 'col-start-2 row-start-2', +}; + +// A figure cell inside a wrapped card: no padding of its own below `sm` (the row +// supplies it and the grid does the spacing), the table cell's own padding from +// `sm` up, smaller type on phones. `whitespace-nowrap` is the one property here +// that is NOT phone-only, and the single respect in which the `sm`-and-up cell +// differs from today's: a locale grouping thousands with a space could break a +// figure in the middle of a number otherwise, at any width. This report has one +// money column and a percentage, each in its own `minmax(0,1fr)` track, so the +// compact `formatCurrencyCompact` amount and the `100.0%` share sit well inside +// it at every phone width; right alignment is presentation, never containment. +const FIGURE_CELL = + 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; + +/** + * The identity cell of a source row and of the totals footer: the same box in + * both, one of the two cells that keep `text-sm` on phones (a source name is + * prose). A category name is UNBOUNDED, so it sits in a `minmax(0,1fr)` track + * with `min-w-0` and the name wraps UNCLAMPED with `break-words sm:break-normal` + * -- a clamp would cut a trailing marker before the tail of the name, and no + * width assertion sees it, while a grid item with `min-w-0` contributes no + * minimum width so `break-words` alone keeps the longest token from reopening + * the sideways scroll. From `sm` up `sm:break-normal` restores today's wrap. + */ +const IDENTITY_CELL = + `${CELL_PLACEMENT.name} min-w-0 p-0 text-sm sm:table-cell sm:px-4 sm:py-3`; + export function IncomeBySourceReport() { const t = useTranslations('reports'); const router = useRouter(); @@ -105,6 +188,25 @@ export function IncomeBySourceReport() { return sorted; }, [chartData, sortField, sortDirection, totalIncome]); + // Exhaustive over the sort field union, so a new field is a compile error + // rather than a column with no control in either header -- and each entry must + // name its own key (see `SortColumnsByField`). These labels are also the phone + // captions, so a value reads under exactly the label its column header uses. + const columns: SortColumnsByField = { + name: { field: 'name', label: t('incomeBySource.colSource') }, + value: { field: 'value', label: t('incomeBySource.colAmount'), align: 'right' }, + percentage: { field: 'percentage', label: t('incomeBySource.colPercentOfTotal'), align: 'right' }, + }; + + // The column order, rendered by BOTH header rows and matched by the cells' DOM + // order. DERIVED from the record rather than re-listed: a hand-written list + // beside an exhaustive record is not exhaustive, so a field added to the union + // would compile (the record forces an entry) and still ship with no sort + // control in either header. The record's declaration order IS the column + // order, and it is today's; where a card PLACES each column is + // `CELL_PLACEMENT`, a separate decision. + const sortColumns: readonly SortColumn[] = Object.values(columns); + const handleExportPdf = async () => { const { exportToPdf } = await import('@/lib/pdf-export'); @@ -203,74 +305,129 @@ export function IncomeBySourceReport() { {t('incomeBySource.noData')}

) : viewType === 'table' ? ( + /* Data Table + + Below `sm` the table becomes a block and each row wraps into a + two-column grid so all three columns fit a phone without a + horizontal scroll, on two lines: the source name beside its amount + -- the figure the row is read for -- then the share beneath the + amount it is a share OF. Nothing is dropped -- the card carries all + three columns -- and the rows stay what they are today: a row for a + real category is clickable (it opens that category's transactions), + an uncategorised row is not. From `sm` up it is the ordinary table. + The sort controls survive as their own phone-only header row, + because the column header row that carries them on desktop is + hidden there. + + Two properties of restyling one tree, both deliberate. Changing the + `display` drops the implicit table semantics below `sm`, so the + explicit ARIA roles below put them back; the phone sort strip is the + header row a phone reader gets, and its three controls sit in the + data cells' own DOM order, so the column association survives there. + Every row exposes all three cells at every width (none is dropped + below `sm`), so no row needs an `aria-colindex`, and the `CellLabel` + captions are REDUNDANT with that association rather than a + substitute for it: the grid paints the cells out of DOM order, so a + sighted phone reader needs the name beside the value. The second + property is an ACCEPTED trade-off the roles do not answer -- they + restore semantics, not reading order -- and the captions limit its + cost, since every value names its own column. */ <>
- - - - - field="name" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('incomeBySource.colSource')} - - - field="value" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('incomeBySource.colAmount')} - - - field="percentage" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('incomeBySource.colPercentOfTotal')} - + {/* Explicit roles: restyling `display` below `sm` strips the + implicit table semantics, and these put them back (inert from + `sm` up). */} +
+ + {/* Phone sort strip: the same three controls, wrapped. */} + + {sortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + className={PHONE_HEADER_CLASS} + > + {col.label} + + ))} + + + {sortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + align={col.align} + className={HEADER_CLASS} + > + {col.label} + + ))} - + {sortedTableData.map((item) => { const percentage = totalIncome > 0 ? (item.value / totalIncome) * 100 : 0; return ( item.id && handleCategoryClick(item.id)} > - ` around it stays the click + target at every width. The colour dot and the name + are exactly today's, the name wrapped in an unclamped + span that shares the grid cell's containment. */} + - - ); })} - - - - + {/* The totals are the largest figures on the table, so this + row wraps exactly the way a source row does -- the same two + tracks, the same placement, each figure captioned -- with + "Total" standing in for the source. Every column has a + total, so no cell leaves the DOM below `sm`: three cells at + every width, and no `aria-colindex` is owed. */} + + + - +
-
+ {/* The identity; the `
+
- {item.name} + {item.name}
+ {/* The amount is the headline: the right of line 1, + beside the source, because it is what the row is read + for. */} + + {columns.value.label} {formatCurrency(item.value)} + {/* The share opens line 2, beneath the amount it is a + share OF. Its value is bounded (`100.0%`) but its + CAPTION is not, so it takes a full `minmax(0,1fr)` + track; the caption wraps, the value never does. */} + + {columns.percentage.label} {formatPercent(percentage, 1)}
{t('incomeBySource.total')} +
+ {t('incomeBySource.total')} + + {columns.value.label} {formatCurrency(totalIncome)} 100% + {columns.percentage.label} + 100% +
From 11f9eb957fa2a78be99eb5a025214acff17186c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 18:20:47 +0000 Subject: [PATCH 18/44] Import the shared chrome constants in the two new report conversions The Income by Source and Spending by Category conversions were written against the pre-consolidation base and re-declared PHONE_HEADER_CLASS and CAPTION_CLASS locally (identical values). Import them from components/ui/Table.tsx instead, as every other converted report does, so the consolidation guard passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- .../components/reports/IncomeBySourceReport.tsx | 13 +------------ .../reports/SpendingByCategoryReport.tsx | 14 +------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/reports/IncomeBySourceReport.tsx b/frontend/src/components/reports/IncomeBySourceReport.tsx index 144ad731e2..59c6b93eb2 100644 --- a/frontend/src/components/reports/IncomeBySourceReport.tsx +++ b/frontend/src/components/reports/IncomeBySourceReport.tsx @@ -25,7 +25,7 @@ import { DateRangeSelector } from '@/components/ui/DateRangeSelector'; import { ChartViewToggle } from '@/components/ui/ChartViewToggle'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { ChartTooltipPanel } from '@/components/reports/ChartTooltip'; import { ReportError } from '@/components/reports/ReportError'; import { CHART_COLOURS_INCOME } from '@/lib/chart-colours'; @@ -71,17 +71,6 @@ type SortColumnsByField = { const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; -// The same sort controls in the phone strip: a wrapped row of compact chips. -// Column alignment means nothing there -- the column header row is hidden and -// each data row is a grid -- so every control is left-aligned and self-naming. -// The border and card background are what say "tappable": there is no hover on a -// touch screen, and without them the strip reads as one more row of captions. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - -// Every caption in a wrapped cell is phone-only. -const CAPTION_CLASS = 'sm:hidden'; - // Where each column sits on the phone grid for a SOURCE row and for the totals // footer, written once: both shapes are 1x1 over the same three columns, so the // footer takes the source row's placement verbatim and a reader finds the share diff --git a/frontend/src/components/reports/SpendingByCategoryReport.tsx b/frontend/src/components/reports/SpendingByCategoryReport.tsx index 2934c834fe..23bc44e8f8 100644 --- a/frontend/src/components/reports/SpendingByCategoryReport.tsx +++ b/frontend/src/components/reports/SpendingByCategoryReport.tsx @@ -28,7 +28,7 @@ import { DateRangeSelector } from '@/components/ui/DateRangeSelector'; import { ChartViewToggle } from '@/components/ui/ChartViewToggle'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; -import { CellLabel } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import { ChartTooltipPanel } from '@/components/reports/ChartTooltip'; import { ReportError } from '@/components/reports/ReportError'; import { exportToCsv } from '@/lib/csv-export'; @@ -75,18 +75,6 @@ type SortColumnsByField = { const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; -// The same sort controls in the phone strip: a wrapped row of compact chips. -// Column alignment means nothing there -- the column header row is hidden and -// each data row is a grid -- so every control is left-aligned and self-naming. -// The border and card background are what say "tappable": there is no hover on -// a touch screen, and without them the strip reads as another row of the -// captions the cells below carry. -const PHONE_HEADER_CLASS = - 'rounded border border-gray-200 bg-white px-2 py-1.5 text-xs font-medium text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 uppercase'; - -// A wrapped cell's caption is replaced by the real column header from `sm` up. -const CAPTION_CLASS = 'sm:hidden'; - // A figure cell inside a wrapped card: no padding of its own below `sm` (the // row supplies it and the grid does the spacing), the table cell's own padding // from `sm` up. Smaller type on phones. `whitespace-nowrap` is the one property From 758c0f0875ab5f7e25789de1745d1ff087442d79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 18:32:37 +0000 Subject: [PATCH 19/44] Wrap Dividend Yield Growth table on phones The per-security yield table is the report's one wide table (five columns: Security, 12M Dividends, Market Value, Yield, Frequency), so below `sm` it wraps each row into a two-line, three-column grid card (mechanism A, one CSS tree) instead of scrolling sideways. Line 1 carries the security identity (spanning two tracks) and the headline yield; line 2 carries the trailing-12m dividends, market value and payout frequency, one track each. Every cell states its own `col-start`/`row-start` and restates its ARIA role (restyling `display` strips the implicit table semantics), each bare figure carries a `CellLabel` reusing the column's existing header key, and money cells keep `whitespace-nowrap`. From `sm` up the resolved output is today's table, the one deliberate difference being that nowrap. The sort controls return below `sm` as a phone strip of the same `SortableHeader` chips, rendered -- with the column header row -- from one exhaustive `{ [K in YieldSortField]: ... }` record so the two rows can never list different fields. Shared chrome (`PHONE_HEADER_CLASS`, `CAPTION_CLASS`, `CellLabel`, the `SortColumn`/`SortColumnsByField` types) is imported from `@/components/ui/Table`; `MONEY_CELL`/`HEADER_CLASS` stay local. The year-over-year and frequency views have three columns each, fit a 320px phone already, and are left as ordinary tables. Adds DividendYieldGrowthReport.mobileWrapped.test.tsx pinning cell placement, the restated roles, the caption-to-column association, the two header rows' control counts and equality, phone-strip sorting, and the rows staying inert. Two row-count assertions in the existing DividendYieldGrowthReport.test.tsx now count body rows, because the wrapped table's header is legitimately two rows. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...ndYieldGrowthReport.mobileWrapped.test.tsx | 356 ++++++++++++++++++ .../DividendYieldGrowthReport.test.tsx | 10 +- .../reports/DividendYieldGrowthReport.tsx | 175 ++++++--- 3 files changed, 478 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/reports/DividendYieldGrowthReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/DividendYieldGrowthReport.mobileWrapped.test.tsx b/frontend/src/components/reports/DividendYieldGrowthReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..2d4fdff238 --- /dev/null +++ b/frontend/src/components/reports/DividendYieldGrowthReport.mobileWrapped.test.tsx @@ -0,0 +1,356 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, act } from '@/test/render'; +import { DividendYieldGrowthReport } from './DividendYieldGrowthReport'; +import type { InvestmentTransaction, HoldingWithMarketValue } from '@/types/investment'; + +/** + * The phone layout of the Dividend Yield Growth report's per-security yield + * table (the default "yield" view). It is the one wide table on the report -- + * five columns -- so it is the one that wraps; the year-over-year and frequency + * views have three columns each and stay ordinary tables. + * + * The table is ONE tree restyled by CSS (mechanism A): below `sm` the rows wrap + * into a three-column, two-line grid and the column header row is replaced by a + * sort strip, from `sm` up it is the ordinary table. jsdom applies no media + * queries, so both header rows and every phone caption are in the DOM here at + * all times -- which is what lets these assertions read the phone markup + * without emulating a viewport, and why the sort controls are addressed by + * position rather than by label (each label matches the phone strip, the column + * header row, and a caption). + */ + +const mockGetInvestmentAccounts = vi.fn(); +const mockGetTransactions = vi.fn(); +const mockGetPortfolioSummary = vi.fn(); + +// A router of this file's own, so "clicking a row navigates nowhere" is an +// assertion about behaviour rather than about a class. Built inside the factory +// because `vi.mock` is hoisted above the const it would close over, and +// returned as one stable object, as the shared setup's router is. +const mockPush = vi.fn(); +vi.mock('next/navigation', () => { + const router = { push: mockPush, replace: vi.fn(), refresh: vi.fn(), back: vi.fn(), prefetch: vi.fn() }; + return { + useRouter: () => router, + usePathname: () => '/reports/dividend-yield-growth', + useSearchParams: () => new URLSearchParams(), + }; +}); + +vi.mock('@/lib/investments', () => ({ + investmentsApi: { + getInvestmentAccounts: (...args: any[]) => mockGetInvestmentAccounts(...args), + getTransactions: (...args: any[]) => mockGetTransactions(...args), + getPortfolioSummary: (...args: any[]) => mockGetPortfolioSummary(...args), + }, +})); + +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ + ...numberFormatMockDefaults(), + formatCurrency: (n: number) => `$${n.toFixed(2)}`, + formatCurrencyAxis: (n: number) => `$${n}`, + formatPercent: (n: number, d = 2) => `${n.toFixed(d)}%`, + formatSignedPercent: (n: number, d = 2) => `${n >= 0 ? '+' : ''}${n.toFixed(d)}%`, + }), + }; +}); + +vi.mock('@/hooks/useExchangeRates', () => ({ + useExchangeRates: () => ({ + defaultCurrency: 'USD', + // Identity conversion: this report's card layout is the subject, not FX, so + // every amount converts to itself and both single- and multi-account paths + // produce the same figures. + convertToDefault: (value: number) => value, + }), +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})); + +vi.mock('@/lib/pdf-export', () => ({ exportToPdf: vi.fn() })); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: any) =>
{children}
, + BarChart: ({ children }: any) =>
{children}
, + Bar: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, +})); + +// Two securities, one holding each, both priced and paying a dividend within +// the trailing 12 months (today is 2026 in the run environment). AAA yields +// 2.50% (2,500 / 100,000) and BBB 2.00% (1,000 / 50,000), so the default +// yield-descending sort is [AAA, BBB] and a dividends-ascending sort is +// [BBB, AAA] -- distinct orders, so the sort assertion cannot pass by accident. +const HOLDINGS: HoldingWithMarketValue[] = [ + { + id: 'h-1', accountId: 'a-1', securityId: 's-1', symbol: 'AAA', name: 'Alpha Corp', + securityType: 'STOCK', currencyCode: 'USD', quantity: 100, averageCost: 900, + costBasis: 90000, marketValue: 100000, + } as HoldingWithMarketValue, + { + id: 'h-2', accountId: 'a-1', securityId: 's-2', symbol: 'BBB', name: 'Beta Industries Incorporated', + securityType: 'STOCK', currencyCode: 'USD', quantity: 50, averageCost: 800, + costBasis: 40000, marketValue: 50000, + } as HoldingWithMarketValue, +]; + +const DIVIDENDS: InvestmentTransaction[] = [ + { + id: 'd-1', accountId: 'a-1', securityId: 's-1', action: 'DIVIDEND', + transactionDate: '2026-03-15', totalAmount: 2500, + } as InvestmentTransaction, + { + id: 'd-2', accountId: 'a-1', securityId: 's-2', action: 'DIVIDEND', + transactionDate: '2026-06-15', totalAmount: 1000, + } as InvestmentTransaction, +]; + +async function renderTable() { + mockGetInvestmentAccounts.mockResolvedValue([ + { id: 'a-1', name: 'Brokerage', currencyCode: 'USD' }, + ]); + mockGetTransactions.mockImplementation((params: { action: string }) => + Promise.resolve({ + data: params.action === 'DIVIDEND' ? DIVIDENDS : [], + pagination: { hasMore: false }, + }), + ); + mockGetPortfolioSummary.mockResolvedValue({ holdings: HOLDINGS }); + + let container!: HTMLElement; + await act(async () => { + container = render().container; + }); + await waitFor(() => + expect( + screen.getByText('Per-Security Dividend Yield (Trailing 12 Months)'), + ).toBeInTheDocument(), + ); + // Wait for the rows themselves, not just the card title (static chrome). + await waitFor(() => expect(container.querySelectorAll('tbody tr').length).toBe(2)); + return container; +} + +const rowText = (row: Element | null | undefined) => row?.textContent ?? ''; + +const findRow = (container: HTMLElement, symbol: string) => + Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes(symbol), + ); + +describe('DividendYieldGrowthReport (phone wrapped yield table)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('captions every figure inside the row so a phone needs no column header', async () => { + const container = await renderTable(); + + const row = findRow(container, 'AAA'); + expect(row).toBeDefined(); + // Each caption sits immediately beside the value it names, as its own text + // node, so a `getByText` on the value still matches the value node. + expect(rowText(row)).toContain('12M Dividends$2500.00'); + expect(rowText(row)).toContain('Market Value$100000.00'); + expect(rowText(row)).toContain('Yield2.50%'); + expect(rowText(row)).toContain('FrequencyUnknown'); + // The security is the row's identity, not one of its figures, so it carries + // no caption -- it names itself with its symbol and name. + const identity = row?.querySelector('td'); + expect(identity?.querySelector('div')?.textContent).toBe('AAA'); + expect(identity?.querySelector('span')).toBeNull(); + // Captions reuse the table's own column keys: no new catalogue string. + for (const caption of ['Security', '12M Dividends', 'Market Value', 'Yield', 'Frequency']) { + expect(screen.getAllByText(caption).length).toBeGreaterThan(0); + } + }); + + it('places every cell on the phone grid explicitly, and never wraps a number', async () => { + const container = await renderTable(); + + for (const row of Array.from(container.querySelectorAll('tbody tr'))) { + const cells = Array.from(row.querySelectorAll('td')); + expect(cells).toHaveLength(5); + for (const cell of cells) { + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + } + // The three numeric cells (dividends, market value, yield) never wrap and + // are right-aligned; the frequency word may wrap and the security is the + // identity. Right alignment is not containment -- a figure past the + // measured budget overflows the end edge -- but truncating would be worse. + const money = cells.filter((c) => c.className.includes('whitespace-nowrap')); + expect(money).toHaveLength(3); + for (const cell of money) { + expect(cell.className).toContain('text-right'); + // `white-space` is inherited, so the caption inside a nowrap cell has to + // take the ban back or an unbreakable caption would overflow its track. + const caption = cell.querySelector('span'); + expect(caption?.className).toContain('whitespace-normal'); + expect(caption?.className).toContain('sm:hidden'); + } + } + }); + + it('wraps each row onto two lines of three tracks, headline yield beside the security', async () => { + const container = await renderTable(); + + // DOM order is the desktop column order (security, dividends, market value, + // yield, frequency), so placement is read off the classes, not off + // position. Line 1: security (spanning two tracks) | yield. Line 2: + // dividends | market value | frequency. + const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const row = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + const span = /\bcol-span-(\d)\b/.exec(cell.className)?.[1] ?? '1'; + return `c${col}/r${row}/s${span}`; + }; + for (const row of Array.from(container.querySelectorAll('tbody tr'))) { + const [security, dividends, marketValue, yieldCell, frequency] = Array.from( + row.querySelectorAll('td'), + ); + expect(row.className).toContain('grid-cols-3'); + expect(placement(security)).toBe('c1/r1/s2'); + expect(placement(yieldCell)).toBe('c3/r1/s1'); + expect(placement(dividends)).toBe('c1/r2/s1'); + expect(placement(marketValue)).toBe('c2/r2/s1'); + expect(placement(frequency)).toBe('c3/r2/s1'); + // Nothing is placed on a third line. + for (const cell of [security, dividends, marketValue, yieldCell, frequency]) { + expect(cell.className).not.toMatch(/\brow-start-3\b/); + } + } + }); + + it('keeps the row a table row from sm up and a grid below it', async () => { + const container = await renderTable(); + + const table = container.querySelector('table'); + expect(table?.className).toContain('block'); + expect(table?.className).toContain('sm:table'); + expect(container.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(container.querySelector('tbody')?.className).toContain('sm:table-row-group'); + const row = container.querySelector('tbody tr'); + expect(row?.className).toContain('grid grid-cols-3'); + expect(row?.className).toContain('sm:table-row'); + // The row keeps the hover treatment it draws today, on both layouts. + expect(row?.className).toContain('hover:bg-gray-50'); + // The wrapper still scrolls horizontally, which is what the table needs from + // `sm` up on a narrow desktop window. + expect(table?.parentElement?.className).toContain('overflow-x-auto'); + }); + + it('restores this table’s own cell padding from sm up', async () => { + const container = await renderTable(); + + // Below `sm` the figure cells carry no padding of their own -- the row + // supplies it; from `sm` up each restores this table's `px-4 py-3`. + for (const row of Array.from(container.querySelectorAll('tbody tr'))) { + const [security, dividends, marketValue, yieldCell, frequency] = Array.from( + row.querySelectorAll('td'), + ); + for (const cell of [security, dividends, marketValue, yieldCell, frequency]) { + expect(cell.className).toContain('p-0'); + expect(cell.className).toContain('sm:px-4'); + expect(cell.className).toContain('sm:py-3'); + } + } + }); + + it('restores the table semantics a phone restyle strips', async () => { + const container = await renderTable(); + + const table = container.querySelector('table'); + expect(table?.getAttribute('role')).toBe('table'); + for (const group of ['thead', 'tbody']) { + expect(container.querySelector(group)?.getAttribute('role')).toBe('rowgroup'); + } + for (const row of Array.from(container.querySelectorAll('tr'))) { + expect(row.getAttribute('role')).toBe('row'); + } + // EVERY `
`, including the ones whose className is a template literal. + const cells = Array.from(container.querySelectorAll('td')); + expect(cells.length).toBeGreaterThan(0); + for (const cell of cells) { + expect(cell.getAttribute('role')).toBe('cell'); + } + // `SortableHeader` restates `columnheader` on the `` it renders, so both + // header rows already carry it. + for (const th of Array.from(container.querySelectorAll('th'))) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + }); + + it('offers the same five sort controls on phones as in the column header', async () => { + const container = await renderTable(); + + const headerRows = Array.from(container.querySelectorAll('thead tr')); + expect(headerRows).toHaveLength(2); + const [phoneRow, columnRow] = headerRows; + // Exactly one of the two is displayed at any width. + expect(phoneRow.className).toContain('sm:hidden'); + expect(columnRow.className).toContain('hidden'); + expect(columnRow.className).toContain('sm:table-row'); + + // The sort indicator glyph rides inside each control, so compare the labels + // with it stripped. + const labelsOf = (row: Element) => + Array.from(row.querySelectorAll('th')).map((th) => + th.textContent?.replace(/[↑↓↕]/g, '').trim(), + ); + const expected = ['Security', '12M Dividends', 'Market Value', 'Yield', 'Frequency']; + expect(labelsOf(phoneRow)).toEqual(expected); + // Both rows are rendered from one list, so they cannot list different + // fields -- assert it rather than trusting the loop. + expect(labelsOf(columnRow)).toEqual(labelsOf(phoneRow)); + }); + + it('sorts from the phone strip, not only from the column header', async () => { + const container = await renderTable(); + + const symbolOrder = () => + Array.from(container.querySelectorAll('tbody tr')).map( + (r) => r.querySelector('td div')?.textContent, + ); + // Default sort is yield descending: AAA (2.50%) before BBB (2.00%). + expect(symbolOrder()).toEqual(['AAA', 'BBB']); + + // "12M Dividends" in the phone strip: the second of the five controls in + // the first header row. Addressed by position because the label also + // appears in the column header row and in every caption. + const phoneDividends = container + .querySelectorAll('thead tr')[0] + .querySelectorAll('th')[1]; + await act(async () => { + fireEvent.click(phoneDividends); + }); + // Ascending by dividends puts BBB's $1,000 before AAA's $2,500. + expect(symbolOrder()).toEqual(['BBB', 'AAA']); + }); + + it('leaves the rows inert: the card is a layout, not a new affordance', async () => { + const container = await renderTable(); + + // These rows have never been clickable, and wrapping them must not make them + // so. Clicking is the live half of the assertion: React attaches handlers + // synthetically and never writes an `onclick` attribute, so an added + // `onClick` is invisible to a markup check. + const rows = Array.from(container.querySelectorAll('tbody tr')); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.className).not.toContain('cursor-pointer'); + await act(async () => { + fireEvent.click(row); + }); + } + expect(mockPush).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/reports/DividendYieldGrowthReport.test.tsx b/frontend/src/components/reports/DividendYieldGrowthReport.test.tsx index 138485ad9c..a7e3c41d03 100644 --- a/frontend/src/components/reports/DividendYieldGrowthReport.test.tsx +++ b/frontend/src/components/reports/DividendYieldGrowthReport.test.tsx @@ -327,8 +327,10 @@ describe('DividendYieldGrowthReport', () => { expect(screen.getByText('VFV')).toBeInTheDocument(); // The unknown-security fallback name would only appear if an unknown row rendered expect(screen.queryByText('Unknown Security')).not.toBeInTheDocument(); - // Only the header row and a single VFV data row should be present - expect(screen.getAllByRole('row')).toHaveLength(2); + // Only a single VFV data row should render. The phone-wrapped yield table + // carries two header rows (the column header and the phone sort strip), so + // count body rows rather than every row. + expect(document.querySelectorAll('tbody tr')).toHaveLength(1); }); it('excludes dividend transactions with no securityId', async () => { @@ -368,7 +370,9 @@ describe('DividendYieldGrowthReport', () => { }); expect(screen.getByText('VFV')).toBeInTheDocument(); expect(screen.queryByText('Unknown Security')).not.toBeInTheDocument(); - expect(screen.getAllByRole('row')).toHaveLength(2); + // Single data row; the wrapped yield table's header is two rows (column + // header plus phone sort strip), so count body rows. + expect(document.querySelectorAll('tbody tr')).toHaveLength(1); }); it('shows growth view with annual data table', async () => { diff --git a/frontend/src/components/reports/DividendYieldGrowthReport.tsx b/frontend/src/components/reports/DividendYieldGrowthReport.tsx index b2899c9318..6efb01795a 100644 --- a/frontend/src/components/reports/DividendYieldGrowthReport.tsx +++ b/frontend/src/components/reports/DividendYieldGrowthReport.tsx @@ -26,6 +26,11 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMultiSelect'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { PartialTotal } from '@/components/ui/PartialTotal'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { createLogger } from '@/lib/logger'; @@ -61,6 +66,34 @@ interface FrequencyBucket { totalDividends: number; } +/** + * One sortable column of the per-security yield table, keyed by its own sort + * field so the record is exhaustive over `YieldSortField` and each entry names + * the field it stands for. Both header rows -- the column header row from `sm` + * up and the phone sort strip below it -- render from the same record, so they + * can never list different fields, and a new sort field is a compile error here + * rather than a column stranded with no control on a phone. + */ +type YieldSortColumn = TableSortColumn; +type YieldSortColumns = TableSortColumnsByField; + +// Today's header cell, unchanged. +const HEADER_CLASS = + 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; + +// A figure cell inside a wrapped card: no padding of its own below `sm` (the +// row supplies it and the grid does the spacing), this table's own `px-4 py-3` +// from `sm` up, and smaller type on phones so a figure fits a third of the +// width. `whitespace-nowrap` is the one property here that is NOT phone-only, +// and the single respect in which the `sm`-and-up cell differs from today's: a +// locale that groups thousands with a space (`1 234 567 zl`) could otherwise +// break a figure in the middle at any width. Right alignment is not a +// containment device -- a nowrap amount longer than its track overflows the end +// edge and reopens the wrapper's scroll -- but `overflow-hidden` here would +// silently cut a figure, which is worse than a crowded one or an honest scroll. +const MONEY_CELL = + 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; + const ACCOUNTS_STORAGE_KEY = 'monize-reports-dividend-yield-growth-accounts'; export function DividendYieldGrowthReport() { @@ -320,6 +353,21 @@ export function DividendYieldGrowthReport() { return sorted; }, [securityYields, yieldSort.sortField, yieldSort.sortDirection]); + // The five sortable columns of the per-security yield table, keyed by field + // so the record is exhaustive over `YieldSortField`. Both header rows render + // one control per column, and the declaration order is the column order. + const yieldColumns: YieldSortColumns = { + symbol: { field: 'symbol', label: t('dividendYieldGrowth.colSecurity') }, + dividends: { field: 'dividends', label: t('dividendYieldGrowth.col12mDividends'), align: 'right' }, + marketValue: { field: 'marketValue', label: t('dividendYieldGrowth.colMarketValue'), align: 'right' }, + yield: { field: 'yield', label: t('dividendYieldGrowth.colYield'), align: 'right' }, + frequency: { field: 'frequency', label: t('dividendYieldGrowth.colFrequency'), align: 'right' }, + }; + // Their order, rendered by BOTH header rows and matched by the cells' DOM + // order. Derived from the record rather than re-listed, so a field added to + // the union cannot compile with no sort control in either header. + const yieldSortColumns: readonly YieldSortColumn[] = Object.values(yieldColumns); + // Year-over-year growth const annualData = useMemo((): AnnualDividend[] => { const yearMap = new Map(); @@ -564,78 +612,85 @@ export function DividendYieldGrowthReport() { {t('dividendYieldGrowth.perSecurityTitle')} + {/* Below `sm` the table becomes a block and each row wraps into a + three-column, two-line grid so all five columns fit a phone + without a horizontal scroll: the security identity (spanning two + tracks) and the headline yield share line 1; the trailing-12-month + dividends, the market value and the payout frequency share line 2, + one track each. Each bare figure carries a `CellLabel` reusing the + column's existing header key, since the column header row is + replaced by a phone sort strip below `sm`. From `sm` up it is the + ordinary table -- every cell restores this table's own `px-4 py-3` + -- and the one deliberate difference above `sm` is + `whitespace-nowrap` on the figures (see `MONEY_CELL`). Restyling + `display` strips the implicit table semantics, so the ARIA roles + are restated explicitly. */}
- - - - - field="symbol" - sortField={yieldSort.sortField} - sortDirection={yieldSort.sortDirection} - onSort={yieldSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendYieldGrowth.colSecurity')} - - - field="dividends" - sortField={yieldSort.sortField} - sortDirection={yieldSort.sortDirection} - onSort={yieldSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendYieldGrowth.col12mDividends')} - - - field="marketValue" - sortField={yieldSort.sortField} - sortDirection={yieldSort.sortDirection} - onSort={yieldSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendYieldGrowth.colMarketValue')} - - - field="yield" - sortField={yieldSort.sortField} - sortDirection={yieldSort.sortDirection} - onSort={yieldSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendYieldGrowth.colYield')} - - - field="frequency" - sortField={yieldSort.sortField} - sortDirection={yieldSort.sortDirection} - onSort={yieldSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendYieldGrowth.colFrequency')} - +
+ + {/* Phone sort strip: the same five controls, wrapped. */} + + {yieldSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={yieldSort.sortField} + sortDirection={yieldSort.sortDirection} + onSort={yieldSort.handleSort} + className={PHONE_HEADER_CLASS} + > + {col.label} + + ))} + + + {yieldSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={yieldSort.sortField} + sortDirection={yieldSort.sortDirection} + onSort={yieldSort.handleSort} + align={col.align} + className={HEADER_CLASS} + > + {col.label} + + ))} - + {sortedSecurityYields.map((sy) => ( - - + {/* The identity: symbol over name. It spans two tracks on + line 1 beside the headline yield; the name wraps on a + phone and hands the wrap back from `sm` up. */} + - - - - From aac52949eec5eeb4a98389056d6a30545fa1517d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 18:36:08 +0000 Subject: [PATCH 20/44] Wrap Security Performance table on phones Convert both history tables in SecurityPerformanceReport to the phone-card layout (mechanism A, one CSS tree), mirroring the converted sibling reports. Below `sm` each table becomes a block and each row wraps into a grid card so every column fits a phone with no horizontal scroll; from `sm` up the resolved output is identical to today (each cell restores its own `sm:px-4 sm:py-3 sm:text-sm`). - Transaction History (6 columns): a three-track, two-line grid -- line 1 is the date (identity), the action pill and the total (headline); line 2 is the account, the share count and the price. - Dividend History (4 columns): a two-track, two-line grid -- date + amount on line 1, account + type pill on line 2 -- plus a footer that wraps the same way, "Total Dividends" beside the total. The footer keeps its desktop `colSpan={3}`, so its cells state `aria-colindex`. Both sort-control sets come back below `sm` as a phone-only strip of the same `SortableHeader` chips, rendered from one exhaustive mapped-type record per table whose `Object.values` both header rows render. Every bare figure carries a `CellLabel` reusing the column's existing header key (no new i18n keys); the date and the self-describing pills carry none. Money and share cells keep `whitespace-nowrap`; the account name wraps. Restyling `display` strips the implicit table semantics below `sm`, so the ARIA roles are restated. Adds SecurityPerformanceReport.mobileWrapped.test.tsx pinning cell placement, roles, caption-to-column association, the header control counts, sorting from the phone strip, the footer's colspan/aria-colindex, and the rows staying inert. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...tyPerformanceReport.mobileWrapped.test.tsx | 444 ++++++++++++++++++ .../reports/SecurityPerformanceReport.tsx | 305 +++++++----- 2 files changed, 631 insertions(+), 118 deletions(-) create mode 100644 frontend/src/components/reports/SecurityPerformanceReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/SecurityPerformanceReport.mobileWrapped.test.tsx b/frontend/src/components/reports/SecurityPerformanceReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..c809720d6a --- /dev/null +++ b/frontend/src/components/reports/SecurityPerformanceReport.mobileWrapped.test.tsx @@ -0,0 +1,444 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, act } from '@/test/render'; +import { SecurityPerformanceReport } from './SecurityPerformanceReport'; + +/** + * The phone layout of the Security Performance report's two history tables + * (Transaction History and Dividend History). + * + * Both are ONE tree restyled by CSS (mechanism A): below `sm` each row wraps + * into a grid card and the column header row is replaced by a sort strip, from + * `sm` up each is the ordinary table. jsdom applies no media queries, so both + * header rows and every phone caption are in the DOM here at all times -- which + * is what lets these assertions read the phone markup without emulating a + * viewport, and why the sort controls are addressed by position (each label + * matches the phone strip, the column header row, and a caption). + */ + +const mockGetSecurities = vi.fn(); +const mockGetPortfolioSummary = vi.fn(); +const mockGetSecurityPrices = vi.fn(); +const mockGetTransactions = vi.fn(); +const mockGetInvestmentAccounts = vi.fn(); +const mockGetMarketIndexes = vi.fn(); +const mockGetPerformanceComparison = vi.fn(); + +// A router of this file's own, so "clicking a row navigates nowhere" is an +// assertion about behaviour rather than about a class. Built inside the factory +// because `vi.mock` is hoisted above the const it would close over, and returned +// as one stable object, as the shared setup's router is. +const mockPush = vi.fn(); +vi.mock('next/navigation', () => { + const router = { push: mockPush, replace: vi.fn(), refresh: vi.fn(), back: vi.fn(), prefetch: vi.fn() }; + return { + useRouter: () => router, + usePathname: () => '/reports/security-performance', + useSearchParams: () => new URLSearchParams(), + }; +}); + +vi.mock('@/lib/pdf-export', () => ({ + exportToPdf: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/components/ui/ExportDropdown', () => ({ + ExportDropdown: ({ onExportPdf }: any) => ( + + ), +})); + +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ + ...numberFormatMockDefaults(), + formatSignedPercent: (n: number, decimals = 2) => `${n >= 0 ? '+' : ''}${n.toFixed(decimals)}%`, + formatCurrency: (n: number) => `$${n.toFixed(2)}`, + formatCurrencyCompact: (n: number) => `$${n.toFixed(0)}`, + formatCurrencyAxis: (n: number) => `$${n}`, + }), + }; +}); + +vi.mock('@/hooks/useExchangeRates', () => ({ + useExchangeRates: () => ({ + defaultCurrency: 'CAD', + convertToDefault: (amount: number) => amount, + }), +})); + +vi.mock('@/lib/utils', async (importActual) => ({ + ...(await importActual()), + parseLocalDate: (d: string) => new Date(d + 'T00:00:00'), + cn: (...inputs: any[]) => inputs.flat(Infinity).filter(Boolean).join(' '), +})); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: any) =>
{children}
, + AreaChart: ({ children }: any) =>
{children}
, + LineChart: ({ children }: any) =>
{children}
, + Line: () => null, + Legend: () => null, + Area: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + ReferenceLine: () => null, +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})); + +vi.mock('@/lib/investments', () => ({ + investmentsApi: { + getSecurities: (...args: any[]) => mockGetSecurities(...args), + getPortfolioSummary: (...args: any[]) => mockGetPortfolioSummary(...args), + getSecurityPrices: (...args: any[]) => mockGetSecurityPrices(...args), + getTransactions: (...args: any[]) => mockGetTransactions(...args), + getInvestmentAccounts: (...args: any[]) => mockGetInvestmentAccounts(...args), + getMarketIndexes: (...args: any[]) => mockGetMarketIndexes(...args), + getPerformanceComparison: (...args: any[]) => mockGetPerformanceComparison(...args), + }, +})); + +const mockSecurities = [ + { id: 's-1', symbol: 'AAPL', name: 'Apple Inc.', isActive: true, currencyCode: 'USD', exchange: 'NASDAQ', securityType: 'STOCK' }, +]; + +const mockHoldings = [ + { + id: 'h-1', accountId: 'acc-1', securityId: 's-1', symbol: 'AAPL', name: 'Apple Inc.', + securityType: 'STOCK', currencyCode: 'USD', quantity: 10, averageCost: 150, costBasis: 1500, + currentPrice: 180, marketValue: 1800, gainLoss: 300, gainLossPercent: 20, costBasisAccountCurrency: 1500, + accountBreakdowns: [ + { id: 'h-1', accountId: 'acc-1', securityId: 's-1', symbol: 'AAPL', name: 'Apple Inc.', securityType: 'STOCK', currencyCode: 'USD', quantity: 10, averageCost: 150, costBasis: 1500, currentPrice: 180, marketValue: 1800, gainLoss: 300, gainLossPercent: 20, costBasisAccountCurrency: 1500 }, + ], + }, +]; + +// Two trades and two dividends. Default sort is date descending on both tables, +// so the June rows lead. Totals are distinct so a sort by amount reorders them. +const TRANSACTIONS = [ + { id: 'tx1', transactionDate: '2024-06-15', action: 'BUY', quantity: 10, price: 150, totalAmount: 1500, securityId: 's-1', security: { symbol: 'AAPL', name: 'Apple Inc.' }, accountId: 'acc-1' }, + { id: 'tx2', transactionDate: '2024-03-10', action: 'SELL', quantity: 5, price: 180, totalAmount: 900, securityId: 's-1', security: { symbol: 'AAPL', name: 'Apple Inc.' }, accountId: 'acc-1' }, + { id: 'd1', transactionDate: '2024-05-01', action: 'DIVIDEND', quantity: null, price: null, totalAmount: 50, securityId: 's-1', security: { symbol: 'AAPL', name: 'Apple Inc.' }, accountId: 'acc-1' }, + { id: 'd2', transactionDate: '2024-02-01', action: 'DIVIDEND', quantity: null, price: null, totalAmount: 30, securityId: 's-1', security: { symbol: 'AAPL', name: 'Apple Inc.' }, accountId: 'acc-1' }, +]; + +const SELECT_PLACEHOLDER = 'Select securities...'; + +async function selectSecurity(optionLabel: string) { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: SELECT_PLACEHOLDER })); + }); + await act(async () => { + fireEvent.click(screen.getByText(optionLabel)); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: SELECT_PLACEHOLDER })); + }); +} + +async function renderAt(view: 'Transactions' | 'Dividends') { + mockGetSecurities.mockResolvedValue(mockSecurities); + mockGetPortfolioSummary.mockResolvedValue({ holdings: mockHoldings }); + mockGetSecurityPrices.mockResolvedValue([]); + mockGetTransactions.mockResolvedValue({ data: TRANSACTIONS, pagination: { hasMore: false } }); + mockGetInvestmentAccounts.mockResolvedValue([{ id: 'acc-1', name: 'Brokerage 1', currencyCode: 'USD' }]); + mockGetMarketIndexes.mockResolvedValue([]); + + let container!: HTMLElement; + await act(async () => { + container = render().container; + }); + await waitFor(() => + expect(screen.getByRole('button', { name: SELECT_PLACEHOLDER })).toBeInTheDocument(), + ); + await selectSecurity('AAPL - Apple Inc.'); + await waitFor(() => expect(screen.getByText(view)).toBeInTheDocument()); + await act(async () => { + fireEvent.click(screen.getByText(view)); + }); + await waitFor(() => expect(container.querySelector('table')).toBeInTheDocument()); + return container; +} + +const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const row = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + return `c${col}/r${row}`; +}; + +const stripGlyph = (el: Element) => el.textContent?.replace(/[↑↓↕]/g, '').trim(); + +describe('SecurityPerformanceReport transactions table (phone wrapped)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('keeps a table from sm up and a grid below it, with the semantics a restyle strips', async () => { + const container = await renderAt('Transactions'); + + const table = container.querySelector('table')!; + expect(table.getAttribute('role')).toBe('table'); + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + expect(container.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(container.querySelector('tbody')?.className).toContain('sm:table-row-group'); + for (const group of container.querySelectorAll('thead, tbody')) { + expect(group.getAttribute('role')).toBe('rowgroup'); + } + for (const row of container.querySelectorAll('tbody tr')) { + expect(row.getAttribute('role')).toBe('row'); + expect(row.className).toContain('grid grid-cols-3'); + expect(row.className).toContain('sm:table-row'); + } + // EVERY `
+
{sy.symbol}
-
{sy.name}
+
{sy.name}
+ + {yieldColumns.dividends.label} {fmtValue(sy.trailing12mDividends)} + + {yieldColumns.marketValue.label} {fmtValue(sy.marketValue)} + {/* Yield is the headline the report is read for: the right + of line 1, beside the security. */} + + {yieldColumns.yield.label} {formatPercent(sy.yield, 2)} + {/* Frequency is a word, so it may wrap; captioned like the + figures, and right-aligned to match the desktop cell. */} + + {yieldColumns.frequency.label} {sy.frequency}
`, including the ones whose className is a template literal. + const cells = Array.from(container.querySelectorAll('td')); + expect(cells.length).toBeGreaterThan(0); + for (const cell of cells) { + expect(cell.getAttribute('role')).toBe('cell'); + } + for (const th of container.querySelectorAll('th')) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + // The wrapper still scrolls horizontally, which is what the table needs from + // `sm` up on a narrow desktop window. + expect(table.parentElement?.className).toContain('overflow-x-auto'); + }); + + it('places every cell on the phone grid explicitly, derived figure beside its identity', async () => { + const container = await renderAt('Transactions'); + + // Line 1: date | action | total. Line 2: account | shares | price. DOM order + // is the desktop column order (date, account, action, shares, price, total), + // so placement is read off the classes rather than off position. + for (const row of container.querySelectorAll('tbody tr')) { + const [date, account, action, shares, price, total] = Array.from(row.querySelectorAll('td')); + expect(placement(date)).toBe('c1/r1'); + expect(placement(account)).toBe('c1/r2'); + expect(placement(action)).toBe('c2/r1'); + expect(placement(shares)).toBe('c2/r2'); + expect(placement(price)).toBe('c3/r2'); + expect(placement(total)).toBe('c3/r1'); + // Explicit placement, never auto-flow. + for (const cell of [date, account, action, shares, price, total]) { + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + expect(cell.className).not.toMatch(/\brow-start-3\b/); + } + } + }); + + it('captions every bare figure with its column key, and leaves the date and pill self-naming', async () => { + const container = await renderAt('Transactions'); + + const buyRow = Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes('$1500.00'), + )!; + // Each caption sits beside the value it names, as its own text node, so a + // value lookup still matches the value node. + expect(buyRow.textContent).toContain('Account' + 'Brokerage 1'); + expect(buyRow.textContent).toContain('Shares' + '10'); + expect(buyRow.textContent).toContain('Price' + '$150.00'); + expect(buyRow.textContent).toContain('Total' + '$1500.00'); + // The date is the identity and the action is a self-describing pill, so + // neither carries a caption. + const date = buyRow.querySelector('.col-start-1.row-start-1')!; + expect(date.textContent).toBe('Jun 15, 2024'); + expect(date.querySelector('span')).toBeNull(); + const action = buyRow.querySelector('.col-start-2.row-start-1')!; + // The pill itself is the only span in the action cell; there is no caption. + expect(action.querySelectorAll('span')).toHaveLength(1); + expect(action.querySelector('span')?.textContent).toBe('BUY'); + // Captions reuse the table's own column keys: no new catalogue string. + for (const caption of ['Date', 'Account', 'Action', 'Shares', 'Price', 'Total']) { + expect(screen.getAllByText(caption).length).toBeGreaterThan(0); + } + }); + + it('never wraps a money or share figure, and gives each caption whitespace-normal back', async () => { + const container = await renderAt('Transactions'); + + for (const row of container.querySelectorAll('tbody tr')) { + // The three figure cells (shares, price, total) are right-aligned and + // never wrap; the account may wrap and the date never wraps but is not a + // figure. + const figures = Array.from(row.querySelectorAll('td')).filter( + (c) => c.className.includes('whitespace-nowrap') && c.className.includes('text-right'), + ); + expect(figures).toHaveLength(3); + for (const cell of figures) { + const caption = cell.querySelector('span'); + expect(caption?.className).toContain('whitespace-normal'); + expect(caption?.className).toContain('sm:hidden'); + } + // The account cell wraps a translated name; it is not right-aligned. + const account = row.querySelector('.col-start-1.row-start-2')!; + expect(account.className).not.toContain('whitespace-nowrap'); + expect(account.className).toContain('break-words'); + } + }); + + it('restores this table’s own cell padding from sm up, per cell', async () => { + const container = await renderAt('Transactions'); + + for (const cell of container.querySelectorAll('tbody td')) { + // No padding of its own below `sm`; the row supplies it. This table's + // `px-4 py-3` restored from `sm` up. + expect(cell.className).toContain('p-0'); + expect(cell.className).toContain('sm:px-4'); + expect(cell.className).toContain('sm:py-3'); + } + }); + + it('offers the same six sort controls on phones as in the column header', async () => { + const container = await renderAt('Transactions'); + + const headerRows = Array.from(container.querySelectorAll('thead tr')); + expect(headerRows).toHaveLength(2); + const [phoneRow, columnRow] = headerRows; + // Exactly one of the two is displayed at any width. + expect(phoneRow.className).toContain('sm:hidden'); + expect(columnRow.className).toContain('hidden'); + expect(columnRow.className).toContain('sm:table-row'); + + const labelsOf = (row: Element) => Array.from(row.querySelectorAll('th')).map(stripGlyph); + const expected = ['Date', 'Account', 'Action', 'Shares', 'Price', 'Total']; + expect(labelsOf(phoneRow)).toEqual(expected); + // Both rows are rendered from one list, so they cannot list different fields. + expect(labelsOf(columnRow)).toEqual(labelsOf(phoneRow)); + }); + + it('sorts from the phone strip, not only from the column header', async () => { + const container = await renderAt('Transactions'); + + const dateOrder = () => + Array.from(container.querySelectorAll('tbody tr')).map( + (r) => r.querySelector('.col-start-1.row-start-1')?.textContent, + ); + // Default sort is date descending: June leads March. + expect(dateOrder()).toEqual(['Jun 15, 2024', 'Mar 10, 2024']); + + // "Total" in the phone strip is the sixth of the six controls in the first + // header row. Addressed by position because the label also appears in the + // column header row and in a caption. + const phoneTotal = container.querySelectorAll('thead tr')[0].querySelectorAll('th')[5]; + await act(async () => { + fireEvent.click(phoneTotal); + }); + // Ascending by total puts the $900 SELL (March) first. + expect(dateOrder()).toEqual(['Mar 10, 2024', 'Jun 15, 2024']); + }); + + it('leaves the rows inert: the card is a layout, not a new affordance', async () => { + const container = await renderAt('Transactions'); + + const rows = Array.from(container.querySelectorAll('tbody tr')); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.className).not.toContain('cursor-pointer'); + await act(async () => { + fireEvent.click(row); + }); + } + expect(mockPush).not.toHaveBeenCalled(); + }); +}); + +describe('SecurityPerformanceReport dividends table (phone wrapped)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('wraps each dividend row onto a two-column grid, amount beside the date', async () => { + const container = await renderAt('Dividends'); + + const table = container.querySelector('table')!; + expect(table.getAttribute('role')).toBe('table'); + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + + for (const row of container.querySelectorAll('tbody tr')) { + expect(row.className).toContain('grid grid-cols-2'); + expect(row.className).toContain('sm:table-row'); + const [date, account, type, amount] = Array.from(row.querySelectorAll('td')); + expect(placement(date)).toBe('c1/r1'); + expect(placement(account)).toBe('c1/r2'); + expect(placement(type)).toBe('c2/r2'); + expect(placement(amount)).toBe('c2/r1'); + for (const cell of [date, account, type, amount]) { + expect(cell.getAttribute('role')).toBe('cell'); + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + } + } + }); + + it('captions the amount and account, and leaves the date and type pill self-naming', async () => { + const container = await renderAt('Dividends'); + + const row = Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes('$50.00'), + )!; + expect(row.textContent).toContain('Account' + 'Brokerage 1'); + expect(row.textContent).toContain('Amount' + '$50.00'); + const date = row.querySelector('.col-start-1.row-start-1')!; + expect(date.textContent).toBe('May 1, 2024'); + expect(date.querySelector('span')).toBeNull(); + const type = row.querySelector('.col-start-2.row-start-2')!; + expect(type.querySelectorAll('span')).toHaveLength(1); + expect(type.querySelector('span')?.textContent).toBe('DIVIDEND'); + // The amount never wraps; its caption takes whitespace-normal back. + const amount = row.querySelector('.col-start-2.row-start-1')!; + expect(amount.className).toContain('whitespace-nowrap'); + expect(amount.className).toContain('text-right'); + expect(amount.querySelector('span')?.className).toContain('whitespace-normal'); + expect(amount.querySelector('span')?.className).toContain('sm:hidden'); + }); + + it('offers the same four sort controls on phones as in the column header', async () => { + const container = await renderAt('Dividends'); + + const headerRows = Array.from(container.querySelectorAll('thead tr')); + expect(headerRows).toHaveLength(2); + const [phoneRow, columnRow] = headerRows; + expect(phoneRow.className).toContain('sm:hidden'); + expect(columnRow.className).toContain('hidden'); + expect(columnRow.className).toContain('sm:table-row'); + const labelsOf = (r: Element) => Array.from(r.querySelectorAll('th')).map(stripGlyph); + expect(labelsOf(phoneRow)).toEqual(['Date', 'Account', 'Type', 'Amount']); + expect(labelsOf(columnRow)).toEqual(labelsOf(phoneRow)); + }); + + it('wraps the footer like a data row and states aria-colindex on its colspan cells', async () => { + const container = await renderAt('Dividends'); + + const foot = container.querySelector('tfoot')!; + expect(foot.getAttribute('role')).toBe('rowgroup'); + expect(foot.className).toContain('sm:table-footer-group'); + const footRow = foot.querySelector('tr')!; + expect(footRow.getAttribute('role')).toBe('row'); + expect(footRow.className).toContain('grid grid-cols-2'); + + const [label, total] = Array.from(footRow.querySelectorAll('td')); + // "Total Dividends" stands in for the identity and keeps its desktop + // `colSpan={3}`; its cells do not map one-to-one to the body columns, so + // each states its column index. + expect(label.getAttribute('colspan')).toBe('3'); + expect(label.getAttribute('aria-colindex')).toBe('1'); + expect(placement(label)).toBe('c1/r1'); + expect(label.textContent).toBe('Total Dividends'); + // The total sits beside the label; it names itself from that label, so it + // carries no caption, and it never wraps. + expect(total.getAttribute('aria-colindex')).toBe('4'); + expect(placement(total)).toBe('c2/r1'); + expect(total.querySelector('span')).toBeNull(); + expect(total.className).toContain('whitespace-nowrap'); + expect(total.className).toContain('text-right'); + // 50 + 30. + expect(total.textContent).toBe('$80.00'); + }); +}); diff --git a/frontend/src/components/reports/SecurityPerformanceReport.tsx b/frontend/src/components/reports/SecurityPerformanceReport.tsx index a3b2a861d4..53ff6d135a 100644 --- a/frontend/src/components/reports/SecurityPerformanceReport.tsx +++ b/frontend/src/components/reports/SecurityPerformanceReport.tsx @@ -30,6 +30,11 @@ import { useExchangeRates } from '@/hooks/useExchangeRates'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { aggregateHoldingsBySecurity } from '@/lib/aggregate-holdings'; import { renderChartFlagDot, ChartFlagShadowFilter } from '@/components/investments/portfolio-chart-utils'; @@ -56,6 +61,34 @@ const INDEX_GROUP_PREFIX = 'region:'; type TradeSortField = 'date' | 'account' | 'action' | 'shares' | 'price' | 'total'; type DividendSortField = 'date' | 'account' | 'type' | 'amount'; +/** + * One sortable column of each history table. Declared once, as a record over the + * sort-field union, and rendered by BOTH header rows -- the column header row + * (from `sm` up) and the phone sort strip -- so the two can never list different + * fields, and adding a member to a union fails `tsc` here rather than stranding + * a phone with no control for it. + */ +type TradeSortColumn = TableSortColumn; +type DividendSortColumn = TableSortColumn; + +// Today's header cell, unchanged (no `tracking-wider`, matching what these two +// tables render). Kept local -- `PHONE_HEADER_CLASS`/`CAPTION_CLASS`/`CellLabel` +// are shared, but a table's own header and money cells stay per-report because +// their track budgets differ. +const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; + +// A money (or share-count) cell inside a wrapped row: no padding of its own +// below `sm` (the row supplies it and the grid does the spacing), this table's +// own `px-4 py-3 text-sm` from `sm` up, smaller type on phones. The colour and +// weight stay on each cell. +// +// `whitespace-nowrap` is the one property here that is NOT phone-only, and it is +// the single respect in which the `sm`-and-up cell differs from today's: a +// locale that groups thousands with a space (`1 234 567 zl`) could otherwise +// break a figure in the middle at any width. A number must not break; the +// caption inside takes `whitespace-normal` back for itself (`CellLabel`). +const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; + interface PriceChartPoint { date: string; ts: number; @@ -435,6 +468,30 @@ export function SecurityPerformanceReport() { const displayCurrency = selectedSecurity?.currencyCode || defaultCurrency; + // The trade table's six sortable columns, keyed by field so the record is + // exhaustive: adding a member to `TradeSortField` is a compile error here + // rather than a header with no control. Their declaration order is the column + // (and cell DOM) order, rendered by BOTH the column header row and the phone + // sort strip from the derived `Object.values`. + const tradeColumns: TableSortColumnsByField = { + date: { field: 'date', label: t('securityPerformance.colDate') }, + account: { field: 'account', label: t('securityPerformance.colAccount') }, + action: { field: 'action', label: t('securityPerformance.colAction') }, + shares: { field: 'shares', label: t('securityPerformance.colShares'), align: 'right' }, + price: { field: 'price', label: t('securityPerformance.colPrice'), align: 'right' }, + total: { field: 'total', label: t('securityPerformance.colTotal'), align: 'right' }, + }; + const tradeSortColumns: readonly TradeSortColumn[] = Object.values(tradeColumns); + + // The dividend table's four sortable columns, same shape and same rule. + const dividendColumns: TableSortColumnsByField = { + date: { field: 'date', label: t('securityPerformance.colDate') }, + account: { field: 'account', label: t('securityPerformance.colAccount') }, + type: { field: 'type', label: t('securityPerformance.colType') }, + amount: { field: 'amount', label: t('securityPerformance.colAmount'), align: 'right' }, + }; + const dividendSortColumns: readonly DividendSortColumn[] = Object.values(dividendColumns); + const handleExportPdf = async () => { if (isComparison) { await comparisonExportRef.current?.exportPdf(); @@ -842,79 +899,72 @@ export function SecurityPerformanceReport() { {tradeTx.length > 0 ? ( + /* Below `sm` the table becomes a block and each row wraps into a + three-column, two-line grid card so all six columns fit a + phone without a horizontal scroll: line 1 is the date (the row + identity), the action pill and the total (the headline); + line 2 is the account, the share count and the price. Nothing + is dropped, and no figure is truncated -- a money value never + wraps (`MONEY_CELL`). From `sm` up it is the ordinary table, + resolving identically to today (each cell restores its own + `sm:px-4 sm:py-3 sm:text-sm`), and the sort controls survive + as their own phone-only header row because the column header + row that carries them on desktop is hidden there. Restyling + `display` strips the implicit table semantics below `sm`, so + the roles are restated and every bare figure carries a + `CellLabel` naming its column; the pill and the date name + themselves. */
- - - - - field="date" - sortField={tradeSort.sortField} - sortDirection={tradeSort.sortDirection} - onSort={tradeSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colDate')} - - - field="account" - sortField={tradeSort.sortField} - sortDirection={tradeSort.sortDirection} - onSort={tradeSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colAccount')} - - - field="action" - sortField={tradeSort.sortField} - sortDirection={tradeSort.sortDirection} - onSort={tradeSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colAction')} - - - field="shares" - sortField={tradeSort.sortField} - sortDirection={tradeSort.sortDirection} - onSort={tradeSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colShares')} - - - field="price" - sortField={tradeSort.sortField} - sortDirection={tradeSort.sortDirection} - onSort={tradeSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colPrice')} - - - field="total" - sortField={tradeSort.sortField} - sortDirection={tradeSort.sortDirection} - onSort={tradeSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colTotal')} - +
+ + {/* Phone sort strip: the same six controls, wrapped. */} + + {tradeSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={tradeSort.sortField} + sortDirection={tradeSort.sortDirection} + onSort={tradeSort.handleSort} + className={PHONE_HEADER_CLASS} + > + {col.label} + + ))} + + + {tradeSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={tradeSort.sortField} + sortDirection={tradeSort.sortDirection} + onSort={tradeSort.handleSort} + align={col.align} + className={HEADER_CLASS} + > + {col.label} + + ))} - + {tradeTx.map((tx) => ( - - + {/* Date: the row identity. A formatted date never wraps. */} + - - - - - @@ -952,75 +1006,90 @@ export function SecurityPerformanceReport() { {dividendTx.length > 0 ? ( + /* Below `sm` the table becomes a block and each row wraps into a + two-column, two-line grid card so all four columns fit a phone + without a horizontal scroll: line 1 is the date (identity) and + the amount (headline); line 2 is the account and the type pill. + Nothing is dropped, and the amount never wraps (`MONEY_CELL`). + From `sm` up it is the ordinary table, resolving identically to + today, and the sort controls survive as their own phone-only + header row. The footer wraps the same way -- "Total Dividends" + beside the total -- and keeps its desktop `colSpan={3}`, so its + cells carry `aria-colindex` (they do not map one-to-one to the + body columns). */
-
+
{format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy')} + + {tradeColumns.account.label} {accountNameById.get(tx.accountId) || '-'} + {/* Action: a self-describing pill, so no caption. */} + + + {tradeColumns.shares.label} {tx.quantity ?? '-'} + + {tradeColumns.price.label} {tx.price != null ? formatCurrencyFull(tx.price, displayCurrency) : '-'} + {/* Total: the headline figure, beside the date. */} + + {tradeColumns.total.label} {formatCurrencyFull(Math.abs(tx.totalAmount), displayCurrency)}
- - - - field="date" - sortField={dividendSort.sortField} - sortDirection={dividendSort.sortDirection} - onSort={dividendSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colDate')} - - - field="account" - sortField={dividendSort.sortField} - sortDirection={dividendSort.sortDirection} - onSort={dividendSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colAccount')} - - - field="type" - sortField={dividendSort.sortField} - sortDirection={dividendSort.sortDirection} - onSort={dividendSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colType')} - - - field="amount" - sortField={dividendSort.sortField} - sortDirection={dividendSort.sortDirection} - onSort={dividendSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('securityPerformance.colAmount')} - +
+ + {/* Phone sort strip: the same four controls, wrapped. */} + + {dividendSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={dividendSort.sortField} + sortDirection={dividendSort.sortDirection} + onSort={dividendSort.handleSort} + className={PHONE_HEADER_CLASS} + > + {col.label} + + ))} + + + {dividendSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={dividendSort.sortField} + sortDirection={dividendSort.sortDirection} + onSort={dividendSort.handleSort} + align={col.align} + className={HEADER_CLASS} + > + {col.label} + + ))} - + {dividendTx.map((tx) => ( - - + {/* Date: the row identity. A formatted date never wraps. */} + - - - ))} - - - + {/* "Total Dividends" stands in for the identity; the total + sits beside it. The label keeps its desktop `colSpan={3}`, + so both cells state `aria-colindex`. The total names + itself from that label, so it carries no caption. */} + + - + + + + + + ))} + + + {/* The country column has no total, so the footer keeps the same + empty spacer the desktop table does -- placed on the phone + grid, unpadded, and inert at `sm` up so it is byte-identical + to today's bare ` + + + + + + +
+
{format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy')} + + {dividendColumns.account.label} {accountNameById.get(tx.accountId) || '-'} + {/* Type: a self-describing pill, so no caption. */} + {tx.action} + {/* Amount: the headline figure, beside the date. */} + + {dividendColumns.amount.label} {formatCurrencyFull(Math.abs(tx.totalAmount), displayCurrency)}
+
{t('securityPerformance.totalDividends')} + {formatCurrencyFull( dividendTx.reduce((sum, tx) => sum + Math.abs(tx.totalAmount), 0), displayCurrency, From 524f47feeafc32b92c6c4785f71c770d51ebb1f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 18:49:45 +0000 Subject: [PATCH 21/44] Wrap Portfolio Value table on phones Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...ortfolioValueReport.mobileWrapped.test.tsx | 309 ++++++++++++++++++ .../reports/PortfolioValueReport.tsx | 166 ++++++---- 2 files changed, 416 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/reports/PortfolioValueReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/PortfolioValueReport.mobileWrapped.test.tsx b/frontend/src/components/reports/PortfolioValueReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..c2a3bc84e1 --- /dev/null +++ b/frontend/src/components/reports/PortfolioValueReport.mobileWrapped.test.tsx @@ -0,0 +1,309 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, act } from '@/test/render'; +import { PortfolioValueReport } from './PortfolioValueReport'; + +/** + * The phone layout of the Portfolio Value report's Portfolio Breakdown table + * (holdings, cash, total and gain/loss per account). + * + * It is ONE tree restyled by CSS (mechanism A): below `sm` each row wraps into a + * grid card and the column header row is replaced by a sort strip, from `sm` up + * it is the ordinary table. jsdom applies no media queries, so both header rows + * and every phone caption are in the DOM here at all times -- which is what lets + * these assertions read the phone markup without emulating a viewport, and why + * the sort controls are addressed by position (each label matches the phone + * strip, the column header row, and a caption). + * + * The report's two chart-view tables are deliberately NOT converted: the plain + * Date/Value table is a genuinely narrow two-column table, and the per-security + * breakdown table has a dynamic column count (one column per security) that + * mechanism A's fixed `grid-cols-N` cannot express. Only the Portfolio + * Breakdown table below the chart wraps. + */ + +const mockGetInvestmentsDaily = vi.fn(); +const mockGetInvestmentsMonthly = vi.fn(); +const mockGetInvestmentsBreakdown = vi.fn(); +const mockGetFirstPricedDay = vi.fn(); +const mockGetPortfolioSummary = vi.fn(); +const mockGetInvestmentAccounts = vi.fn(); +const mockGetIntradayValue = vi.fn(); +const mockGetIntradayBreakdown = vi.fn(); + +// A router of this file's own, so "clicking a row navigates nowhere" is an +// assertion about behaviour rather than about a class. Built inside the factory +// because `vi.mock` is hoisted above the const it would close over, and returned +// as one stable object, as the shared setup's router is. +const mockPush = vi.fn(); +vi.mock('next/navigation', () => { + const router = { push: mockPush, replace: vi.fn(), refresh: vi.fn(), back: vi.fn(), prefetch: vi.fn() }; + return { + useRouter: () => router, + usePathname: () => '/reports/portfolio-value', + useSearchParams: () => new URLSearchParams(), + }; +}); + +vi.mock('@/lib/pdf-export', () => ({ + exportToPdf: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/components/ui/ExportDropdown', () => ({ + ExportDropdown: ({ onExportPdf }: any) => ( + + ), +})); + +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ + ...numberFormatMockDefaults(), + formatCurrency: (n: number) => `$${n.toFixed(2)}`, + formatCurrencyCompact: (n: number) => `$${n.toFixed(0)}`, + formatCurrencyAxis: (n: number) => `$${n}`, + formatCurrencyFlag: (n: number) => `$${n.toFixed(2)}`, + formatSignedPercent: (n: number, decimals = 2) => `${n >= 0 ? '+' : ''}${n.toFixed(decimals)}%`, + }), + }; +}); + +vi.mock('@/hooks/useExchangeRates', () => ({ + useExchangeRates: () => ({ + defaultCurrency: 'CAD', + convertToDefault: (amount: number) => amount, + }), +})); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: any) =>
{children}
, + AreaChart: ({ children }: any) =>
{children}
, + Area: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + Legend: () => null, +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})); + +vi.mock('@/lib/net-worth', () => ({ + netWorthApi: { + getInvestmentsDaily: (...args: any[]) => mockGetInvestmentsDaily(...args), + getInvestmentsMonthly: (...args: any[]) => mockGetInvestmentsMonthly(...args), + getInvestmentsBreakdown: (...args: any[]) => mockGetInvestmentsBreakdown(...args), + getFirstPricedDay: (...args: any[]) => mockGetFirstPricedDay(...args), + }, +})); + +vi.mock('@/lib/investments', () => ({ + investmentsApi: { + getPortfolioSummary: (...args: any[]) => mockGetPortfolioSummary(...args), + getInvestmentAccounts: (...args: any[]) => mockGetInvestmentAccounts(...args), + getIntradayValue: (...args: any[]) => mockGetIntradayValue(...args), + getIntradayBreakdown: (...args: any[]) => mockGetIntradayBreakdown(...args), + }, +})); + +// Two accounts with distinct totals so a sort reorders them. Default breakdown +// sort is total descending, so Brokerage A (1,500) leads Brokerage B (500). +const HOLDINGS_BY_ACCOUNT = [ + { accountId: 'acc-a', accountName: 'Brokerage A', totalMarketValue: 1000, cashBalance: 500, totalGainLoss: 300 }, + { accountId: 'acc-b', accountName: 'Brokerage B', totalMarketValue: 400, cashBalance: 100, totalGainLoss: -50 }, +]; + +async function renderReport() { + mockGetInvestmentsMonthly.mockResolvedValue([]); + mockGetInvestmentsDaily.mockResolvedValue([]); + mockGetInvestmentsBreakdown.mockResolvedValue({ series: [], points: [] }); + mockGetFirstPricedDay.mockResolvedValue({ date: null }); + mockGetIntradayValue.mockResolvedValue({ points: [], fallbackToDaily: false, skippedSymbols: [] }); + mockGetIntradayBreakdown.mockResolvedValue({ series: [], points: [], fallbackToDaily: false, skippedSymbols: [] }); + mockGetPortfolioSummary.mockResolvedValue({ holdingsByAccount: HOLDINGS_BY_ACCOUNT }); + mockGetInvestmentAccounts.mockResolvedValue([]); + + let container!: HTMLElement; + await act(async () => { + container = render().container; + }); + await waitFor(() => expect(container.querySelector('table')).toBeInTheDocument()); + return container; +} + +const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const row = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + return `c${col}/r${row}`; +}; + +const stripGlyph = (el: Element) => el.textContent?.replace(/[↑↓↕]/g, '').trim(); + +describe('PortfolioValueReport breakdown table (phone wrapped)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('keeps a table from sm up and a grid below it, with the semantics a restyle strips', async () => { + const container = await renderReport(); + + const table = container.querySelector('table')!; + expect(table.getAttribute('role')).toBe('table'); + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + expect(container.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(container.querySelector('tbody')?.className).toContain('sm:table-row-group'); + for (const group of container.querySelectorAll('thead, tbody')) { + expect(group.getAttribute('role')).toBe('rowgroup'); + } + for (const row of container.querySelectorAll('tbody tr')) { + expect(row.getAttribute('role')).toBe('row'); + expect(row.className).toContain('grid grid-cols-3'); + expect(row.className).toContain('sm:table-row'); + } + // EVERY `
`, including the ones whose className is a template literal. + const cells = Array.from(container.querySelectorAll('td')); + expect(cells.length).toBeGreaterThan(0); + for (const cell of cells) { + expect(cell.getAttribute('role')).toBe('cell'); + } + for (const th of container.querySelectorAll('th')) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + // The wrapper still scrolls horizontally, which is what the table needs from + // `sm` up on a narrow desktop window. + expect(table.parentElement?.className).toContain('overflow-x-auto'); + }); + + it('places every cell on the phone grid explicitly, headline figure beside its identity', async () => { + const container = await renderReport(); + + // Line 1: account | (empty) | total. Line 2: holdings | cash | gain/loss. + // DOM order is the desktop column order (account, holdings, cash, total, + // gainLoss), so placement is read off the classes rather than off position. + for (const row of container.querySelectorAll('tbody tr')) { + const [account, holdings, cash, total, gainLoss] = Array.from(row.querySelectorAll('td')); + expect(placement(account)).toBe('c1/r1'); + expect(placement(holdings)).toBe('c1/r2'); + expect(placement(cash)).toBe('c2/r2'); + expect(placement(total)).toBe('c3/r1'); + expect(placement(gainLoss)).toBe('c3/r2'); + for (const cell of [account, holdings, cash, total, gainLoss]) { + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + expect(cell.className).not.toMatch(/\brow-start-3\b/); + } + } + }); + + it('captions every bare figure with its column key, and leaves the account self-naming', async () => { + const container = await renderReport(); + + const rowA = Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes('Brokerage A'), + )!; + // Each caption sits beside the value it names, as its own text node, so a + // value lookup still matches the value node. + expect(rowA.textContent).toContain('Holdings' + '$1000.00'); + expect(rowA.textContent).toContain('Cash' + '$500.00'); + expect(rowA.textContent).toContain('Total' + '$1500.00'); + expect(rowA.textContent).toContain('Gain/Loss' + '+$300.00'); + // The account is the identity, so it carries no caption span. + const account = rowA.querySelector('.col-start-1.row-start-1')!; + expect(account.textContent).toBe('Brokerage A'); + expect(account.querySelector('span')).toBeNull(); + // Captions reuse the table's own column keys: no new catalogue string. + for (const caption of ['Account', 'Holdings', 'Cash', 'Total', 'Gain/Loss']) { + expect(screen.getAllByText(caption).length).toBeGreaterThan(0); + } + }); + + it('never wraps a money figure, and gives each caption whitespace-normal back', async () => { + const container = await renderReport(); + + for (const row of container.querySelectorAll('tbody tr')) { + // The four figure cells (holdings, cash, total, gain/loss) are + // right-aligned and never wrap; the account may wrap and is not a figure. + const figures = Array.from(row.querySelectorAll('td')).filter( + (c) => c.className.includes('whitespace-nowrap') && c.className.includes('text-right'), + ); + expect(figures).toHaveLength(4); + for (const cell of figures) { + const caption = cell.querySelector('span'); + expect(caption?.className).toContain('whitespace-normal'); + expect(caption?.className).toContain('sm:hidden'); + } + // The account cell wraps its name; it is not right-aligned. + const account = row.querySelector('.col-start-1.row-start-1')!; + expect(account.className).not.toContain('whitespace-nowrap'); + expect(account.className).toContain('break-words'); + } + }); + + it('restores this table’s own cell padding from sm up, per cell', async () => { + const container = await renderReport(); + + for (const cell of container.querySelectorAll('tbody td')) { + // No padding of its own below `sm`; the row supplies it. This table's + // `px-4 py-3` restored from `sm` up. + expect(cell.className).toContain('p-0'); + expect(cell.className).toContain('sm:px-4'); + expect(cell.className).toContain('sm:py-3'); + } + }); + + it('offers the same five sort controls on phones as in the column header', async () => { + const container = await renderReport(); + + const headerRows = Array.from(container.querySelectorAll('thead tr')); + expect(headerRows).toHaveLength(2); + const [phoneRow, columnRow] = headerRows; + // Exactly one of the two is displayed at any width. + expect(phoneRow.className).toContain('sm:hidden'); + expect(columnRow.className).toContain('hidden'); + expect(columnRow.className).toContain('sm:table-row'); + + const labelsOf = (row: Element) => Array.from(row.querySelectorAll('th')).map(stripGlyph); + const expected = ['Account', 'Holdings', 'Cash', 'Total', 'Gain/Loss']; + expect(labelsOf(phoneRow)).toEqual(expected); + // Both rows are rendered from one list, so they cannot list different fields. + expect(labelsOf(columnRow)).toEqual(labelsOf(phoneRow)); + }); + + it('sorts from the phone strip, not only from the column header', async () => { + const container = await renderReport(); + + const accountOrder = () => + Array.from(container.querySelectorAll('tbody tr')).map( + (r) => r.querySelector('.col-start-1.row-start-1')?.textContent, + ); + // Default sort is total descending: Brokerage A (1,500) leads B (500). + expect(accountOrder()).toEqual(['Brokerage A', 'Brokerage B']); + + // "Total" in the phone strip is the fourth of the five controls in the first + // header row. Addressed by position because the label also appears in the + // column header row and in a caption. + const phoneTotal = container.querySelectorAll('thead tr')[0].querySelectorAll('th')[3]; + await act(async () => { + fireEvent.click(phoneTotal); + }); + // Toggling total to ascending puts Brokerage B (500) first. + expect(accountOrder()).toEqual(['Brokerage B', 'Brokerage A']); + }); + + it('leaves the rows inert: the card is a layout, not a new affordance', async () => { + const container = await renderReport(); + + const rows = Array.from(container.querySelectorAll('tbody tr')); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.className).not.toContain('cursor-pointer'); + await act(async () => { + fireEvent.click(row); + }); + } + expect(mockPush).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/reports/PortfolioValueReport.tsx b/frontend/src/components/reports/PortfolioValueReport.tsx index 2290b5bcbe..d65ec28994 100644 --- a/frontend/src/components/reports/PortfolioValueReport.tsx +++ b/frontend/src/components/reports/PortfolioValueReport.tsx @@ -36,6 +36,11 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMultiSelect'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { exportToCsv } from '@/lib/csv-export'; import { createLogger } from '@/lib/logger'; @@ -44,6 +49,15 @@ import { EmptyState } from '@/components/ui/EmptyState'; type PortfolioBreakdownSortField = 'account' | 'holdings' | 'cash' | 'total' | 'gainLoss'; type PortfolioChartSortField = 'name' | 'value'; +/** + * One sortable column of the Portfolio Breakdown table. Declared once as a + * record over the sort-field union and rendered by BOTH header rows -- the + * column header row (from `sm` up) and the phone sort strip -- so the two can + * never list different fields, and adding a member to the union fails `tsc` + * here rather than stranding a phone with no control for it. + */ +type PortfolioBreakdownSortColumn = TableSortColumn; + // Normalized per-security breakdown ready to render. Point `name` is already // the display label (daily/monthly date or intraday time), so the chart, table // and CSV render the same way regardless of which endpoint produced it. `kind` @@ -82,6 +96,24 @@ const DAILY_RANGES = new Set(['1w', '1m', '3m', 'ytd', '1y']); const RANGE_STORAGE_KEY = 'monize-reports-portfolio-value-range'; const ACCOUNTS_STORAGE_KEY = 'monize-reports-portfolio-value-accounts'; +// Today's header cell for the Portfolio Breakdown table, unchanged (no +// `tracking-wider`, matching what this table renders). Kept local -- +// `PHONE_HEADER_CLASS`/`CAPTION_CLASS`/`CellLabel` are the shared chrome, but a +// table's own header and money cells stay per-report because their track +// budgets differ. +const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; + +// A money cell inside a wrapped breakdown row: no padding of its own below `sm` +// (the row's grid supplies it), this table's own `px-4 py-3 text-sm` from `sm` +// up, smaller type on phones. Colour and weight stay on each cell. +// +// `whitespace-nowrap` is the one property here that is NOT phone-only, and it is +// the single respect in which the `sm`-and-up cell differs from today's: a +// locale that groups thousands with a space (`1 234 567 zl`) could otherwise +// break a figure in the middle at any width. A number must not break; the +// caption inside takes `whitespace-normal` back for itself (`CellLabel`). +const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; + function CustomTooltip({ active, payload, fmtFull, portfolioLabel }: { active?: boolean; payload?: Array<{ value: number; payload: { name: string } }>; @@ -692,6 +724,20 @@ export function PortfolioValueReport() { ); const showFlags = summary.highest !== summary.lowest; + // The Portfolio Breakdown table's five sortable columns, keyed by field so the + // record is exhaustive: adding a member to `PortfolioBreakdownSortField` is a + // compile error here rather than a header with no control. Their declaration + // order is the column (and cell DOM) order, rendered by BOTH the column header + // row and the phone sort strip from the derived `Object.values`. + const breakdownColumns: TableSortColumnsByField = { + account: { field: 'account', label: t('portfolioValue.colAccount') }, + holdings: { field: 'holdings', label: t('portfolioValue.colHoldings'), align: 'right' }, + cash: { field: 'cash', label: t('portfolioValue.colCash'), align: 'right' }, + total: { field: 'total', label: t('portfolioValue.colTotal'), align: 'right' }, + gainLoss: { field: 'gainLoss', label: t('portfolioValue.colGainLoss'), align: 'right' }, + }; + const breakdownSortColumns: readonly PortfolioBreakdownSortColumn[] = Object.values(breakdownColumns); + const handleExportPdf = async () => { const { exportToPdf } = await import('@/lib/pdf-export'); const accountLabel = selectedAccount @@ -1187,77 +1233,79 @@ export function PortfolioValueReport() { {t('portfolioValue.breakdownTitle')} + {/* Below `sm` the table becomes a block and each row wraps into a + three-column, two-line grid card so all five columns fit a phone + without a horizontal scroll: line 1 is the account (the row + identity) and the total (the headline); line 2 is holdings, cash + and the gain/loss. Nothing is dropped, and no figure is truncated + -- a money value never wraps (`MONEY_CELL`). From `sm` up it is the + ordinary table, resolving identically to today (each cell restores + its own `sm:px-4 sm:py-3 sm:text-sm`), and the sort controls + survive as their own phone-only header row because the column + header row that carries them on desktop is hidden there. Restyling + `display` strips the implicit table semantics below `sm`, so the + roles are restated and every bare figure carries a `CellLabel` + naming its column; the account name names itself. */}
- - - - - field="account" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('portfolioValue.colAccount')} - - - field="holdings" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('portfolioValue.colHoldings')} - - - field="cash" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('portfolioValue.colCash')} - - - field="total" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('portfolioValue.colTotal')} - - - field="gainLoss" - sortField={sortField} - sortDirection={sortDirection} - onSort={handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('portfolioValue.colGainLoss')} - +
+ + {/* Phone sort strip: the same five controls, wrapped. */} + + {breakdownSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + className={PHONE_HEADER_CLASS} + > + {col.label} + + ))} + + + {breakdownSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={handleSort} + align={col.align} + className={HEADER_CLASS} + > + {col.label} + + ))} - + {sortedBreakdown.map((acct) => ( - - + {/* Account: the row identity. The name wraps unclamped. */} + - - - - From 726724682d31e86ba3ffc9958ba42499442215dd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 18:50:35 +0000 Subject: [PATCH 22/44] Wrap Monthly Comparison table on phones Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...hlyComparisonReport.mobileWrapped.test.tsx | 391 ++++++++++++++++++ .../reports/MonthlyComparisonReport.tsx | 306 ++++++++------ 2 files changed, 579 insertions(+), 118 deletions(-) create mode 100644 frontend/src/components/reports/MonthlyComparisonReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/MonthlyComparisonReport.mobileWrapped.test.tsx b/frontend/src/components/reports/MonthlyComparisonReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..24df7d89c0 --- /dev/null +++ b/frontend/src/components/reports/MonthlyComparisonReport.mobileWrapped.test.tsx @@ -0,0 +1,391 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, act } from '@/test/render'; +import { MonthlyComparisonReport } from './MonthlyComparisonReport'; + +/** + * The phone layout of the Monthly Comparison report's two wide tables (the + * monthly expense comparison and the investment Top Movers). + * + * Both are ONE tree restyled by CSS (mechanism A): below `sm` each row wraps into + * a three-column grid card and the column header row is replaced by a sort strip, + * from `sm` up each is the ordinary table. jsdom applies no media queries, so both + * header rows and every phone caption are in the DOM here at all times -- which is + * what lets these assertions read the phone markup without emulating a viewport, + * and why the sort controls are addressed by position (each label also appears in + * the column header row, and often in a caption or the month picker). + */ + +vi.mock('@/lib/pdf-export', () => ({ + exportToPdf: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ + ...numberFormatMockDefaults(), + formatSignedPercent: (n: number, decimals = 2) => `${n >= 0 ? '+' : ''}${n.toFixed(decimals)}%`, + formatPercent: (n: number, decimals = 2) => `${n.toFixed(decimals)}%`, + formatCurrency: (n: number) => `$${n.toFixed(2)}`, + formatCurrencyCompact: (n: number) => `$${Math.round(n)}`, + formatCurrencyAxis: (n: number) => `$${n}`, + defaultCurrency: 'CAD', + }), + }; +}); + +vi.mock('@/lib/chart-colours', () => ({ + CHART_COLOURS: ['#3b82f6', '#ef4444', '#22c55e', '#f97316'], +})); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: any) =>
{children}
, + PieChart: ({ children }: any) =>
{children}
, + Pie: () => null, + Cell: () => null, + Tooltip: () => null, + BarChart: ({ children }: any) =>
{children}
, + Bar: ({ children }: any) =>
{children}
, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})); + +const mockGetMonthlyComparison = vi.fn(); + +vi.mock('@/lib/built-in-reports', () => ({ + builtInReportsApi: { + getMonthlyComparison: (...args: any[]) => mockGetMonthlyComparison(...args), + }, +})); + +// Two comparison rows and two top movers, so a sort reorders something visible. +// Default sorts are current-total descending (comparison) and change-percent +// descending (movers), so Groceries and AAPL lead their tables. +const mockResponse = { + currentMonth: '2026-01', + previousMonth: '2025-12', + currentMonthLabel: 'January 2026', + previousMonthLabel: 'December 2025', + currency: 'CAD', + incomeExpenses: { + currentMonth: '2026-01', + previousMonth: '2025-12', + currentIncome: 5000, + previousIncome: 4500, + incomeChange: 500, + incomeChangePercent: 11.11, + currentExpenses: 3000, + previousExpenses: 3500, + expensesChange: -500, + expensesChangePercent: -14.29, + currentSavings: 2000, + previousSavings: 1000, + savingsChange: 1000, + savingsChangePercent: 100, + }, + notes: { savingsNote: 'saved more', incomeNote: 'income up' }, + expenses: { + currentMonth: [ + { categoryId: 'cat-1', categoryName: 'Groceries', color: '#ff0000', total: 800 }, + ], + previousMonth: [ + { categoryId: 'cat-1', categoryName: 'Groceries', color: '#ff0000', total: 700 }, + ], + comparison: [ + { categoryId: 'cat-1', categoryName: 'Groceries', color: '#ff0000', currentTotal: 800, previousTotal: 700, change: 100, changePercent: 14.29 }, + { categoryId: 'cat-2', categoryName: 'Utilities', color: '#00ff00', currentTotal: 400, previousTotal: 0, change: 400, changePercent: 100 }, + ], + currentTotal: 1200, + previousTotal: 700, + }, + topCategories: { currentMonth: [], previousMonth: [] }, + netWorth: { + monthlyHistory: [{ month: '2026-01', netWorth: 52000 }], + currentNetWorth: 52000, + previousNetWorth: 50000, + netWorthChange: 2000, + netWorthChangePercent: 4, + }, + investments: { + accountPerformance: [], + topMovers: [ + { securityId: 'sec-1', symbol: 'AAPL', name: 'Apple Inc.', currentPrice: 195.5, previousPrice: 190, change: 5.5, changePercent: 2.89, marketValue: 19550 }, + { securityId: 'sec-2', symbol: 'MSFT', name: 'Microsoft Corp.', currentPrice: 410, previousPrice: 415, change: -5, changePercent: -1.2, marketValue: 41000 }, + ], + }, +}; + +async function renderReport() { + mockGetMonthlyComparison.mockResolvedValue(mockResponse); + let container!: HTMLElement; + await act(async () => { + container = render().container; + }); + await waitFor(() => expect(container.querySelectorAll('table').length).toBeGreaterThanOrEqual(2)); + return container; +} + +const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const row = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + return `c${col}/r${row}`; +}; + +const stripGlyph = (el: Element) => el.textContent?.replace(/[↑↓↕]/g, '').trim(); + +// table[0] is the expense comparison; table[1] is Top Movers (a later section). +const comparisonTable = (c: HTMLElement) => c.querySelectorAll('table')[0]; +const topMoversTable = (c: HTMLElement) => c.querySelectorAll('table')[1]; + +describe('MonthlyComparisonReport comparison table (phone wrapped)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('keeps a table from sm up and a grid below it, with the semantics a restyle strips', async () => { + const container = await renderReport(); + const table = comparisonTable(container); + + expect(table.getAttribute('role')).toBe('table'); + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + expect(table.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(table.querySelector('tbody')?.className).toContain('sm:table-row-group'); + for (const group of table.querySelectorAll('thead, tbody')) { + expect(group.getAttribute('role')).toBe('rowgroup'); + } + for (const row of table.querySelectorAll('tbody tr')) { + expect(row.getAttribute('role')).toBe('row'); + expect(row.className).toContain('grid grid-cols-3'); + expect(row.className).toContain('sm:table-row'); + } + // EVERY `
` rows of a wrapped table's body. */ +function topOf(table: Element) { + return Array.from(table.querySelectorAll('tbody tr')); +} diff --git a/frontend/src/components/reports/MonthlyComparisonReport.tsx b/frontend/src/components/reports/MonthlyComparisonReport.tsx index fdaba2a717..225c522bc6 100644 --- a/frontend/src/components/reports/MonthlyComparisonReport.tsx +++ b/frontend/src/components/reports/MonthlyComparisonReport.tsx @@ -27,6 +27,11 @@ import { CHART_COLOURS } from '@/lib/chart-colours'; import { chartColors } from '@/lib/chart-colors'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; import { ReportError } from '@/components/reports/ReportError'; @@ -34,6 +39,32 @@ import { ReportError } from '@/components/reports/ReportError'; type ComparisonSortField = 'category' | 'current' | 'previous' | 'change' | 'changePercent'; type TopMoversSortField = 'symbol' | 'name' | 'price' | 'change' | 'changePercent'; +/** + * One sortable column of each history table. Declared once, as a record over the + * sort-field union, and rendered by BOTH header rows -- the column header row + * (from `sm` up) and the phone sort strip -- so the two can never list different + * fields, and adding a member to a union fails `tsc` here rather than stranding a + * phone with no control for it. + */ +type ComparisonSortColumn = TableSortColumn; +type TopMoversSortColumn = TableSortColumn; + +// Today's header cell, unchanged: what both tables already render (no +// `tracking-wider`). Kept local -- `PHONE_HEADER_CLASS`/`CAPTION_CLASS`/`CellLabel` +// are shared, but a table's own header and money cells stay per-report. +const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; + +// A money (or percentage) cell inside a wrapped row: no padding of its own below +// `sm` (the row supplies it and the grid does the spacing), this table's own +// `px-4 py-3 text-sm` from `sm` up, smaller type on phones. +// +// `whitespace-nowrap` is the one property here that is NOT phone-only, and it is +// the single respect in which the `sm`-and-up cell differs from today's: a locale +// that groups thousands with a space (`1 234 567 zl`) could otherwise break a +// figure in the middle at any width. A number must not break; the caption inside +// takes `whitespace-normal` back for itself (`CellLabel`). +const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; + function getDefaultMonth(): string { const now = new Date(); const prev = subMonths(now, 1); @@ -332,6 +363,31 @@ export function MonthlyComparisonReport() { }); }; + // The comparison table's five sortable columns, keyed by field so the record is + // exhaustive: adding a member to `ComparisonSortField` is a compile error here + // rather than a header with no control. Their declaration order is the column + // (and cell DOM) order, rendered by BOTH the column header row and the phone + // sort strip from the derived `Object.values`. The two month columns carry the + // locale-aware month labels the desktop header already showed. + const comparisonColumns: TableSortColumnsByField = { + category: { field: 'category', label: t('monthlyComparison.colCategory') }, + current: { field: 'current', label: currentMonthLabel, align: 'right' }, + previous: { field: 'previous', label: previousMonthLabel, align: 'right' }, + change: { field: 'change', label: t('monthlyComparison.colChange'), align: 'right' }, + changePercent: { field: 'changePercent', label: t('monthlyComparison.colChangePercent'), align: 'right' }, + }; + const comparisonSortColumns: readonly ComparisonSortColumn[] = Object.values(comparisonColumns); + + // The top-movers table's five sortable columns, same shape and same rule. + const topMoversColumns: TableSortColumnsByField = { + symbol: { field: 'symbol', label: t('monthlyComparison.colSymbol') }, + name: { field: 'name', label: t('monthlyComparison.colName') }, + price: { field: 'price', label: t('monthlyComparison.colPrice'), align: 'right' }, + change: { field: 'change', label: t('monthlyComparison.colChange'), align: 'right' }, + changePercent: { field: 'changePercent', label: t('monthlyComparison.colChangePercent'), align: 'right' }, + }; + const topMoversSortColumns: readonly TopMoversSortColumn[] = Object.values(topMoversColumns); + return (
{/* Month Picker */} @@ -450,79 +506,85 @@ export function MonthlyComparisonReport() { {/* Comparison Table */} {expenses.comparison.length > 0 && (
-
+
{acct.accountName} + + {breakdownColumns.holdings.label} {fmtFull(acct.totalMarketValue)} + + {breakdownColumns.cash.label} {fmtFull(acct.cashBalance)} + {/* Total: the headline figure, beside the account. */} + + {breakdownColumns.total.label} {fmtFull(acct.totalMarketValue + acct.cashBalance)} + + {breakdownColumns.gainLoss.label} {acct.totalGainLoss >= 0 ? '+' : ''}{fmtFull(acct.totalGainLoss)}
`, including the ones whose className is a template literal. + const cells = Array.from(table.querySelectorAll('td')); + expect(cells.length).toBeGreaterThan(0); + for (const cell of cells) { + expect(cell.getAttribute('role')).toBe('cell'); + } + for (const th of table.querySelectorAll('th')) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + // The wrapper still scrolls horizontally, which is what the table needs from + // `sm` up on a narrow desktop window. + expect(table.parentElement?.className).toContain('overflow-x-auto'); + }); + + it('places every cell on the phone grid explicitly, derived figure under its month', async () => { + const container = await renderReport(); + // DOM order is the desktop column order (category, current, previous, change, + // change %); placement is read off the classes, not off position. Line 1: + // category | current | previous. Line 2: change (under current) | change % + // (under previous). + for (const row of topOf(comparisonTable(container))) { + const [category, current, previous, change, changePercent] = Array.from(row.querySelectorAll('td')); + expect(placement(category)).toBe('c1/r1'); + expect(placement(current)).toBe('c2/r1'); + expect(placement(previous)).toBe('c3/r1'); + expect(placement(change)).toBe('c2/r2'); + expect(placement(changePercent)).toBe('c3/r2'); + for (const cell of [category, current, previous, change, changePercent]) { + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + expect(cell.className).not.toMatch(/\brow-start-3\b/); + } + } + }); + + it('captions every bare figure with its column key, and leaves the category self-naming', async () => { + const container = await renderReport(); + const row = Array.from(comparisonTable(container).querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes('Groceries'), + )!; + + // The two month columns caption with the same locale-aware month labels the + // column header shows; change and change % with the existing header keys. + expect(row.querySelector('.col-start-2.row-start-1')?.textContent).toBe('January 2026$800.00'); + expect(row.querySelector('.col-start-3.row-start-1')?.textContent).toBe('December 2025$700.00'); + expect(row.querySelector('.col-start-2.row-start-2')?.textContent).toBe('Change+$100.00'); + expect(row.querySelector('.col-start-3.row-start-2')?.textContent).toBe('Change %+14.3%'); + + // Category is the identity: no caption, just the name (its colour dot is the + // only span besides the name wrapper). + const category = row.querySelector('.col-start-1.row-start-1')!; + expect(category.textContent).toBe('Groceries'); + // The captions reuse the table's own column labels: no new catalogue string. + for (const caption of ['Change', 'Change %']) { + expect(screen.getAllByText(caption).length).toBeGreaterThan(0); + } + }); + + it('never wraps a money or percentage figure, and gives each caption whitespace-normal back', async () => { + const container = await renderReport(); + for (const row of topOf(comparisonTable(container))) { + // The four figure cells (current, previous, change, change %) never wrap. + const figures = Array.from(row.querySelectorAll('td')).filter( + (c) => c.className.includes('whitespace-nowrap') && c.className.includes('text-right'), + ); + expect(figures).toHaveLength(4); + for (const cell of figures) { + const caption = cell.querySelector('span'); + expect(caption?.className).toContain('whitespace-normal'); + expect(caption?.className).toContain('sm:hidden'); + } + // The category wraps unclamped and is not right-aligned. + const category = row.querySelector('.col-start-1.row-start-1')!; + expect(category.className).not.toContain('whitespace-nowrap'); + expect(category.querySelector('.break-words')).not.toBeNull(); + } + }); + + it('restores this table’s own cell padding from sm up, per figure cell', async () => { + const container = await renderReport(); + for (const cell of comparisonTable(container).querySelectorAll('tbody td')) { + // No padding of its own below `sm`; the row supplies it. This table's + // `px-4 py-3` restored from `sm` up. + expect(cell.className).toContain('p-0'); + expect(cell.className).toContain('sm:px-4'); + expect(cell.className).toContain('sm:py-3'); + } + }); + + it('offers the same five sort controls on phones as in the column header', async () => { + const container = await renderReport(); + const headerRows = Array.from(comparisonTable(container).querySelectorAll('thead tr')); + expect(headerRows).toHaveLength(2); + const [phoneRow, columnRow] = headerRows; + expect(phoneRow.className).toContain('sm:hidden'); + expect(columnRow.className).toContain('hidden'); + expect(columnRow.className).toContain('sm:table-row'); + const labelsOf = (r: Element) => Array.from(r.querySelectorAll('th')).map(stripGlyph); + expect(labelsOf(phoneRow)).toEqual(['Category', 'January 2026', 'December 2025', 'Change', 'Change %']); + // Both rows are rendered from one list, so they cannot list different fields. + expect(labelsOf(columnRow)).toEqual(labelsOf(phoneRow)); + }); + + it('sorts from the phone strip, not only from the column header', async () => { + const container = await renderReport(); + const order = () => + Array.from(comparisonTable(container).querySelectorAll('tbody tr')).map( + (r) => r.querySelector('.col-start-1.row-start-1')?.textContent, + ); + // Default sort is current-total descending: Groceries (800) leads Utilities (400). + expect(order()).toEqual(['Groceries', 'Utilities']); + + // "December 2025" (previous) is the third of the five controls in the phone + // strip. Clicking a new field sorts ascending: Utilities (0) leads Groceries (700). + const phonePrevious = comparisonTable(container).querySelectorAll('thead tr')[0].querySelectorAll('th')[2]; + await act(async () => { + fireEvent.click(phonePrevious); + }); + expect(order()).toEqual(['Utilities', 'Groceries']); + }); + + it('leaves the rows inert: the card is a layout, not a new affordance', async () => { + const container = await renderReport(); + const rows = Array.from(comparisonTable(container).querySelectorAll('tbody tr')); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.className).not.toContain('cursor-pointer'); + } + }); +}); + +describe('MonthlyComparisonReport top movers table (phone wrapped)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('wraps each mover onto a three-column grid, change beside the symbol', async () => { + const container = await renderReport(); + const table = topMoversTable(container); + expect(table.getAttribute('role')).toBe('table'); + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + + for (const row of table.querySelectorAll('tbody tr')) { + expect(row.className).toContain('grid grid-cols-3'); + expect(row.className).toContain('sm:table-row'); + // DOM order is the desktop column order: symbol, name, price, change, change %. + const [symbol, name, price, change, changePercent] = Array.from(row.querySelectorAll('td')); + expect(placement(symbol)).toBe('c1/r1'); + expect(placement(name)).toBe('c1/r2'); + expect(placement(price)).toBe('c2/r2'); + expect(placement(change)).toBe('c2/r1'); + expect(placement(changePercent)).toBe('c3/r1'); + for (const cell of [symbol, name, price, change, changePercent]) { + expect(cell.getAttribute('role')).toBe('cell'); + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + } + } + }); + + it('captions the figures, and leaves the symbol and its name descriptor self-naming', async () => { + const container = await renderReport(); + const row = Array.from(topMoversTable(container).querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes('AAPL'), + )!; + + // Symbol is the identity and the name is a descriptor sitting under it, so + // neither carries a caption. + expect(row.querySelector('.col-start-1.row-start-1')?.textContent).toBe('AAPL'); + expect(row.querySelector('.col-start-1.row-start-2')?.textContent).toBe('Apple Inc.'); + expect(row.querySelector('.col-start-1.row-start-1')?.querySelector('span')).toBeNull(); + expect(row.querySelector('.col-start-1.row-start-2')?.querySelector('span')).toBeNull(); + + // The three figures each caption with an existing column key. + expect(row.querySelector('.col-start-2.row-start-1')?.textContent).toBe('Change+$5.50'); + expect(row.querySelector('.col-start-3.row-start-1')?.textContent).toBe('Change %+2.89%'); + expect(row.querySelector('.col-start-2.row-start-2')?.textContent).toBe('Price$195.50'); + + // Each figure never wraps and its caption takes whitespace-normal back. + for (const sel of ['.col-start-2.row-start-1', '.col-start-3.row-start-1', '.col-start-2.row-start-2']) { + const cell = row.querySelector(sel)!; + expect(cell.className).toContain('whitespace-nowrap'); + expect(cell.className).toContain('text-right'); + expect(cell.querySelector('span')?.className).toContain('whitespace-normal'); + expect(cell.querySelector('span')?.className).toContain('sm:hidden'); + } + }); + + it('offers the same five sort controls on phones as in the column header', async () => { + const container = await renderReport(); + const headerRows = Array.from(topMoversTable(container).querySelectorAll('thead tr')); + expect(headerRows).toHaveLength(2); + const [phoneRow, columnRow] = headerRows; + expect(phoneRow.className).toContain('sm:hidden'); + expect(columnRow.className).toContain('hidden'); + expect(columnRow.className).toContain('sm:table-row'); + const labelsOf = (r: Element) => Array.from(r.querySelectorAll('th')).map(stripGlyph); + expect(labelsOf(phoneRow)).toEqual(['Symbol', 'Name', 'Price', 'Change', 'Change %']); + expect(labelsOf(columnRow)).toEqual(labelsOf(phoneRow)); + }); + + it('sorts from the phone strip, not only from the column header', async () => { + const container = await renderReport(); + const order = () => + Array.from(topMoversTable(container).querySelectorAll('tbody tr')).map( + (r) => r.querySelector('.col-start-1.row-start-1')?.textContent, + ); + // Default sort is change-percent descending: AAPL (+2.89%) leads MSFT (-1.2%). + expect(order()).toEqual(['AAPL', 'MSFT']); + + // "Change" is the fourth of the five controls in the phone strip; a new field + // sorts ascending: MSFT (-5) leads AAPL (+5.5). + const phoneChange = topMoversTable(container).querySelectorAll('thead tr')[0].querySelectorAll('th')[3]; + await act(async () => { + fireEvent.click(phoneChange); + }); + expect(order()).toEqual(['MSFT', 'AAPL']); + }); +}); + +/** The `
- - - - field="category" - sortField={comparisonSort.sortField} - sortDirection={comparisonSort.sortDirection} - onSort={comparisonSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('monthlyComparison.colCategory')} - - - field="current" - sortField={comparisonSort.sortField} - sortDirection={comparisonSort.sortDirection} - onSort={comparisonSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {currentMonthLabel} - - - field="previous" - sortField={comparisonSort.sortField} - sortDirection={comparisonSort.sortDirection} - onSort={comparisonSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {previousMonthLabel} - - - field="change" - sortField={comparisonSort.sortField} - sortDirection={comparisonSort.sortDirection} - onSort={comparisonSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('monthlyComparison.colChange')} - - - field="changePercent" - sortField={comparisonSort.sortField} - sortDirection={comparisonSort.sortDirection} - onSort={comparisonSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('monthlyComparison.colChangePercent')} - + {/* Below `sm` the table becomes a block and each row wraps into a + three-column grid card so all five columns fit a phone without a + horizontal scroll, on two lines: the category (the row identity), + the current-month total and the previous-month total on line 1; + the change and its percentage under the two months on line 2. + Nothing is dropped, and no figure is truncated -- a money value + never wraps (`MONEY_CELL`). From `sm` up it is the ordinary table, + resolving identically to today (each cell restores its own + `sm:px-4 sm:py-3 sm:text-sm`), and the sort controls survive as + their own phone-only header row because the column header row that + carries them on desktop is hidden there. Restyling `display` + strips the implicit table semantics below `sm`, so the roles are + restated and every bare figure carries a `CellLabel` naming its + column; the category names itself. DOM order is the desktop column + order, which the grid placement overrides visually on the phone. */} +
+ + {/* Phone sort strip: the same five controls, wrapped. */} + + {comparisonSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={comparisonSort.sortField} + sortDirection={comparisonSort.sortDirection} + onSort={comparisonSort.handleSort} + className={PHONE_HEADER_CLASS} + > + {col.label} + + ))} + + + {comparisonSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={comparisonSort.sortField} + sortDirection={comparisonSort.sortDirection} + onSort={comparisonSort.handleSort} + align={col.align} + className={HEADER_CLASS} + > + {col.label} + + ))} - + {sortedComparison.map((item) => ( - - + {/* Category: the row identity. It wraps unclamped in its own + track; the colour dot never shrinks. Kept `flex` at every + width (as today), so no `sm:table-cell` -- the grid + placement is inert once the row is a table-row. */} + - - - - @@ -644,71 +706,79 @@ export function MonthlyComparisonReport() {

{t('monthlyComparison.topMovers')}

-
+
{item.color && ( )} - {item.categoryName} + {item.categoryName} + + {currentMonthLabel} {formatCurrency(item.currentTotal, currency)} + + {previousMonthLabel} {formatCurrency(item.previousTotal, currency)} + + {t('monthlyComparison.colChange')} {item.change >= 0 ? '+' : ''}{formatCurrency(item.change, currency)} + + {t('monthlyComparison.colChangePercent')} {formatSignedPercent(item.changePercent, 1)}
- - - - field="symbol" - sortField={topMoversSort.sortField} - sortDirection={topMoversSort.sortDirection} - onSort={topMoversSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('monthlyComparison.colSymbol')} - - - field="name" - sortField={topMoversSort.sortField} - sortDirection={topMoversSort.sortDirection} - onSort={topMoversSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('monthlyComparison.colName')} - - - field="price" - sortField={topMoversSort.sortField} - sortDirection={topMoversSort.sortDirection} - onSort={topMoversSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('monthlyComparison.colPrice')} - - - field="change" - sortField={topMoversSort.sortField} - sortDirection={topMoversSort.sortDirection} - onSort={topMoversSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('monthlyComparison.colChange')} - - - field="changePercent" - sortField={topMoversSort.sortField} - sortDirection={topMoversSort.sortDirection} - onSort={topMoversSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('monthlyComparison.colChangePercent')} - + {/* Below `sm` the table becomes a block and each row wraps into a + three-column, two-line grid card so all five columns fit a + phone without a horizontal scroll: line 1 is the symbol (the + row identity), its change and its change percentage; line 2 is + the security name (a descriptor under its symbol) and the + price. Nothing is dropped, and no figure is truncated -- a + money value never wraps (`MONEY_CELL`). From `sm` up it is the + ordinary table, resolving identically to today, and the sort + controls survive as their own phone-only header row. Restyling + `display` strips the table semantics below `sm`, so the roles + are restated and every bare figure carries a `CellLabel`; the + symbol names itself and the name sits under it. DOM order is + the desktop column order, which the grid placement overrides + visually on the phone. */} +
+ + {/* Phone sort strip: the same five controls, wrapped. */} + + {topMoversSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={topMoversSort.sortField} + sortDirection={topMoversSort.sortDirection} + onSort={topMoversSort.handleSort} + className={PHONE_HEADER_CLASS} + > + {col.label} + + ))} + + + {topMoversSortColumns.map((col) => ( + + key={col.field} + field={col.field} + sortField={topMoversSort.sortField} + sortDirection={topMoversSort.sortDirection} + onSort={topMoversSort.handleSort} + align={col.align} + className={HEADER_CLASS} + > + {col.label} + + ))} - + {sortedTopMovers.map((mover) => ( - - - - + {/* Symbol: the row identity, self-naming. */} + + {/* Name: a descriptor under the symbol, so no caption. */} + + - - From ab1f3ce5f2adc564c8d668e5158e6efea1a3945f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 19:10:14 +0000 Subject: [PATCH 23/44] Wrap Geographic Allocation table on phones Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...hicAllocationReport.mobileWrapped.test.tsx | 407 ++++++++++++ .../reports/GeographicAllocationReport.tsx | 590 +++++++++++------- 2 files changed, 775 insertions(+), 222 deletions(-) create mode 100644 frontend/src/components/reports/GeographicAllocationReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/GeographicAllocationReport.mobileWrapped.test.tsx b/frontend/src/components/reports/GeographicAllocationReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..75e57a67d6 --- /dev/null +++ b/frontend/src/components/reports/GeographicAllocationReport.mobileWrapped.test.tsx @@ -0,0 +1,407 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, act } from '@/test/render'; +import { GeographicAllocationReport } from './GeographicAllocationReport'; + +/** + * The phone layout of the Geographic Allocation report's three data tables. + * + * Each table is ONE tree restyled by CSS (mechanism A): below `sm` the rows wrap + * into a grid card and the column header row is hidden, from `sm` up it is the + * ordinary table. jsdom applies no media queries, so both header rows and every + * phone caption are in the DOM here at all times -- which is what lets these + * assertions read the phone markup without emulating a viewport, and why the + * sort controls are addressed by position rather than by label (each label + * matches the phone strip, the column header row and a caption). + * + * The report has three views reached by the toggle -- Region (4 columns), + * Exchange (5 columns) and Country (3 columns) -- and the claim these tests hold + * is that all three wrap without a horizontal scroll, keep their `sm`+ output, + * and strand no column with no way to sort by it on a phone. + */ + +const mockPush = vi.fn(); +// One router for the run, as `src/test/setup.ts` builds it: `useRouter()` returns +// the same object every render in the real hook, so a factory handing back a +// fresh one changes the identity of every `useCallback([router])` and an effect +// that also sets state loops. Built lazily inside the factory because `vi.mock` +// is hoisted above the `const` it closes over. +let router: { push: typeof mockPush; replace: () => void; back: () => void; prefetch: () => void }; +vi.mock('next/navigation', () => ({ + useRouter: () => { + router ??= { push: mockPush, replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }; + return router; + }, + usePathname: () => '/reports/geographic-allocation', + useSearchParams: () => new URLSearchParams(), + useParams: () => ({ reportId: 'geographic-allocation' }), +})); + +vi.mock('@/lib/pdf-export', () => ({ + exportToPdf: vi.fn().mockResolvedValue(undefined), +})); + +// The 2dp `formatCurrency` this table's cells really use -- it is what makes a +// six-figure amount too wide for the money column on a phone. Spread the shared +// defaults first so a formatter the component happens to call is never missing. +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ + ...numberFormatMockDefaults(), + formatCurrencyCompact: (n: number) => `$${n.toFixed(0)}`, + formatCurrency: (n: number) => `$${n.toFixed(2)}`, + formatCurrencyAxis: (n: number) => `$${n}`, + }), + }; +}); + +vi.mock('@/hooks/useExchangeRates', () => ({ + useExchangeRates: () => ({ + defaultCurrency: 'CAD', + // Identity conversion so a holding's market value survives to the table. + convertToDefault: (amount: number) => amount, + }), +})); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: any) =>
{children}
, + PieChart: ({ children }: any) =>
{children}
, + BarChart: ({ children }: any) =>
{children}
, + Pie: ({ children }: any) =>
{children}
, + Bar: ({ children }: any) =>
{children}
, + Cell: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: () => null, + Legend: () => null, +})); + +const mockGetPortfolioSummary = vi.fn(); +const mockGetInvestmentAccounts = vi.fn(); +const mockGetSecurities = vi.fn(); +const mockGetCountryWeightings = vi.fn(); + +vi.mock('@/lib/investments', () => ({ + investmentsApi: { + getPortfolioSummary: (...args: any[]) => mockGetPortfolioSummary(...args), + getInvestmentAccounts: (...args: any[]) => mockGetInvestmentAccounts(...args), + getSecurities: (...args: any[]) => mockGetSecurities(...args), + getCountryWeightings: (...args: any[]) => mockGetCountryWeightings(...args), + }, +})); + +vi.mock('@/lib/logger', () => ({ + createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), +})); + +// Two North American exchanges, one European and one Asia-Pacific, with market +// values chosen so every percentage is a round number and the sort order is +// unambiguous. `convertToDefault` is the identity above, so the market value is +// the holding's own. +const HOLDINGS = [ + { securityId: 's-nasdaq', currencyCode: 'USD', quantity: 1, marketValue: 2000 }, + { securityId: 's-tsx', currencyCode: 'CAD', quantity: 1, marketValue: 3000 }, + { securityId: 's-lse', currencyCode: 'GBP', quantity: 1, marketValue: 1000 }, + { securityId: 's-tyo', currencyCode: 'JPY', quantity: 1, marketValue: 4000 }, +]; + +const SECURITIES = [ + { id: 's-nasdaq', symbol: 'AAPL', name: 'Apple Inc.', exchange: 'NASDAQ', isActive: true }, + { id: 's-tsx', symbol: 'RY.TO', name: 'Royal Bank of Canada', exchange: 'TSX', isActive: true }, + { id: 's-lse', symbol: 'BP.L', name: 'BP plc', exchange: 'LSE', isActive: true }, + { id: 's-tyo', symbol: '6758.T', name: 'Sony Group', exchange: 'TYO', isActive: true }, +]; + +const COUNTRY_WEIGHTINGS = { + items: [ + { country: 'United States', directValue: 0, etfValue: 6000, totalValue: 6000, percentage: 60 }, + { country: 'Canada', directValue: 0, etfValue: 3000, totalValue: 3000, percentage: 30 }, + ], + totalPortfolioValue: 10000, + totalDirectValue: 0, + totalEtfValue: 9000, + unclassifiedValue: 1000, +}; + +async function renderReport() { + mockGetPortfolioSummary.mockResolvedValue({ holdings: HOLDINGS }); + mockGetInvestmentAccounts.mockResolvedValue([]); + mockGetSecurities.mockResolvedValue(SECURITIES); + mockGetCountryWeightings.mockResolvedValue(COUNTRY_WEIGHTINGS); + let container!: HTMLElement; + await act(async () => { + ({ container } = render()); + }); + // The region view is the default; wait for its table to mount. + await waitFor(() => expect(container.querySelector('table')).toBeInTheDocument()); + return container; +} + +async function switchView(name: 'By Exchange' | 'By Country') { + await act(async () => { + fireEvent.click(screen.getByText(name)); + }); +} + +const cellsOf = (row: Element) => Array.from(row.querySelectorAll('td')); + +/** `c/r` for a cell, read off its explicit grid placement. */ +const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const line = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + return `c${col}/r${line}`; +}; +const placements = (row: Element) => cellsOf(row).map(placement); + +/** The phone sort strip: the header row hidden from `sm` up, not the column row. */ +const phoneStrip = (container: Element) => + Array.from(container.querySelectorAll('thead tr')).find((r) => + r.className.includes('sm:hidden'), + )!; + +const bodyRows = (container: Element) => + Array.from(container.querySelectorAll('tbody tr')); + +const regionOrder = (container: Element) => + bodyRows(container).map((r) => r.querySelector('td')?.querySelector('span')?.textContent); + +describe('GeographicAllocationReport (phone wrapped tables)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPush.mockClear(); + window.localStorage.clear(); + }); + + it('makes each table a block below sm and a table from sm up, on every view', async () => { + const container = await renderReport(); + for (const view of [null, 'By Exchange', 'By Country'] as const) { + if (view) await switchView(view); + const table = container.querySelector('table')!; + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + expect(container.querySelector('thead')!.className).toContain('sm:table-header-group'); + expect(container.querySelector('tbody')!.className).toContain('sm:table-row-group'); + expect(container.querySelector('tfoot')!.className).toContain('sm:table-footer-group'); + // The wrapper still scrolls horizontally, which is what a narrow desktop + // window needs from `sm` up. + expect(table.parentElement!.className).toContain('overflow-x-auto'); + const row = container.querySelector('tbody tr')!; + expect(row.className).toContain('grid grid-cols-2'); + expect(row.className).toContain('sm:table-row'); + } + }); + + it('restores the table semantics a phone restyle strips, on every view', async () => { + const container = await renderReport(); + for (const view of [null, 'By Exchange', 'By Country'] as const) { + if (view) await switchView(view); + const table = container.querySelector('table')!; + expect(table.getAttribute('role')).toBe('table'); + for (const group of ['thead', 'tbody', 'tfoot']) { + expect(container.querySelector(group)!.getAttribute('role')).toBe('rowgroup'); + } + for (const row of Array.from(table.querySelectorAll('tr'))) { + expect(row.getAttribute('role')).toBe('row'); + } + // EVERY `
+ {columns.map((col) => ( + + key={col.field} + field={col.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={onSort} + className={PHONE_HEADER_CLASS} + > + {col.label} + + ))} + + + {columns.map((col) => ( + + key={col.field} + field={col.field} + sortField={sortField} + sortDirection={sortDirection} + onSort={onSort} + align={col.align} + className={HEADER_CLASS} + > + {col.label} + + ))} + + + ); +} + function CustomTooltip({ active, payload, formatCurrencyFull, holdingLabel }: { active?: boolean; payload?: Array<{ payload: RegionAllocation | ExchangeAllocation }>; @@ -248,6 +385,35 @@ export function GeographicAllocationReport() { return sorted; }, [countryData, countrySort.sortField, countrySort.sortDirection]); + // Exhaustive over each view's sort-field union, so a new field is a compile + // error rather than a column with no control in either header. Declaration + // order IS the column (and DOM) order, and it is today's; these labels are + // also the phone captions, so a value reads under exactly its column header. + const regionColumns: RegionColumns = { + region: { field: 'region', label: t('geographicAllocation.colRegion') }, + count: { field: 'count', label: t('geographicAllocation.colHoldings'), align: 'right' }, + marketValue: { field: 'marketValue', label: t('geographicAllocation.colMarketValue'), align: 'right' }, + percentage: { field: 'percentage', label: t('geographicAllocation.colPortfolioPct'), align: 'right' }, + }; + const exchangeColumns: ExchangeColumns = { + exchange: { field: 'exchange', label: t('geographicAllocation.colExchange') }, + country: { field: 'country', label: t('geographicAllocation.colCountry') }, + count: { field: 'count', label: t('geographicAllocation.colHoldings'), align: 'right' }, + marketValue: { field: 'marketValue', label: t('geographicAllocation.colMarketValue'), align: 'right' }, + percentage: { field: 'percentage', label: t('geographicAllocation.colPortfolioPct'), align: 'right' }, + }; + const countryColumns: CountryColumns = { + country: { field: 'country', label: t('geographicAllocation.colCountry') }, + marketValue: { field: 'marketValue', label: t('geographicAllocation.colMarketValue'), align: 'right' }, + percentage: { field: 'percentage', label: t('geographicAllocation.colPortfolioPct'), align: 'right' }, + }; + // The column order, rendered by both header rows and matched by the cells' DOM + // order. DERIVED from each record rather than re-listed: a hand-written list + // beside an exhaustive record is not itself exhaustive. + const regionSortColumns: readonly RegionSortColumn[] = Object.values(regionColumns); + const exchangeSortColumns: readonly ExchangeSortColumn[] = Object.values(exchangeColumns); + const countrySortColumns: readonly CountrySortColumn[] = Object.values(countryColumns); + const handleExportPdf = async () => { if (viewType === 'country') { const { exportToPdf } = await import('@/lib/pdf-export'); @@ -542,74 +708,84 @@ export function GeographicAllocationReport() { )} - {/* Data Table */} + {/* Data Table + + Below `sm` each table becomes a block and every row wraps into a grid + card so all its columns fit a phone without a horizontal scroll -- + `REGION_PLACEMENT`, `EXCHANGE_PLACEMENT` and `COUNTRY_PLACEMENT` hold + where each column lands. Nothing is dropped: every row carries all its + columns at every width, from `sm` up it is the ordinary table, and the + sort controls survive as a phone-only header strip because the column + header row that carries them on desktop is hidden there. + + Two properties of restyling one tree, both deliberate. Changing the + `display` drops the implicit table semantics below `sm`, so the + explicit ARIA roles put them back. And the DOM keeps the desktop column + order while the grid paints the cells out of that order, so a + screen-reader user hears the column order rather than the painted one + (the WCAG 1.3.2 tension mechanism A carries); the `CellLabel` captions + limit the cost, since every value names its own column. */} {viewType === 'country' ? (
-
{mover.symbol}{mover.name} +
+ {mover.symbol} + + {mover.name} + + {t('monthlyComparison.colPrice')} {formatCurrency(mover.currentPrice, currency)} + {/* Change: the headline, beside the symbol. */} + + {t('monthlyComparison.colChange')} {mover.change >= 0 ? '+' : ''}{formatCurrency(mover.change, currency)} + + {t('monthlyComparison.colChangePercent')} {formatSignedPercent(mover.changePercent, 2)}
` -- a cell whose className is a template literal is exactly + // where `role="cell"` gets forgotten -- and the footer's empty spacer too. + for (const cell of Array.from(table.querySelectorAll('td'))) { + expect(cell.getAttribute('role')).toBe('cell'); + } + for (const th of Array.from(table.querySelectorAll('th'))) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + } + }); + + it('places every cell explicitly and never wraps a figure, on every view', async () => { + const container = await renderReport(); + for (const view of [null, 'By Exchange', 'By Country'] as const) { + if (view) await switchView(view); + const rows = [...bodyRows(container), container.querySelector('tfoot tr')!]; + for (const row of rows) { + for (const cell of cellsOf(row)) { + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + } + // Money and the count never wrap and stay right-aligned: right alignment + // is not containment, but truncating a money value would be worse. + for (const cell of cellsOf(row).filter((c) => c.className.includes('whitespace-nowrap'))) { + expect(cell.className).toContain('text-right'); + } + } + } + }); + + it('keeps every figure cell identical from sm up', async () => { + const container = await renderReport(); + // Every original cell was `text-sm px-4 py-3`, so the wrapped figure cell + // must resolve to exactly that at 640px+ (and never silently to `text-base` + // or a dropped padding). Below `sm` it is the phone-only `text-xs`. + for (const view of [null, 'By Exchange', 'By Country'] as const) { + if (view) await switchView(view); + const figures = cellsOf(container.querySelector('tbody tr')!).filter((c) => + c.className.includes('whitespace-nowrap'), + ); + expect(figures.length).toBeGreaterThan(0); + for (const cell of figures) { + expect(cell.className).toContain('sm:px-4'); + expect(cell.className).toContain('sm:py-3'); + expect(cell.className).toContain('sm:text-sm'); + expect(cell.className).toContain('text-xs'); + expect(cell.className).not.toContain('sm:text-base'); + } + // The identity cell keeps `text-sm` at every width (a name is prose) and + // carries the desktop padding from `sm` up. + const identity = cellsOf(container.querySelector('tbody tr')!)[0]; + expect(identity.className).toContain('min-w-0'); + expect(identity.className).toContain('text-sm'); + expect(identity.className).toContain('sm:px-4'); + const name = identity.querySelector('span')!; + expect(name.getAttribute('title')).toBe(name.textContent); + if (view === 'By Country') { + // The country identity spans the whole line, so it wraps UNCLAMPED -- + // `break-words` on the cell contains an unbreakable token in a full-width + // track, no clamp needed (and `sm:break-normal` gives today's wrap back). + expect(identity.className).toContain('break-words'); + expect(identity.className).toContain('sm:break-normal'); + expect(name.className).not.toContain('line-clamp'); + } else { + // Region/exchange identities share their line with the market value, so + // the span clamps: the clamp's `overflow: hidden` is what contains an + // unbreakable token in the narrow track, handed back at `sm`. + expect(name.className).toContain('line-clamp-3'); + expect(name.className).toContain('break-words'); + expect(name.className).toContain('sm:line-clamp-none'); + expect(name.className).toContain('sm:break-normal'); + } + } + }); + + it('wraps the region table into two-line cards with all four columns', async () => { + const container = await renderReport(); + // Region view is the default. Every row has four cells, laid out region + + // market value on line 1, share + holdings on line 2. + const rows = [...bodyRows(container), container.querySelector('tfoot tr')!]; + for (const row of rows) { + const cells = cellsOf(row); + expect(cells).toHaveLength(4); + // DOM order is the desktop column order: region, count, market value, share. + expect(placements(row)).toEqual(['c1/r1', 'c2/r2', 'c2/r1', 'c1/r2']); + // Every footer column has a total, so nothing hides below `sm` and no cell + // owes an `aria-colindex`. + for (const cell of cells) { + expect(cell.className).not.toMatch(/\bhidden\b/); + expect(cell.getAttribute('aria-colindex')).toBeNull(); + } + } + // Each bare figure names its own column, and the value node is still findable. + const naRow = bodyRows(container).find((r) => r.textContent?.includes('North America'))!; + expect(naRow.textContent).toContain('Holdings2'); + expect(naRow.textContent).toContain('Market Value$5000.00'); + expect(naRow.textContent).toContain('% of Portfolio50.0%'); + }); + + it('offers the same four sort controls on the region phone strip as in the header', async () => { + const container = await renderReport(); + const headerRows = Array.from(container.querySelectorAll('thead tr')); + expect(headerRows).toHaveLength(2); + const [strip, columnRow] = headerRows; + expect(strip.className).toContain('sm:hidden'); + expect(columnRow.className).toContain('hidden'); + expect(columnRow.className).toContain('sm:table-row'); + const labelsOf = (row: Element) => + Array.from(row.querySelectorAll('th')).map((th) => + th.textContent?.replace(/[↑↓↕]/g, '').trim(), + ); + expect(labelsOf(strip)).toEqual(['Region', 'Holdings', 'Market Value', '% of Portfolio']); + expect(labelsOf(columnRow)).toEqual(labelsOf(strip)); + // No column stranded without a control: a header row carries as many controls + // as a data row has cells. + const perRow = + container.querySelectorAll('tbody tr td').length / bodyRows(container).length; + expect(strip.querySelectorAll('th')).toHaveLength(perRow); + }); + + it('sorts the region table from the phone strip, not only from the column header', async () => { + const container = await renderReport(); + // Default is market value descending: North America, Asia-Pacific, Europe. + expect(regionOrder(container)).toEqual(['North America', 'Asia-Pacific', 'Europe']); + // The first strip control is Region; a new field sorts ascending. + await act(async () => { + fireEvent.click(phoneStrip(container).querySelectorAll('th')[0]); + }); + expect(regionOrder(container)).toEqual(['Asia-Pacific', 'Europe', 'North America']); + // A second tap reverses it -- the escape from any sort a phone can reach. + await act(async () => { + fireEvent.click(phoneStrip(container).querySelectorAll('th')[0]); + }); + expect(regionOrder(container)).toEqual(['North America', 'Europe', 'Asia-Pacific']); + }); + + it('wraps the exchange table into three-line cards, country as a descriptor', async () => { + const container = await renderReport(); + await switchView('By Exchange'); + await waitFor(() => expect(screen.getByText('NASDAQ')).toBeInTheDocument()); + + const rows = [...bodyRows(container), container.querySelector('tfoot tr')!]; + for (const row of rows) { + const cells = cellsOf(row); + expect(cells).toHaveLength(5); + // DOM order: exchange, country, count, market value, share. The country + // sits under the exchange identity (c1/r2); the count drops to line 3. + expect(placements(row)).toEqual(['c1/r1', 'c1/r2', 'c2/r3', 'c2/r1', 'c2/r2']); + } + const nasdaqRow = bodyRows(container).find((r) => r.textContent?.includes('NASDAQ'))!; + // The country is a descriptor under the identity, so it carries NO caption. + const countryCell = cellsOf(nasdaqRow)[1]; + expect(countryCell.textContent).toBe('United States'); + expect(countryCell.querySelector('span.sm\\:hidden')).toBeNull(); + // The three figure cells each name their own column. + expect(nasdaqRow.textContent).toContain('Holdings1'); + expect(nasdaqRow.textContent).toContain('Market Value$2000.00'); + expect(nasdaqRow.textContent).toContain('% of Portfolio20.0%'); + // The footer's country column has no total: an empty, captionless spacer that + // is never hidden below `sm`, so it owes no `aria-colindex`. + const footCountry = cellsOf(container.querySelector('tfoot tr')!)[1]; + expect(footCountry.textContent).toBe(''); + expect(footCountry.getAttribute('role')).toBe('cell'); + expect(footCountry.getAttribute('aria-colindex')).toBeNull(); + }); + + it('wraps the country table into two-line cards with the identity on line 1', async () => { + const container = await renderReport(); + await switchView('By Country'); + await waitFor(() => expect(screen.getByText('Other')).toBeInTheDocument()); + + const rows = [...bodyRows(container), container.querySelector('tfoot tr')!]; + for (const row of rows) { + const cells = cellsOf(row); + expect(cells).toHaveLength(3); + // The unbounded identity takes the whole of line 1; the two figures share + // line 2. + expect(placements(row)).toEqual(['c1/r1', 'c1/r2', 'c2/r2']); + expect(cellsOf(row)[0].className).toMatch(/\bcol-span-2\b/); + } + const usRow = bodyRows(container).find((r) => r.textContent?.includes('United States'))!; + expect(usRow.textContent).toContain('Market Value$6000.00'); + expect(usRow.textContent).toContain('% of Portfolio60.0%'); + // The "Other" look-through remainder is still surfaced. + expect(bodyRows(container).some((r) => r.textContent?.includes('Other'))).toBe(true); + // The footer totals are captioned like any other cell. + const foot = container.querySelector('tfoot tr')!; + expect(foot.textContent).toContain('Total'); + expect(foot.textContent).toContain('Market Value$10000.00'); + expect(foot.textContent).toContain('% of Portfolio100%'); + }); + + it('leaves the surfaces outside the tables alone', async () => { + await renderReport(); + // Filters, summary cards and the region pie chart are not part of the + // conversion; a phone still gets all of them. + expect(screen.getByText('Total Portfolio')).toBeInTheDocument(); + expect(screen.getByText('Regions')).toBeInTheDocument(); + expect(screen.getByText('Exchanges')).toBeInTheDocument(); + expect(screen.getByTestId('pie-chart')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/reports/GeographicAllocationReport.tsx b/frontend/src/components/reports/GeographicAllocationReport.tsx index fc67ae4f46..3135fc10ff 100644 --- a/frontend/src/components/reports/GeographicAllocationReport.tsx +++ b/frontend/src/components/reports/GeographicAllocationReport.tsx @@ -23,6 +23,11 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMultiSelect'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; import { PartialTotal } from '@/components/ui/PartialTotal'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { useReportData } from '@/hooks/useReportData'; @@ -54,6 +59,138 @@ interface CountryRow { color: string; } +// One column of a data table, declared once as a record over its sort-field +// union and rendered by BOTH header rows -- the column header row (from `sm` up) +// and the phone sort strip -- so the two can never list different fields, and a +// new union member fails `tsc` rather than stranding a phone with no control for +// it. The `SortColumnsByField` alias ties each key to its entry's own `field`, +// so `count: { field: 'percentage', ... }` is a compile error rather than a +// duplicate React key a label-comparing test cannot see. One record per view, +// because the three views carry different sort fields. +type RegionSortColumn = TableSortColumn; +type RegionColumns = TableSortColumnsByField; +type ExchangeSortColumn = TableSortColumn; +type ExchangeColumns = TableSortColumnsByField; +type CountrySortColumn = TableSortColumn; +type CountryColumns = TableSortColumnsByField; + +// Today's header cell, unchanged (previously inlined at every column header). +const HEADER_CLASS = + 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'; + +// Where each column sits on a phone card, written once per view. Auto-flow would +// place cells by DOM order and silently re-flow the moment a column set changed +// between views, so every cell states its own column and line; the placements +// are inert from `sm` up, where every row is an ordinary table row again. +// +// All three grids are two columns. A REGION row is two lines -- the region and +// its market value (the figure the row is read for) on line 1, the share and +// the holdings count on line 2 -- laid out exactly as the Security Type report's +// four-column card. A COUNTRY row gives its unbounded identity the whole of line +// 1 (only three columns, so there is room) and drops its two figures to line 2. +// An EXCHANGE row is three lines: the exchange and its market value on line 1, +// the exchange's country as a descriptor under the identity beside the share on +// line 2, and the holdings count on line 3. +const REGION_PLACEMENT: Record = { + region: 'col-start-1 row-start-1', + marketValue: 'col-start-2 row-start-1', + percentage: 'col-start-1 row-start-2', + count: 'col-start-2 row-start-2', +}; +const COUNTRY_PLACEMENT: Record = { + country: 'col-start-1 col-span-2 row-start-1', + marketValue: 'col-start-1 row-start-2', + percentage: 'col-start-2 row-start-2', +}; +const EXCHANGE_PLACEMENT: Record = { + exchange: 'col-start-1 row-start-1', + marketValue: 'col-start-2 row-start-1', + country: 'col-start-1 row-start-2', + percentage: 'col-start-2 row-start-2', + count: 'col-start-2 row-start-3', +}; + +// The phone card's row grid, shared by all three views' body and footer rows so +// a placement above means the same track in each. Inert from `sm` up. +const ROW_GRID = 'grid grid-cols-2 items-start gap-x-3 gap-y-1.5 px-4 py-3'; + +// A figure cell inside a wrapped card: no padding of its own below `sm` (the row +// supplies it and the grid does the spacing), the table cell's own padding from +// `sm` up, smaller type on phones. Every original cell here is `text-sm`, so +// `sm:text-sm` reproduces the desktop cell exactly. `whitespace-nowrap` is the +// one property that is NOT phone-only and the single respect in which the +// `sm`-and-up cell differs from today's: a locale grouping thousands with a +// space could otherwise break a figure in the middle of a number, at any width. +const FIGURE_CELL = + 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; + +// A left-aligned text cell (the exchange's country descriptor): reproduces the +// desktop `px-4 py-3 text-sm` from `sm` up, wraps unclamped below it in its +// `minmax(0,1fr)` track so a long country name cannot set the table's width. +const TEXT_CELL = + 'min-w-0 p-0 text-xs break-words sm:table-cell sm:px-4 sm:py-3 sm:text-sm sm:break-normal'; + +// The identity cell of a data row and of the totals footer, sharing this box in +// every view: the one cell that keeps `text-sm` on phones (a name is prose, and +// the figures' `text-xs` would cost the clamp a character a line). Its placement +// is prepended per view, because the identity column differs between them. +const IDENTITY_CELL = 'min-w-0 p-0 text-sm sm:table-cell sm:px-4 sm:py-3'; + +// The two header rows every view draws: a phone-only sort strip of compact chips +// (the column header row is hidden below `sm`, so its controls must return +// somewhere a phone can reach) and the ordinary column header row from `sm` up. +// Both are rendered from ONE `columns` list, so they cannot list different +// fields. Generic over the view's sort field, so a control cannot address a +// column the rows do not render. +function SortHeaderRows({ + columns, + sortField, + sortDirection, + onSort, +}: { + columns: readonly TableSortColumn[]; + sortField: F; + sortDirection: 'asc' | 'desc'; + onSort: (field: F) => void; +}) { + return ( + <> + {/* Phone sort strip: the same controls, wrapped and self-naming. The + border and card background are what say "tappable" -- there is no hover + on a touch screen. */} +
- - - - field="country" - sortField={countrySort.sortField} - sortDirection={countrySort.sortDirection} - onSort={countrySort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colCountry')} - - - field="marketValue" - sortField={countrySort.sortField} - sortDirection={countrySort.sortDirection} - onSort={countrySort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colMarketValue')} - - - field="percentage" - sortField={countrySort.sortField} - sortDirection={countrySort.sortDirection} - onSort={countrySort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colPortfolioPct')} - - +
+ + - + {sortedCountryData.map((item) => ( - - + {/* The country identity spans the whole of line 1, so unlike + the region/exchange identities -- which share their line + with the market value and clamp for containment and row + height -- it wraps UNCLAMPED: a full-width `min-w-0` track + contains an unbreakable token through `break-words` alone, + the same treatment the Security Type report gives its + full-line holding identity. */} + - - ))} - - - + {/* Every column has a total, so no footer cell hides below `sm` + and none owes an `aria-colindex`. */} + + - - @@ -617,185 +793,155 @@ export function GeographicAllocationReport() {
+
- {item.country} + {item.country}
+ + {countryColumns.marketValue.label} {formatCurrencyFull(item.marketValue, defaultCurrency)} + + {countryColumns.percentage.label} {formatPercent(item.percentage, 1)}
- {t('geographicAllocation.total')} +
+ + {t('geographicAllocation.total')} + + + {countryColumns.marketValue.label} {formatCurrencyFull(countryTotalValue, defaultCurrency)} + + {countryColumns.percentage.label} 100%
- ) : ( -
-
- - - - {viewType === 'region' ? ( - - field="region" - sortField={regionSort.sortField} - sortDirection={regionSort.sortDirection} - onSort={regionSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colRegion')} - - ) : ( - - field="exchange" - sortField={exchangeSort.sortField} - sortDirection={exchangeSort.sortDirection} - onSort={exchangeSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colExchange')} - - )} - {viewType === 'exchange' && ( - - field="country" - sortField={exchangeSort.sortField} - sortDirection={exchangeSort.sortDirection} - onSort={exchangeSort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colCountry')} - - )} - {viewType === 'region' ? ( - - field="count" - sortField={regionSort.sortField} - sortDirection={regionSort.sortDirection} - onSort={regionSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colHoldings')} - - ) : ( - - field="count" - sortField={exchangeSort.sortField} - sortDirection={exchangeSort.sortDirection} - onSort={exchangeSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colHoldings')} - - )} - {viewType === 'region' ? ( - - field="marketValue" - sortField={regionSort.sortField} - sortDirection={regionSort.sortDirection} - onSort={regionSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colMarketValue')} - - ) : ( - - field="marketValue" - sortField={exchangeSort.sortField} - sortDirection={exchangeSort.sortDirection} - onSort={exchangeSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" - > - {t('geographicAllocation.colMarketValue')} - - )} - {viewType === 'region' ? ( - - field="percentage" - sortField={regionSort.sortField} - sortDirection={regionSort.sortDirection} - onSort={regionSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" + ) : viewType === 'region' ? ( +
+
+
+ + + + + {sortedRegionData.map((item) => ( + - {t('geographicAllocation.colPortfolioPct')} - - ) : ( - - field="percentage" - sortField={exchangeSort.sortField} - sortDirection={exchangeSort.sortDirection} - onSort={exchangeSort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider" + {/* DOM order stays the desktop column order (region, count, + market value, share); the grid only repositions. */} + + + + + + ))} + + + + + + + + + +
+
+
+ {item.region} +
+
+ {regionColumns.count.label} + {item.count} + + {regionColumns.marketValue.label} + {formatCurrencyFull(item.marketValue, defaultCurrency)} + + {regionColumns.percentage.label} + {formatPercent(item.percentage, 1)} +
+ + {t('geographicAllocation.total')} + + + {regionColumns.count.label} + {holdings.length} + + {regionColumns.marketValue.label} + {formatCurrencyFull(totalValue, defaultCurrency)} + + {regionColumns.percentage.label} + 100% +
+
+
+ ) : ( +
+
+ + + + + + {sortedExchangeData.map((item, idx) => ( + - {t('geographicAllocation.colPortfolioPct')} - - )} - - - - {viewType === 'region' - ? sortedRegionData.map((item) => ( - - - - - - - )) - : sortedExchangeData.map((item, idx) => ( - - - - - - - - ))} - - - - - {viewType === 'exchange' && - - - - -
-
-
- {item.region} -
-
- {item.count} - - {formatCurrencyFull(item.marketValue, defaultCurrency)} - - {formatPercent(item.percentage, 1)} -
-
-
- {item.exchange} -
-
- {item.country} - - {item.count} - - {formatCurrencyFull(item.marketValue, defaultCurrency)} - - {formatPercent(item.percentage, 1)} -
- {t('geographicAllocation.total')} - } - - {holdings.length} - - {formatCurrencyFull(totalValue, defaultCurrency)} - - 100% -
+ {/* DOM order stays the desktop column order (exchange, + country, count, market value, share); the grid only + repositions. The country sits under the exchange + identity as a descriptor and so carries no caption. */} +
+
+
+ {item.exchange} +
+
+ {item.country} + + {exchangeColumns.count.label} + {item.count} + + {exchangeColumns.marketValue.label} + {formatCurrencyFull(item.marketValue, defaultCurrency)} + + {exchangeColumns.percentage.label} + {formatPercent(item.percentage, 1)} +
` there. It is never hidden below `sm`, + so it owes no `aria-colindex`. */} +
+ + {t('geographicAllocation.total')} + + + + {exchangeColumns.count.label} + {holdings.length} + + {exchangeColumns.marketValue.label} + {formatCurrencyFull(totalValue, defaultCurrency)} + + {exchangeColumns.percentage.label} + 100% +
+
- )} ); From c7ebfdc832e30ba5db96b234bb16002839a6d0d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 08:12:25 +0000 Subject: [PATCH 24/44] Wrap Dividend Income tables on phones Convert the fixed-column Income by Security table to the below-`sm` phone-card layout (mechanism A, CSS single tree), mirroring RealizedGainsReport's security summary: a two-column, three-line grid card with the identity wrapping unclamped, every figure carrying a `CellLabel`, and the sort controls returned as a phone-only strip built from one exhaustive `SortColumnsByField` record. The Monthly and Daily tables are left as fixed tables: their column count varies at runtime with the `visibleSeries` toggles (four to seven columns), so they are not fixed-column and are out of scope for mechanism A's static grid. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...ividendIncomeReport.mobileWrapped.test.tsx | 219 ++++++++++++++++++ .../reports/DividendIncomeReport.tsx | 165 ++++++++----- 2 files changed, 322 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/reports/DividendIncomeReport.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/DividendIncomeReport.mobileWrapped.test.tsx b/frontend/src/components/reports/DividendIncomeReport.mobileWrapped.test.tsx new file mode 100644 index 0000000000..35d24414ea --- /dev/null +++ b/frontend/src/components/reports/DividendIncomeReport.mobileWrapped.test.tsx @@ -0,0 +1,219 @@ +import { act, fireEvent, render, screen, waitFor } from '@/test/render'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DividendIncomeReport } from './DividendIncomeReport'; + +vi.mock('@/hooks/useNumberFormat', async () => { + const { numberFormatMockDefaults } = await import('@/test/number-format-mock'); + return { + useNumberFormat: () => ({ + ...numberFormatMockDefaults(), + formatCurrency: (value: number) => `MONEY<${value}>`, + formatCurrencyAxis: (value: number) => `AXIS<${value}>`, + }), + }; +}); + +vi.mock('@/hooks/useExchangeRates', () => ({ + useExchangeRates: () => ({ + convertToDefault: (amount: number) => amount, + defaultCurrency: 'CAD', + }), +})); + +const STABLE_RESOLVED_RANGE = { start: '2024-01-01', end: '2025-01-01' }; +vi.mock('@/hooks/useDateRange', () => ({ + useDateRange: () => ({ + dateRange: '1y', + setDateRange: vi.fn(), + startDate: '', + setStartDate: vi.fn(), + endDate: '', + setEndDate: vi.fn(), + resolvedRange: STABLE_RESOLVED_RANGE, + isValid: true, + }), +})); + +vi.mock('@/lib/utils', async (importActual) => ({ + ...(await importActual()), + parseLocalDate: (d: string) => new Date(d + 'T00:00:00'), +})); + +vi.mock('@/components/ui/DateRangeSelector', () => ({ + DateRangeSelector: () =>
, +})); + +vi.mock('@/components/ui/ExportDropdown', () => ({ + ExportDropdown: () =>
, +})); + +vi.mock('@/components/reports/RefreshPricesButton', () => ({ + RefreshPricesButton: () => , +})); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + BarChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + Bar: () => null, + XAxis: () => null, + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + Legend: () => null, + ReferenceLine: () => null, + Cell: () => null, +})); + +const mockGetTransactions = vi.fn(); +const mockGetInvestmentAccounts = vi.fn(); +const mockGetCapitalGains = vi.fn(); + +vi.mock('@/lib/investments', () => ({ + investmentsApi: { + getTransactions: (...args: unknown[]) => mockGetTransactions(...args), + getInvestmentAccounts: (...args: unknown[]) => mockGetInvestmentAccounts(...args), + getCapitalGains: (...args: unknown[]) => mockGetCapitalGains(...args), + }, +})); + +const LONG_NAME = 'A deliberately long security name that must wrap without a clamp'; + +const TXNS = [ + { + id: 'tx-div', + action: 'DIVIDEND', + securityId: 'sec-1', + security: { symbol: 'LONG', name: LONG_NAME }, + accountId: 'acc-1', + totalAmount: 120, + transactionDate: '2024-06-15', + }, + { + id: 'tx-int', + action: 'INTEREST', + securityId: 'sec-1', + security: { symbol: 'LONG', name: LONG_NAME }, + accountId: 'acc-1', + totalAmount: 30, + transactionDate: '2024-07-20', + }, +]; + +const CAPITAL_GAINS = [ + { + month: '2024-08', + accountId: 'acc-1', + accountName: 'TFSA', + accountCurrencyCode: 'CAD', + securityId: 'sec-1', + symbol: 'LONG', + securityName: LONG_NAME, + securityCurrencyCode: 'CAD', + startQuantity: 10, + endQuantity: 0, + startValue: 800, + endValue: 0, + buys: 0, + sells: 800, + realizedGain: 90, + unrealizedGain: 0, + totalCapitalGain: 90, + }, +]; + +function placement(cell: Element): string { + const column = [...cell.classList].find((name) => name.startsWith('col-start-')); + const row = [...cell.classList].find((name) => name.startsWith('row-start-')); + return `${column}/${row}`; +} + +async function renderBySecurityTable() { + mockGetTransactions.mockResolvedValue({ data: TXNS, pagination: { hasMore: false } }); + mockGetInvestmentAccounts.mockResolvedValue([]); + mockGetCapitalGains.mockResolvedValue(CAPITAL_GAINS); + const view = render(); + const bySecurityButton = await screen.findByRole('button', { name: 'By Security' }); + await act(async () => { + fireEvent.click(bySecurityButton); + }); + await waitFor(() => expect(view.container.querySelector('table')).toBeTruthy()); + return { ...view, securityTable: view.container.querySelector('table') as HTMLTableElement }; +} + +describe('DividendIncomeReport mobile wrapped tables', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + }); + + it('wraps the income-by-security row into three explicit phone lines', async () => { + const { securityTable } = await renderBySecurityTable(); + + expect(securityTable).toHaveAttribute('role', 'table'); + expect(securityTable.className).toContain('block'); + expect(securityTable.className).toContain('sm:table'); + expect(securityTable.querySelector('thead')).toHaveAttribute('role', 'rowgroup'); + expect(securityTable.querySelector('tbody')).toHaveAttribute('role', 'rowgroup'); + + const row = securityTable.querySelector('tbody tr'); + expect(row).toHaveAttribute('role', 'row'); + expect(row?.className).toContain('grid-cols-2'); + expect(row?.className).toContain('sm:table-row'); + + const cells = Array.from(row?.querySelectorAll('td') ?? []); + expect(cells).toHaveLength(5); + expect(cells.map(placement)).toEqual([ + 'col-start-1/row-start-1', + 'col-start-2/row-start-1', + 'col-start-1/row-start-2', + 'col-start-2/row-start-2', + 'col-start-1/row-start-3', + ]); + expect(cells[4].className).toContain('col-span-2'); + expect(cells.every((cell) => cell.getAttribute('role') === 'cell')).toBe(true); + + // The identity cell names itself (no caption); every figure carries one, + // reusing the column's existing header key. + expect(cells[0].querySelector('span')).toBeNull(); + expect(cells.slice(1).map((cell) => cell.querySelector('span')?.textContent)).toEqual([ + 'Dividends', + 'Interest', + 'Capital Gains', + 'Total', + ]); + + // Identity wraps unclamped; nothing is truncated. + expect(cells[0].textContent).toContain('A deliberately long security name'); + expect(cells[0].querySelector('.break-words')).toBeInTheDocument(); + + // Every figure is on the preference-aware formatter. + expect(cells[1]).toHaveTextContent('MONEY<120>'); + expect(cells[2]).toHaveTextContent('MONEY<30>'); + expect(cells[3]).toHaveTextContent('MONEY<90>'); + expect(cells[4]).toHaveTextContent('MONEY<240>'); + }); + + it('keeps every sort control in accessible phone strips and restores the desktop table', async () => { + const { securityTable } = await renderBySecurityTable(); + + const headerRows = securityTable.querySelectorAll('thead tr'); + expect(headerRows).toHaveLength(2); + expect(headerRows[0].className).toContain('sm:hidden'); + expect(headerRows[1].className).toContain('hidden'); + expect(headerRows[1].className).toContain('sm:table-row'); + expect(headerRows[0].querySelectorAll('th')).toHaveLength(5); + expect(headerRows[1].querySelectorAll('th')).toHaveLength(5); + expect(securityTable.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(securityTable.querySelector('tbody')?.className).toContain('sm:table-row-group'); + for (const cell of securityTable.querySelectorAll('tbody td')) { + expect(cell.className).toContain('sm:table-cell'); + } + + // The persisted default sort (total, descending) is reflected in the phone + // strip so a dropped chip would strand it. + const activeSort = securityTable.querySelector( + 'thead tr.sm\\:hidden th[aria-sort="descending"]', + ); + expect(activeSort).toHaveTextContent('Total'); + }); +}); diff --git a/frontend/src/components/reports/DividendIncomeReport.tsx b/frontend/src/components/reports/DividendIncomeReport.tsx index def734f9bd..cb0135f2f1 100644 --- a/frontend/src/components/reports/DividendIncomeReport.tsx +++ b/frontend/src/components/reports/DividendIncomeReport.tsx @@ -32,6 +32,13 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { MultiSelect } from '@/components/ui/MultiSelect'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { + CAPTION_CLASS, + CellLabel, + PHONE_HEADER_CLASS, + type SortColumn, + type SortColumnsByField, +} from '@/components/ui/Table'; import { useSortableTable, compareValues } from '@/hooks/useSortableTable'; import { exportToCsv } from '@/lib/csv-export'; import { chartColors, CHART_SERIES } from '@/lib/chart-colors'; @@ -88,6 +95,18 @@ const SERIES_COLORS: Record = const ACCOUNTS_STORAGE_KEY = 'monize-reports-dividend-income-accounts'; +// Today's header cell, unchanged. Kept local -- PHONE_HEADER_CLASS/CAPTION_CLASS/ +// CellLabel are shared, but a table's own header stays per-report because track +// budgets differ. +const HEADER_CLASS = + 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; +// A money cell inside a wrapped row: no padding of its own below `sm` (the grid +// spaces it), this table's own `px-4 py-3 text-sm` from `sm` up, smaller type on +// phones. `whitespace-nowrap` is the one property that is not phone-only -- a +// number must never break; the caption inside takes it back (`CellLabel`). +const FIGURE_CELL = + 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; + export function DividendIncomeReport() { const t = useTranslations('reports'); const formatChartDate = useChartDateFormat(); @@ -135,6 +154,23 @@ export function DividendIncomeReport() { { field: 'total', direction: 'desc' }, ); + // The by-security table's five sortable columns, keyed by field so the record + // is exhaustive: adding a member to `SecurityIncomeSortField` is a compile + // error here rather than a header with no control. Declaration order is the + // column (and cell DOM) order, rendered by BOTH the column header row and the + // phone sort strip from the derived `Object.values`. + const securityColumns: SortColumnsByField< + SecurityIncomeSortField, + SortColumn + > = { + symbol: { field: 'symbol', label: t('dividendIncome.colSecurity') }, + dividends: { field: 'dividends', label: t('dividendIncome.colDividends'), align: 'right' }, + interest: { field: 'interest', label: t('dividendIncome.colInterest'), align: 'right' }, + capitalGains: { field: 'capitalGains', label: t('dividendIncome.colCapitalGains'), align: 'right' }, + total: { field: 'total', label: t('dividendIncome.colTotal'), align: 'right' }, + }; + const securitySortColumns = Object.values(securityColumns); + const { start: rangeStart, end: rangeEnd } = resolvedRange; // Capital gains require a window; fall back to a wide window when the user // picks "All Time" so the backend still has bounds to enumerate. @@ -1641,80 +1677,81 @@ export function DividendIncomeReport() { {t('dividendIncome.incomeBySecurityTitle')}
+ {/* Below `sm` the table becomes a block and each row wraps into a + two-column, three-line grid card so all five columns fit a phone + without a horizontal scroll: line 1 is the security identity and + its Dividends; line 2 is Interest and Capital Gains; line 3 is the + Total, spanning both tracks as the row's headline. Nothing is + dropped, and no figure is truncated -- a money value never wraps + (`FIGURE_CELL`). From `sm` up it is the ordinary table, resolving + identically to today (each cell restores its own + `sm:px-4 sm:py-3 sm:text-sm`; the identity cell keeps its 16px + symbol by carrying no `sm:text-*`), and the sort controls survive + as their own phone-only header row because the column header row + that carries them on desktop is hidden there. Restyling `display` + strips the implicit table semantics below `sm`, so the roles are + restated and every bare figure carries a `CellLabel` naming its + column; the symbol names itself. */}
- - - - - field="symbol" - sortField={securitySort.sortField} - sortDirection={securitySort.sortDirection} - onSort={securitySort.handleSort} - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendIncome.colSecurity')} - - - field="dividends" - sortField={securitySort.sortField} - sortDirection={securitySort.sortDirection} - onSort={securitySort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendIncome.colDividends')} - - - field="interest" - sortField={securitySort.sortField} - sortDirection={securitySort.sortDirection} - onSort={securitySort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendIncome.colInterest')} - - - field="capitalGains" - sortField={securitySort.sortField} - sortDirection={securitySort.sortDirection} - onSort={securitySort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendIncome.colCapitalGains')} - - - field="total" - sortField={securitySort.sortField} - sortDirection={securitySort.sortDirection} - onSort={securitySort.handleSort} - align="right" - className="px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase" - > - {t('dividendIncome.colTotal')} - +
+ + {/* Phone sort strip: the same five controls, wrapped. */} + + {securitySortColumns.map((column) => ( + + key={column.field} + field={column.field} + sortField={securitySort.sortField} + sortDirection={securitySort.sortDirection} + onSort={securitySort.handleSort} + className={PHONE_HEADER_CLASS} + > + {column.label} + + ))} + + + {securitySortColumns.map((column) => ( + + key={column.field} + field={column.field} + sortField={securitySort.sortField} + sortDirection={securitySort.sortDirection} + onSort={securitySort.handleSort} + align={column.align} + className={HEADER_CLASS} + > + {column.label} + + ))} - + {sortedSecurityData.map((security) => ( - - + {/* Security: the row identity, wrapping unclamped. No caption. */} + - - From f09b1b7283fc08a982fb783d3befdc99c21f28f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 08:16:34 +0000 Subject: [PATCH 25/44] Wrap Monte Carlo tables on phones Convert the three fixed-column Monte Carlo tables to the below-`sm` phone-card layout (mechanism A, single CSS tree resolving identically at `sm`+): the year-by-year results table (7 columns), the per-account holding-stats table (5 columns), and the performance-summary statistics table (6 columns). Each row wraps into a two-track grid card with every bare figure captioned by its existing column key, money never wrapping, and the ARIA table roles restated since restyling `display` strips them. The holding-stats card now shows the security name that today hides below `sm`. All three keep their `divide-y` chrome (still recorded in the ui-conventions baseline) and their injected-formatter / `useTranslations` patterns; no new i18n keys. Adds a `*.mobileWrapped.test.tsx` beside each and fixes the now-ambiguous `getByText('50th Percentile')` in the existing performance-summary test (the key is reused as a per-row caption). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...loHoldingStatsTable.mobileWrapped.test.tsx | 139 ++++++++++++++++++ .../reports/MonteCarloHoldingStatsTable.tsx | 75 +++++++--- ...oPerformanceSummary.mobileWrapped.test.tsx | 132 +++++++++++++++++ .../MonteCarloPerformanceSummary.test.tsx | 4 +- .../reports/MonteCarloPerformanceSummary.tsx | 79 +++++++--- ...teCarloResultsTable.mobileWrapped.test.tsx | 136 +++++++++++++++++ .../reports/MonteCarloResultsTable.tsx | 89 ++++++++--- 7 files changed, 599 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/reports/MonteCarloHoldingStatsTable.mobileWrapped.test.tsx create mode 100644 frontend/src/components/reports/MonteCarloPerformanceSummary.mobileWrapped.test.tsx create mode 100644 frontend/src/components/reports/MonteCarloResultsTable.mobileWrapped.test.tsx diff --git a/frontend/src/components/reports/MonteCarloHoldingStatsTable.mobileWrapped.test.tsx b/frontend/src/components/reports/MonteCarloHoldingStatsTable.mobileWrapped.test.tsx new file mode 100644 index 0000000000..c82fc42228 --- /dev/null +++ b/frontend/src/components/reports/MonteCarloHoldingStatsTable.mobileWrapped.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@/test/render'; +import { HoldingStatsTable } from './MonteCarloHoldingStatsTable'; + +/** + * The phone layout of the Monte Carlo per-account holding-stats table. + * + * It is ONE tree restyled by CSS (mechanism A): below `sm` each holding row + * wraps into a two-track grid card and the (non-sortable) column header row is + * simply block-hidden, from `sm` up it is the ordinary five-column table. jsdom + * applies no media queries, so the header row, the security name (which today + * hides below `sm`) and every phone caption are in the DOM here at all times. + * This is a pure render helper -- it takes its `NumberFormatters` as a prop and + * calls no hook, so the formatters are a plain stub. + */ + +const fmt = (v: number, currencyCode?: string) => `${v.toFixed(0)} ${currencyCode ?? 'DEFAULT'}`; +const fmts = { + formatCurrency: fmt, + formatNumber: (v: number, d = 2) => v.toFixed(d), + formatPercent: (v: number, d = 2) => `${v.toFixed(d)}%`, +} as never; + +function renderTable() { + return render( + , + ); +} + +const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const row = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + return `c${col}/r${row}`; +}; + +describe('MonteCarloHoldingStatsTable (phone wrapped)', () => { + it('keeps a table from sm up and a grid below it, with the semantics a restyle strips', () => { + const { container } = renderTable(); + + const table = container.querySelector('table')!; + expect(table.getAttribute('role')).toBe('table'); + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + expect(container.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(container.querySelector('thead')?.className).toContain('hidden'); + expect(container.querySelector('tbody')?.className).toContain('sm:table-row-group'); + for (const group of container.querySelectorAll('thead, tbody')) { + expect(group.getAttribute('role')).toBe('rowgroup'); + } + for (const row of container.querySelectorAll('tbody tr')) { + expect(row.getAttribute('role')).toBe('row'); + expect(row.className).toContain('grid grid-cols-2'); + expect(row.className).toContain('sm:table-row'); + } + for (const cell of container.querySelectorAll('td')) { + expect(cell.getAttribute('role')).toBe('cell'); + } + for (const th of container.querySelectorAll('th')) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + expect(table.parentElement?.className).toContain('overflow-x-auto'); + }); + + it('places every cell on the phone grid explicitly, value beside the symbol', () => { + const { container } = renderTable(); + const rows = Array.from(container.querySelectorAll('tbody tr')); + expect(rows).toHaveLength(2); + for (const row of rows) { + const [symbol, name, value, mean, vol] = Array.from(row.querySelectorAll('td')); + expect(placement(symbol)).toBe('c1/r1'); + expect(placement(name)).toBe('c1/r2'); + expect(placement(value)).toBe('c2/r1'); + expect(placement(mean)).toBe('c1/r3'); + expect(placement(vol)).toBe('c2/r3'); + // The name spans both tracks; it is the descriptor under the symbol. + expect(name.className).toContain('col-span-2'); + for (const cell of [symbol, name, value, mean, vol]) { + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + } + } + }); + + it('shows the name on a phone (wrapping) and keeps its desktop truncate from sm up', () => { + const { container } = renderTable(); + const row = container.querySelector('tbody tr')!; + const name = row.querySelector('.col-start-1.row-start-2')!; + expect(name.textContent).toBe('Apple Inc.'); + expect(name.className).toContain('break-words'); + expect(name.className).toContain('sm:truncate'); + expect(name.className).toContain('sm:max-w-[200px]'); + }); + + it('captions each figure with its column key, and leaves the symbol and name self-naming', () => { + const { container } = renderTable(); + const row = Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes('1000 USD'), + )!; + expect(row.textContent).toContain('Value' + '1000 USD'); + expect(row.textContent).toContain('Mean' + '12.00%'); + expect(row.textContent).toContain('Volatility' + '20.00%'); + // Symbol is the identity and the name is its descriptor: neither is captioned. + const symbol = row.querySelector('.col-start-1.row-start-1')!; + expect(symbol.textContent).toBe('AAPL'); + expect(symbol.querySelector('span')).toBeNull(); + const name = row.querySelector('.col-start-1.row-start-2')!; + expect(name.querySelector('span')).toBeNull(); + }); + + it('never wraps a figure and gives each caption whitespace-normal back', () => { + const { container } = renderTable(); + for (const row of container.querySelectorAll('tbody tr')) { + const figures = Array.from(row.querySelectorAll('td')).filter( + (c) => c.className.includes('whitespace-nowrap') && c.className.includes('text-right'), + ); + // Value, mean and volatility. + expect(figures).toHaveLength(3); + for (const cell of figures) { + const caption = cell.querySelector('span'); + expect(caption?.className).toContain('whitespace-normal'); + expect(caption?.className).toContain('sm:hidden'); + } + } + }); +}); diff --git a/frontend/src/components/reports/MonteCarloHoldingStatsTable.tsx b/frontend/src/components/reports/MonteCarloHoldingStatsTable.tsx index 791b68aa72..aa13381675 100644 --- a/frontend/src/components/reports/MonteCarloHoldingStatsTable.tsx +++ b/frontend/src/components/reports/MonteCarloHoldingStatsTable.tsx @@ -3,6 +3,16 @@ import { AccountHoldingStats } from '@/lib/monte-carlo'; import { useTranslations } from 'next-intl'; import type { NumberFormatters } from '@/hooks/useNumberFormat'; +import { CAPTION_CLASS, CellLabel } from '@/components/ui/Table'; + +// A money (or percentage) cell inside a wrapped row: no padding of its own +// below `sm` (the row supplies it and the grid does the spacing), this table's +// own `px-3 py-1.5` from `sm` up. The text size inherits `text-xs` from the +// table element at every width, so there is no per-cell size to preserve. +// `whitespace-nowrap` is not phone-only: a locale grouping thousands with a +// space could otherwise break a figure in the middle, at any width. A number +// must not break; the caption inside takes `whitespace-normal` back (CellLabel). +const FIGURE_CELL = 'p-0 text-right whitespace-nowrap sm:table-cell sm:px-3 sm:py-1.5'; export function HoldingStatsTable({ data, @@ -63,42 +73,71 @@ export function HoldingStatsTable({ {t('monteCarloHoldingStats.noHoldings')} ) : ( + // Below `sm` the table becomes a block and each row wraps into a + // two-track grid card so all five columns fit a phone without a + // horizontal scroll: line 1 is the symbol (the row identity) and the + // market value (the headline); line 2 is the security name, spanning + // both tracks; line 3 is the mean return and the volatility. Nothing + // is dropped -- the name that today hides below `sm` returns as the + // card's descriptor -- and no figure wraps (`FIGURE_CELL`). From `sm` + // up it is the ordinary table, each cell restoring its own + // `px-3 py-1.5` and the name its `truncate max-w-[200px]`, resolving + // identically to today. This table's header is not sortable, so below + // `sm` the column header row is simply block-hidden and every bare + // figure carries a `CellLabel` naming its column; the symbol and the + // name name themselves. Restyling `display` strips the implicit table + // semantics, so the ARIA roles are restated.
-
+
{security.symbol}
-
+
{security.name}
+ + {securityColumns.dividends.label} {security.dividends === null ? fmtValue(null) : security.dividends > 0 ? fmtValue(security.dividends) : '-'} + + {securityColumns.interest.label} {security.interest === null ? fmtValue(null) : security.interest > 0 @@ -1722,21 +1759,25 @@ export function DividendIncomeReport() { : '-'} + {securityColumns.capitalGains.label} {security.capitalGains !== 0 ? fmtValue(security.capitalGains) : '-'} + {securityColumns.total.label} {fmtValue(security.total)}
- - - - {/* Name is decorative; hide on small screens so the - numeric columns fit on a phone without horizontal - scroll. */} -
{t('monteCarloHoldingStats.colSymbol')} + + + + + - - + - - + {acct.holdings.map((h) => ( - - + {/* Symbol: the row identity, so it carries no caption. */} + - - - - diff --git a/frontend/src/components/reports/MonteCarloPerformanceSummary.mobileWrapped.test.tsx b/frontend/src/components/reports/MonteCarloPerformanceSummary.mobileWrapped.test.tsx new file mode 100644 index 0000000000..5bc25dbaf7 --- /dev/null +++ b/frontend/src/components/reports/MonteCarloPerformanceSummary.mobileWrapped.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@/test/render'; +import { PerformanceSummaryTable } from './MonteCarloPerformanceSummary'; + +/** + * The phone layout of the Monte Carlo performance-summary statistics table. + * + * It is ONE tree restyled by CSS (mechanism A): below `sm` each statistic row + * wraps into a two-track grid card and the (non-sortable) column header row is + * simply block-hidden, from `sm` up it is the ordinary six-column table. jsdom + * applies no media queries, so the header row and every phone caption are in the + * DOM here at all times. This is a pure render helper -- it takes its + * `NumberFormatters` as a prop and calls no hook, so the formatters are a stub. + */ + +const band = (p: number) => ({ p10: p, p25: p, p50: p, p75: p, p90: p }); +const summary: never = { + twrNominal: band(0.05), + twrReal: band(0.03), + endBalanceNominal: band(100000), + endBalanceReal: band(80000), + meanReturnNominal: band(0.06), + annualizedVolatility: band(0.15), + maxDrawdown: band(-0.2), + maxDrawdownExcludingCashflows: band(-0.18), + safeWithdrawalRate: band(0.04), + perpetualWithdrawalRate: band(0.035), +} as never; + +const fmt = (v: number) => `$${v.toFixed(0)}`; +const fmts = { + formatCurrency: fmt, + formatNumber: (v: number, d = 2) => v.toFixed(d), + formatPercent: (v: number, d = 2) => `${v.toFixed(d)}%`, +} as never; + +function renderTable() { + return render(); +} + +const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const row = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + return `c${col}/r${row}`; +}; + +describe('MonteCarloPerformanceSummary (phone wrapped)', () => { + it('keeps a table from sm up and a grid below it, with the semantics a restyle strips', () => { + const { container } = renderTable(); + + const table = container.querySelector('table')!; + expect(table.getAttribute('role')).toBe('table'); + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + expect(container.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(container.querySelector('thead')?.className).toContain('hidden'); + expect(container.querySelector('tbody')?.className).toContain('sm:table-row-group'); + for (const group of container.querySelectorAll('thead, tbody')) { + expect(group.getAttribute('role')).toBe('rowgroup'); + } + for (const row of container.querySelectorAll('tbody tr')) { + expect(row.getAttribute('role')).toBe('row'); + expect(row.className).toContain('grid grid-cols-2'); + expect(row.className).toContain('sm:table-row'); + } + for (const cell of container.querySelectorAll('td')) { + expect(cell.getAttribute('role')).toBe('cell'); + } + for (const th of container.querySelectorAll('th')) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + expect(table.parentElement?.className).toContain('overflow-x-auto'); + }); + + it('places every cell on the phone grid explicitly, label spanning the top line', () => { + const { container } = renderTable(); + const rows = Array.from(container.querySelectorAll('tbody tr')); + expect(rows).toHaveLength(10); + for (const row of rows) { + const [label, p10, p25, p50, p75, p90] = Array.from(row.querySelectorAll('td')); + expect(placement(label)).toBe('c1/r1'); + expect(label.className).toContain('col-span-2'); + expect(placement(p10)).toBe('c1/r2'); + expect(placement(p25)).toBe('c2/r2'); + expect(placement(p50)).toBe('c1/r3'); + expect(placement(p75)).toBe('c2/r3'); + expect(placement(p90)).toBe('c1/r4'); + for (const cell of [label, p10, p25, p50, p75, p90]) { + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + } + } + }); + + it('captions each percentile value, and leaves the statistic label self-naming', () => { + const { container } = renderTable(); + const row = Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes('$100000'), + )!; + expect(row.textContent).toContain('10th Percentile' + '$100000'); + expect(row.textContent).toContain('50th Percentile' + '$100000'); + // The label is the row identity (with its info tooltip, which is icon-only), + // so it carries no CellLabel caption -- its text is just the statistic name. + const label = row.querySelector('.col-start-1.row-start-1')!; + expect(label.textContent?.startsWith('Portfolio End Balance (nominal)')).toBe(true); + expect(label.querySelector('[class*="text-[10px]"]')).toBeNull(); + }); + + it('carries the 50th-percentile highlight into the phone card', () => { + const { container } = renderTable(); + const row = container.querySelector('tbody tr')!; + const median = row.querySelector('.col-start-1.row-start-3')!; + expect(median.className).toContain('bg-blue-50'); + expect(median.className).toContain('font-semibold'); + }); + + it('never wraps a value and gives each caption whitespace-normal back', () => { + const { container } = renderTable(); + for (const row of container.querySelectorAll('tbody tr')) { + const figures = Array.from(row.querySelectorAll('td')).filter( + (c) => c.className.includes('whitespace-nowrap') && c.className.includes('text-right'), + ); + // The five percentile values. + expect(figures).toHaveLength(5); + for (const cell of figures) { + const caption = cell.querySelector('span'); + expect(caption?.className).toContain('whitespace-normal'); + expect(caption?.className).toContain('sm:hidden'); + } + } + }); +}); diff --git a/frontend/src/components/reports/MonteCarloPerformanceSummary.test.tsx b/frontend/src/components/reports/MonteCarloPerformanceSummary.test.tsx index ce978bcc15..e78fdb783e 100644 --- a/frontend/src/components/reports/MonteCarloPerformanceSummary.test.tsx +++ b/frontend/src/components/reports/MonteCarloPerformanceSummary.test.tsx @@ -64,7 +64,9 @@ describe('buildPerformanceSummaryRows', () => { describe('PerformanceSummaryTable', () => { it('renders all rows including currency, percent, and ratio formats', () => { render(); - expect(screen.getByText('50th Percentile')).toBeInTheDocument(); + // '50th Percentile' now appears in the (hidden) column header AND as each + // row's phone caption reusing the same column key, so it is no longer unique. + expect(screen.getAllByText('50th Percentile').length).toBeGreaterThan(0); expect(screen.getAllByText('5.00%').length).toBeGreaterThan(0); expect(screen.getAllByText('$100000').length).toBeGreaterThan(0); expect(screen.getAllByText('Maximum Drawdown').length).toBeGreaterThan(0); diff --git a/frontend/src/components/reports/MonteCarloPerformanceSummary.tsx b/frontend/src/components/reports/MonteCarloPerformanceSummary.tsx index c794619f98..ebf7b12e33 100644 --- a/frontend/src/components/reports/MonteCarloPerformanceSummary.tsx +++ b/frontend/src/components/reports/MonteCarloPerformanceSummary.tsx @@ -4,6 +4,20 @@ import { PerformanceSummary } from '@/lib/monte-carlo'; import { InfoTooltip } from '@/components/ui/InfoTooltip'; import { useTranslations } from 'next-intl'; import type { NumberFormatters } from '@/hooks/useNumberFormat'; +import { CAPTION_CLASS, CellLabel } from '@/components/ui/Table'; + +// A value cell inside a wrapped row: no padding of its own below `sm` (the row +// supplies it and the grid does the spacing), this table's own `px-3 py-1.5` +// from `sm` up. The text size inherits `text-xs` from the table element at +// every width, so there is no per-cell size to preserve. `whitespace-nowrap` is +// not phone-only: a locale grouping thousands with a space could otherwise +// break a figure in the middle, at any width. A number must not break; the +// caption inside takes `whitespace-normal` back (CellLabel). +const VALUE_CELL = 'p-0 text-right whitespace-nowrap sm:table-cell sm:px-3 sm:py-1.5'; + +// The 50th-percentile column carries a highlight on both the header and the body +// cell, and it survives into the phone card so the median value still stands out. +const MEDIAN_HIGHLIGHT = 'bg-blue-50 dark:bg-blue-900/30'; export type SummaryRow = { label: string; @@ -120,42 +134,73 @@ export function PerformanceSummaryTable({ formatSummaryValue(v, kind, formatters); return ( + // Below `sm` the table becomes a block and each row wraps into a two-track + // grid card so all six columns fit a phone without a horizontal scroll: line + // 1 is the statistic's label (the row identity, with its info tooltip), + // spanning both tracks; lines 2 to 4 carry the five percentile values two to + // a line, the 90th alone on the last. Nothing is dropped, and no value wraps + // (`VALUE_CELL`). From `sm` up it is the ordinary table, each cell restoring + // its own `px-3 py-1.5` and the table's `text-xs` inherited at every width, + // so it resolves identically to today at 640px+. This table's header is not + // sortable, so below `sm` the column header row is simply block-hidden and + // every bare value carries a `CellLabel` naming its column; the label names + // itself. The 50th-percentile highlight follows its column into the card. + // Restyling `display` strips the implicit table semantics, so the ARIA roles + // are restated; the phone grid places cells out of DOM order, which stays + // the desktop column order.
-
{t('monteCarloHoldingStats.colSymbol')} {t('monteCarloHoldingStats.colName')} {t('monteCarloHoldingStats.colValue')} + {t('monteCarloHoldingStats.colValue')} {t('monteCarloHoldingStats.colMean')} + {t('monteCarloHoldingStats.colVolatility')}
+
{h.symbol} + {/* Name: the descriptor under the symbol identity, so no + caption. It wraps on a phone and keeps its desktop + `truncate max-w-[200px]` from `sm` up. */} + {h.name} + {/* Market value: the headline, beside the symbol. */} + + {t('monteCarloHoldingStats.colValue')} {fmtValue(h.marketValue, h.currencyCode)} + + {t('monteCarloHoldingStats.colMean')} {fmtPct(h.meanReturn)} + + {t('monteCarloHoldingStats.colVolatility')} {fmtPct(h.volatility)}
- - -
+ + + + - - - + + - - + + - + {rows.map((row) => ( - - + {/* The statistic's label is the row identity, so it carries no + caption; it spans both tracks and wraps on a phone. */} + - - - - - diff --git a/frontend/src/components/reports/MonteCarloResultsTable.mobileWrapped.test.tsx b/frontend/src/components/reports/MonteCarloResultsTable.mobileWrapped.test.tsx new file mode 100644 index 0000000000..40c089f6ff --- /dev/null +++ b/frontend/src/components/reports/MonteCarloResultsTable.mobileWrapped.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@/test/render'; +import { ResultsTable } from './MonteCarloResultsTable'; + +/** + * The phone layout of the Monte Carlo year-by-year results table. + * + * It is ONE tree restyled by CSS (mechanism A): below `sm` each row wraps into a + * two-track grid card and the (non-sortable) column header row is simply + * block-hidden, from `sm` up it is the ordinary seven-column table. jsdom + * applies no media queries, so the header row and every phone caption are in the + * DOM here at all times -- which is what lets these assertions read the phone + * markup without emulating a viewport. + */ + +const fmt = (v: number) => `$${v.toFixed(0)}`; + +function renderTable() { + return render( + , + ); +} + +const placement = (cell: Element) => { + const col = /\bcol-start-(\d)\b/.exec(cell.className)?.[1]; + const row = /\brow-start-(\d)\b/.exec(cell.className)?.[1]; + return `c${col}/r${row}`; +}; + +describe('MonteCarloResultsTable (phone wrapped)', () => { + it('keeps a table from sm up and a grid below it, with the semantics a restyle strips', () => { + const { container } = renderTable(); + + const table = container.querySelector('table')!; + expect(table.getAttribute('role')).toBe('table'); + expect(table.className).toContain('block'); + expect(table.className).toContain('sm:table'); + expect(container.querySelector('thead')?.className).toContain('sm:table-header-group'); + expect(container.querySelector('tbody')?.className).toContain('sm:table-row-group'); + for (const group of container.querySelectorAll('thead, tbody')) { + expect(group.getAttribute('role')).toBe('rowgroup'); + } + for (const row of container.querySelectorAll('tbody tr')) { + expect(row.getAttribute('role')).toBe('row'); + expect(row.className).toContain('grid grid-cols-2'); + expect(row.className).toContain('sm:table-row'); + } + for (const cell of container.querySelectorAll('td')) { + expect(cell.getAttribute('role')).toBe('cell'); + } + for (const th of container.querySelectorAll('th')) { + expect(th.getAttribute('role')).toBe('columnheader'); + } + // The wrapper still scrolls horizontally, which is what the table needs from + // `sm` up on a narrow desktop window. + expect(table.parentElement?.className).toContain('overflow-x-auto'); + }); + + it('block-hides the single non-sortable header row below sm', () => { + const { container } = renderTable(); + const headRows = Array.from(container.querySelectorAll('thead tr')); + expect(headRows).toHaveLength(1); + expect(container.querySelector('thead')?.className).toContain('hidden'); + // Seven column headers in desktop column order. + expect( + Array.from(headRows[0].querySelectorAll('th')).map((th) => th.textContent), + ).toEqual(['Year', '10th', '25th', 'Median', '75th', '90th', 'Events']); + }); + + it('places every cell on the phone grid explicitly, median beside its year', () => { + const { container } = renderTable(); + for (const row of container.querySelectorAll('tbody tr')) { + const [year, p10, p25, median, p75, p90, events] = Array.from(row.querySelectorAll('td')); + expect(placement(year)).toBe('c1/r1'); + expect(placement(p10)).toBe('c1/r2'); + expect(placement(p25)).toBe('c2/r2'); + expect(placement(median)).toBe('c2/r1'); + expect(placement(p75)).toBe('c1/r3'); + expect(placement(p90)).toBe('c2/r3'); + expect(placement(events)).toBe('c1/r4'); + // Explicit placement, never auto-flow. + for (const cell of [year, p10, p25, median, p75, p90, events]) { + expect(cell.className).toMatch(/\bcol-start-\d\b/); + expect(cell.className).toMatch(/\brow-start-\d\b/); + } + } + }); + + it('captions every money figure with its column key, and leaves the year self-naming', () => { + const { container } = renderTable(); + const row = Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes('$300'), + )!; + expect(row.textContent).toContain('10th' + '$100'); + expect(row.textContent).toContain('25th' + '$200'); + expect(row.textContent).toContain('Median' + '$300'); + expect(row.textContent).toContain('75th' + '$400'); + expect(row.textContent).toContain('90th' + '$500'); + // The year is the row identity, so it carries no caption. + const year = row.querySelector('.col-start-1.row-start-1')!; + expect(year.textContent).toBe('2025'); + expect(year.querySelector('span')).toBeNull(); + }); + + it('never wraps a money figure and gives each caption whitespace-normal back', () => { + const { container } = renderTable(); + for (const row of container.querySelectorAll('tbody tr')) { + const figures = Array.from(row.querySelectorAll('td')).filter( + (c) => c.className.includes('whitespace-nowrap') && c.className.includes('text-right'), + ); + // The five percentile figures. + expect(figures).toHaveLength(5); + for (const cell of figures) { + const caption = cell.querySelector('span'); + expect(caption?.className).toContain('whitespace-normal'); + expect(caption?.className).toContain('sm:hidden'); + } + } + }); +}); diff --git a/frontend/src/components/reports/MonteCarloResultsTable.tsx b/frontend/src/components/reports/MonteCarloResultsTable.tsx index 8153c36bf7..34a37baa7e 100644 --- a/frontend/src/components/reports/MonteCarloResultsTable.tsx +++ b/frontend/src/components/reports/MonteCarloResultsTable.tsx @@ -2,6 +2,7 @@ import { CashFlowEvent, CashFlowLegendSwatch } from './MonteCarloChartParts'; import { useTranslations } from 'next-intl'; +import { CAPTION_CLASS, CellLabel } from '@/components/ui/Table'; export function SummaryStat({ label, @@ -26,6 +27,15 @@ export function SummaryStat({ ); } +// A money cell inside a wrapped row: no padding of its own below `sm` (the row +// supplies it and the grid does the spacing), this table's own `px-3 py-1.5` +// from `sm` up. The text size inherits `text-xs` from the table element at +// every width, so there is no per-cell size to preserve. `whitespace-nowrap` +// is the one property that is NOT phone-only: a locale grouping thousands with +// a space could otherwise break a figure in the middle, at any width. A number +// must not break; the caption inside takes `whitespace-normal` back (CellLabel). +const MONEY_CELL = 'p-0 text-right whitespace-nowrap sm:table-cell sm:px-3 sm:py-1.5'; + export function ResultsTable({ rows, formatCurrency, @@ -43,33 +53,74 @@ export function ResultsTable({ }) { const t = useTranslations('reports'); return ( + // Below `sm` the table becomes a block and each row wraps into a two-track + // grid card so all seven columns fit a phone without a horizontal scroll: + // line 1 is the year (the row identity) and the median (the headline + // percentile); lines 2 and 3 carry the four remaining percentiles two to a + // line; line 4 is the year's cash-flow events, spanning both tracks. Nothing + // is dropped, and no money figure wraps (`MONEY_CELL`). From `sm` up it is + // the ordinary table, each cell restoring its own `px-3 py-1.5` and the + // table's `text-xs` inherited at every width, so it resolves identically to + // today at 640px+. This table's header is not sortable, so below `sm` the + // column header row is simply block-hidden and every bare figure carries a + // `CellLabel` naming its column; the year names itself. Restyling `display` + // strips the implicit table semantics, so the ARIA roles are restated. The + // phone grid places cells out of DOM order, which stays the desktop column + // order.
-
{t('monteCarloPerformance.colSummaryStatistics')} {t('monteCarloPerformance.col10thPercentile')}{t('monteCarloPerformance.col25thPercentile')} + {t('monteCarloPerformance.col10thPercentile')}{t('monteCarloPerformance.col25thPercentile')} {t('monteCarloPerformance.col50thPercentile')} {t('monteCarloPerformance.col75thPercentile')}{t('monteCarloPerformance.col90thPercentile')}{t('monteCarloPerformance.col75thPercentile')}{t('monteCarloPerformance.col90thPercentile')}
+
{row.label} + + {t('monteCarloPerformance.col10thPercentile')} {formatValue(row.band.p10, row.format)} + + {t('monteCarloPerformance.col25thPercentile')} {formatValue(row.band.p25, row.format)} + + {t('monteCarloPerformance.col50thPercentile')} {formatValue(row.band.p50, row.format)} + + {t('monteCarloPerformance.col75thPercentile')} {formatValue(row.band.p75, row.format)} + + {t('monteCarloPerformance.col90thPercentile')} {formatValue(row.band.p90, row.format)}
- - - - - - - - - +
{t('monteCarloResults.colYear')}{t('monteCarloResults.col10th')}{t('monteCarloResults.col25th')}{t('monteCarloResults.colMedian')}{t('monteCarloResults.col75th')}{t('monteCarloResults.col90th')}{t('monteCarloResults.colEvents')}
+ + + + + + + + + - + {rows.map((r) => ( - - + {/* Year: the row identity, so it carries no caption. */} + - - - + + {/* Median: the headline percentile, beside the year. */} + - - - + + {/* Events span both tracks on their own line; the list wraps. */} + ` around it stays the click target at every width. A type label is bounded in practice (five diff --git a/frontend/src/components/ui/SortableHeader.tsx b/frontend/src/components/ui/SortableHeader.tsx index 7c3ac8b426..f98c5b0f8d 100644 --- a/frontend/src/components/ui/SortableHeader.tsx +++ b/frontend/src/components/ui/SortableHeader.tsx @@ -1,7 +1,8 @@ 'use client'; -import { type KeyboardEvent, ReactNode } from 'react'; +import { ReactNode } from 'react'; import { SortIcon } from './SortIcon'; +import { INTERACTIVE_ROW_FOCUS_CLASS, activateOnKey } from './interactive-row'; import type { SortDirection } from '@/hooks/useSortableTable'; interface SortableHeaderProps { @@ -31,11 +32,6 @@ export function SortableHeader({ align === 'right' ? 'justify-end' : align === 'center' ? 'justify-center' : ''; const isActive = sortField === field; const sort = () => onSort(field); - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - sort(); - }; return ( // `role="columnheader"` is the implicit role of a `` with `onClick` and `cursor-pointer` needs `tabIndex` plus an + * Enter/Space handler to be reachable at all, and a focus ring to be followable + * -- three tables wrote both out by hand, character for character, and the + * copies that came before them each drifted (a `focus:` ring that paints on a + * mouse click, a ring with no inset offset). One copy that forgets the handler + * is a row nothing but a mouse can use, and nothing fails. + * + * Two fingerprints, one per half: + * + * 1. a `key` comparison against BOTH `'Enter'` and `' '` in the same block -- + * the activation contract, in either the negated-early-return or the + * positive-`if` form, since both shipped here; + * 2. the inset focus ring's `outline-offset-[-2px]`, which is the ring's + * fingerprint (the colour and width utilities appear on their own + * elsewhere and mean other things). + * + * The comparison scan needs an offender list, because three call sites this + * package does not own still hold a near-copy. They are recorded below with the + * reason, and the list is SHRINK-ONLY: a file that no longer holds the pattern + * fails its own entry, so a fix cannot leave a stale exemption covering the + * next copy. + */ + +const sources = import.meta.glob('/src/**/*.{ts,tsx}', { + query: '?raw', + eager: true, + import: 'default', +}) as Record; + +/** The module that is allowed to hold both halves: it is the one home for them. */ +const MODULE = '/src/components/ui/interactive-row.ts'; + +/** Source files only: a test may legitimately spell the pattern it asserts on. */ +function productionSources(): [string, string][] { + return Object.entries(sources).filter(([path]) => !/\.test\.tsx?$/.test(path)); +} + +/** 1-indexed line number of a character offset, for an offender report. */ +function lineOf(source: string, index: number): number { + return source.slice(0, index).split('\n').length; +} + +/** + * A `key` comparison against `'Enter'` and one against a single space, within + * one short window -- the whole activation block in either direction, so the + * order the two keys are tested in does not matter. + */ +const ACTIVATION_BLOCK = [ + /key\s*(?:===|!==)\s*(['"])Enter\1[\s\S]{0,160}?key\s*(?:===|!==)\s*(['"]) \2/g, + /key\s*(?:===|!==)\s*(['"]) \1[\s\S]{0,160}?key\s*(?:===|!==)\s*(['"])Enter\2/g, +]; + +/** + * The un-converted near-copies, with why each is still here. Every one is a + * `role="button"` element rather than a table row, carries its own focus-ring + * spelling, and belongs to a surface outside this change -- so converting them + * is a follow-up, not a silent exemption. Delete an entry when its file moves + * onto `activateOnKey`; leaving it fails the staleness check below. + */ +const UNCONVERTED: Record = { + '/src/components/accounts/loan-detail/SavedScenariosPanel.tsx': + 'pre-existing near-copy on a role="button" row with a `focus-visible:ring-*` ring; not owned by this change', + '/src/components/dashboard/UpcomingBills.tsx': + 'pre-existing near-copy on a role="button" div with a `focus:ring-*` ring; not owned by this change', + '/src/app/reports/page.tsx': + 'pre-existing near-copy on the favourite-star role="button" overlay, whose handler re-casts the event; not owned by this change', +}; + +describe('the comment stripper', () => { + it('blanks a comment while preserving line numbers', () => { + const stripped = blankComments("const a = 1;\n// key === 'Enter' || key === ' '\nconst b = 2;"); + expect(stripped).not.toContain('Enter'); + expect(stripped.split('\n')).toHaveLength(3); + }); + + it('leaves code alone, so a real offender is still found', () => { + const stripped = blankComments("if (e.key === 'Enter' || e.key === ' ') act();"); + expect(stripped).toContain("'Enter'"); + }); +}); + +describe('keyboard activation lives in one module', () => { + function offenders(): string[] { + const found: string[] = []; + for (const [path, raw] of productionSources()) { + if (path === MODULE || path in UNCONVERTED) continue; + const content = blankComments(raw); + for (const pattern of ACTIVATION_BLOCK) { + for (const match of content.matchAll(pattern)) { + found.push(`${path}:${lineOf(content, match.index)}`); + } + } + } + return found; + } + + it('has no hand-rolled Enter/Space activation block', () => { + // Use `activateOnKey(handler)` from `@/components/ui/interactive-row` for + // the `onKeyDown`, and `INTERACTIVE_ROW_FOCUS_CLASS` for the ring beside it. + expect(offenders()).toEqual([]); + }); + + it('still finds the module, so the rule cannot pass by accident', () => { + // Were the module renamed or its exports moved, the scan above would pass + // trivially over a codebase with no shared helper at all. + const helper = sources[MODULE]; + expect(helper, `${MODULE} not found -- update this guard`).toBeTruthy(); + expect(helper).toContain('export function activateOnKey'); + expect(helper).toContain('export const INTERACTIVE_ROW_FOCUS_CLASS'); + }); + + it('finds the canonical block inside the module', () => { + // The scan is only worth its allowlist if it matches the real thing. The + // module's own body is the positive control. + const content = blankComments(sources[MODULE]); + const matched = ACTIVATION_BLOCK.some((pattern) => { + pattern.lastIndex = 0; + return pattern.test(content); + }); + expect(matched, 'the scan no longer matches the helper it was written for').toBe(true); + }); + + it('keeps every recorded near-copy honest', () => { + for (const path of Object.keys(UNCONVERTED)) { + expect(sources[path], `${path} is recorded as un-converted but does not exist`).toBeTruthy(); + const content = blankComments(sources[path]); + const stillThere = ACTIVATION_BLOCK.some((pattern) => { + pattern.lastIndex = 0; + return pattern.test(content); + }); + expect( + stillThere, + `${path} no longer hand-rolls the activation block -- delete its entry`, + ).toBe(true); + } + }); +}); + +describe('the inset focus ring lives in one module', () => { + /** + * The ring's fingerprint. `focus-visible:outline-2` and the blue colour + * utilities appear on their own elsewhere for other elements; the negative + * offset is what makes this ring the table one. + */ + const INSET_RING = /outline-offset-\[-2px\]/g; + + it('is not spelled out anywhere else', () => { + const found: string[] = []; + for (const [path, raw] of productionSources()) { + if (path === MODULE) continue; + const content = blankComments(raw); + for (const match of content.matchAll(INSET_RING)) { + found.push(`${path}:${lineOf(content, match.index)}`); + } + } + + // Compose the ring as `${INTERACTIVE_ROW_FOCUS_CLASS}` instead. There is no + // allowlist here on purpose: the three files that held it are converted. + expect(found).toEqual([]); + }); + + it('is still in the module', () => { + expect(blankComments(sources[MODULE])).toContain('outline-offset-[-2px]'); + }); +}); diff --git a/frontend/src/components/ui/interactive-row.test.ts b/frontend/src/components/ui/interactive-row.test.ts new file mode 100644 index 0000000000..328253cadd --- /dev/null +++ b/frontend/src/components/ui/interactive-row.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { KeyboardEvent } from 'react'; +import { INTERACTIVE_ROW_FOCUS_CLASS, activateOnKey } from './interactive-row'; + +/** A synthetic keyboard event with just the two fields the helper touches. */ +function keyEvent(key: string): KeyboardEvent & { preventDefault: ReturnType } { + return { key, preventDefault: vi.fn() } as unknown as KeyboardEvent & { + preventDefault: ReturnType; + }; +} + +describe('activateOnKey', () => { + it.each(['Enter', ' '])('activates on %j and swallows the key', (key) => { + const handler = vi.fn(); + const event = keyEvent(key); + + activateOnKey(handler)(event); + + expect(handler).toHaveBeenCalledTimes(1); + // Space would otherwise scroll the page under the row it just activated. + expect(event.preventDefault).toHaveBeenCalledTimes(1); + }); + + it.each(['Tab', 'ArrowDown', 'a', 'Escape', 'Spacebar'])( + 'ignores %j and leaves its default alone', + (key) => { + const handler = vi.fn(); + const event = keyEvent(key); + + activateOnKey(handler)(event); + + expect(handler).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }, + ); + + it('hands the event to the handler', () => { + const handler = vi.fn(); + const event = keyEvent('Enter'); + + activateOnKey(handler)(event); + + expect(handler).toHaveBeenCalledWith(event); + }); + + it('returns a fresh handler per call, so no state leaks between rows', () => { + const first = vi.fn(); + const second = vi.fn(); + + activateOnKey(first)(keyEvent('Enter')); + activateOnKey(second)(keyEvent(' ')); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); +}); + +describe('INTERACTIVE_ROW_FOCUS_CLASS', () => { + it('is the inset focus ring the converted call sites spelled out', () => { + // Pinned by value: this string replaced four utilities written out verbatim + // in three files, and the refactor's claim is that it renders identically. + expect(INTERACTIVE_ROW_FOCUS_CLASS).toBe( + 'focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-600 dark:focus-visible:outline-blue-400', + ); + }); + + it('paints on Tab only -- every utility is focus-visible', () => { + // `focus:` paints on a mouse click as well as a Tab (frontend/CLAUDE.md). + const utilities = INTERACTIVE_ROW_FOCUS_CLASS.split(/\s+/); + expect(utilities.length).toBeGreaterThan(0); + for (const utility of utilities) { + expect(utility).toMatch(/^(dark:)?focus-visible:/); + } + }); +}); diff --git a/frontend/src/components/ui/interactive-row.ts b/frontend/src/components/ui/interactive-row.ts new file mode 100644 index 0000000000..661b554e1a --- /dev/null +++ b/frontend/src/components/ui/interactive-row.ts @@ -0,0 +1,69 @@ +import type { KeyboardEvent } from 'react'; + +/** + * The one keyboard-activation contract for a row or header that is clickable + * without being a `` or ` + * ``` + * + * A row whose click is CONDITIONAL passes both through the same condition, so it + * is focusable exactly when it is activatable -- a focus stop that does nothing + * is worse than none: + * + * ```tsx + * tabIndex={item.id ? 0 : undefined} + * onKeyDown={item.id ? activateOnKey(() => open(item.id)) : undefined} + * className={item.id ? `cursor-pointer ${INTERACTIVE_ROW_FOCUS_CLASS}` : ''} + * ``` + * + * `interactive-row.guard.test.ts` scans for a hand-rolled copy of either half. + * This is deliberately NOT a component: the tables it serves are hand-laid with + * colspans, sticky cells and per-report grid placements (the same reason + * `ui/Table.tsx` is constants rather than a `
{t('monteCarloResults.colYear')}{t('monteCarloResults.col10th')}{t('monteCarloResults.col25th')}{t('monteCarloResults.colMedian')}{t('monteCarloResults.col75th')}{t('monteCarloResults.col90th')}{t('monteCarloResults.colEvents')}
+
{r.year} {formatCurrency(r.p10)}{formatCurrency(r.p25)} + + {t('monteCarloResults.col10th')} + {formatCurrency(r.p10)} + + {t('monteCarloResults.col25th')} + {formatCurrency(r.p25)} + + {t('monteCarloResults.colMedian')} {formatCurrency(r.p50)} {formatCurrency(r.p75)}{formatCurrency(r.p90)} + + {t('monteCarloResults.col75th')} + {formatCurrency(r.p75)} + + {t('monteCarloResults.col90th')} + {formatCurrency(r.p90)} + + {t('monteCarloResults.colEvents')} {r.events.length === 0 ? ( — ) : ( From 281c25e8415d011040fbb6be8b58c0a091638963 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:34:17 +0000 Subject: [PATCH 26/44] fix(ui): one keyboard-activation helper, and a formatMonth that cannot throw B4: the branch wrote the same Enter/Space handler and the same four focus-ring utilities out by hand in three places (`ui/SortableHeader`, `SecurityTypeAllocationReport`, `InvestmentPerformanceReport`), and near-copies predate them. Both halves now live in `ui/interactive-row.ts` as `INTERACTIVE_ROW_FOCUS_CLASS` and `activateOnKey(handler)`; the three call sites compose them. The ring string is byte-identical to what they spelled out, so nothing renders differently at any width, and each of the three already had Enter, Space and non-activating-key cases -- which is why the suite staying green is evidence of an identical refactor rather than an untested one. `interactive-row.guard.test.ts` scans production sources for a hand-rolled Enter+Space block in either the negated-early-return or the positive-`if` form, and for the inset ring's `outline-offset-[-2px]` fingerprint. Proven to fail on the original mistake (both halves, with the right line numbers). Three un-converted near-copies outside this change are recorded with a reason in a shrink-only list whose staleness check fails an entry once its file is fixed; the ring scan needs no list. B2: `formatMonth` ran `padStart` on the split result before any format branch, so `formatMonth('undefined')` -- what a recharts tooltip's optional `label` becomes through `String(label)` -- threw a TypeError inside a tooltip's render and blanked the report subtree beneath it. It now parses a leading `YYYY-MM` (a real 1-12 month, optionally followed by the rest of an ISO date, as the payoff-date callers pass) and hands back anything else unchanged, `''` for a non-string. Guarding the helper fixes all its call sites at once. The new cases each threw before the guard. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- .../reports/InvestmentPerformanceReport.tsx | 9 +- .../reports/SecurityTypeAllocationReport.tsx | 9 +- frontend/src/components/ui/SortableHeader.tsx | 12 +- .../ui/interactive-row.guard.test.ts | 173 ++++++++++++++++++ .../src/components/ui/interactive-row.test.ts | 75 ++++++++ frontend/src/components/ui/interactive-row.ts | 69 +++++++ frontend/src/lib/utils.test.ts | 48 +++++ frontend/src/lib/utils.ts | 29 ++- 8 files changed, 403 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/ui/interactive-row.guard.test.ts create mode 100644 frontend/src/components/ui/interactive-row.test.ts create mode 100644 frontend/src/components/ui/interactive-row.ts diff --git a/frontend/src/components/reports/InvestmentPerformanceReport.tsx b/frontend/src/components/reports/InvestmentPerformanceReport.tsx index ec4e0a51eb..c605e44beb 100644 --- a/frontend/src/components/reports/InvestmentPerformanceReport.tsx +++ b/frontend/src/components/reports/InvestmentPerformanceReport.tsx @@ -21,6 +21,7 @@ import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMultiSelect'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { INTERACTIVE_ROW_FOCUS_CLASS, activateOnKey } from '@/components/ui/interactive-row'; import { CAPTION_CLASS, CellLabel, @@ -467,13 +468,9 @@ export function InvestmentPerformanceReport() { role="row" tabIndex={isExpandable ? 0 : undefined} aria-expanded={isExpandable ? isExpanded : undefined} - className={`grid grid-cols-2 items-start gap-x-3 gap-y-1.5 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 sm:table-row sm:p-0 ${isExpandable ? 'cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-600 dark:focus-visible:outline-blue-400' : ''}`} + className={`grid grid-cols-2 items-start gap-x-3 gap-y-1.5 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 sm:table-row sm:p-0 ${isExpandable ? `cursor-pointer ${INTERACTIVE_ROW_FOCUS_CLASS}` : ''}`} onClick={isExpandable ? () => setExpandedSecurityId(isExpanded ? null : holding.securityId) : undefined} - onKeyDown={isExpandable ? (event) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - setExpandedSecurityId(isExpanded ? null : holding.securityId); - } : undefined} + onKeyDown={isExpandable ? activateOnKey(() => setExpandedSecurityId(isExpanded ? null : holding.securityId)) : undefined} >
diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx index b39c4cd2da..bcd8010fdd 100644 --- a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx @@ -25,6 +25,7 @@ import { ReportAccountMultiSelect } from '@/components/reports/ReportAccountMult import { resolvePdfColor } from '@/components/reports/resolve-pdf-color'; import { RefreshPricesButton } from '@/components/reports/RefreshPricesButton'; import { SortableHeader } from '@/components/ui/SortableHeader'; +import { INTERACTIVE_ROW_FOCUS_CLASS, activateOnKey } from '@/components/ui/interactive-row'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; import type { SortColumn as TableSortColumn, @@ -617,13 +618,9 @@ export function SecurityTypeAllocationReport() { role="row" tabIndex={0} aria-expanded={expandedType === item.type} - className="grid grid-cols-2 items-start gap-x-3 gap-y-1.5 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-600 dark:focus-visible:outline-blue-400 cursor-pointer sm:table-row sm:p-0" + className={`grid grid-cols-2 items-start gap-x-3 gap-y-1.5 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 ${INTERACTIVE_ROW_FOCUS_CLASS} cursor-pointer sm:table-row sm:p-0`} onClick={() => setExpandedType((current) => current === item.type ? null : item.type)} - onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - setExpandedType((current) => current === item.type ? null : item.type); - }} + onKeyDown={activateOnKey(() => setExpandedType((current) => current === item.type ? null : item.type))} > {/* The identity; the `
`, restated so it @@ -46,8 +42,8 @@ export function SortableHeader({ tabIndex={0} aria-sort={isActive ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} onClick={sort} - onKeyDown={handleKeyDown} - className={`cursor-pointer transition-colors motion-reduce:transition-none hover:bg-gray-100 dark:hover:bg-gray-700 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-600 dark:focus-visible:outline-blue-400 select-none ${className}`} + onKeyDown={activateOnKey(sort)} + className={`cursor-pointer transition-colors motion-reduce:transition-none hover:bg-gray-100 dark:hover:bg-gray-700 ${INTERACTIVE_ROW_FOCUS_CLASS} select-none ${className}`} >
{children} diff --git a/frontend/src/components/ui/interactive-row.guard.test.ts b/frontend/src/components/ui/interactive-row.guard.test.ts new file mode 100644 index 0000000000..abfed9d554 --- /dev/null +++ b/frontend/src/components/ui/interactive-row.guard.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { blankComments } from '@/test/blank-comments'; + +/** + * Guard for B4: keyboard activation of a non-button control is written once, + * in `ui/interactive-row.ts`. + * + * The mistake this catches is mechanical and had already happened four times. + * A `
` carrying `onClick` and `cursor-pointer` is unreachable + * without a pointer unless it is also focusable and answers the activation keys + * (WCAG 2.1.1). Three tables wrote the same `tabIndex` + Enter/Space handler and + * the same four focus utilities out by hand, and near-copies exist elsewhere -- + * which is how a row ends up focusable but inert, or focusable with no visible + * ring. Both halves live here so a new clickable row gets them together: + * + * ```tsx + *
` wrapper). + */ + +/** + * The focus ring for a focusable row or header cell: `focus-visible:` so it + * paints on Tab and not on a mouse click, and inset (`-2px` offset) because an + * outward ring on a table cell is clipped by the row above it. + * + * Byte-identical to the four utilities the three converted call sites spelled + * out, so moving them here changed no rendering at any width. + */ +export const INTERACTIVE_ROW_FOCUS_CLASS = + 'focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-600 dark:focus-visible:outline-blue-400'; + +/** + * An `onKeyDown` that runs `handler` for Enter and Space, and for nothing else. + * + * `preventDefault()` before the handler is what keeps Space from scrolling the + * page under the row it just activated. Every other key is passed through + * untouched, so Tab still moves and the arrow keys still scroll. + * + * The handler is given the event so a caller that needs it can read it; a plain + * `() => void` is assignable and is what most rows pass. + */ +export function activateOnKey( + handler: (event: KeyboardEvent) => void, +): (event: KeyboardEvent) => void { + return (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + handler(event); + }; +} diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts index 972890e310..444bb0c8e5 100644 --- a/frontend/src/lib/utils.test.ts +++ b/frontend/src/lib/utils.test.ts @@ -180,6 +180,54 @@ describe('formatMonth', () => { expect(typeof result).toBe('string'); expect(result.length).toBeGreaterThan(0); }); + + it('reads the month out of a full ISO date, as the payoff-date callers pass', () => { + expect(formatMonth('2029-04-15', 'YYYY-MM-DD')).toBe('2029-04'); + expect(formatMonth('2029-04-15T00:00:00Z', 'YYYY-MM-DD')).toBe('2029-04'); + }); + + it('accepts a single-digit month, as it always has', () => { + expect(formatMonth('2026-3', 'YYYY-MM-DD')).toBe('2026-03'); + }); + + /** + * The guard, and every case in it threw a TypeError before it existed: + * `padStart` ran on the split result before any format branch, so a value + * with no `-` gave `monStr === undefined`. Two of these reach the helper for + * real -- a recharts tooltip `label` is optional, so `String(label)` is the + * literal `'undefined'`, and a `monthKey` can be absent mid rolling deploy -- + * and a throw inside a tooltip's render blanks the report subtree around it. + */ + describe('a value with no parseable month', () => { + it.each(['undefined', '', '2026', 'not-a-month', 'null', '2026-13', '2026-00'])( + 'hands %j back unchanged rather than throwing', + (input) => { + for (const format of ['browser', 'YYYY-MM-DD', 'MM/DD/YYYY', 'DD/MM/YYYY', 'DD-MMM-YYYY']) { + expect(() => formatMonth(input, format)).not.toThrow(); + expect(formatMonth(input, format)).toBe(input); + } + }, + ); + + it('renders nothing for an argument that is not a string at all', () => { + // TypeScript says this cannot happen; a stale bundle reading a renamed + // field says otherwise, and `undefined.split` is a blank screen. + expect(() => formatMonth(undefined as unknown as string)).not.toThrow(); + expect(formatMonth(undefined as unknown as string)).toBe(''); + expect(formatMonth(null as unknown as string)).toBe(''); + }); + + it('leaves the valid path untouched in every format', () => { + // The guard must not have widened into a passthrough: these are the same + // expectations as the cases above it, restated as one set. + expect(formatMonth('2026-01', 'YYYY-MM-DD')).toBe('2026-01'); + expect(formatMonth('2026-01', 'MM/DD/YYYY')).toBe('01/2026'); + expect(formatMonth('2026-01', 'DD/MM/YYYY')).toBe('01/2026'); + expect(formatMonth('2026-01', 'DD-MMM-YYYY')).toBe('Jan-2026'); + expect(formatMonth('2026-12', 'YYYY-MM-DD')).toBe('2026-12'); + expect(formatMonth('2026-01', 'browser', 'en-US')).toBe('01/2026'); + }); + }); }); describe('formatChartDate', () => { diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index b531346229..72f67389a8 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -128,17 +128,44 @@ export function formatDateWithoutYear(date: Date | string, pattern: string): str ); } +/** + * A leading `YYYY-MM` with a real calendar month, optionally followed by the + * rest of an ISO date (`2029-04-15`, `2029-04-15T00:00:00Z`) -- the payoff-date + * callers pass those. A single-digit month is accepted because it always was. + * `2026-13` and `2026-00` are refused: a month index outside 1-12 silently + * rolls into another year rather than reading as the value it was written as. + */ +const MONTH_KEY_PREFIX = /^(\d{4})-(0[1-9]|1[0-2]|[1-9])(?![0-9])/; + /** * Format a year-month value (YYYY-MM) according to the user's date format, * dropping the day component. Used for month column headers where only the * month and year are meaningful, so headers follow the same ordering and * separators as full dates rendered elsewhere. + * + * **A value with no parseable month comes back unchanged.** Several callers + * hand this a value they cannot vouch for -- a recharts tooltip `label` is + * optional, so `formatMonth(String(label))` arrives as the literal + * `'undefined'`, and a monthKey can be absent mid rolling deploy. The old body + * called `.padStart` on the split result before any format branch, so those + * threw a TypeError inside a tooltip's render and blanked the whole report + * subtree beneath it. Guarding here fixes every call site at once, which is why + * the guard is here and not at any of them. + * * @param month - Year-month string in YYYY-MM form * @param format - Date format string or 'browser' for locale-based formatting * @param locale - Optional BCP 47 locale used only when format is 'browser' + * @returns The formatted month, or the input unchanged when it holds no month + * ( `''` when the argument is not a string at all, since there is nothing to + * hand back and printing `"undefined"` in a label is worse than printing + * nothing). */ export function formatMonth(month: string, format: string = 'browser', locale?: string): string { - const [yearStr, monStr] = month.split('-'); + if (typeof month !== 'string') return ''; + const parsed = MONTH_KEY_PREFIX.exec(month); + if (!parsed) return month; + + const [, yearStr, monStr] = parsed; const year = Number(yearStr); const monthIndex = Number(monStr) - 1; const monthPadded = monStr.padStart(2, '0'); From f4ded8307812d963be73c43a320c5f064a38205b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:50:52 +0000 Subject: [PATCH 27/44] fix(reports): a dividend total that does not drift, and investment figures in the reader's locale B1: `SecurityPerformanceReport`'s dividend total was accumulated with `dividendTx.reduce((sum, tx) => sum + Math.abs(tx.totalAmount), 0)` -- root `CLAUDE.md`'s own WRONG example -- in the table footer and again, independently, in the PDF export. Five plausible dividend amounts reach 389.46000000000004, so the footer disagreed with the sum of the figures printed above it. Both surfaces now call one `sumDividends`, which coerces each row with `Number(...)` (a `decimal(20,4)` crosses the wire as a string; the old expression coerced only because `Math.abs` does) and sums through `sumMoney` from `@/lib/format`, the repo's integer ten-thousandths helper. Four new cases fail on the original expression -- the test's money formatter prints a value money cannot hold instead of rounding the drift away, so `$389.46000000000004` is visible in the DOM. B3: `SecurityPerformanceReport` (both tables and the PDF) and `InvestmentTransactionHistoryReport` were left behind by the migration that moved three sibling reports onto `useDateFormat().formatDate` and `formatShareQuantity`, so a `de` reader saw `Jan 5, 2026` on one report and their own arrangement on the next, and a share count printed as the raw wire string `10.0000`. Both now go through those seams; the CSV date stays ISO deliberately (a machine reads it, and every unconverted sibling writes ISO), while the PDF -- read by a person -- gets the formatted date and count. `report-locale.guard.test.ts` is the durable half: it scans `src/components/reports/` for a date-fns `format(...)` with a month-NAME pattern (leaving `formatChartDate`'s identical token and every ISO pattern alone, with positive and negative controls for both) and for a `toFixed` rendering a share count in either the receiver form or the 4dp form. Proven to fail on the original mistakes. Three reports outside this change are recorded in a shrink-only baseline whose staleness check fails an entry once its file is fixed. B5: `KNOWN_SECURITY_TYPES` hand-wrote the five codes `TYPE_COLOURS` already keys, so a sixth type added to one gave either a slice captioned with the raw enum name or a missing-message key. The colour record is now the single declaration (`keyof typeof`), and the i18n guard checks the third party no type can reach -- the `dashboard` catalog's `types.*` keys, in both directions. B6: the spanning dividend-footer cell restates its `colSpan` as `aria-colspan`, for the same reason it restates `role="cell"`: below `sm` it is not a `table-cell`, so the span the table layout carried is gone. No `aria-colcount`: no converted sibling declares one. B7: deleted the orphaned `CAPTION_CLASS` JSDoc left over `TYPE_COLOURS`, and corrected two width-measurement comments in `InvestmentTransactionHistoryReportParts` that still described the `toFixed(4)` and English-date renderings this change replaced. Also added the PDF/CSV quantity case the suite had no coverage for at all -- formatted for the reader, a plain number for the spreadsheet. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- ...actionHistoryReport.mobileWrapped.test.tsx | 52 ++++- .../InvestmentTransactionHistoryReport.tsx | 23 +- ...nvestmentTransactionHistoryReportParts.tsx | 16 +- ...tyPerformanceReport.mobileWrapped.test.tsx | 192 ++++++++++++++++- .../reports/SecurityPerformanceReport.tsx | 74 +++++-- ...ityTypeAllocationReport.i18n.guard.test.ts | 45 ++++ .../reports/SecurityTypeAllocationReport.tsx | 27 ++- .../reports/report-locale.guard.test.ts | 196 ++++++++++++++++++ 8 files changed, 584 insertions(+), 41 deletions(-) create mode 100644 frontend/src/components/reports/report-locale.guard.test.ts diff --git a/frontend/src/components/reports/InvestmentTransactionHistoryReport.mobileWrapped.test.tsx b/frontend/src/components/reports/InvestmentTransactionHistoryReport.mobileWrapped.test.tsx index 943c9ffc1f..8f2ea8f896 100644 --- a/frontend/src/components/reports/InvestmentTransactionHistoryReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/InvestmentTransactionHistoryReport.mobileWrapped.test.tsx @@ -50,6 +50,17 @@ vi.mock('@/hooks/useNumberFormat', async () => { }; }); +// The date arrangement and the share count are both the reader's preference, +// so the row must go through those two seams rather than through date-fns' +// English and `toFixed`. Each stand-in names itself, so an assertion reads as +// "the preference decided this". +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (date: string) => `preferred-date:${date}`, + formatMonth: (month: string) => `preferred-month:${month}`, + }), +})); + vi.mock("@/hooks/useExchangeRates", () => ({ useExchangeRates: () => ({ convertToDefault: (amount: number, _currency: string) => amount, @@ -97,6 +108,10 @@ vi.mock("@/lib/csv-export", () => ({ exportToCsv: (...args: any[]) => mockExportToCsv(...args), })); +vi.mock("@/lib/pdf-export", () => ({ + exportToPdf: vi.fn().mockResolvedValue(undefined), +})); + /** * Three rows on two accounts, chosen so the stored default sort (date, * descending) and an ascending sort by Account disagree -- otherwise a tap on @@ -332,9 +347,12 @@ describe("InvestmentTransactionHistoryReport (phone wrapped rows)", () => { const row = txRow(container, "VWCE.DE")!; // Each caption sits immediately beside the value it names, as its own text // node, so a `getByText` on the value still matches the value node. - expect(row.textContent).toContain("DateJan 5, 2025"); + expect(row.textContent).toContain("Datepreferred-date:2025-01-05"); expect(row.textContent).toContain("AccountZeta Brokerage"); - expect(row.textContent).toContain("Quantity50.0000"); + // Eight decimals through `formatShareQuantity`, trailing zeros trimmed: the + // raw `"50.0000"` the wire carries is not a figure in anybody's number + // locale, and a residual position must survive the formatting. + expect(row.textContent).toContain("Quantity50"); expect(row.textContent).toContain("Price$100.00"); expect(row.textContent).toContain("Total$5000.00"); // Scoped to the row: the summary cards above print the same figures. @@ -599,6 +617,36 @@ describe("InvestmentTransactionHistoryReport (phone wrapped rows)", () => { expect(bare[COL.price]).toBe(""); }); + it("formats the quantity for the PDF and leaves the CSV a number", async () => { + // Two surfaces, one record, two answers. The PDF is read by a person, so + // its share count goes through the number locale like the price and the + // total beside it; the CSV is summed by a spreadsheet, so it stays a + // number -- a formatted string there is the defect that stops an amount + // column adding up (issue #1134's family). The suite had no case for the + // formatted half at all, which is why this one is here. + const { exportToPdf } = await import("@/lib/pdf-export"); + const container = await renderTable(); + + fireEvent.click(within(container).getByTitle("Export report")); + await act(async () => { + fireEvent.click(within(container).getByText("PDF")); + }); + + const call = vi.mocked(exportToPdf).mock.calls.at(-1)![0] as { + tableData?: { rows: (string | number)[][] }; + }; + const pdfFirst = call.tableData!.rows[0]; + expect(pdfFirst[COL.quantity]).toBe("30"); + expect(typeof pdfFirst[COL.quantity]).toBe("string"); + + fireEvent.click(within(container).getByTitle("Export report")); + await act(async () => { + fireEvent.click(within(container).getByText("CSV")); + }); + const [, , csvRows] = mockExportToCsv.mock.calls.at(-1)!; + expect(csvRows[0][COL.quantity]).toBe(30); + }); + it("renders an unnamed account as a dash rather than an empty captioned cell", async () => { const container = await renderTable([ { ...TRANSACTIONS[0], accountId: "acc-unknown" }, diff --git a/frontend/src/components/reports/InvestmentTransactionHistoryReport.tsx b/frontend/src/components/reports/InvestmentTransactionHistoryReport.tsx index e1319e9157..840c59280a 100644 --- a/frontend/src/components/reports/InvestmentTransactionHistoryReport.tsx +++ b/frontend/src/components/reports/InvestmentTransactionHistoryReport.tsx @@ -7,6 +7,7 @@ import { investmentsApi } from '@/lib/investments'; import { InvestmentTransaction, InvestmentAction } from '@/types/investment'; import { Account } from '@/types/account'; import { parseLocalDate } from '@/lib/utils'; +import { useDateFormat } from '@/hooks/useDateFormat'; import { useNumberFormat } from '@/hooks/useNumberFormat'; import { useExchangeRates } from '@/hooks/useExchangeRates'; import { useDateRange } from '@/hooks/useDateRange'; @@ -54,7 +55,11 @@ export function InvestmentTransactionHistoryReport() { const t = useTranslations('reports'); const tCommon = useTranslations('common'); const mainAccountName = useMainAccountName(); - const { formatCurrency: formatCurrencyFull } = useNumberFormat(); + const { formatCurrency: formatCurrencyFull, formatShareQuantity } = useNumberFormat(); + // The on-screen date goes through the reader's preference. The CSV's date + // deliberately does not (see the `date` column's `csvValue`): a machine reads + // that one, and ISO is what every unconverted sibling export writes. + const { formatDate } = useDateFormat(); const { defaultCurrency, convertToDefault } = useExchangeRates(); const [accounts, setAccounts] = useState([]); // Persisted so the report opens on the accounts the user last chose. @@ -298,7 +303,15 @@ export function InvestmentTransactionHistoryReport() { field: 'quantity', label: t('investmentTransactions.colQuantity'), align: 'right', - csvValue: (tx) => (tx.quantity != null ? Math.abs(tx.quantity) : ''), + // Formatted for the PDF, a plain number for the CSV -- the same split the + // price and total entries below make, and the reason is the same: one is + // read by a person, the other summed by a spreadsheet. + csvValue: (tx, formatted) => + tx.quantity != null + ? formatted + ? formatShareQuantity(Math.abs(Number(tx.quantity))) + : Math.abs(Number(tx.quantity)) + : '', }, price: { field: 'price', @@ -314,7 +327,7 @@ export function InvestmentTransactionHistoryReport() { csvValue: (tx, formatted) => formatted ? fmtValue(Math.abs(tx.totalAmount)) : Math.abs(tx.totalAmount), }, - }), [t, actionLabels, accountNameMap, fmtValue]); + }), [t, actionLabels, accountNameMap, fmtValue, formatShareQuantity]); // Their order, rendered by BOTH header rows, matched by the cells' DOM order // and by the export's columns. DERIVED from the record rather than re-listed: @@ -642,7 +655,7 @@ export function InvestmentTransactionHistoryReport() { className={`col-start-2 row-start-4 text-gray-900 dark:text-gray-100 ${DATE_CELL}`} > {columns.date.label} - {format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy')} + {formatDate(tx.transactionDate)} - diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.i18n.guard.test.ts b/frontend/src/components/reports/SecurityTypeAllocationReport.i18n.guard.test.ts index 1b941214b0..860bd1c02b 100644 --- a/frontend/src/components/reports/SecurityTypeAllocationReport.i18n.guard.test.ts +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.i18n.guard.test.ts @@ -6,6 +6,51 @@ const sources = import.meta.glob('/src/components/reports/SecurityTypeAllocation import: 'default', }) as Record; +const dashboardCatalog = import.meta.glob('/src/i18n/messages/en/dashboard.json', { + eager: true, + import: 'default', +}) as Record } }>; + +describe('the known security types are declared once', () => { + /** + * `TYPE_COLOURS`' keys ARE the known set: the colour and the translated label + * are two halves of one agreement, and a sixth type added to one and not the + * other is visible either way -- a slice captioned with the raw enum name, or + * one coloured off the fallback ramp. `keyof typeof` holds the source side; + * this holds the catalog side, which no type can reach. + */ + function colourKeys(): string[] { + const source = Object.values(sources)[0]; + const record = /const TYPE_COLOURS = \{([\s\S]*?)\} as const/.exec(source)?.[1]; + expect(record, 'TYPE_COLOURS is no longer a literal record -- update this guard').toBeDefined(); + return [...record!.matchAll(/^\s{2}([A-Z_]+):/gm)].map((m) => m[1]); + } + + it('does not re-list them beside the colour record', () => { + // A second literal list of the same codes is the shape this replaced. + expect(Object.values(sources)[0]).not.toMatch(/const\s+KNOWN_SECURITY_TYPES\b/); + expect(Object.values(sources)[0]).toContain('type in TYPE_COLOURS'); + }); + + it('gives every coloured type a label in the en catalog', () => { + const keys = colourKeys(); + expect(keys.length).toBeGreaterThan(0); + const labels = dashboardCatalog['/src/i18n/messages/en/dashboard.json'].securityTypeAllocation + ?.types; + expect(labels).toBeDefined(); + expect(keys.filter((key) => !(key in labels!))).toEqual([]); + }); + + it('has no catalog label for a type the report cannot colour', () => { + // The other direction: a stale label is a translated string nothing renders, + // and it hides the fact that the type was dropped. + const keys = new Set(colourKeys()); + const labels = dashboardCatalog['/src/i18n/messages/en/dashboard.json'].securityTypeAllocation + ?.types; + expect(Object.keys(labels!).filter((key) => !keys.has(key))).toEqual([]); + }); +}); + describe('SecurityTypeAllocationReport translates type and quantity labels', () => { it('scans the production component', () => { expect(Object.keys(sources)).toEqual([ diff --git a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx index bcd8010fdd..991b360851 100644 --- a/frontend/src/components/reports/SecurityTypeAllocationReport.tsx +++ b/frontend/src/components/reports/SecurityTypeAllocationReport.tsx @@ -175,19 +175,30 @@ const IDENTITY_CELL = const CHILD_IDENTITY_CELL = `${CHILD_CELL_PLACEMENT.label} min-w-0 p-0 pl-8 text-sm break-words sm:table-cell sm:px-4 sm:py-2 sm:pl-10 sm:break-normal`; -/** Every caption in a wrapped cell is phone-only. */ -const TYPE_COLOURS: Record = { +/** + * The security types this report knows: their slice colour, and -- because the + * key set IS the known set -- which types have a translated label. + * + * One declaration, not two. A hand-written list of the same five codes beside + * this record drifts the moment a sixth type is added to one of them, and each + * direction of that drift is a defect a reader sees: a colour with no label + * gives a slice captioned with the raw enum name in every locale, and a label + * with no colour gives a slice coloured from the fallback ramp. `keyof typeof` + * makes the compiler hold the two in step, and the i18n guard checks the third + * party to the agreement -- the `dashboard` catalog's `types.*` keys. + */ +const TYPE_COLOURS = { STOCK: CHART_SERIES[0], ETF: CHART_SERIES[1], MUTUAL_FUND: CHART_SERIES[8], BOND: CHART_SERIES[4], CASH: chartColors.axis, -}; +} as const satisfies Record; -const KNOWN_SECURITY_TYPES = ['STOCK', 'ETF', 'MUTUAL_FUND', 'BOND', 'CASH'] as const; +type KnownSecurityType = keyof typeof TYPE_COLOURS; -function isKnownSecurityType(type: string): type is (typeof KNOWN_SECURITY_TYPES)[number] { - return (KNOWN_SECURITY_TYPES as readonly string[]).includes(type); +function isKnownSecurityType(type: string): type is KnownSecurityType { + return type in TYPE_COLOURS; } interface TypeAllocation { @@ -201,7 +212,9 @@ interface TypeAllocation { } function getColor(type: string, index: number): string { - return TYPE_COLOURS[type] || chartSeriesColor(index); + // Through the same predicate the label uses, so a type cannot be known to one + // and unknown to the other. + return isKnownSecurityType(type) ? TYPE_COLOURS[type] : chartSeriesColor(index); } function CustomTooltip({ active, payload, formatCurrencyFull, getHoldingsLabel }: { diff --git a/frontend/src/components/reports/report-locale.guard.test.ts b/frontend/src/components/reports/report-locale.guard.test.ts new file mode 100644 index 0000000000..8c32332993 --- /dev/null +++ b/frontend/src/components/reports/report-locale.guard.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { blankComments } from '@/test/blank-comments'; + +/** + * Guard for B3: a date or a share count a reader sees in a report goes through + * the preference seam -- `useDateFormat().formatDate` for the date, + * `useNumberFormat().formatShareQuantity` for the count. + * + * This is the "the fix for one surface is not the fix" shape. One pass migrated + * `BillPaymentHistoryReport`, `RealizedGainsReport` and + * `UncategorizedTransactionsReport` onto `formatDate`, and + * `InvestmentPerformanceReport` / `RealizedGainsReport` off `.toFixed(4)`, and + * left the investment reports beside them untouched -- so a `de` reader saw + * `Jan 5, 2026` on one report's table and their own arrangement on the next, + * and a `decimal(20,4)` share count printed as the raw string `10.0000` with a + * `.` decimal in every locale. Two scans, one per half: + * + * 1. a date-fns `format(...)` call whose pattern names a month by NAME + * (`MMM`), which is English here because no locale is ever passed. The + * token itself is fine as an argument to `useChartDateFormat()`'s + * `formatChartDate`, which localizes the month name -- that is the chart + * convention and is deliberately not matched. An ISO pattern + * (`yyyy-MM-dd`, `yyyy-MM`) is also not matched: those are machine values + * -- query bounds, month keys, CSV cells -- and ISO is what a machine + * surface should write. + * 2. a `.toFixed(...)` rendering a share count, in either of the two forms + * this codebase produced it: a receiver that names a quantity at any + * precision, and a bare `.toFixed(4)` whatever it is called, because the + * alias is how the last one got through. + * + * The date scan carries a shrink-only list of reports outside this change that + * still hold the pattern; its staleness check fails an entry once its file is + * fixed, so an exemption cannot outlive the defect it records. The quantity + * scan has no list: after this change, nothing under `src/components/reports/` + * renders a count that way. + */ + +const sources = import.meta.glob('/src/components/reports/**/*.{ts,tsx}', { + query: '?raw', + eager: true, + import: 'default', +}) as Record; + +/** Source files only: a test may legitimately spell the pattern it asserts on. */ +function productionSources(): [string, string][] { + return Object.entries(sources).filter(([path]) => !/\.test\.tsx?$/.test(path)); +} + +/** 1-indexed line number of a character offset, for an offender report. */ +function lineOf(source: string, index: number): number { + return source.slice(0, index).split('\n').length; +} + +/** + * A call to the bare `format` (date-fns) whose last argument is a quoted + * pattern containing `MMM`. The lookbehind is what keeps `formatChartDate(...)` + * and a `.format(...)` on an `Intl` formatter out: only an identifier that IS + * `format` counts. + */ +const ENGLISH_MONTH_NAME_FORMAT = + /(? = { + '/src/components/reports/DuplicateTransactionReport.tsx': + "the duplicate-pair table's transaction date on screen; not owned by this change", + '/src/components/reports/LoanAmortizationReport.tsx': + 'the projected payoff month on screen and in the PDF, and the schedule row date; not owned by this change', + '/src/components/reports/UpcomingBillsReport.tsx': + "the calendar's month heading, the PDF subtitle and each bill's due date; not owned by this change", +}; + +describe('the comment stripper', () => { + it('blanks a comment while preserving line numbers', () => { + const stripped = blankComments("const a = 1;\n// format(d, 'MMM d, yyyy') and toFixed(4)\nconst b = 2;"); + expect(stripped).not.toContain('MMM'); + expect(stripped).not.toContain('toFixed'); + expect(stripped.split('\n')).toHaveLength(3); + }); + + it('leaves code alone, so a real offender is still found', () => { + const stripped = blankComments("const d = format(parseLocalDate(x), 'MMM d, yyyy');"); + expect(stripped).toContain("'MMM d, yyyy'"); + }); +}); + +describe("a report's date is the reader's arrangement", () => { + function offenders(): string[] { + const found: string[] = []; + for (const [path, raw] of productionSources()) { + if (path in ENGLISH_MONTH_BASELINE) continue; + const content = blankComments(raw); + for (const match of content.matchAll(ENGLISH_MONTH_NAME_FORMAT)) { + found.push(`${path}:${lineOf(content, match.index)}`); + } + } + return found; + } + + it('has no date-fns format with an English month name', () => { + // Use `useDateFormat().formatDate(value)` for a date in a table, a cell or + // a PDF; `useChartDateFormat()` with the same token for a chart label. + expect(offenders()).toEqual([]); + }); + + it('matches the shape it was written for, and not the localizing helper', () => { + // Positive and negative controls. Without the first, the scan above could + // pass over a codebase full of offenders; without the second it would fail + // every chart in the directory and the honest response would be to delete + // it. + const offending = "const s = format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy');"; + const legitimate = "const s = formatChartDate(parsed, 'MMM d, yyyy');"; + const machine = "const s = format(parseLocalDate(tx.transactionDate), 'yyyy-MM-dd');"; + + ENGLISH_MONTH_NAME_FORMAT.lastIndex = 0; + expect(ENGLISH_MONTH_NAME_FORMAT.test(offending)).toBe(true); + ENGLISH_MONTH_NAME_FORMAT.lastIndex = 0; + expect(ENGLISH_MONTH_NAME_FORMAT.test(legitimate)).toBe(false); + ENGLISH_MONTH_NAME_FORMAT.lastIndex = 0; + expect(ENGLISH_MONTH_NAME_FORMAT.test(machine)).toBe(false); + }); + + it('keeps every baselined report honest', () => { + for (const path of Object.keys(ENGLISH_MONTH_BASELINE)) { + expect(sources[path], `${path} is baselined but does not exist`).toBeTruthy(); + const content = blankComments(sources[path]); + ENGLISH_MONTH_NAME_FORMAT.lastIndex = 0; + expect( + ENGLISH_MONTH_NAME_FORMAT.test(content), + `${path} no longer formats an English month name -- delete its baseline entry`, + ).toBe(true); + } + }); +}); + +describe("a report's share count is the reader's number locale", () => { + it('renders no quantity through toFixed', () => { + // A `Set`: the two patterns overlap on the commonest offender + // (`quantity.toFixed(4)`), and one line reported twice reads as two defects. + const found = new Set(); + for (const [path, raw] of productionSources()) { + const content = blankComments(raw); + for (const pattern of [QUANTITY_TO_FIXED, FOUR_DP_TO_FIXED]) { + for (const match of content.matchAll(pattern)) { + found.add(`${path}:${lineOf(content, match.index)}`); + } + } + } + + // Use `useNumberFormat().formatShareQuantity(value)`: eight decimals so a + // residual position survives, the reader's own decimal mark, and `-0` + // normalized. `toFixed` writes a `.` in every locale. + expect([...found]).toEqual([]); + }); + + it('matches both shapes it was written for', () => { + // The receiver form and the precision form, each of which shipped here. + QUANTITY_TO_FIXED.lastIndex = 0; + expect(QUANTITY_TO_FIXED.test('{Math.abs(tx.quantity).toFixed(4)}')).toBe(true); + QUANTITY_TO_FIXED.lastIndex = 0; + expect(QUANTITY_TO_FIXED.test('{holding.totalShares.toFixed(8)}')).toBe(true); + FOUR_DP_TO_FIXED.lastIndex = 0; + expect(FOUR_DP_TO_FIXED.test('{splitPreview.currentAvg.toFixed(4)}')).toBe(true); + + // And leaves a rate and a money figure to the rules that own them: an FX + // rate is 6dp by `FX_RATE_DISPLAY_DECIMALS` and a percentage is the `%` + // scan's subject in `src/test/number-locale.guard.test.ts`. + QUANTITY_TO_FIXED.lastIndex = 0; + expect(QUANTITY_TO_FIXED.test('item.rate.toFixed(FX_RATE_DISPLAY_DECIMALS)')).toBe(false); + FOUR_DP_TO_FIXED.lastIndex = 0; + expect(FOUR_DP_TO_FIXED.test('point.savingsRate.toFixed(1)')).toBe(false); + }); + + it('offers the formatter to migrate to', () => { + // Without it the rule has no answer for a holdings column. + const hook = import.meta.glob('/src/hooks/useNumberFormat.ts', { + query: '?raw', + eager: true, + import: 'default', + }) as Record; + expect(hook['/src/hooks/useNumberFormat.ts']).toContain( + 'const formatShareQuantity = useCallback', + ); + }); +}); From 50c20220a95b1f632333b23fa333f640de521b38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 12:00:06 +0000 Subject: [PATCH 28/44] docs(ui): say why the focus ring is safe to keep in a .ts module `outline-offset-[-2px]` now appears nowhere else in the tree, and Tailwind v4 emits a utility only for a class it finds in a source file, so the constant's move depends on automatic source detection covering `.ts`. Verified by compiling `globals.css` through `@tailwindcss/postcss` against the new module rather than assumed, and recorded so a future reader does not move the constant back into a `.tsx` out of doubt. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- frontend/src/components/ui/interactive-row.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/src/components/ui/interactive-row.ts b/frontend/src/components/ui/interactive-row.ts index 661b554e1a..5444cbefa6 100644 --- a/frontend/src/components/ui/interactive-row.ts +++ b/frontend/src/components/ui/interactive-row.ts @@ -44,6 +44,14 @@ import type { KeyboardEvent } from 'react'; * * Byte-identical to the four utilities the three converted call sites spelled * out, so moving them here changed no rendering at any width. + * + * `outline-offset-[-2px]` now appears nowhere else in the tree, and Tailwind v4 + * emits utilities only for classes it finds in a source file -- so the move is + * only safe because its automatic source detection covers `.ts` as well as + * `.tsx`. Checked by compiling `globals.css` through `@tailwindcss/postcss` + * against this file: `outline-offset: -2px` is in the output under a + * `:focus-visible` selector. (`lib/scheduled-kind.ts` holds class constants for + * the same reason.) */ export const INTERACTIVE_ROW_FOCUS_CLASS = 'focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-blue-600 dark:focus-visible:outline-blue-400'; From 4078921b9b717023975181579c4167600128a0a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 12:03:33 +0000 Subject: [PATCH 29/44] fix(reports): read the date-fns call's arguments instead of regexing them The B3 date scan's first draft matched the call with a regex, and its own positive control caught what that costs: `'MMM d, yyyy'` contains a COMMA, so splitting the argument list without knowing about string literals cut the pattern in half, read `yyyy'` as the last argument, and reported the directory clean while `DuplicateTransactionReport` held the exact call the guard was written for. A paren inside a literal misleads a depth count the same way, and a bounded-wildcard alternative had the opposite failure -- running past the closing paren to borrow the NEXT call's token, so `format(d, 'yyyy-MM-dd')` beside `formatChartDate(e, 'MMM')` read as one offender. It now reads the balanced argument list the way a parser does, skipping string literals in both the depth count and the argument split. Ten control cases pin it: the two shapes this change removed, a call nested two deep, one split across lines with a trailing comma, the localizing chart helper with the identical token, an `Intl` formatter's own `.format(`, two ISO patterns, and the borrow-the-next-token false positive. Re-verified against a scratch offender holding all three date shapes: all three are reported, at the right lines. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- .../reports/report-locale.guard.test.ts | 158 +++++++++++++++--- 1 file changed, 137 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/reports/report-locale.guard.test.ts b/frontend/src/components/reports/report-locale.guard.test.ts index 8c32332993..c650dbb11c 100644 --- a/frontend/src/components/reports/report-locale.guard.test.ts +++ b/frontend/src/components/reports/report-locale.guard.test.ts @@ -52,13 +52,113 @@ function lineOf(source: string, index: number): number { } /** - * A call to the bare `format` (date-fns) whose last argument is a quoted - * pattern containing `MMM`. The lookbehind is what keeps `formatChartDate(...)` - * and a `.format(...)` on an `Intl` formatter out: only an identifier that IS - * `format` counts. + * Every call to the bare `format` (date-fns). The lookbehind keeps + * `formatChartDate(...)` and a `.format(...)` on an `Intl` formatter out: only + * an identifier that IS `format`, called directly, counts. */ -const ENGLISH_MONTH_NAME_FORMAT = - /(? part.trim()).filter((part) => part.length > 0); + return meaningful[meaningful.length - 1] ?? ''; +} + +/** Offsets in `content` of a date-fns `format(...)` given an English month name. */ +function englishMonthFormatCalls(content: string): number[] { + const found: number[] = []; + for (const match of content.matchAll(BARE_FORMAT_CALL)) { + const openParen = match.index + match[0].length - 1; + const args = argumentsOf(content, openParen); + if (args === null) continue; + if (MONTH_NAME_PATTERN.test(lastArgument(args))) found.push(match.index); + } + return found; +} /** A `.toFixed(...)` whose receiver names a share count, at any precision. */ const QUANTITY_TO_FIXED = /\b\w*(?:quantity|Quantity|shares|Shares)\w*(?:\s*\))*\s*\.toFixed\(/g; @@ -101,8 +201,8 @@ describe("a report's date is the reader's arrangement", () => { for (const [path, raw] of productionSources()) { if (path in ENGLISH_MONTH_BASELINE) continue; const content = blankComments(raw); - for (const match of content.matchAll(ENGLISH_MONTH_NAME_FORMAT)) { - found.push(`${path}:${lineOf(content, match.index)}`); + for (const index of englishMonthFormatCalls(content)) { + found.push(`${path}:${lineOf(content, index)}`); } } return found; @@ -119,27 +219,43 @@ describe("a report's date is the reader's arrangement", () => { // pass over a codebase full of offenders; without the second it would fail // every chart in the directory and the honest response would be to delete // it. - const offending = "const s = format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy');"; - const legitimate = "const s = formatChartDate(parsed, 'MMM d, yyyy');"; - const machine = "const s = format(parseLocalDate(tx.transactionDate), 'yyyy-MM-dd');"; - - ENGLISH_MONTH_NAME_FORMAT.lastIndex = 0; - expect(ENGLISH_MONTH_NAME_FORMAT.test(offending)).toBe(true); - ENGLISH_MONTH_NAME_FORMAT.lastIndex = 0; - expect(ENGLISH_MONTH_NAME_FORMAT.test(legitimate)).toBe(false); - ENGLISH_MONTH_NAME_FORMAT.lastIndex = 0; - expect(ENGLISH_MONTH_NAME_FORMAT.test(machine)).toBe(false); + const cases: [string, boolean][] = [ + // The two shapes this change removed. + ["const s = format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy');", true], + ["const s = format(parseISO(summary.projectedPayoffDate), 'MMM yyyy');", true], + // Nested two deep, and split across lines with a trailing comma: both are + // ordinary formatting of this call and a regex misses them. + ["const s = format(startOfMonth(new Date(y, m, 1)), 'MMMM yyyy');", true], + ["const s = format(\n parseLocalDate(d),\n 'MMM d, yyyy',\n);", true], + // The localizing chart helper takes the identical token: not an offender. + ["const s = formatChartDate(parsed, 'MMM d, yyyy');", false], + ["const s = useChartDateFormat()(parsed, 'MMM yyyy');", false], + // An Intl formatter's own method. + ["const s = monthFormatter.format(date) + 'MMM';", false], + // ISO patterns are machine values: a query bound, a month key, a CSV cell. + ["const s = format(parseLocalDate(tx.transactionDate), 'yyyy-MM-dd');", false], + ["const s = format(month, 'yyyy-MM');", false], + // A bare `format` beside a chart call must not borrow the chart's token: + // this is the false positive a bounded wildcard produces. + [ + "const a = format(d, 'yyyy-MM-dd');\nconst b = formatChartDate(e, 'MMM');", + false, + ], + ]; + + for (const [source, expected] of cases) { + expect(englishMonthFormatCalls(source).length > 0, source).toBe(expected); + } }); it('keeps every baselined report honest', () => { for (const path of Object.keys(ENGLISH_MONTH_BASELINE)) { expect(sources[path], `${path} is baselined but does not exist`).toBeTruthy(); const content = blankComments(sources[path]); - ENGLISH_MONTH_NAME_FORMAT.lastIndex = 0; expect( - ENGLISH_MONTH_NAME_FORMAT.test(content), + englishMonthFormatCalls(content).length, `${path} no longer formats an English month name -- delete its baseline entry`, - ).toBe(true); + ).toBeGreaterThan(0); } }); }); From a3b1a3b12b410904f8da6fcc4b8ad3c97f1728bf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:47:57 +0000 Subject: [PATCH 30/44] Fix the report exports, keyboard rows and shared declarations Package A of the reviewed follow-ups to the phone-wrapping conversion. Nine items over six report components; the CSV one is a returning defect and the keyboard one is a WCAG failure the whole suite was green over. A1 -- The Uncategorized CSV amount column is a number again. The conversion exported `formatCurrency(tx.amount, tx.currencyCode)`, and `csv-export.ts`'s injection guard tab-prefixes any value opening with `-` that its `NUMERIC_VALUE` test cannot read as a number. That test admits digits, separators and currency SYMBOLS, so a currency whose narrow symbol is written in LETTERS falls outside it: `-zl 1,234.56`, `-CHF 1,234.56`, `-kr 1,234.56`, `-R$1,234.56` and pl-PL's `-1234,56 zl` are all tab-prefixed, Excel stores them as text and the column stops adding up. That is issue #1134 returning. The value is now `Number(tx.amount)` -- the coercion because a `decimal(20,4)` crosses the wire as a string whatever the type says, and a string never takes the guard's `typeof value === 'number'` path. A2 -- The three converted CSV date columns are ISO again (`yyyy-MM-dd`, via `format(parseLocalDate(...))` as every unconverted sibling export writes): Uncategorized, Recurring Expenses and Bill Payment History had been switched to the reader's locale format. A CSV is machine-read, so two readers exporting the same rows must get one file, and a localized date is ambiguous and sorts lexicographically wrong. A1 and A2 go through one mechanism: `getExportData` takes an `ExportSurface` (`'csv' | 'pdf'`) and only the cells whose rendering depends on their reader branch, so the two exports cannot come to hold different columns. The PDF is a reading surface and keeps the reader's own number and date formats. In Recurring Expenses the surface reaches the cell through the column record's `csvValue`, which already owned the export's order. A3 -- Six clickable rows are keyboard-operable. Each was a `` with `cursor-pointer` and an `onClick` and no `tabIndex` and no `onKeyDown` (WCAG 2.1.1). They now take `tabIndex` and `activateOnKey` from the one shared `ui/interactive-row` module rather than a per-report copy of the handler. Where the click is conditional (Income by Source, Spending by Category, Recurring Expenses) the tab stop, the focus ring and the key handler appear only when the click does something: a focus stop that does nothing on Enter is one the reader has to escape. A4 -- Income by Source and Spending by Category import `SortColumn` / `SortColumnsByField` from `ui/Table` as eleven siblings do, instead of re-declaring them with ~25 lines of duplicated prose. A5 -- Income vs Expenses imports `CAPTION_CLASS` instead of inlining `"sm:hidden"` at its eight `CellLabel` caption sites. A6 -- Income vs Expenses' column record is `TableSortColumnsByField` rather than a plain `Record<>`: a `Record` lets an entry name a DIFFERENT field, which compiles into a duplicate React key, a header sorting by the wrong column and one unsortable column, none of which a label-comparing test can see. A7 -- The Frequency column sorts ordinally. It compared the LOCALIZED label, so ascending gave "Every 2 Weeks, Irregular, Monthly, Occasional, Weekly" -- alphabetical, and a different order in every language. It now compares position in `RECURRING_EXPENSE_FREQUENCIES`, which is declared in true frequency order for this. Both readers of a frequency also became defensive, because during a rolling deploy an older backend sends a code this build has no entry for: the badge takes IRREGULAR's neutral classes rather than putting the literal string `undefined` in the class attribute, and the label falls back to the raw code rather than rendering the `reports.recurringExpenses.frequency.` key path on screen. An unknown code sorts after every known one rather than taking `indexOf`'s `-1` and claiming to be more frequent than WEEKLY. A8 -- Spending by Category keeps a `CELL_PLACEMENT` record, as its siblings do, instead of writing `col-start-1 row-start-2` and `col-start-2 row-start-2` out twice each; the footer now takes a data row's placement by construction. A9 -- The orphaned doc comments left behind when `CAPTION_CLASS` and `PHONE_HEADER_CLASS` moved into `ui/Table.tsx` no longer make false claims about the declaration under them. The stray `/** Every caption in a wrapped cell is phone-only. */` above the exported component in Uncategorized, Bill Payment History and (over `FREQUENCY_BADGE_CLASS`) Recurring Expenses is gone, and the phone-strip essays that had merged into the next constant's comment -- in Bill Payment History, Uncategorized and Income vs Expenses -- are re-attached to the phone sort strip they describe. Tests. Five expectations asserted the defects A1 and A2 fix (the CSV amount as `EUR -50.00`, the CSV date as the reader's format) and are corrected, each with the reason on the record; the sibling PDF assertions keep the localized strings and pin the other half of the split. A2 and A1 gain the assertions the suite lacked: the CSV amount is `typeof number`, the CSV date is ISO, and a new Recurring Expenses case holds the PDF's localized date against the CSV's ISO one from the same record. A3 and A7 changed behaviour with the suite staying green, which meant the suite had no case for either. Added: a keyboard-activation case per report (`tabIndex`, Enter, Space, an unclaimed key ignored, and for the three conditional rows that a non-clickable row is not a tab stop), an ordinal-sort case whose fixture is in neither frequency nor alphabetical order, and an unknown-frequency case. Each was checked to fail against the pre-fix code. No new i18n keys (the unknown-frequency label is the raw code). `ui/interactive-row` is Package B's module and is not on this base yet, so the six imports resolve only once B lands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01P4ukv9x4ZA53UT4tQtChad --- .../reports/BillPaymentHistoryReport.test.tsx | 53 +++++- .../reports/BillPaymentHistoryReport.tsx | 86 ++++++--- .../reports/IncomeBySourceReport.test.tsx | 43 +++++ .../reports/IncomeBySourceReport.tsx | 55 +++--- .../reports/IncomeVsExpensesReport.test.tsx | 31 +++ .../reports/IncomeVsExpensesReport.tsx | 64 ++++--- ...rringExpensesReport.mobileWrapped.test.tsx | 7 +- .../reports/RecurringExpensesReport.test.tsx | 177 +++++++++++++++++- .../reports/RecurringExpensesReport.tsx | 147 ++++++++++++--- .../reports/SpendingByCategoryReport.test.tsx | 45 +++++ .../reports/SpendingByCategoryReport.tsx | 85 +++++---- .../UncategorizedTransactionsReport.test.tsx | 90 ++++++++- .../UncategorizedTransactionsReport.tsx | 102 +++++++--- 13 files changed, 807 insertions(+), 178 deletions(-) diff --git a/frontend/src/components/reports/BillPaymentHistoryReport.test.tsx b/frontend/src/components/reports/BillPaymentHistoryReport.test.tsx index c8dbe9781c..7d09ef557b 100644 --- a/frontend/src/components/reports/BillPaymentHistoryReport.test.tsx +++ b/frontend/src/components/reports/BillPaymentHistoryReport.test.tsx @@ -227,6 +227,47 @@ describe('BillPaymentHistoryReport', () => { expect(mockPush).toHaveBeenCalledWith('/bills'); }); + // The row is the click target, so it has to be reachable and operable from + // the keyboard as well (WCAG 2.1.1). Before the fix this row was a + // `cursor-pointer` `` with an `onClick` and no `tabIndex` and no + // `onKeyDown` -- the whole suite was green over a row no keyboard user could + // use, so this case is what fails on that shape. + it('activates a bill row from the keyboard', async () => { + mockGetBillPaymentHistory.mockResolvedValue({ + billPayments: [ + { + scheduledTransactionId: 'st-1', + scheduledTransactionName: 'Rent', + payeeName: 'Landlord', + paymentCount: 12, + averagePayment: 1500, + totalPaid: 18000, + lastPaymentDate: '2025-01-01', + }, + ], + monthlyTotals: [], + summary: { totalPaid: 18000, monthlyAverage: 1500, uniqueBills: 1, totalPayments: 12 }, + }); + render(); + await waitFor(() => expect(screen.getByText('By Bill')).toBeInTheDocument()); + fireEvent.click(screen.getByText('By Bill')); + await waitFor(() => expect(screen.getByText('Rent')).toBeInTheDocument()); + const row = screen.getByText('Rent').closest('tr') as HTMLElement; + expect(row).toHaveAttribute('tabindex', '0'); + + fireEvent.keyDown(row, { key: 'Enter' }); + expect(mockPush).toHaveBeenCalledWith('/bills'); + + mockPush.mockClear(); + fireEvent.keyDown(row, { key: ' ' }); + expect(mockPush).toHaveBeenCalledWith('/bills'); + + // A key the row does not claim stays the browser's. + mockPush.mockClear(); + fireEvent.keyDown(row, { key: 'a' }); + expect(mockPush).not.toHaveBeenCalled(); + }); + it('exports CSV when export button is clicked', async () => { mockGetBillPaymentHistory.mockResolvedValue({ billPayments: [ @@ -251,9 +292,15 @@ describe('BillPaymentHistoryReport', () => { expect.any(Array), expect.any(Array), ); - expect(mockExportToCsv.mock.calls[0][2][0][5]).toBe( - 'preferred-date:2025-01-01', - ); + // A CSV is machine-read, so the date column is ISO and NOT the reader's + // preferred format: two readers exporting the same rows must get one file, + // and a localized date is ambiguous and sorts lexicographically wrong. + // `preferred-date:...` here would be asserting that defect. The sibling + // test below holds the reading surface's half of the split. + expect(mockExportToCsv.mock.calls[0][2][0][5]).toBe('2025-01-01'); + // The figure columns are raw numbers, for the reason issue #1134 records. + expect(mockExportToCsv.mock.calls[0][2][0][3]).toBe(1500); + expect(mockExportToCsv.mock.calls[0][2][0][4]).toBe(18000); }); it('exports preferred dates to PDF', async () => { diff --git a/frontend/src/components/reports/BillPaymentHistoryReport.tsx b/frontend/src/components/reports/BillPaymentHistoryReport.tsx index 3af43e2432..68dd9f47fd 100644 --- a/frontend/src/components/reports/BillPaymentHistoryReport.tsx +++ b/frontend/src/components/reports/BillPaymentHistoryReport.tsx @@ -12,7 +12,9 @@ import { Tooltip, ResponsiveContainer, } from 'recharts'; +import { format } from 'date-fns'; import { builtInReportsApi } from '@/lib/built-in-reports'; +import { parseLocalDate } from '@/lib/utils'; import { BillPaymentHistoryResponse } from '@/types/built-in-reports'; import { useNumberFormat } from '@/hooks/useNumberFormat'; import { useDateFormat } from '@/hooks/useDateFormat'; @@ -22,6 +24,7 @@ import { exportToCsv } from '@/lib/csv-export'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import { INTERACTIVE_ROW_FOCUS_CLASS, activateOnKey } from '@/components/ui/interactive-row'; import type { SortColumn as TableSortColumn, SortColumnsByField as TableSortColumnsByField, @@ -59,21 +62,6 @@ type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged. const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; -// The same sort controls in the phone strip: a wrapped row of compact chips. -// Column alignment means nothing there -- the column header row is hidden and -// each data row is a grid -- so every control is left-aligned and self-naming. -// The border is what says "tappable": there is no hover on a touch screen, and -// the chip's own fill is a shade off the header band it sits on (this table's -// `` keeps its `bg-gray-50` / `dark:bg-gray-900/50`, so the strip is on -// that band rather than on the card, as it is on the sibling tables whose card -// has no header band). The shared `PHONE_HEADER_CLASS` keeps those controls -// identical across the reports. -// -// Five chips wrap to three lines at 320px in `en`/`pl`/`ru`/`id` (114px), four -// in `de` (148px) and five in the pseudo-locale (182px) above the first row. -// That is a measured cost, not a reason to drop a control: `reports.bill- -// payment-history.sort` persists any of the five, so a field with no control -// anywhere would leave a phone POINTING at a sort with no pointer back. // A value cell inside a wrapped row: no padding of its own below `sm` and this // table's own `px-4 py-3` from `sm` up. Smaller type on phones so an // eight-figure compact amount still fits half the width. @@ -124,7 +112,17 @@ const MONEY_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px // it. const DATE_CELL = 'p-0 text-right text-xs whitespace-nowrap sm:table-cell sm:px-4 sm:py-3 sm:text-sm'; -/** Every caption in a wrapped cell is phone-only. */ +/** + * Which surface an export row is being built for. + * + * A CSV is read by a MACHINE and a PDF by a person, so a date is written + * differently for each: the CSV gets ISO `yyyy-MM-dd`, which a spreadsheet + * sorts, and the PDF gets the reader's own date format. One function builds + * both so the two cannot come to hold different COLUMNS; only the cell + * rendering branches. + */ +type ExportSurface = 'csv' | 'pdf'; + export function BillPaymentHistoryReport() { const t = useTranslations('reports'); const router = useRouter(); @@ -210,28 +208,44 @@ export function BillPaymentHistoryReport() { // The record's declaration order is the column order. const sortColumns: readonly SortColumn[] = Object.values(columns); - const getExportData = () => { + /** + * The rows both exports write, in the columns both write, differing only in + * how a machine-read cell is rendered against a human-read one. + * + * The Last Payment column is ISO in the CSV: two readers exporting the same + * rows must get one file, `yyyy-MM-dd` is the form a spreadsheet sorts and + * every unconverted sibling export writes, and a localized date is ambiguous + * (`03/04/2026`) and sorts lexicographically wrong. The PDF is a reading + * surface and takes the reader's own format. The three figure columns are + * already raw numbers and stay that way -- a formatted amount is what + * reopened issue #1134 on the sibling report. + */ + const getExportData = (surface: ExportSurface) => { if (!billData) return null; const headers = [t('billPaymentHistory.colBill'), t('billPaymentHistory.colPayee'), t('billPaymentHistory.colPayments'), t('billPaymentHistory.colAverage'), t('billPaymentHistory.colTotalPaid'), t('billPaymentHistory.colLastPayment')]; - const rows = billData.billPayments.map((bp) => [ + const rows: (string | number)[][] = billData.billPayments.map((bp) => [ bp.scheduledTransactionName, bp.payeeName || '', bp.paymentCount, bp.averagePayment, bp.totalPaid, - bp.lastPaymentDate ? formatDate(bp.lastPaymentDate) : '', + bp.lastPaymentDate + ? surface === 'pdf' + ? formatDate(bp.lastPaymentDate) + : format(parseLocalDate(bp.lastPaymentDate), 'yyyy-MM-dd') + : '', ]); return { headers, rows }; }; const handleExportCsv = () => { - const data = getExportData(); + const data = getExportData('csv'); if (!data) return; exportToCsv('bill-payment-history', data.headers, data.rows); }; const handleExportPdf = async () => { - const data = getExportData(); + const data = getExportData('pdf'); if (!data || !billData) return; const { exportToPdf } = await import('@/lib/pdf-export'); await exportToPdf({ @@ -468,7 +482,26 @@ export function BillPaymentHistoryReport() {
{columns.quantity.label} - {tx.quantity != null ? Math.abs(tx.quantity).toFixed(4) : '-'} + {tx.quantity != null ? formatShareQuantity(Math.abs(Number(tx.quantity))) : '-'} { useNumberFormat: () => ({ ...numberFormatMockDefaults(), formatSignedPercent: (n: number, decimals = 2) => `${n >= 0 ? '+' : ''}${n.toFixed(decimals)}%`, - formatCurrency: (n: number) => `$${n.toFixed(2)}`, + /** + * `$1500.00` for any figure money can hold, and the raw value for one it + * cannot. + * + * A plain `toFixed(2)` rounds IEEE-754 accumulation drift away, so a + * footer summed with `+` and a footer summed in integer ten-thousandths + * render the same string and no assertion can tell them apart. Money is + * `decimal(20,4)`: a figure that survives a round trip through that scale + * formats as usual, and one that does not is printed as it arrived, so a + * drifted total is visible in the DOM. The real hook is an `Intl` + * formatter with a fixed fraction count -- it would round the drift away + * on screen too, which is exactly why the drift needs a test rather than + * a reader. + */ + formatCurrency: (n: number) => + `$${Math.round(n * 10_000) / 10_000 === n ? n.toFixed(2) : String(n)}`, formatCurrencyCompact: (n: number) => `$${n.toFixed(0)}`, formatCurrencyAxis: (n: number) => `$${n}`, + /** + * A share count that names its seam, so an assertion says "the number + * locale decided this" rather than "the value happened to stringify that + * way". The shared default is `String(value)`, which a raw + * `{tx.quantity}` matches exactly -- so a cell that never reached this + * formatter would have passed. + */ + formatShareQuantity: (value: number | null | undefined) => `shares:${value}`, }), }; }); +// The date arrangement is the reader's preference, so the tables must go +// through this seam rather than through date-fns' English. The stand-in names +// itself so an assertion reads as "the preference decided this". +vi.mock('@/hooks/useDateFormat', () => ({ + useDateFormat: () => ({ + formatDate: (date: string) => `preferred-date:${date}`, + formatMonth: (month: string) => `preferred-month:${month}`, + }), +})); + vi.mock('@/hooks/useExchangeRates', () => ({ useExchangeRates: () => ({ defaultCurrency: 'CAD', @@ -141,11 +174,14 @@ async function selectSecurity(optionLabel: string) { }); } -async function renderAt(view: 'Transactions' | 'Dividends') { +async function renderAt( + view: 'Transactions' | 'Dividends', + transactions: unknown[] = TRANSACTIONS, +) { mockGetSecurities.mockResolvedValue(mockSecurities); mockGetPortfolioSummary.mockResolvedValue({ holdings: mockHoldings }); mockGetSecurityPrices.mockResolvedValue([]); - mockGetTransactions.mockResolvedValue({ data: TRANSACTIONS, pagination: { hasMore: false } }); + mockGetTransactions.mockResolvedValue({ data: transactions, pagination: { hasMore: false } }); mockGetInvestmentAccounts.mockResolvedValue([{ id: 'acc-1', name: 'Brokerage 1', currencyCode: 'USD' }]); mockGetMarketIndexes.mockResolvedValue([]); @@ -241,13 +277,13 @@ describe('SecurityPerformanceReport transactions table (phone wrapped)', () => { // Each caption sits beside the value it names, as its own text node, so a // value lookup still matches the value node. expect(buyRow.textContent).toContain('Account' + 'Brokerage 1'); - expect(buyRow.textContent).toContain('Shares' + '10'); + expect(buyRow.textContent).toContain('Shares' + 'shares:10'); expect(buyRow.textContent).toContain('Price' + '$150.00'); expect(buyRow.textContent).toContain('Total' + '$1500.00'); // The date is the identity and the action is a self-describing pill, so // neither carries a caption. const date = buyRow.querySelector('.col-start-1.row-start-1')!; - expect(date.textContent).toBe('Jun 15, 2024'); + expect(date.textContent).toBe('preferred-date:2024-06-15'); expect(date.querySelector('span')).toBeNull(); const action = buyRow.querySelector('.col-start-2.row-start-1')!; // The pill itself is the only span in the action cell; there is no caption. @@ -320,7 +356,7 @@ describe('SecurityPerformanceReport transactions table (phone wrapped)', () => { (r) => r.querySelector('.col-start-1.row-start-1')?.textContent, ); // Default sort is date descending: June leads March. - expect(dateOrder()).toEqual(['Jun 15, 2024', 'Mar 10, 2024']); + expect(dateOrder()).toEqual(['preferred-date:2024-06-15', 'preferred-date:2024-03-10']); // "Total" in the phone strip is the sixth of the six controls in the first // header row. Addressed by position because the label also appears in the @@ -330,7 +366,7 @@ describe('SecurityPerformanceReport transactions table (phone wrapped)', () => { fireEvent.click(phoneTotal); }); // Ascending by total puts the $900 SELL (March) first. - expect(dateOrder()).toEqual(['Mar 10, 2024', 'Jun 15, 2024']); + expect(dateOrder()).toEqual(['preferred-date:2024-03-10', 'preferred-date:2024-06-15']); }); it('leaves the rows inert: the card is a layout, not a new affordance', async () => { @@ -386,7 +422,7 @@ describe('SecurityPerformanceReport dividends table (phone wrapped)', () => { expect(row.textContent).toContain('Account' + 'Brokerage 1'); expect(row.textContent).toContain('Amount' + '$50.00'); const date = row.querySelector('.col-start-1.row-start-1')!; - expect(date.textContent).toBe('May 1, 2024'); + expect(date.textContent).toBe('preferred-date:2024-05-01'); expect(date.querySelector('span')).toBeNull(); const type = row.querySelector('.col-start-2.row-start-2')!; expect(type.querySelectorAll('span')).toHaveLength(1); @@ -429,6 +465,9 @@ describe('SecurityPerformanceReport dividends table (phone wrapped)', () => { // each states its column index. expect(label.getAttribute('colspan')).toBe('3'); expect(label.getAttribute('aria-colindex')).toBe('1'); + // The span is restated for the same reason the role is: below `sm` the cell + // is not a `table-cell`, so the span carried by the table layout is gone. + expect(label.getAttribute('aria-colspan')).toBe('3'); expect(placement(label)).toBe('c1/r1'); expect(label.textContent).toBe('Total Dividends'); // The total sits beside the label; it names itself from that label, so it @@ -442,3 +481,140 @@ describe('SecurityPerformanceReport dividends table (phone wrapped)', () => { expect(total.textContent).toBe('$80.00'); }); }); + +/** + * The dividend total is money, and money is summed in integer ten-thousandths. + * + * Both the footer and the PDF export accumulated it with + * `dividendTx.reduce((sum, tx) => sum + Math.abs(tx.totalAmount), 0)` -- the + * expression root `CLAUDE.md` gives as its WRONG example -- so the footer + * disagreed with the sum of the figures printed above it. These fixtures are + * chosen for that: added left to right in IEEE-754 they give + * 389.46000000000004, and in `decimal(20,4)` they give exactly 389.46. + */ +const DRIFTING_DIVIDENDS = [136.4, 111.26, 110.66, 1.05, 30.09]; + +/** The rows those amounts arrive as, newest first so the table's sort is stable. */ +function driftingDividendRows(amount: (value: number) => number | string) { + return DRIFTING_DIVIDENDS.map((value, index) => ({ + id: `dd${index}`, + // Distinct days, descending, so the default sort does not have to tie-break. + transactionDate: `2024-05-${String(20 - index).padStart(2, '0')}`, + action: 'DIVIDEND', + quantity: null, + price: null, + totalAmount: amount(value), + securityId: 's-1', + security: { symbol: 'AAPL', name: 'Apple Inc.' }, + accountId: 'acc-1', + })); +} + +describe('SecurityPerformanceReport share counts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the count through the number locale, not as the wire string', async () => { + // `quantity` is declared `number` and a `decimal(20,4)` column crosses the + // wire as `"10.0000"`. The cell was `{tx.quantity ?? '-'}`, so that string + // reached the screen verbatim -- a `.` decimal and four trailing zeros in + // every locale. A residual position is the other half: `0.30000000000000004` + // printed all seventeen digits. + const container = await renderAt('Transactions', [ + { + ...TRANSACTIONS[0], + quantity: '10.0000', + }, + { + ...TRANSACTIONS[1], + quantity: 0.30000000000000004, + }, + ]); + + const shares = Array.from(container.querySelectorAll('tbody tr')).map( + (row) => row.querySelector('.col-start-2.row-start-2')?.textContent, + ); + // The stand-in names the seam, so this fails both on the raw render and on + // any cell that stops going through the formatter. + expect(shares).toEqual(['Sharesshares:10', 'Sharesshares:0.30000000000000004']); + }); + + it('still renders a dash for an absent count rather than a zero', async () => { + // `formatShareQuantity` answers "0" for nullish -- correct for a holdings + // column, wrong for a trade that records no share movement, where the + // figure is not known rather than zero. + const container = await renderAt('Transactions', [ + { ...TRANSACTIONS[0], quantity: null }, + ]); + + const shares = container.querySelector('tbody tr .col-start-2.row-start-2')!; + expect(shares.textContent).toBe('Shares-'); + }); +}); + +describe('SecurityPerformanceReport dividend total', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sums to the exact 4dp figure rather than a float-accumulated one', async () => { + const container = await renderAt('Dividends', driftingDividendRows((v) => v)); + + const total = Array.from(container.querySelectorAll('tfoot td'))[1]; + // Left-to-right float addition renders `$389.46000000000004` here; the + // mocked formatter prints a value money cannot hold rather than rounding it + // away, so this assertion fails on the original expression. + expect(total.textContent).toBe('$389.46'); + }); + + it('agrees with the sum of the amounts printed in the rows', async () => { + const container = await renderAt('Dividends', driftingDividendRows((v) => v)); + + const rowAmounts = Array.from(container.querySelectorAll('tbody tr')).map((row) => { + const cell = row.querySelector('.col-start-2.row-start-1')!; + return Number(cell.textContent!.replace(/^Amount\$/, '')); + }); + expect(rowAmounts).toHaveLength(DRIFTING_DIVIDENDS.length); + + // The claim the footer makes is "this is what the rows above add up to", so + // the figures on screen are what it is compared against -- summed here in + // ten-thousandths, because a float sum of them is the defect. + const expected = + rowAmounts.reduce((units, value) => units + Math.round(value * 10_000), 0) / 10_000; + const total = Array.from(container.querySelectorAll('tfoot td'))[1]; + expect(total.textContent).toBe(`$${expected}`); + }); + + it('coerces the string a decimal(20,4) column arrives as', async () => { + // `totalAmount` is declared `number` and crosses the wire as `"136.4000"`. + // The old expression survived that by accident (`Math.abs` coerces); the + // sum is explicit about it now, so dropping the `Math.abs` for a signed + // total cannot silently start concatenating strings. + const container = await renderAt( + 'Dividends', + driftingDividendRows((v) => v.toFixed(4)), + ); + + const total = Array.from(container.querySelectorAll('tfoot td'))[1]; + expect(total.textContent).toBe('$389.46'); + }); + + it('gives the PDF export the same total as the footer', async () => { + const { exportToPdf } = await import('@/lib/pdf-export'); + const container = await renderAt('Dividends', driftingDividendRows((v) => v)); + + await act(async () => { + fireEvent.click(screen.getByTestId('export-pdf')); + }); + + const call = vi.mocked(exportToPdf).mock.calls.at(-1)![0] as { + tableData?: { totalRow?: (string | number)[] }; + }; + const footer = Array.from(container.querySelectorAll('tfoot td'))[1].textContent; + // One sum, two surfaces: the export used to compute its own copy of the + // same reduce, so the two could drift apart independently. + expect(call.tableData?.totalRow?.at(-1)).toBe(footer); + expect(call.tableData?.totalRow?.at(-1)).toBe('$389.46'); + }); +}); diff --git a/frontend/src/components/reports/SecurityPerformanceReport.tsx b/frontend/src/components/reports/SecurityPerformanceReport.tsx index 53ff6d135a..f6d7ce596a 100644 --- a/frontend/src/components/reports/SecurityPerformanceReport.tsx +++ b/frontend/src/components/reports/SecurityPerformanceReport.tsx @@ -3,7 +3,7 @@ import { useState, useMemo, useRef } from 'react'; import { useTranslations } from 'next-intl'; import { useMainAccountName } from '@/hooks/useMainAccountName'; -import { gainLossColor } from '@/lib/format'; +import { gainLossColor, sumMoney } from '@/lib/format'; import { baseInvestmentAction } from '@/lib/investment-actions'; import { Skeleton } from '@/components/ui/LoadingSkeleton'; import { useReportData } from '@/hooks/useReportData'; @@ -18,13 +18,14 @@ import { ResponsiveContainer, ReferenceLine, } from 'recharts'; -import { format, differenceInDays } from 'date-fns'; +import { differenceInDays } from 'date-fns'; import { chartColors } from '@/lib/chart-colors'; import { investmentsApi } from '@/lib/investments'; import { Security, SecurityPrice, InvestmentTransaction, HoldingWithMarketValue } from '@/types/investment'; import { Account } from '@/types/account'; import { parseLocalDate, type ChartDatePattern } from '@/lib/utils'; import { useChartDateFormat } from '@/hooks/useChartDateFormat'; +import { useDateFormat } from '@/hooks/useDateFormat'; import { useNumberFormat } from '@/hooks/useNumberFormat'; import { useExchangeRates } from '@/hooks/useExchangeRates'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; @@ -98,13 +99,46 @@ interface PriceChartPoint { sellMarker?: number; } +/** + * The dividend total, for the table footer and the PDF export alike. + * + * It accumulated with `sum + Math.abs(tx.totalAmount)` -- the expression root + * `CLAUDE.md` gives as the WRONG example -- so the footer disagreed with the + * sum of the figures printed above it in the last decimal place (five + * plausible dividend amounts reach `389.46000000000004`). `sumMoney` + * accumulates in integer ten-thousandths, the scale the amounts are stored at: + * the same helper the other client-side money totals use, mirroring the + * server's own. + * + * `Number(...)` is explicit rather than incidental. A `decimal(20,4)` crosses + * the wire as a STRING however `InvestmentTransaction` declares it; the old + * expression coerced it only because `Math.abs` does, so a later edit taking + * the abs off for a signed total would have started concatenating strings. + * + * Every row is a known amount here (`totalAmount` is non-nullable on a dividend + * row), so this is a total and not a subtotal. + */ +function sumDividends(rows: InvestmentTransaction[]): number { + return sumMoney(rows.map((tx) => Math.abs(Number(tx.totalAmount)))); +} + export function SecurityPerformanceReport() { const t = useTranslations('reports'); const tc = useTranslations('common'); const ti = useTranslations('marketIndexes'); const formatChartDate = useChartDateFormat(); + // Both history tables print a calendar date and a share count, so both go + // through the preference seams -- `formatDate` for the date's arrangement and + // separators, `formatShareQuantity` for the count's decimal mark and its 8dp + // (a residual position is what that column exists to expose). + const { formatDate } = useDateFormat(); const mainAccountName = useMainAccountName(); - const { formatCurrency: formatCurrencyFull, formatCurrencyAxis, formatSignedPercent } = useNumberFormat(); + const { + formatCurrency: formatCurrencyFull, + formatCurrencyAxis, + formatSignedPercent, + formatShareQuantity, + } = useNumberFormat(); const { defaultCurrency } = useExchangeRates(); const chartRef = useRef(null); // Export handle for the comparison chart, which owns its own data and DOM. @@ -527,16 +561,19 @@ export function SecurityPerformanceReport() { t('securityPerformance.pdfColTotal'), ], rows: tradeTx.map((tx) => [ - format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy'), + formatDate(tx.transactionDate), accountNameById.get(tx.accountId) || '-', tx.action, - tx.quantity != null ? String(tx.quantity) : '-', + // The PDF is a reading surface, so its share count is formatted like + // the table's -- `String(tx.quantity)` printed the raw `"10.0000"` the + // wire carries, in nobody's number locale. + tx.quantity != null ? formatShareQuantity(Number(tx.quantity)) : '-', tx.price != null ? formatCurrencyFull(tx.price, displayCurrency) : '-', formatCurrencyFull(Math.abs(tx.totalAmount), displayCurrency), ]), }; } else { - const totalDividends = dividendTx.reduce((sum, tx) => sum + Math.abs(tx.totalAmount), 0); + const totalDividends = sumDividends(dividendTx); tableData = { headers: [ t('securityPerformance.pdfColDateTx'), @@ -545,7 +582,7 @@ export function SecurityPerformanceReport() { t('securityPerformance.colAmount'), ], rows: dividendTx.map((tx) => [ - format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy'), + formatDate(tx.transactionDate), accountNameById.get(tx.accountId) || '-', tx.action, formatCurrencyFull(Math.abs(tx.totalAmount), displayCurrency), @@ -957,7 +994,7 @@ export function SecurityPerformanceReport() { > {/* Date: the row identity. A formatted date never wraps. */} - {format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy')} + {formatDate(tx.transactionDate)} {tradeColumns.account.label} @@ -977,7 +1014,7 @@ export function SecurityPerformanceReport() { {tradeColumns.shares.label} - {tx.quantity ?? '-'} + {tx.quantity != null ? formatShareQuantity(Number(tx.quantity)) : '-'} {tradeColumns.price.label} @@ -1060,7 +1097,7 @@ export function SecurityPerformanceReport() { > {/* Date: the row identity. A formatted date never wraps. */} - {format(parseLocalDate(tx.transactionDate), 'MMM d, yyyy')} + {formatDate(tx.transactionDate)} {dividendColumns.account.label} @@ -1084,16 +1121,21 @@ export function SecurityPerformanceReport() { {/* "Total Dividends" stands in for the identity; the total sits beside it. The label keeps its desktop `colSpan={3}`, so both cells state `aria-colindex`. The total names - itself from that label, so it carries no caption. */} + itself from that label, so it carries no caption. + + `aria-colspan` restates that `colSpan` for the same + reason `role="cell"` restates the implicit role: below + `sm` the `display` is no longer `table-cell`, and the + span an assistive technology reads from the table's own + layout goes with it. The table declares no + `aria-colcount` -- neither does any converted sibling, + and one here alone would be a convention of one file. */}
+ {t('securityPerformance.totalDividends')} - {formatCurrencyFull( - dividendTx.reduce((sum, tx) => sum + Math.abs(tx.totalAmount), 0), - displayCurrency, - )} + {formatCurrencyFull(sumDividends(dividendTx), displayCurrency)}
- {/* Phone sort strip: the same five controls, wrapped. */} + {/* Phone sort strip: the same five controls, as a wrapped row + of compact chips. Column alignment means nothing here -- + the column header row is hidden and each data row is a grid + -- so every control is left-aligned and self-naming. The + border is what says "tappable": there is no hover on a + touch screen, and the chip's own fill is a shade off the + header band it sits on (this table's `` keeps its + `bg-gray-50` / `dark:bg-gray-900/50`, so the strip is on + that band rather than on the card, as it is on the sibling + tables whose card has no header band). The shared + `PHONE_HEADER_CLASS` keeps those controls identical across + the reports. + + Five chips wrap to three lines at 320px in + `en`/`pl`/`ru`/`id` (114px), four in `de` (148px) and five + in the pseudo-locale (182px) above the first row. That is a + measured cost, not a reason to drop a control: + `reports.bill-payment-history.sort` persists any of the + five, so a field with no control anywhere would leave a + phone POINTING at a sort with no pointer back. */} {sortColumns.map((col) => ( @@ -500,12 +533,19 @@ export function BillPaymentHistoryReport() { + {/* Each row is the click target at every width, so it is also a + KEYBOARD target: `tabIndex` puts it in the tab order and + `activateOnKey` runs the same handler on Enter and Space + (WCAG 2.1.1). Both come from the one shared module rather + than a per-report copy of the handler. */} {sortedBillPayments.map((bp) => ( ` with an `onClick` and + // no `tabIndex` and no `onKeyDown` -- the whole suite was green over a row no + // keyboard user could use, so this case is what fails on that shape. + it("activates a source row from the keyboard, and only where it is clickable", async () => { + mockGetIncomeBySource.mockResolvedValue({ + data: [ + { categoryId: "cat-1", categoryName: "Salary", total: 5000, color: "" }, + { categoryId: "", categoryName: "Other", total: 50, color: "" }, + ], + totalIncome: 5050, + }); + const { container } = render(); + await waitFor(() => expect(screen.getByTestId("toggle-table")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("toggle-table")); + await waitFor(() => expect(container.querySelector('table')).toBeInTheDocument()); + + const rows = Array.from(container.querySelectorAll('tbody tr')); + const clickable = rows.find((r) => r.textContent?.includes("Salary")) as HTMLElement; + const inert = rows.find((r) => r.textContent?.includes("Other")) as HTMLElement; + expect(clickable).toHaveAttribute('tabindex', '0'); + // A row whose click does nothing is not a tab stop either: a focus stop + // that does nothing on Enter is one the reader has to escape. + expect(inert).not.toHaveAttribute('tabindex'); + + const expected = + "/transactions?categoryId=cat-1&startDate=2024-01-01&endDate=2025-01-01"; + fireEvent.keyDown(clickable, { key: 'Enter' }); + expect(mockPush).toHaveBeenCalledWith(expected); + + mockPush.mockClear(); + fireEvent.keyDown(clickable, { key: ' ' }); + expect(mockPush).toHaveBeenCalledWith(expected); + + // A key the row does not claim stays the browser's, and the inert row + // answers no key at all. + mockPush.mockClear(); + fireEvent.keyDown(clickable, { key: 'a' }); + fireEvent.keyDown(inert, { key: 'Enter' }); + expect(mockPush).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/reports/IncomeBySourceReport.tsx b/frontend/src/components/reports/IncomeBySourceReport.tsx index 59c6b93eb2..59efc208d9 100644 --- a/frontend/src/components/reports/IncomeBySourceReport.tsx +++ b/frontend/src/components/reports/IncomeBySourceReport.tsx @@ -26,6 +26,11 @@ import { ChartViewToggle } from '@/components/ui/ChartViewToggle'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; +import { INTERACTIVE_ROW_FOCUS_CLASS, activateOnKey } from '@/components/ui/interactive-row'; import { ChartTooltipPanel } from '@/components/reports/ChartTooltip'; import { ReportError } from '@/components/reports/ReportError'; import { CHART_COLOURS_INCOME } from '@/lib/chart-colours'; @@ -39,31 +44,13 @@ type IncomeSourceSortField = 'name' | 'value' | 'percentage'; type ChartDataItem = ChartDatum & { id: string; colour: string }; /** - * One column of the data table. The three are declared once, as a record over - * the sort field union, and rendered by BOTH header rows -- the column header - * row (from `sm` up) and the phone sort strip -- so the two can never list - * different fields, and a new union member fails `tsc` rather than stranding a - * phone with no control for it. + * One column of the data table, and the record the two header rows are built + * from -- the shared declarations from `ui/Table`, as eleven sibling reports + * use them. The alignment is narrowed to `'right'` because that is the only one + * this table's amount and share columns take. */ -interface SortColumn { - field: IncomeSourceSortField; - label: string; - /** The amount and the share columns are right-aligned on desktop. */ - align?: 'right'; -} - -/** - * The record the two header rows are built from, each key tied to its entry's - * own `field`. A plain `Record` forces an - * entry to EXIST for every union member but lets it name a different one, so - * `percentage: { field: 'value', label: colPercent }` would type-check: two - * controls keyed `value` (a duplicate React key), "% of Total" sorting by - * amount, and "% of Total" unsortable -- none of which a test comparing header - * LABELS can see, because the labels stay right. Here it is a compile error. - */ -type SortColumnsByField = { - [K in IncomeSourceSortField]: SortColumn & { field: K }; -}; +type SortColumn = TableSortColumn; +type SortColumnsByField = TableSortColumnsByField; // Today's header cell, unchanged (this report's header carries no // `tracking-wider`, so neither does this constant -- the `sm`-and-up output @@ -362,12 +349,26 @@ export function IncomeBySourceReport() { {sortedTableData.map((item) => { const percentage = totalIncome > 0 ? (item.value / totalIncome) * 100 : 0; + // The row is the click target where it names a category, so + // it is also a KEYBOARD target there (WCAG 2.1.1) -- + // `tabIndex`, the focus ring and the key handler only when + // the click does something, because a focus stop that does + // nothing on Enter is a tab stop the reader has to escape. + // The ring and the handler come from the one shared module + // rather than a per-report copy. + const categoryId = item.id; return ( item.id && handleCategoryClick(item.id)} + tabIndex={categoryId ? 0 : undefined} + className={`grid grid-cols-2 items-start gap-x-3 gap-y-1.5 px-4 py-3 ${categoryId ? `cursor-pointer ${INTERACTIVE_ROW_FOCUS_CLASS}` : ''} hover:bg-gray-50 dark:hover:bg-gray-700/50 sm:table-row sm:p-0`} + onClick={() => categoryId && handleCategoryClick(categoryId)} + onKeyDown={ + categoryId + ? activateOnKey(() => handleCategoryClick(categoryId)) + : undefined + } > {/* The identity; the `` around it stays the click target at every width. The colour dot and the name diff --git a/frontend/src/components/reports/IncomeVsExpensesReport.test.tsx b/frontend/src/components/reports/IncomeVsExpensesReport.test.tsx index e3cdcc2280..066ef8faa7 100644 --- a/frontend/src/components/reports/IncomeVsExpensesReport.test.tsx +++ b/frontend/src/components/reports/IncomeVsExpensesReport.test.tsx @@ -291,4 +291,35 @@ describe("IncomeVsExpensesReport", () => { await act(async () => { fireEvent.click(rows[0]); }); await act(async () => { fireEvent.click(screen.getByTestId("export-csv")); }); }); + + // The row is the click target, so it has to be reachable and operable from + // the keyboard as well (WCAG 2.1.1). Before the fix this row was a + // `cursor-pointer` `` with an `onClick` and no `tabIndex` and no + // `onKeyDown` -- the whole suite was green over a row no keyboard user could + // use, so this case is what fails on that shape. + it("activates a month row from the keyboard", async () => { + mockGetIncomeVsExpenses.mockResolvedValue({ + data: [{ month: "2024-01", income: 5000, expenses: 3000, net: 2000 }], + totals: { income: 5000, expenses: 3000 }, + }); + const { container } = render(); + await waitFor(() => expect(screen.getByTestId("toggle-table")).toBeInTheDocument()); + await act(async () => { fireEvent.click(screen.getByTestId("toggle-table")); }); + await waitFor(() => expect(container.querySelector('table')).toBeInTheDocument()); + const row = container.querySelector('tbody tr') as HTMLElement; + expect(row).toHaveAttribute('tabindex', '0'); + + const expected = "/transactions?startDate=2024-01-01&endDate=2024-01-31"; + await act(async () => { fireEvent.keyDown(row, { key: 'Enter' }); }); + expect(mockPush).toHaveBeenCalledWith(expected); + + mockPush.mockClear(); + await act(async () => { fireEvent.keyDown(row, { key: ' ' }); }); + expect(mockPush).toHaveBeenCalledWith(expected); + + // A key the row does not claim stays the browser's. + mockPush.mockClear(); + await act(async () => { fireEvent.keyDown(row, { key: 'a' }); }); + expect(mockPush).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/reports/IncomeVsExpensesReport.tsx b/frontend/src/components/reports/IncomeVsExpensesReport.tsx index c8f675857e..63379061c0 100644 --- a/frontend/src/components/reports/IncomeVsExpensesReport.tsx +++ b/frontend/src/components/reports/IncomeVsExpensesReport.tsx @@ -1,8 +1,12 @@ "use client"; import { useState, useMemo, useRef } from "react"; -import { CellLabel, PHONE_HEADER_CLASS } from "@/components/ui/Table"; -import type { SortColumn as TableSortColumn } from '@/components/ui/Table'; +import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from "@/components/ui/Table"; +import type { + SortColumn as TableSortColumn, + SortColumnsByField as TableSortColumnsByField, +} from '@/components/ui/Table'; +import { INTERACTIVE_ROW_FOCUS_CLASS, activateOnKey } from '@/components/ui/interactive-row'; import { Skeleton } from '@/components/ui/LoadingSkeleton'; import { useRouter } from "next/navigation"; import { @@ -45,12 +49,6 @@ type SortColumn = TableSortColumn; const HEADER_CLASS = 'px-4 py-3 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase'; -// The same sort controls in the phone strip: a wrapped row of compact chips. -// Column alignment means nothing there -- the column header row is hidden and -// each data row is a grid -- so every control is left-aligned and self-naming. -// The border and card background are what say "tappable": there is no hover on -// a touch screen, and without them the strip reads as another row of the -// captions the cells below carry. // A money cell inside a wrapped card: no padding of its own below `sm` (the row // supplies it and the grid does the spacing), the table cell's own padding from // `sm` up. Smaller type on phones so a six-figure amount still fits a @@ -179,7 +177,15 @@ export function IncomeVsExpensesReport() { // header rows render is DERIVED from the record, never re-listed beside it: // a hand-written list next to an exhaustive record is not exhaustive. The // record's declaration order is the column order. - const columns: Record = { + // The key is tied to the entry's own `field`, which a plain + // `Record` does not do: that forces an + // entry to EXIST for every member of the union but lets it name a different + // one, so `savingsRate: { field: 'savings', ... }` would type-check. Both + // header rows would then render two controls keyed `savings` (a duplicate + // React key), tapping "Savings Rate" would sort by Savings, and "Savings + // Rate" would be unsortable -- none of which a test comparing header LABELS + // can see, because the labels stay right. Here it is a compile error. + const columns: TableSortColumnsByField = { name: { field: 'name', label: t('incomeVsExpenses.colMonth') }, income: { field: 'income', label: t('incomeVsExpenses.colIncome'), align: 'right' }, expenses: { field: 'expenses', label: t('incomeVsExpenses.colExpenses'), align: 'right' }, @@ -188,6 +194,11 @@ export function IncomeVsExpensesReport() { }; const sortColumns: readonly SortColumn[] = Object.values(columns); + // The row's primary action, named once so the pointer and the keyboard cannot + // come to run two slightly different pushes. + const openMonth = (row: ChartDataItem) => + router.push(`/transactions?startDate=${row.monthStart}&endDate=${row.monthEnd}`); + const handleExportPdf = async () => { const { exportToPdf } = await import("@/lib/pdf-export"); await exportToPdf({ @@ -343,7 +354,14 @@ export function IncomeVsExpensesReport() { table semantics, and these put them back (inert from `sm` up). */}
{ rows.forEach((tr) => fireEvent.click(tr)); fireEvent.click(screen.getByTestId("export-csv")); }); + + // A source row is the click target where it names a category, so it has to be + // reachable and operable from the keyboard there as well (WCAG 2.1.1). + // Before the fix this row was a `cursor-pointer` `
- {/* Phone sort strip: the same five controls, wrapped. */} + {/* Phone sort strip: the same five controls, as a wrapped + row of compact chips. Column alignment means nothing here + -- the column header row is hidden and each data row is a + grid -- so every control is left-aligned and self-naming. + The border and card background are what say "tappable": + there is no hover on a touch screen, and without them the + strip reads as another row of the captions the cells below + carry. */} {sortColumns.map((col) => ( @@ -379,22 +397,20 @@ export function IncomeVsExpensesReport() { - router.push( - `/transactions?startDate=${row.monthStart}&endDate=${row.monthEnd}`, - ) - } + tabIndex={0} + className={`grid grid-cols-3 items-start gap-x-3 gap-y-1.5 px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 ${INTERACTIVE_ROW_FOCUS_CLASS} sm:table-row sm:p-0`} + onClick={() => openMonth(row)} + onKeyDown={activateOnKey(() => openMonth(row))} > {/* Savings takes the middle of line 1 beside the month: @@ -402,7 +418,7 @@ export function IncomeVsExpensesReport() { {/* The rate spans the first two tracks so its caption -- @@ -411,7 +427,7 @@ export function IncomeVsExpensesReport() { @@ -424,23 +440,23 @@ export function IncomeVsExpensesReport() { diff --git a/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx b/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx index 1eb703ed41..fe2ce0f23a 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.mobileWrapped.test.tsx @@ -460,7 +460,10 @@ describe('RecurringExpensesReport (phone wrapped rows)', () => { // added to the table without appearing in the export (the strings stay the // catalogue's own `csvCol*` keys, which happen to match the headers). expect(headers).toEqual(EXPECTED_LABELS); - // The export is the SERVER's order, not the table's sort. + // The export is the SERVER's order, not the table's sort. The date is ISO + // because a CSV is machine-read: `preferred-date:...` in this position is + // the defect (two readers, two different ambiguous files), and the PDF + // assertion below holds the other half of the split. expect(rows[0]).toEqual([ 'Water Utility', 'Utilities', @@ -468,7 +471,7 @@ describe('RecurringExpensesReport (phone wrapped rows)', () => { 6, 50, 300, - 'preferred-date:2024-06-15', + '2024-06-15', ]); expect(rows[1][0]).toBe('Zebra Market'); }); diff --git a/frontend/src/components/reports/RecurringExpensesReport.test.tsx b/frontend/src/components/reports/RecurringExpensesReport.test.tsx index 5d947159de..9298fec8bd 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.test.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.test.tsx @@ -254,6 +254,97 @@ describe("RecurringExpensesReport", () => { expect(screen.getByText("Occasional")).toHaveClass("bg-gray-100"); }); + // During a rolling deploy an older backend answers with a frequency code this + // build has no entry for. `FREQUENCY_BADGE_CLASS[code]` was `undefined` -- + // which an interpolated className carries to the DOM as the literal class + // name `undefined` -- and the catalogue lookup missed, rendering the raw + // `reports.recurringExpenses.frequency.` key path on screen. + it("falls back for a frequency code this build does not know", async () => { + mockGetRecurringExpenses.mockResolvedValue({ + data: [ + { + payeeId: "p-1", + payeeName: "Legacy Sub", + categoryName: "Subscriptions", + // What an older backend sent before the codes were uppercased. + frequency: "Monthly" as unknown as "MONTHLY", + occurrences: 6, + averageAmount: 10, + totalAmount: 60, + lastTransactionDate: "2025-01-15", + }, + ], + summary: { uniquePayees: 1, totalRecurring: 60, monthlyEstimate: 10 }, + }); + render(); + await waitFor(() => expect(screen.getByText("Legacy Sub")).toBeInTheDocument()); + // The code itself, never the key path. + const badge = screen.getByText("Monthly"); + expect(badge.textContent).not.toContain("recurringExpenses.frequency"); + // IRREGULAR's neutral classes, never the literal `undefined`. + expect(badge).toHaveClass("bg-gray-100"); + expect(badge.className).not.toContain("undefined"); + }); + + // The Frequency column is ORDINAL: sorting it on the localized LABEL gives + // "Every 2 Weeks, Irregular, Monthly, Occasional, Weekly" ascending -- + // alphabetical, and a different order in every language. Ascending must be + // most frequent first, which is `RECURRING_EXPENSE_FREQUENCIES`' own order. + it("sorts the frequency column by frequency, not by its label's alphabet", async () => { + const row = (payeeName: string, frequency: string) => ({ + payeeId: null, + payeeName, + categoryName: null, + frequency: frequency as unknown as "MONTHLY", + occurrences: 6, + averageAmount: 10, + totalAmount: 60, + lastTransactionDate: "2025-01-15", + }); + mockGetRecurringExpenses.mockResolvedValue({ + // Deliberately in an order neither the frequency order nor the alphabet. + data: [ + row("Occasional Co", "OCCASIONAL"), + row("Weekly Co", "WEEKLY"), + row("Irregular Co", "IRREGULAR"), + row("Monthly Co", "MONTHLY"), + row("Biweekly Co", "BIWEEKLY"), + ], + summary: { uniquePayees: 5, totalRecurring: 300, monthlyEstimate: 50 }, + }); + const { container } = render(); + await waitFor(() => expect(screen.getByText("Weekly Co")).toBeInTheDocument()); + + // The Frequency header is the third column of the column header row. + const columnHeader = container.querySelectorAll("table thead tr")[1]; + const frequencyHeader = columnHeader.querySelectorAll("th")[2]; + fireEvent.click(frequencyHeader); + + const payees = Array.from(container.querySelectorAll("tbody tr")).map( + (tr) => tr.querySelector("td")?.textContent, + ); + expect(payees).toEqual([ + "Weekly Co", + "Biweekly Co", + "Monthly Co", + "Occasional Co", + "Irregular Co", + ]); + + // Descending is the same order reversed, not another alphabet. + fireEvent.click(frequencyHeader); + const reversed = Array.from(container.querySelectorAll("tbody tr")).map( + (tr) => tr.querySelector("td")?.textContent, + ); + expect(reversed).toEqual([ + "Irregular Co", + "Occasional Co", + "Monthly Co", + "Biweekly Co", + "Weekly Co", + ]); + }); + it("renders minimum occurrences selector", async () => { mockGetRecurringExpenses.mockResolvedValue({ data: [], @@ -314,6 +405,50 @@ describe("RecurringExpensesReport", () => { await waitFor(() => expect(screen.getByText("Unknown Store")).toBeInTheDocument()); fireEvent.click(screen.getByText("Unknown Store")); expect(mockPush).not.toHaveBeenCalled(); + // ...and a row whose click does nothing is not a tab stop either: a focus + // stop that does nothing on Enter is one the reader has to escape. + expect(screen.getByText("Unknown Store").closest("tr")).not.toHaveAttribute( + "tabindex", + ); + }); + + // The row is the click target where it names a payee, so it has to be + // reachable and operable from the keyboard there as well (WCAG 2.1.1). + // Before the fix this row was a `cursor-pointer` `` with an `onClick` and + // no `tabIndex` and no `onKeyDown` -- the whole suite was green over a row no + // keyboard user could use, so this case is what fails on that shape. + it("activates a payee row from the keyboard", async () => { + mockGetRecurringExpenses.mockResolvedValue({ + data: [ + { + payeeId: "p-1", + payeeName: "Netflix", + categoryName: "Entertainment", + frequency: "MONTHLY", + occurrences: 6, + averageAmount: 15.99, + totalAmount: 95.94, + lastTransactionDate: "2025-01-15", + }, + ], + summary: { uniquePayees: 1, totalRecurring: 95.94, monthlyEstimate: 15.99 }, + }); + render(); + await waitFor(() => expect(screen.getByText("Netflix")).toBeInTheDocument()); + const row = screen.getByText("Netflix").closest("tr") as HTMLElement; + expect(row).toHaveAttribute("tabindex", "0"); + + fireEvent.keyDown(row, { key: "Enter" }); + expect(mockPush).toHaveBeenCalledWith("/transactions?payeeId=p-1"); + + mockPush.mockClear(); + fireEvent.keyDown(row, { key: " " }); + expect(mockPush).toHaveBeenCalledWith("/transactions?payeeId=p-1"); + + // A key the row does not claim stays the browser's. + mockPush.mockClear(); + fireEvent.keyDown(row, { key: "a" }); + expect(mockPush).not.toHaveBeenCalled(); }); it("exports CSV when export button clicked", async () => { @@ -345,9 +480,47 @@ describe("RecurringExpensesReport", () => { "Uncategorized", "Monthly", ]); - expect(mockExportToCsv.mock.calls[0][2][0][6]).toBe( + // A CSV is machine-read, so the Last Paid column is ISO and NOT the + // reader's preferred format: two readers exporting the same rows must get + // one file, and a localized date is ambiguous and sorts lexicographically + // wrong. `preferred-date:...` here would be asserting that defect; the PDF + // keeps the reader's format, which the mobileWrapped suite pins. + expect(mockExportToCsv.mock.calls[0][2][0][6]).toBe("2025-01-15"); + }); + + it("writes the reader's own date format to the PDF, where the CSV writes ISO", async () => { + mockGetRecurringExpenses.mockResolvedValue({ + data: [ + { + payeeId: "p-1", + payeeName: "Netflix", + categoryName: null, + frequency: "MONTHLY", + occurrences: 6, + averageAmount: 15.99, + totalAmount: 95.94, + lastTransactionDate: "2025-01-15", + }, + ], + summary: { uniquePayees: 1, totalRecurring: 95.94, monthlyEstimate: 15.99 }, + }); + render(); + await waitFor(() => expect(screen.getByTestId("export-pdf")).toBeInTheDocument()); + await act(async () => { + fireEvent.click(screen.getByTestId("export-pdf")); + }); + await waitFor(() => expect(mockExportToPdf).toHaveBeenCalledTimes(1)); + // Same record, same columns, same order as the CSV -- a PDF is a READING + // surface, so the one cell whose rendering depends on its reader differs. + expect(mockExportToPdf.mock.calls[0][0].tableData.rows[0]).toEqual([ + "Netflix", + "Uncategorized", + "Monthly", + 6, + 15.99, + 95.94, "preferred-date:2025-01-15", - ); + ]); }); it("changes min occurrences when selector changes", async () => { diff --git a/frontend/src/components/reports/RecurringExpensesReport.tsx b/frontend/src/components/reports/RecurringExpensesReport.tsx index 99ae0c4e5f..d643b5d19d 100644 --- a/frontend/src/components/reports/RecurringExpensesReport.tsx +++ b/frontend/src/components/reports/RecurringExpensesReport.tsx @@ -2,6 +2,8 @@ import { useCallback, useState, useMemo, useRef } from 'react'; import { useTranslations } from 'next-intl'; +import { format } from 'date-fns'; +import { parseLocalDate } from '@/lib/utils'; import { Skeleton } from '@/components/ui/LoadingSkeleton'; import { useRouter } from 'next/navigation'; import { @@ -13,6 +15,7 @@ import { } from 'recharts'; import { builtInReportsApi } from '@/lib/built-in-reports'; import { + RECURRING_EXPENSE_FREQUENCIES, RecurringExpenseItem, RecurringExpenseFrequency, } from '@/types/built-in-reports'; @@ -24,6 +27,7 @@ import { exportToCsv } from '@/lib/csv-export'; import { ExportDropdown } from '@/components/ui/ExportDropdown'; import { SortableHeader } from '@/components/ui/SortableHeader'; import { CAPTION_CLASS, CellLabel, PHONE_HEADER_CLASS } from '@/components/ui/Table'; +import { INTERACTIVE_ROW_FOCUS_CLASS, activateOnKey } from '@/components/ui/interactive-row'; import type { SortColumn as TableSortColumn, SortColumnsByField as TableSortColumnsByField, @@ -58,9 +62,18 @@ interface SortColumn extends TableSortColumn { * count at seven and against the `
{row.fullName} - {t('incomeVsExpenses.colIncome')} + {t('incomeVsExpenses.colIncome')} {formatCurrency(row.Income)} - {t('incomeVsExpenses.colExpenses')} + {t('incomeVsExpenses.colExpenses')} {formatCurrency(row.Expenses)} = 0 ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400'} ${MONEY_CELL}`} > - {t('incomeVsExpenses.colSavings')} + {t('incomeVsExpenses.colSavings')} {formatCurrency(row.Savings)} = 0 ? 'text-purple-600 dark:text-purple-400' : 'text-orange-600 dark:text-orange-400'} ${MONEY_CELL}`} > - {t('incomeVsExpenses.colSavingsRate')} + {t('incomeVsExpenses.colSavingsRate')} {formatPercentTrimmed(row.SavingsRate)}
{t('incomeVsExpenses.total')} - {t('incomeVsExpenses.colIncome')} + {t('incomeVsExpenses.colIncome')} {formatCurrency(totals.totalIncome)} - {t('incomeVsExpenses.colExpenses')} + {t('incomeVsExpenses.colExpenses')} {formatCurrency(totals.totalExpenses)} = 0 ? 'text-blue-600 dark:text-blue-400' : 'text-orange-600 dark:text-orange-400'} ${MONEY_CELL}`} > - {t('incomeVsExpenses.colSavings')} + {t('incomeVsExpenses.colSavings')} {formatCurrency(totals.totalSavings)} = 0 ? 'text-purple-600 dark:text-purple-400' : 'text-orange-600 dark:text-orange-400'} ${MONEY_CELL}`} > - {t('incomeVsExpenses.colSavingsRate')} + {t('incomeVsExpenses.colSavingsRate')} {formatPercent(totals.savingsRate, 1)}
` count of each header row. */ csvLabel: string; - csvValue: (expense: RecurringExpenseItem) => string | number; + /** + * This column's exported cell, for the surface it is being written to. A CSV + * is read by a MACHINE and a PDF by a person, so a date is ISO in the one and + * the reader's own format in the other; a column whose cell does not depend + * on the surface simply ignores the argument. + */ + csvValue: (expense: RecurringExpenseItem, surface: ExportSurface) => string | number; } +/** Which surface an export row is being built for; see `csvValue`. */ +type ExportSurface = 'csv' | 'pdf'; + /** * The record the two header rows are built from, keyed by sort field. * @@ -78,21 +91,6 @@ type SortColumnsByField = TableSortColumnsByField