)
}
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 }) =>
,
YAxis: ({ tickFormatter }: { tickFormatter: (value: number) => string }) =>
,
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 && }