From b8d0b799f67b9ea158b6ffa6f8e031d84ea4b6ef Mon Sep 17 00:00:00 2001 From: 31b4 Date: Tue, 27 Oct 2026 16:51:24 +0100 Subject: [PATCH] Align Cash Balance Trend timeframes to the calendar Label month, year and all-time views on calendar days, months, quarters or years that fit the chart width. Start month views on the 1st with the carried opening balance when earlier history covers it, and open year views on January 1 the same way. Add Compare previous year for the year view, reusing the monthly comparison builder with month/day-aligned slots. Give the comparison and privacy views the same readout, plot and legend rows as the trend chart and keep the toggle label fixed, so switching views never changes the card height or moves the toggle. --- .../analytics-module/Analytics.test.tsx | 6 +- .../components/analytics-module/Analytics.tsx | 41 +++------ .../CashBalanceComparisonChart.tsx | 83 ++++++++++++----- .../NetWorthTrendChart.test.tsx | 55 ++++++++++-- .../analytics-module/NetWorthTrendChart.tsx | 38 +++++--- .../cash-balance-comparison.test.ts | 33 ++++++- .../cash-balance-comparison.ts | 90 ++++++++++++------- .../cash-balance-trend.test.ts | 33 +++++++ .../analytics-module/cash-balance-trend.ts | 30 +++++++ .../components/common/calendar-ticks.test.ts | 31 +++++++ .../src/components/common/calendar-ticks.ts | 66 ++++++++++++++ .../common/interactive-area-chart.tsx | 17 +++- 12 files changed, 413 insertions(+), 110 deletions(-) create mode 100644 client/src/components/analytics-module/cash-balance-trend.test.ts create mode 100644 client/src/components/analytics-module/cash-balance-trend.ts create mode 100644 client/src/components/common/calendar-ticks.test.ts create mode 100644 client/src/components/common/calendar-ticks.ts diff --git a/client/src/components/analytics-module/Analytics.test.tsx b/client/src/components/analytics-module/Analytics.test.tsx index aee2b8a9..f184da97 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 ca8e3aeb..b62752e8 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 16cf3498..28906974 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 04d71840..45f1fa73 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 32623458..29c70066 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 fb1965ab..b5f87e5a 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 8a4343fe..5877a79b 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 00000000..0650b6b2 --- /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 00000000..5a0e655d --- /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 00000000..3c95b6f5 --- /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 00000000..15ad0a0a --- /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 f7d700e6..8e62df04 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 && }