diff --git a/client/src/components/analytics-module/Analytics.test.tsx b/client/src/components/analytics-module/Analytics.test.tsx index aee2b8a..f184da9 100644 --- a/client/src/components/analytics-module/Analytics.test.tsx +++ b/client/src/components/analytics-module/Analytics.test.tsx @@ -215,11 +215,15 @@ describe('Analytics Projected mode', () => { expect(comparison().unavailableReason).toBeNull() expect(comparison().points[0]).toMatchObject({ selected: 100, previous: 100 }) expect(screen.getByTestId('cash-trend')).not.toHaveAttribute('data-disabled-reason') + const points = JSON.parse(screen.getByTestId('cash-trend').getAttribute('data-points')!) + expect(points[0]).toMatchObject({ date: '2026-09-01', balance: 100 }) + expect(points.at(-1)).toMatchObject({ date: '2026-09-15', balance: 100 }) + expect(points).toHaveLength(15) fireEvent.click(screen.getByRole('button', { name: 'Projected (1)' })) expect(screen.getByTestId('cash-trend')).toHaveAttribute('data-disabled-reason', 'Switch to Actual to compare previous-month balances.') expect(comparison().points[0]).toMatchObject({ selected: 100, previous: 100 }) fireEvent.click(screen.getByRole('button', { name: 'Year' })) - expect(screen.getByTestId('cash-trend')).not.toHaveAttribute('data-comparison') + expect(comparison()).toMatchObject({ unit: 'year', selectedStart: '2026-01-01', previousStart: '2025-01-01', unavailableReason: 'Not enough cash history to cover the full previous year.' }) fireEvent.click(screen.getByRole('button', { name: 'All Time' })) expect(screen.getByTestId('cash-trend')).not.toHaveAttribute('data-comparison') fireEvent.click(screen.getByRole('button', { name: 'Month' })) diff --git a/client/src/components/analytics-module/Analytics.tsx b/client/src/components/analytics-module/Analytics.tsx index ca8e3ae..b62752e 100644 --- a/client/src/components/analytics-module/Analytics.tsx +++ b/client/src/components/analytics-module/Analytics.tsx @@ -32,7 +32,8 @@ import { MoneyMapChart } from './MoneyMapChart' import { TopExpensesList } from './TopExpensesList' import type { Transaction, Category, Account, TimePeriod, FinancialOutlookSnapshot, ChartDataPoint, TrendDataPoint } from './types' import { isUpcomingProjectionTransaction } from '../../lib/transaction-review' -import { buildCashBalanceMonthComparison } from './cash-balance-comparison' +import { buildCashBalanceComparison } from './cash-balance-comparison' +import { clipBalancesToPeriod } from './cash-balance-trend' type AnalyticsProps = { transactions: Transaction[] @@ -346,26 +347,12 @@ export function Analytics({ runningBalance -= transactionsByDate[date] }) - const rawData = Object.entries(dateBalances) - .map(([date, balance]) => ({ - date, - formattedDate: format(parseISO(date), 'MMM d'), - balance - })) - .filter(d => { - const txDate = parseISO(d.date) - switch (period) { - case 'month': - case 'year': - return isWithinInterval(txDate, { - start: parseISO(customDateRange.startDate), - end: parseISO(customDateRange.endDate) - }) - default: - return true - } - }) - .sort((a, b) => parseISO(a.date).getTime() - parseISO(b.date).getTime()) + const balances = Object.entries(dateBalances) + .map(([date, balance]) => ({ date, balance })) + .sort((a, b) => a.date.localeCompare(b.date)) + const rawData = (period === 'allTime' ? balances : clipBalancesToPeriod(balances, { + start: customDateRange.startDate, end: customDateRange.endDate, daily: period === 'month', + })).map(point => ({ ...point, formattedDate: format(parseISO(point.date), 'MMM d') })) // Calculate EMA (Exponential Moving Average) as a smoothed baseline if (rawData.length === 0) return rawData @@ -380,16 +367,16 @@ export function Analytics({ }, [conversionsAvailable, transactionsForAnalytics, accounts, period, exchangeRates, masterCurrency, customDateRange]) const today = format(new Date(), 'yyyy-MM-dd') - const cashBalanceComparison = useMemo(() => period === 'month' ? buildCashBalanceMonthComparison({ - accounts, transactions, selectedMonth: customDateRange.startDate, today, masterCurrency, exchangeRates, - }) : undefined, [period, accounts, transactions, customDateRange.startDate, today, masterCurrency, exchangeRates]) - const cashComparisonDisabledReason = isProjected ? 'Switch to Actual to compare previous-month balances.' + const cashBalanceComparison = useMemo(() => period === 'allTime' ? undefined : buildCashBalanceComparison({ + accounts, transactions, unit: period, selectedDate: customDateRange.startDate, today, masterCurrency, exchangeRates, + }), [period, accounts, transactions, customDateRange.startDate, today, masterCurrency, exchangeRates]) + const cashComparisonDisabledReason = isProjected ? `Switch to Actual to compare previous-${period === 'year' ? 'year' : 'month'} balances.` : !comparisonReady ? 'Comparison is unavailable until account and transaction history refresh succeeds.' : undefined // A month with no movements still has a balance history. The existing sparse // chart can be empty or have only today's point in those months. - const quietMonthBalanceData = cashBalanceComparison?.points.flatMap(point => point.selected !== null && point.selectedDate - ? [{ date: point.selectedDate, formattedDate: format(parseISO(point.selectedDate), 'MMM d'), balance: point.selected, smoothed: point.selected }] : []) ?? [] + const quietMonthBalanceData = cashBalanceComparison?.unit !== 'month' ? [] : cashBalanceComparison.points.flatMap(point => point.selected !== null && point.selectedDate + ? [{ date: point.selectedDate, formattedDate: format(parseISO(point.selectedDate), 'MMM d'), balance: point.selected, smoothed: point.selected }] : []) const displayedCashBalanceData = !isProjected && cashBalanceTrendData.length < 2 && quietMonthBalanceData.length > 1 ? quietMonthBalanceData : cashBalanceTrendData diff --git a/client/src/components/analytics-module/CashBalanceComparisonChart.tsx b/client/src/components/analytics-module/CashBalanceComparisonChart.tsx index 16cf349..2890697 100644 --- a/client/src/components/analytics-module/CashBalanceComparisonChart.tsx +++ b/client/src/components/analytics-module/CashBalanceComparisonChart.tsx @@ -1,11 +1,13 @@ import { useId } from 'react' +import type { ReactNode } from 'react' import { format, parseISO } from 'date-fns' import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' import { usePrivacy } from '../../context/PrivacyContext' +import { cn } from '../../lib/utils' import { comparisonDelta } from './cash-balance-comparison' -import type { CashBalanceComparison, CashBalanceComparisonPoint } from './cash-balance-comparison' +import type { CashBalanceComparison, CashBalanceComparisonPoint, CashBalanceComparisonUnit } from './cash-balance-comparison' -function ComparisonReadout({ point, currency }: { point: CashBalanceComparisonPoint; currency: string }) { +function ComparisonReadout({ point, currency, unit }: { point: CashBalanceComparisonPoint; currency: string; unit: CashBalanceComparisonUnit }) { const { privacyMode } = usePrivacy() const hidden = privacyMode === 'hidden' const amount = (value: number, signed = false) => hidden ? '••••••' : `${signed && value > 0 ? '+' : ''}${value.toLocaleString('hu-HU', { maximumFractionDigits: 0 })} ${currency}` @@ -28,15 +30,25 @@ function ComparisonReadout({ point, currency }: { point: CashBalanceComparisonPo Difference: {amount(delta.amount, true)}{!hidden && delta.percentage !== null ? ` (${delta.percentage > 0 ? '+' : ''}${delta.percentage.toFixed(1)}%)` : ''}

} - {point.selected === null && point.previous !== null &&

Only the previous month has an actual balance on this day.

} - {point.previous === null && point.selected !== null &&

The previous month has no matching calendar day.

} + {point.selected === null && point.previous !== null &&

Only the previous {unit} has an actual balance on this day.

} + {point.previous === null && point.selected !== null &&

The previous {unit} has no matching calendar day.

} {point.selected === null && point.previous === null &&

No actual balance is available on this day yet.

} ) } -export function CashBalanceComparisonChart({ comparison, currency }: { comparison: CashBalanceComparison; currency: string }) { +// Mirrors InteractiveAreaChart's readout, plot and legend rows (and the +// caller's plot height), so switching views keeps the card and its toggles still. +export function CashBalanceComparisonChart({ comparison, currency, title, controls, footer, className }: { + comparison: CashBalanceComparison + currency: string + title: string + controls?: ReactNode + footer?: ReactNode + className?: string +}) { const { privacyMode } = usePrivacy() + const hidden = privacyMode === 'hidden' const gradientId = useId() const common = comparison.points.filter(point => point.selected !== null && point.previous !== null) const latestCommon = common[common.length - 1] @@ -45,22 +57,39 @@ export function CashBalanceComparisonChart({ comparison, currency }: { compariso const max = Math.max(...values) const padding = Math.max((max - min) * 0.15, Math.max(Math.abs(min), Math.abs(max)) * 0.05, 1) const delta = latestCommon ? comparisonDelta(latestCommon.selected, latestCommon.previous) : null - const selectedLabel = format(parseISO(comparison.selectedMonth), 'MMMM yyyy') - const previousLabel = format(parseISO(comparison.previousMonth), 'MMMM yyyy') + const yearly = comparison.unit === 'year' + const labelFormat = yearly ? 'yyyy' : 'MMMM yyyy' + const selectedLabel = format(parseISO(comparison.selectedStart), labelFormat) + const previousLabel = format(parseISO(comparison.previousStart), labelFormat) + // Yearly slots are month/day positions; label every other month start. + const monthStarts = comparison.points.filter(point => (point.selectedDate ?? point.previousDate)?.endsWith('-01')) + const ticks = yearly ? monthStarts.filter((_, index) => index % 2 === 0).map(point => point.day) : [1, 5, 10, 15, 20, 25, comparison.points.length] + const tickLabel = (day: number) => { + if (!yearly) return `${day}` + const point = comparison.points[day - 1] + const date = point?.selectedDate ?? point?.previousDate + return date ? format(parseISO(date), 'MMM') : '' + } return ( -
-
- - +
+
+
+

{title}

+

+ {!delta ? '–' : hidden ? '••••••' : `${delta.amount > 0 ? '+' : ''}${delta.amount.toLocaleString('hu-HU', { maximumFractionDigits: 0 })} ${currency}`} +

+ {/* One line, like the balance readout, so the card height never changes. */} +

+ {delta && !hidden && delta.percentage !== null && = 0 ? 'easyui-chart-positive' : 'easyui-chart-negative'}>{delta.percentage > 0 ? '+' : ''}{delta.percentage.toFixed(1)}% } + {delta && latestCommon + ? `${format(parseISO(latestCommon.selectedDate!), 'MMM d')} vs ${format(parseISO(latestCommon.previousDate!), 'MMM d')} · latest shared day` + : 'No shared day to compare yet'} +

+
+ {controls}
- {delta && latestCommon &&
-

- {privacyMode === 'hidden' ? '••••••' : `${delta.amount > 0 ? '+' : ''}${delta.amount.toLocaleString('hu-HU', { maximumFractionDigits: 0 })} ${currency}${delta.percentage !== null ? ` (${delta.percentage > 0 ? '+' : ''}${delta.percentage.toFixed(1)}%)` : ''}`} -

-

{format(parseISO(latestCommon.selectedDate!), 'MMM d, yyyy')} vs {format(parseISO(latestCommon.previousDate!), 'MMM d, yyyy')} · latest shared day

-
} -
+
@@ -68,18 +97,26 @@ export function CashBalanceComparisonChart({ comparison, currency }: { compariso - `${day}`} stroke="hsl(var(--muted-foreground))" fontSize={10} tickLine={false} axisLine={false} /> + privacyMode === 'hidden' ? '••••' : new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(value)} /> + tickFormatter={value => hidden ? '••••' : new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(value)} /> { const activePoint = payload?.[0]?.payload as CashBalanceComparisonPoint | undefined - return active && activePoint ?
: null + return active && activePoint ?
: null }} /> - - + {/* A year has too many days for point markers. */} + +
+
+
+ + +
+ {footer &&
{footer}
} +
) } diff --git a/client/src/components/analytics-module/NetWorthTrendChart.test.tsx b/client/src/components/analytics-module/NetWorthTrendChart.test.tsx index 04d7184..45f1fa7 100644 --- a/client/src/components/analytics-module/NetWorthTrendChart.test.tsx +++ b/client/src/components/analytics-module/NetWorthTrendChart.test.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' import { fireEvent, render, screen, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { NetWorthTrendChart } from './NetWorthTrendChart' -import { buildCashBalanceMonthComparison } from './cash-balance-comparison' +import { buildCashBalanceMonthComparison, buildCashBalanceYearComparison } from './cash-balance-comparison' import type { CashBalanceComparisonPoint } from './cash-balance-comparison' const state = vi.hoisted(() => ({ privacyMode: 'visible', tooltipDay: 2, points: [] as CashBalanceComparisonPoint[] })) @@ -16,7 +16,7 @@ vi.mock('recharts', () => ({ }, Area: ({ dataKey, strokeDasharray }: { dataKey: string; strokeDasharray?: string }) =>
, Line: ({ dataKey, strokeDasharray }: { dataKey: string; strokeDasharray?: string }) =>
, - XAxis: () => null, + XAxis: ({ ticks, tickFormatter }: { ticks: number[]; tickFormatter: (value: number) => string }) =>
{ticks.map(tickFormatter).join(',')}
, YAxis: ({ tickFormatter }: { tickFormatter: (value: number) => string }) =>
{tickFormatter(1000)}
, CartesianGrid: () => null, Tooltip: ({ content }: { content: (props: unknown) => ReactNode }) =>
{content({ @@ -31,7 +31,7 @@ const comparison = () => buildCashBalanceMonthComparison({ selectedMonth: '2026-10-01', today: '2026-10-13', masterCurrency: 'HUF', exchangeRates: {}, }) const renderChart = () => render() -const toggle = () => fireEvent.click(screen.getByRole('button', { name: /^(Compare previous month|Hide comparison)$/ })) +const toggle = () => fireEvent.click(screen.getByRole('button', { name: 'Compare previous month' })) describe('Cash Balance Trend comparison', () => { beforeEach(() => { state.privacyMode = 'visible'; state.tooltipDay = 2; state.points = [] }) @@ -45,12 +45,12 @@ describe('Cash Balance Trend comparison', () => { fireEvent.click(screen.getByRole('button', { name: 'Trend' })) expect(screen.queryByTestId('series-previous')).not.toBeInTheDocument() toggle() - expect(screen.getByRole('button', { name: 'Hide comparison' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Compare previous month' })).toHaveAttribute('aria-pressed', 'true') expect(document.querySelector('[data-series=reference]')).toBeNull() expect(screen.getByTestId('series-previous')).not.toHaveAttribute('data-dashes') expect(screen.getByText('October 2026')).toBeInTheDocument() expect(screen.getByText('September 2026')).toBeInTheDocument() - expect(screen.getByText('Oct 13, 2026 vs Sep 13, 2026 · latest shared day')).toBeInTheDocument() + expect(screen.getByText('Oct 13 vs Sep 13 · latest shared day')).toBeInTheDocument() expect(within(screen.getByTestId('tooltip')).getByText('+200 HUF (+20.0%)')).toBeInTheDocument() expect(screen.queryByRole('slider')).not.toBeInTheDocument() expect(screen.queryByText(/Inspect day/)).not.toBeInTheDocument() @@ -74,6 +74,8 @@ describe('Cash Balance Trend comparison', () => { it('masks amounts, deltas, percentages and axes throughout comparison', () => { state.privacyMode = 'hidden' renderChart() + expect(screen.getByText('Chart hidden for privacy')).toHaveClass('h-[240px]', 'sm:h-[280px]') + expect(screen.getByText('••••••')).toBeInTheDocument() toggle() expect(screen.getByTestId('axis-value')).toHaveTextContent('••••') expect(screen.getByTestId('tooltip')).not.toHaveTextContent('HUF') @@ -99,13 +101,50 @@ describe('Cash Balance Trend comparison', () => { state.tooltipDay = 31 const result = comparison() result.points = result.points.map((point): CashBalanceComparisonPoint => ({ ...point, previous: point.day <= 28 ? 0 : null, previousDate: point.day <= 28 ? `2026-02-${String(point.day).padStart(2, '0')}` : null, selected: -100, selectedDate: `2026-03-${String(point.day).padStart(2, '0')}` })) - result.selectedMonth = '2026-03-01' - result.previousMonth = '2026-02-01' + result.selectedStart = '2026-03-01' + result.previousStart = '2026-02-01' render() toggle() - expect(screen.getByText('Mar 28, 2026 vs Feb 28, 2026 · latest shared day')).toBeInTheDocument() + expect(screen.getByText('Mar 28 vs Feb 28 · latest shared day')).toBeInTheDocument() expect(screen.getAllByText('-100 HUF').length).toBeGreaterThan(0) expect(screen.getByText('The previous month has no matching calendar day.')).toBeInTheDocument() expect(screen.getByTestId('tooltip')).not.toHaveTextContent('%') }) + + it('labels the main chart on calendar days', () => { + renderChart() + const labels = [...document.querySelectorAll('[data-axis-label=x]')].map(label => label.textContent) + expect(labels).toEqual(['Oct 1', 'Oct 3', 'Oct 5', 'Oct 7', 'Oct 9', 'Oct 11', 'Oct 13']) + }) + + it('keeps the plot height and toggle slots when comparing', () => { + renderChart() + const plots = () => document.querySelectorAll('.h-\\[240px\\].sm\\:h-\\[280px\\]') + const order = () => [...screen.getByRole('button', { name: 'Compare previous month' }).parentElement!.children].map(button => button.textContent) + expect(plots()).toHaveLength(1) + expect(order()).toEqual(['Trend', 'Compare previous month']) + toggle() + expect(plots()).toHaveLength(1) + expect(order()).toEqual(['Trend', 'Compare previous month']) + expect(screen.getByText('Trend', { selector: 'button' })).toHaveClass('invisible') + expect(screen.getByText('+200 HUF')).toBeInTheDocument() + expect(screen.getByText('+20.0%', { selector: '.easyui-chart-positive', exact: false })).toBeInTheDocument() + }) + + it('compares a year with the previous year on month ticks', () => { + const yearly = buildCashBalanceYearComparison({ + accounts: [{ id: 'cash', name: 'Cash', type: 'cash', currency: 'HUF', balance: 1200 }], + transactions: [{ id: 'opening', account_id: 'cash', date: '2025-01-01', amount: 100, status: 'posted' }, { id: 'change', account_id: 'cash', date: '2026-10-02', amount: 200, status: 'posted' }], + selectedYear: '2026-01-01', today: '2026-10-13', masterCurrency: 'HUF', exchangeRates: {}, + }) + state.tooltipDay = yearly.points.findIndex(point => point.selectedDate === '2026-10-13') + 1 + render() + fireEvent.click(screen.getByRole('button', { name: 'Compare previous year' })) + expect(screen.getByRole('button', { name: 'Compare previous year' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByText('2026')).toBeInTheDocument() + expect(screen.getByText('2025')).toBeInTheDocument() + expect(screen.getByTestId('axis-day')).toHaveTextContent('Jan,Mar,May,Jul,Sep,Nov') + expect(screen.getByText('Oct 13 vs Oct 13 · latest shared day')).toBeInTheDocument() + expect(within(screen.getByTestId('tooltip')).getByText('+200 HUF (+20.0%)')).toBeInTheDocument() + }) }) diff --git a/client/src/components/analytics-module/NetWorthTrendChart.tsx b/client/src/components/analytics-module/NetWorthTrendChart.tsx index 3262345..29c7006 100644 --- a/client/src/components/analytics-module/NetWorthTrendChart.tsx +++ b/client/src/components/analytics-module/NetWorthTrendChart.tsx @@ -2,6 +2,7 @@ import { memo, useId, useState } from 'react' import type { ReactNode } from 'react' import { InteractiveAreaChart } from '../common/interactive-area-chart' import { usePrivacy } from '../../context/PrivacyContext' +import { cn } from '../../lib/utils' import type { TrendDataPoint } from './types' import { CashBalanceComparisonChart } from './CashBalanceComparisonChart' import type { CashBalanceComparison } from './cash-balance-comparison' @@ -24,29 +25,44 @@ export const NetWorthTrendChart = memo(function NetWorthTrendChart({ data, maste const unavailableReason = comparisonDisabledReason || comparison?.unavailableReason const comparing = compare && comparison !== undefined && !unavailableReason const hidden = privacyMode === 'hidden' - const showsChart = !comparing && data.length > 1 + const chartHeight = 'h-[240px] sm:h-[280px]' + // The label never changes and Trend keeps its slot while comparing, so the + // toggle stays under the pointer in both views. const buttons = <> - {!comparing && !hidden && } + {!hidden && } {comparison && } return
- {comparing ? <> -

{title}

{controls}
- - : data.length > 1 ? + // Keeps the masked comparison's readout and plot rows, so hiding values + // doesn't make the compare toggle jump either. + : hidden && data.length > 1 ?
+
+
+

{title}

+

••••••

+

{periodLabel ?? 'In period'} · balance change

+
+ {controls} +
+
Chart hidden for privacy
+ {comparison &&
{buttons}
} +
+ : data.length > 1 ? ({ date: point.date, value: point.balance, reference: point.smoothed }))} label="Cash balance" title={title} controls={controls} periodLabel={periodLabel} formatValue={value => value === null ? 'Unavailable' : `${value.toLocaleString('hu-HU', { maximumFractionDigits: 0 })} ${masterCurrency}`} reference={showTrend ? { label: 'Trend', color: 'hsl(var(--muted-foreground))', dashed: true } : undefined} - hidden={hidden} showReadout showChange - className="h-[240px] sm:h-[280px]" - footer={(comparison || !hidden) ? buttons : undefined} + showReadout showChange calendarAxis + className={chartHeight} + footer={buttons} /> : <>

{title}

Need more data points to show trend
} - {!showsChart && (comparison || (!comparing && !hidden)) &&
{buttons}
} + {!comparing && data.length <= 1 && (comparison || !hidden) &&
{buttons}
} {comparison && unavailableReason &&

{unavailableReason}

}
}) diff --git a/client/src/components/analytics-module/cash-balance-comparison.test.ts b/client/src/components/analytics-module/cash-balance-comparison.test.ts index fb1965a..b5f87e5 100644 --- a/client/src/components/analytics-module/cash-balance-comparison.test.ts +++ b/client/src/components/analytics-module/cash-balance-comparison.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildCashBalanceMonthComparison, comparisonDelta } from './cash-balance-comparison' +import { buildCashBalanceMonthComparison, buildCashBalanceYearComparison, comparisonDelta } from './cash-balance-comparison' import type { Account, Transaction } from './types' const accounts: Account[] = [{ id: 'cash', name: 'Cash', type: 'cash', currency: 'HUF', balance: 1200 }] @@ -106,7 +106,7 @@ describe('monthly cash balance comparison', () => { it('aligns January to the preceding December across the year boundary', () => { const result = buildCashBalanceMonthComparison({ ...defaults, selectedMonth: '2026-01-01', transactions: [transaction('2025-12-01', 100)] }) - expect(result.previousMonth).toBe('2025-12-01') + expect(result.previousStart).toBe('2025-12-01') expect(result.points[30]).toMatchObject({ selectedDate: '2026-01-31', previousDate: '2025-12-31' }) }) @@ -118,3 +118,32 @@ describe('monthly cash balance comparison', () => { expect(comparisonDelta(75, 100)).toEqual({ amount: -25, percentage: -25 }) }) }) + +describe('yearly cash balance comparison', () => { + const yearly = (overrides: Partial[0]> = {}) => buildCashBalanceYearComparison({ + accounts, masterCurrency: 'HUF', exchangeRates: {}, selectedYear: '2026-01-01', today: '2026-10-13', + transactions: [transaction('2025-01-01', 500), transaction('2025-06-10', -200), transaction('2026-03-01', 400)], ...overrides, + }) + + it('aligns days by month and day across years and stops at local today', () => { + const result = yearly() + expect(result).toMatchObject({ unit: 'year', selectedStart: '2026-01-01', previousStart: '2025-01-01', unavailableReason: null }) + expect(result.points).toHaveLength(365) + expect(result.points[0]).toEqual({ day: 1, selectedDate: '2026-01-01', previousDate: '2025-01-01', selected: 800, previous: 1000 }) + expect(result.points[59]).toMatchObject({ selectedDate: '2026-03-01', previousDate: '2025-03-01', selected: 1200, previous: 1000 }) + expect(result.points.find(point => point.previousDate === '2025-06-10')).toMatchObject({ selectedDate: '2026-06-10', selected: 1200, previous: 800 }) + expect(result.points.find(point => point.selectedDate === '2026-10-14')).toMatchObject({ selected: null, previous: 800 }) + }) + + it('keeps a Feb 29 slot with no counterpart in the other year', () => { + const result = yearly({ selectedYear: '2025-01-01', transactions: [transaction('2024-01-01', 100)] }) + expect(result.points).toHaveLength(366) + expect(result.points[59]).toMatchObject({ selectedDate: null, previousDate: '2024-02-29', selected: null, previous: 1200 }) + expect(result.points[60]).toMatchObject({ selectedDate: '2025-03-01', previousDate: '2024-03-01' }) + }) + + it('requires history from the first day of the previous year', () => { + expect(yearly({ transactions: [transaction('2025-01-02', 100)] }).unavailableReason).toBe('Not enough cash history to cover the full previous year.') + expect(yearly({ selectedYear: '2027-01-01' }).unavailableReason).toBe('Comparison is available for current and past years only.') + }) +}) diff --git a/client/src/components/analytics-module/cash-balance-comparison.ts b/client/src/components/analytics-module/cash-balance-comparison.ts index 8a4343f..5877a79 100644 --- a/client/src/components/analytics-module/cash-balance-comparison.ts +++ b/client/src/components/analytics-module/cash-balance-comparison.ts @@ -1,4 +1,4 @@ -import { addDays, endOfMonth, format, getDaysInMonth, parseISO, startOfMonth, subMonths } from 'date-fns' +import { addDays, endOfMonth, endOfYear, format, getDaysInMonth, parseISO, startOfMonth, startOfYear, subMonths, subYears } from 'date-fns' import { convertCurrency } from '../../../../shared/currency' import type { Account, Transaction } from './types' @@ -10,9 +10,12 @@ export type CashBalanceComparisonPoint = { previous: number | null } +export type CashBalanceComparisonUnit = 'month' | 'year' + export type CashBalanceComparison = { - selectedMonth: string - previousMonth: string + unit: CashBalanceComparisonUnit + selectedStart: string + previousStart: string points: CashBalanceComparisonPoint[] unavailableReason: string | null } @@ -23,28 +26,53 @@ export function comparisonDelta(selected: number | null, previous: number | null return { amount, percentage: previous > 0 ? amount / previous * 100 : null } } -// The caller supplies complete posted history and current account balances. -// There is no durable opening-date history: never claim coverage before the -// earliest posted cash record, even though reverse reconstruction is possible. -export function buildCashBalanceMonthComparison({ - accounts, transactions, selectedMonth, today, masterCurrency, exchangeRates, -}: { +type ComparisonInput = { accounts: Account[] transactions: Transaction[] - selectedMonth: string today: string masterCurrency: string exchangeRates: Record -}): CashBalanceComparison { - const selectedStart = startOfMonth(parseISO(selectedMonth)) - const previousStart = subMonths(selectedStart, 1) - const previousMonth = format(previousStart, 'yyyy-MM-dd') +} + +export const buildCashBalanceMonthComparison = ({ selectedMonth, ...input }: ComparisonInput & { selectedMonth: string }) => + buildCashBalanceComparison({ ...input, unit: 'month', selectedDate: selectedMonth }) + +export const buildCashBalanceYearComparison = ({ selectedYear, ...input }: ComparisonInput & { selectedYear: string }) => + buildCashBalanceComparison({ ...input, unit: 'year', selectedDate: selectedYear }) + +// Calendar slots shared by both periods: day N of the month, or the same +// month/day of the year. A slot missing from one period (the 31st, Feb 29) +// has a null date on that side. +function comparisonSlots(unit: CashBalanceComparisonUnit, selectedStart: Date, previousStart: Date) { + const slots: { selectedDate: string | null; previousDate: string | null }[] = [] + const months = unit === 'month' ? 1 : 12 + for (let month = 0; month < months; month++) { + const selectedMonth = new Date(selectedStart.getFullYear(), selectedStart.getMonth() + month, 1) + const previousMonth = new Date(previousStart.getFullYear(), previousStart.getMonth() + month, 1) + const selectedDays = getDaysInMonth(selectedMonth), previousDays = getDaysInMonth(previousMonth) + for (let day = 1; day <= Math.max(selectedDays, previousDays); day++) slots.push({ + selectedDate: day <= selectedDays ? format(addDays(selectedMonth, day - 1), 'yyyy-MM-dd') : null, + previousDate: day <= previousDays ? format(addDays(previousMonth, day - 1), 'yyyy-MM-dd') : null, + }) + } + return slots +} + +// The caller supplies complete posted history and current account balances. +// There is no durable opening-date history: never claim coverage before the +// earliest posted cash record, even though reverse reconstruction is possible. +export function buildCashBalanceComparison({ + accounts, transactions, unit, selectedDate, today, masterCurrency, exchangeRates, +}: ComparisonInput & { unit: CashBalanceComparisonUnit; selectedDate: string }): CashBalanceComparison { + const selectedStart = unit === 'month' ? startOfMonth(parseISO(selectedDate)) : startOfYear(parseISO(selectedDate)) + const previousStart = unit === 'month' ? subMonths(selectedStart, 1) : subYears(selectedStart, 1) + const previousKey = format(previousStart, 'yyyy-MM-dd') const result: CashBalanceComparison = { - selectedMonth: format(selectedStart, 'yyyy-MM-dd'), previousMonth, + unit, selectedStart: format(selectedStart, 'yyyy-MM-dd'), previousStart: previousKey, points: [], unavailableReason: null, } - if (result.selectedMonth > today) { - return { ...result, unavailableReason: 'Comparison is available for current and past months only.' } + if (result.selectedStart > today) { + return { ...result, unavailableReason: `Comparison is available for current and past ${unit}s only.` } } const cashAccounts = accounts.filter(account => account.type !== 'investment' && !account.exclude_from_cash_balance) @@ -53,8 +81,8 @@ export function buildCashBalanceMonthComparison({ && (transaction.status === undefined || transaction.status === 'posted') && transaction.date <= today) const firstDate = posted.reduce((first, transaction) => first === null || transaction.date < first ? transaction.date : first, null) - if (firstDate === null || firstDate > previousMonth) { - return { ...result, unavailableReason: 'Not enough cash history to cover the full previous month.' } + if (firstDate === null || firstDate > previousKey) { + return { ...result, unavailableReason: `Not enough cash history to cover the full previous ${unit}.` } } let balance = 0 @@ -71,29 +99,23 @@ export function buildCashBalanceMonthComparison({ movements.set(transaction.date, (movements.get(transaction.date) ?? 0) + amount) } - const end = [format(endOfMonth(selectedStart), 'yyyy-MM-dd'), today].sort()[0] + const periodEnd = unit === 'month' ? endOfMonth(selectedStart) : endOfYear(selectedStart) + const end = [format(periodEnd, 'yyyy-MM-dd'), today].sort()[0] for (const [date, amount] of movements) { if (date > end) balance -= amount } const balances = new Map() - // At most two months are expanded, regardless of the ledger's age. - for (let date = parseISO(end); format(date, 'yyyy-MM-dd') >= previousMonth; date = addDays(date, -1)) { + // At most two periods are expanded, regardless of the ledger's age. + for (let date = parseISO(end); format(date, 'yyyy-MM-dd') >= previousKey; date = addDays(date, -1)) { const key = format(date, 'yyyy-MM-dd') balances.set(key, balance) balance -= movements.get(key) ?? 0 } - const selectedDays = getDaysInMonth(selectedStart) - const previousDays = getDaysInMonth(previousStart) - result.points = Array.from({ length: Math.max(selectedDays, previousDays) }, (_, index) => { - const day = index + 1 - const selectedDate = day <= selectedDays ? format(addDays(selectedStart, index), 'yyyy-MM-dd') : null - const previousDate = day <= previousDays ? format(addDays(previousStart, index), 'yyyy-MM-dd') : null - return { - day, selectedDate, previousDate, - selected: selectedDate === null ? null : balances.get(selectedDate) ?? null, - previous: previousDate === null ? null : balances.get(previousDate) ?? null, - } - }) + result.points = comparisonSlots(unit, selectedStart, previousStart).map(({ selectedDate, previousDate }, index) => ({ + day: index + 1, selectedDate, previousDate, + selected: selectedDate === null ? null : balances.get(selectedDate) ?? null, + previous: previousDate === null ? null : balances.get(previousDate) ?? null, + })) return result } diff --git a/client/src/components/analytics-module/cash-balance-trend.test.ts b/client/src/components/analytics-module/cash-balance-trend.test.ts new file mode 100644 index 0000000..0650b6b --- /dev/null +++ b/client/src/components/analytics-module/cash-balance-trend.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { clipBalancesToPeriod } from './cash-balance-trend' + +const balances = [ + { date: '2026-09-20', balance: 1000 }, + { date: '2026-10-05', balance: 1500 }, + { date: '2026-10-27', balance: 1400 }, +] + +describe('cash balance period clipping', () => { + it('opens a month on the 1st with the carried balance and fills quiet days through today', () => { + const month = clipBalancesToPeriod(balances, { start: '2026-10-01', end: '2026-10-31', daily: true }) + expect(month).toHaveLength(27) + expect(month[0]).toEqual({ date: '2026-10-01', balance: 1000 }) + expect(month[3]).toEqual({ date: '2026-10-04', balance: 1000 }) + expect(month[4]).toEqual({ date: '2026-10-05', balance: 1500 }) + expect(month.at(-1)).toEqual({ date: '2026-10-27', balance: 1400 }) + }) + + it('carries a finished period to its last day and pads a year without filling every day', () => { + expect(clipBalancesToPeriod(balances, { start: '2026-09-01', end: '2026-09-30' })).toEqual([ + { date: '2026-09-20', balance: 1000 }, { date: '2026-09-30', balance: 1000 }, + ]) + expect(clipBalancesToPeriod([{ date: '2025-12-30', balance: 50 }, ...balances], { start: '2026-01-01', end: '2026-12-31' })).toEqual([ + { date: '2026-01-01', balance: 50 }, ...balances, + ]) + }) + + it('does not invent a balance before the first record', () => { + expect(clipBalancesToPeriod(balances, { start: '2026-09-01', end: '2026-09-30', daily: true })[0]).toEqual({ date: '2026-09-20', balance: 1000 }) + expect(clipBalancesToPeriod([], { start: '2026-10-01', end: '2026-10-31', daily: true })).toEqual([]) + }) +}) diff --git a/client/src/components/analytics-module/cash-balance-trend.ts b/client/src/components/analytics-module/cash-balance-trend.ts new file mode 100644 index 0000000..5a0e655 --- /dev/null +++ b/client/src/components/analytics-module/cash-balance-trend.ts @@ -0,0 +1,30 @@ +import { addDays, format, parseISO } from 'date-fns' + +type BalancePoint = { date: string; balance: number } + +// Clips complete ascending closing balances to a calendar period. The period +// opens on its first day only when an earlier record covers that day, so no +// pre-ledger balance is invented; it closes on the last recorded day (today, +// or a projected date) or carries forward to the end of a finished period. +// Daily mode adds every quiet day so short periods read as a calendar. +export function clipBalancesToPeriod(points: BalancePoint[], { start, end, daily = false }: { start: string; end: string; daily?: boolean }): BalancePoint[] { + const before = points.filter(point => point.date < start).at(-1) + const inside = points.filter(point => point.date >= start && point.date <= end) + const after = points.some(point => point.date > end) + const clipped = before && inside[0]?.date !== start ? [{ date: start, balance: before.balance }, ...inside] : inside + const last = clipped.at(-1) + if (last && after && last.date < end) clipped.push({ date: end, balance: last.balance }) + if (!daily || clipped.length < 2) return clipped + + const filled: BalancePoint[] = [] + for (let index = 0; index < clipped.length; index++) { + const point = clipped[index] + filled.push(point) + const next = clipped[index + 1] + if (!next) break + for (let date = addDays(parseISO(point.date), 1); format(date, 'yyyy-MM-dd') < next.date; date = addDays(date, 1)) { + filled.push({ date: format(date, 'yyyy-MM-dd'), balance: point.balance }) + } + } + return filled +} diff --git a/client/src/components/common/calendar-ticks.test.ts b/client/src/components/common/calendar-ticks.test.ts new file mode 100644 index 0000000..3c95b6f --- /dev/null +++ b/client/src/components/common/calendar-ticks.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { calendarTicks } from './calendar-ticks' + +const labels = (start: string, end: string, width: number) => calendarTicks(start, end, width).map(tick => tick.label) + +describe('calendar axis ticks', () => { + it('labels month views on calendar days that fit the width', () => { + expect(labels('2026-10-01', '2026-10-27', 900)).toEqual(['Oct 1', 'Oct 4', 'Oct 7', 'Oct 10', 'Oct 13', 'Oct 16', 'Oct 19', 'Oct 22', 'Oct 25']) + expect(labels('2026-10-01', '2026-10-27', 280)).toEqual(['Oct 1', 'Oct 8', 'Oct 15', 'Oct 22']) + }) + + it('labels year views on month starts, thinning to alternate months on narrow widths', () => { + expect(labels('2026-01-01', '2026-10-27', 900)).toEqual(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct']) + expect(calendarTicks('2026-01-01', '2026-10-27', 280)).toEqual([ + { date: '2026-01-01', label: 'Jan' }, { date: '2026-03-01', label: 'Mar' }, { date: '2026-05-01', label: 'May' }, + { date: '2026-07-01', label: 'Jul' }, { date: '2026-09-01', label: 'Sep' }, + ]) + }) + + it('labels multi-year spans on quarters, halves or years and names each January by year', () => { + expect(labels('2024-10-27', '2026-10-27', 900)).toEqual(['2025', 'Apr', 'Jul', 'Oct', '2026', 'Apr', 'Jul', 'Oct']) + expect(labels('2024-10-27', '2026-10-27', 280)).toEqual(['2025', 'Jul', '2026', 'Jul']) + expect(labels('2016-01-01', '2026-10-27', 280)).toEqual(['2016', '2018', '2020', '2022', '2024', '2026']) + }) + + it('never crowds the 1st of a month after a month-end tick and handles short or invalid spans', () => { + expect(labels('2026-01-25', '2026-02-10', 900).filter(label => label.startsWith('Jan 3'))).toEqual([]) + expect(labels('2026-10-26', '2026-10-27', 300)).toEqual(['Oct 26', 'Oct 27']) + expect(calendarTicks('2026-10-27', '2026-10-01', 300)).toEqual([]) + }) +}) diff --git a/client/src/components/common/calendar-ticks.ts b/client/src/components/common/calendar-ticks.ts new file mode 100644 index 0000000..15ad0a0 --- /dev/null +++ b/client/src/components/common/calendar-ticks.ts @@ -0,0 +1,66 @@ +// Calendar-aligned x-axis ticks for date-proportional charts. The finest unit +// whose label spacing fits the plot width wins: days, months, then years. +export type CalendarTick = { date: string; label: string } + +type Unit = 'day' | 'month' | 'year' +type Candidate = { unit: Unit; step: number; spacing: number; minimum: number } + +const DAY = 86_400_000 +const MAX_LABELS = 10 +const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] +// Approximate spacing in days, and the minimum pixel gap each label style needs. +const candidates: Candidate[] = [ + ...[1, 2, 3, 7].map(step => ({ unit: 'day' as const, step, spacing: step, minimum: 56 })), + { unit: 'day', step: 14, spacing: 14, minimum: 56 }, + ...[1, 2, 3, 6].map(step => ({ unit: 'month' as const, step, spacing: step * 30.4, minimum: 44 })), + ...[1, 2, 5, 10].map(step => ({ unit: 'year' as const, step, spacing: step * 365.25, minimum: 48 })), +] + +const iso = (time: number) => new Date(time).toISOString().slice(0, 10) +const daysInMonth = (year: number, month: number) => new Date(Date.UTC(year, month + 1, 0)).getUTCDate() + +function tickDates({ unit, step }: Candidate, start: number, end: number) { + const dates: number[] = [] + const first = new Date(start) + if (unit === 'day') { + for (let time = start; time <= end; time += DAY) { + const date = new Date(time) + const day = date.getUTCDate() + // Restart on the 1st each month and skip a crowded final day before it. + const aligned = step === 14 ? day === 1 || day === 15 + : (day - 1) % step === 0 && day + step <= daysInMonth(date.getUTCFullYear(), date.getUTCMonth()) + 1 + if (aligned) dates.push(time) + } + return dates + } + // Months align to January (Jan/Mar/May, quarters, halves); years to multiples of the step. + for (let index = first.getUTCFullYear() * 12 + first.getUTCMonth(); ; index++) { + const year = Math.floor(index / 12), month = index % 12 + const time = Date.UTC(year, month, 1) + if (time > end) break + const aligned = unit === 'month' ? month % step === 0 : month === 0 && year % step === 0 + if (time >= start && aligned) dates.push(time) + } + return dates +} + +export function calendarTicks(startDate: string, endDate: string, width: number): CalendarTick[] { + const start = Date.parse(`${startDate}T00:00:00Z`), end = Date.parse(`${endDate}T00:00:00Z`) + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return [] + const days = Math.max(1, (end - start) / DAY) + const pixelsPerDay = width / days + const multiYear = new Date(start).getUTCFullYear() !== new Date(end).getUTCFullYear() + for (const candidate of candidates) { + if (candidate.spacing * pixelsPerDay < candidate.minimum) continue + const dates = tickDates(candidate, start, end) + if (!dates.length || dates.length > MAX_LABELS) continue + return dates.map(time => { + const date = new Date(time), month = date.getUTCMonth() + const label = candidate.unit === 'day' ? `${months[month]} ${date.getUTCDate()}` + : candidate.unit === 'year' || (multiYear && month === 0) ? String(date.getUTCFullYear()) + : months[month] + return { date: iso(time), label } + }) + } + return [{ date: endDate, label: `${months[new Date(end).getUTCMonth()]} ${new Date(end).getUTCDate()}` }] +} diff --git a/client/src/components/common/interactive-area-chart.tsx b/client/src/components/common/interactive-area-chart.tsx index f7d700e..8e62df0 100644 --- a/client/src/components/common/interactive-area-chart.tsx +++ b/client/src/components/common/interactive-area-chart.tsx @@ -2,6 +2,7 @@ import { useEffect, useId, useRef, useState } from 'react' import type { PointerEvent, ReactNode } from 'react' import { motion, useReducedMotion } from 'framer-motion' import { cn } from '../../lib/utils' +import { calendarTicks } from './calendar-ticks' // Adapted from EasyUI's Interactive Area Chart (MIT; see licenses/easyui.txt). // Retains the reference's smooth SVG curve, reveal and scrubbing treatment. @@ -27,6 +28,8 @@ type InteractiveAreaChartProps = { renderTooltip?: (index: number) => ReactNode // Shares the legend row, so toggling the reference line keeps it in place. footer?: ReactNode + // Labels calendar boundaries (days, months, years) instead of evenly spaced points. + calendarAxis?: boolean } const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max) @@ -34,7 +37,7 @@ const finite = (value: number | null | undefined): value is number => value != n const shortDate = (date: string) => new Date(`${date}T00:00:00Z`).toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' }) const fullDate = (date: string) => new Date(`${date}T00:00:00Z`).toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) -export function InteractiveAreaChart({ data, label, formatValue, color = 'hsl(var(--primary))', title, controls, showChange = false, periodLabel = 'in period', reference, hidden = false, showReadout = false, className, activeIndex, onInspect, renderTooltip, footer }: InteractiveAreaChartProps) { +export function InteractiveAreaChart({ data, label, formatValue, color = 'hsl(var(--primary))', title, controls, showChange = false, periodLabel = 'in period', reference, hidden = false, showReadout = false, className, activeIndex, onInspect, renderTooltip, footer, calendarAxis = false }: InteractiveAreaChartProps) { const uid = useId().replaceAll(':', '') const reduce = useReducedMotion() const wrapRef = useRef(null) @@ -75,7 +78,8 @@ export function InteractiveAreaChart({ data, label, formatValue, color = 'hsl(va const low = minimum - padding, high = maximum + padding const times = data.map(point => Date.parse(`${point.date}T00:00:00Z`)) const start = times[0] ?? 0, span = (times.at(-1) ?? 0) - start - const x = (i: number) => pad.left + (span > 0 ? (times[i] - start) / span : 0.5) * innerWidth + const at = (time: number) => pad.left + (span > 0 ? (time - start) / span : 0.5) * innerWidth + const x = (i: number) => at(times[i]) const y = (value: number) => pad.top + (1 - (value - low) / (high - low)) * (bottom - pad.top) const ticks = [high, (high + low) / 2, low] const tooltipWidth = Math.min(renderTooltip ? 280 : 164, width) @@ -84,7 +88,10 @@ export function InteractiveAreaChart({ data, label, formatValue, color = 'hsl(va const change = finite(first) && first > 0 && finite(shown?.value) ? (shown.value - first) / first * 100 : null const seriesKey = `${data[0]?.date}:${data.at(-1)?.date}:${data.length}` const labelCount = width < 400 ? 3 : 5 - const labels = [...new Set(Array.from({ length: labelCount }, (_, i) => Math.round(i / (labelCount - 1) * (data.length - 1))))] + const labels = !data.length ? [] : calendarAxis + ? calendarTicks(data[0].date, data[data.length - 1].date, innerWidth).map(tick => ({ key: tick.date, x: at(Date.parse(`${tick.date}T00:00:00Z`)), text: tick.label })) + : [...new Set(Array.from({ length: labelCount }, (_, i) => Math.round(i / (labelCount - 1) * (data.length - 1))))] + .map(i => ({ key: data[i].date, x: x(i), text: shortDate(data[i].date) })) const paths = (key: 'value' | 'reference', step = false) => { const segments: { line: string; area: string; single: number | null }[] = [] @@ -167,7 +174,9 @@ export function InteractiveAreaChart({ data, label, formatValue, color = 'hsl(va {Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(value)} )} - {labels.map((i, j) => {shortDate(data[i].date)})} + {labels.map((tick, j) => {tick.text})} {reference && {paths('reference', reference.step).map((path, i) => {path.single !== null && }