Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions client/src/components/analytics-module/AICashOutlookChart.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useId, useMemo, useState } from 'react'
import { Area, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { useCalendarAxis } from '../common/calendar-axis'
import { addDays, format, isLastDayOfMonth, startOfDay, subMonths } from 'date-fns'
import { usePrivacy } from '../../context/PrivacyContext'
import type { Account, FinancialOutlookHistoricalCashPoint, FinancialOutlookSnapshot, Transaction } from './types'
Expand Down Expand Up @@ -181,10 +182,10 @@ export function AICashOutlookChart({ snapshot, transactions, accounts, convertTo
return [min - padding, max + padding]
}, [chartData])

// Chart times are local midnights.
const { ref: axisRef, axisProps } = useCalendarAxis(chartData[0]?.timestamp, chartData.at(-1)?.timestamp, { plotInset: 50, local: true })

if (!snapshot || chartData.length < 8) return null
const firstTimestamp = chartData[0].timestamp
const lastTimestamp = chartData.at(-1)!.timestamp
const xTicks = Array.from({ length: 8 }, (_, index) => startOfDay(new Date(firstTimestamp + (lastTimestamp - firstTimestamp) * index / 7)).getTime())

return (
<section className="px-5 pb-5 pt-6 sm:px-7">
Expand All @@ -208,7 +209,7 @@ export function AICashOutlookChart({ snapshot, transactions, accounts, convertTo
{usesReconstruction && <p className="mt-1 text-xs text-muted-foreground">Earlier history is reconstructed using current transactions, account settings, and exchange rates, so it may change.</p>}
{hasMissingFx && <p className="mt-1 text-xs text-muted-foreground">Some account currencies have no exchange rate; their earlier movements are excluded.</p>}
{hasMonthlyHistory && <p className="mt-1 text-xs text-muted-foreground">History before {format(dateFromHistory(dailyHistoryStart!), 'MMM d, yyyy')} uses saved monthly balances; the past year uses daily balances.</p>}
<div className="mt-5 h-64 sm:h-80">
<div ref={axisRef} className="mt-5 h-64 sm:h-80">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 24, right: 10, left: -10, bottom: 0 }}>
<defs>
Expand All @@ -218,7 +219,7 @@ export function AICashOutlookChart({ snapshot, transactions, accounts, convertTo
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="hsl(var(--border))" />
<XAxis dataKey="timestamp" type="number" scale="time" domain={['dataMin', 'dataMax']} ticks={xTicks} interval="preserveStartEnd" minTickGap={24} tickFormatter={value => format(new Date(value), historyRange === 'all' ? 'MMM d, yy' : 'MMM d')} stroke="hsl(var(--muted-foreground))" fontSize={10} tickLine={false} axisLine={false} />
<XAxis dataKey="timestamp" type="number" scale="time" domain={['dataMin', 'dataMax']} {...axisProps} stroke="hsl(var(--muted-foreground))" fontSize={10} tickLine={false} axisLine={false} />
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={10} tickLine={false} axisLine={false} domain={yDomain} width={50} tickFormatter={value => hidden ? '••••' : Math.abs(value) >= 1_000_000 ? `${(value / 1_000_000).toFixed(1)}M` : `${Math.round(value / 1_000)}K`} />
<Tooltip content={({ active, payload }) => {
const point = payload?.[0]?.payload as ChartPoint | undefined
Expand Down
26 changes: 26 additions & 0 deletions client/src/components/analytics-module/Analytics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ vi.mock('./NetWorthTrendChart', () => ({
),
}))

vi.mock('./PerAccountTrendChart', () => ({
PerAccountTrendChart: ({ account, data }: { account: Account; data: Array<{ date: string; balance: number }> }) => (
<div data-testid={`account-trend-${account.id}`} data-points={JSON.stringify(data.map(({ date, balance }) => ({ date, balance })))} />
),
}))

const accounts: Account[] = [
{ id: 'cash', name: 'Cash', type: 'checking', balance: 100, currency: 'HUF' },
{ id: 'investment', name: 'Investments', type: 'investment', balance: 0, currency: 'HUF' },
Expand Down Expand Up @@ -197,6 +203,26 @@ describe('Analytics Projected mode', () => {
])
})

it('rebuilds per-account trends from the whole ledger and opens month views on the 1st', () => {
localStorage.setItem('analytics-widget-visibility', JSON.stringify({ 'account-trends': true }))
const ledger: Transaction[] = [
{ id: 'august', account_id: 'cash', date: '2026-08-10', amount: 300, status: 'posted' },
{ id: 'september', account_id: 'cash', date: '2026-09-05', amount: -100, status: 'posted' },
]
const cash: Account = { id: 'cash', name: 'Cash', type: 'checking', balance: 1000, currency: 'HUF' }
const filters = (date: string) => ({ period: 'month' as const, date, pinned: false, mode: 'actual' as const, expenseCategory: 'all', incomeCategory: 'all', resolution: 'default' as const })
const points = () => JSON.parse(screen.getByTestId('account-trend-cash').getAttribute('data-points')!)
const { rerender } = render(<Analytics accounts={[cash]} transactions={ledger} categories={[]} filters={filters('2026-09-01')} onFiltersChange={vi.fn()} />)
expect(points()).toHaveLength(15)
expect(points()[0]).toEqual({ date: '2026-09-01', balance: 1100 })
expect(points()[4]).toEqual({ date: '2026-09-05', balance: 1000 })
expect(points().at(-1)).toEqual({ date: '2026-09-15', balance: 1000 })
// A past month excludes later movements and carries its closing balance to month end.
rerender(<Analytics accounts={[cash]} transactions={ledger} categories={[]} filters={filters('2026-08-01')} onFiltersChange={vi.fn()} />)
expect(points()[0]).toEqual({ date: '2026-08-10', balance: 1100 })
expect(points().at(-1)).toEqual({ date: '2026-08-31', balance: 1100 })
})

it('requires FX for accounts included in cash even when excluded from net worth', () => {
render(<Analytics transactions={[]} categories={[]} accounts={[
{ id: 'eur', name: 'Cash', type: 'checking', balance: 100, currency: 'EUR', exclude_from_net_worth: true },
Expand Down
77 changes: 19 additions & 58 deletions client/src/components/analytics-module/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,71 +381,36 @@ export function Analytics({
? quietMonthBalanceData : cashBalanceTrendData

// Per-account trends remain in each account's native currency (exclude investment accounts).
// Balances are rebuilt from the whole ledger, then clipped like Cash Balance Trend,
// so past periods exclude later movements and 1M/1Y open on the period's first day.
const perAccountTrendData = useMemo(() => {
const today = format(new Date(), 'yyyy-MM-dd')
const bounded = period === 'month' || period === 'year'
return accounts
.filter(account => account.type !== 'investment')
.map(account => {
const accountTransactions = transactionsForAnalytics.filter(t => {
const txDate = parseISO(t.date)
const matchesAccount = t.account_id === account.id
if (!matchesAccount) return false

switch (period) {
case 'month':
case 'year':
return isWithinInterval(txDate, {
start: parseISO(customDateRange.startDate),
end: parseISO(customDateRange.endDate)
})
default:
return true
}
})

const accountTransactions = transactionsForAnalytics.filter(t => t.account_id === account.id)
const hasTransactions = accountTransactions.some(t => !bounded
|| (t.date >= customDateRange.startDate && t.date <= customDateRange.endDate))

const transactionsByDate: Record<string, number> = {}
accountTransactions.forEach(tx => {
transactionsByDate[tx.date] = (transactionsByDate[tx.date] || 0) + tx.amount
})
const dateBalances: Record<string, number> = {}

const dateBalances: Record<string, number> = { [today]: account.balance }
let runningBalance = account.balance

const today = format(new Date(), 'yyyy-MM-dd')

const todayInRange = (() => {
const todayDate = parseISO(today)
switch (period) {
case 'month':
case 'year':
return isWithinInterval(todayDate, {
start: parseISO(customDateRange.startDate),
end: parseISO(customDateRange.endDate)
})
default:
return true
}
})()

if (todayInRange) {
dateBalances[today] = account.balance
}

const uniqueDates = Object.keys(transactionsByDate).sort(
(a, b) => parseISO(b).getTime() - parseISO(a).getTime()
)

uniqueDates.forEach(date => {
Object.keys(transactionsByDate).sort((a, b) => b.localeCompare(a)).forEach(date => {
dateBalances[date] = runningBalance
runningBalance -= transactionsByDate[date]
})

const rawData: TrendDataPoint[] = Object.entries(dateBalances)
.map(([date, balance]) => ({
date,
formattedDate: format(parseISO(date), 'MMM d'),
balance
}))
.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: TrendDataPoint[] = (bounded ? clipBalancesToPeriod(balances, {
start: customDateRange.startDate, end: customDateRange.endDate, daily: period === 'month',
}) : balances).map(point => ({ ...point, formattedDate: format(parseISO(point.date), 'MMM d') }))

// Calculate EMA (Exponential Moving Average) as a smoothed baseline
let data = rawData
Expand All @@ -460,11 +425,7 @@ export function Analytics({
})
}

return {
account,
data,
hasTransactions: accountTransactions.length > 0
}
return { account, data, hasTransactions }
})
.filter(({ hasTransactions }) => hasTransactions)
}, [accounts, transactionsForAnalytics, period, customDateRange])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const NetWorthTrendChart = memo(function NetWorthTrendChart({ data, maste
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}
showReadout showChange calendarAxis
showReadout showChange
className={chartHeight}
footer={buttons}
/> : <><h3 className="text-[13px] text-muted-foreground">{title}</h3><div className="flex h-[280px] items-center justify-center text-sm text-muted-foreground">Need more data points to show trend</div></>}
Expand Down
26 changes: 26 additions & 0 deletions client/src/components/common/calendar-axis.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { render, renderHook } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { useCalendarAxis } from './calendar-axis'

const utc = (date: string) => Date.parse(`${date}T00:00:00Z`)

describe('useCalendarAxis', () => {
it('gives Recharts calendar tick times and renders emphasized year labels', () => {
const { result } = renderHook(() => useCalendarAxis(utc('2024-10-27'), utc('2026-10-27'), { plotInset: 40 }))
const { ticks, tick, interval } = result.current.axisProps
expect(interval).toBe(0)
expect(ticks).toContain(utc('2025-01-01'))
const { container } = render(<svg>{ticks.map(time => <g key={time}>{tick({ x: 10, y: 10, payload: { value: time } })}</g>)}</svg>)
const labels = [...container.querySelectorAll('[data-axis-label="x"]')]
expect(labels[0]).toHaveTextContent('2025')
expect(labels[0]).toHaveAttribute('data-major')
expect(labels[1]).not.toHaveAttribute('data-major')
})

it('maps local-midnight chart times and has no ticks without a range', () => {
const local = (date: string) => new Date(`${date}T00:00:00`).getTime()
const { result } = renderHook(() => useCalendarAxis(local('2026-10-01'), local('2026-10-27'), { local: true }))
expect(result.current.axisProps.ticks[0]).toBe(local('2026-10-01'))
expect(renderHook(() => useCalendarAxis(undefined, undefined)).result.current.axisProps.ticks).toEqual([])
})
})
51 changes: 51 additions & 0 deletions client/src/components/common/calendar-axis.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useRef, useState } from 'react'
import { format, parseISO } from 'date-fns'
import { cn } from '../../lib/utils'
import { calendarTicks } from './calendar-ticks'

type AxisTick = { label: string; major?: boolean; anchor: 'start' | 'middle' | 'end' }
type TickProps = { x?: number | string; y?: number | string; payload?: { value?: unknown } }

const utcTime = (date: string) => Date.parse(`${date}T00:00:00Z`)
const utcDate = (time: number) => new Date(time).toISOString().slice(0, 10)
const localTime = (date: string) => parseISO(date).getTime()
const localDate = (time: number) => format(new Date(time), 'yyyy-MM-dd')

// Calendar-aligned ticks for Recharts time axes, matching InteractiveAreaChart.
// Attach `ref` to the chart's sizing container; `plotInset` is the width the
// Y axis and margins take from it. `local` charts store local-midnight times.
export function useCalendarAxis(start: number | undefined, end: number | undefined, { plotInset = 64, local = false }: { plotInset?: number; local?: boolean } = {}) {
const ref = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState(640)

useEffect(() => {
const element = ref.current
if (!element) return
const measure = () => { const next = element.getBoundingClientRect().width; if (next > 0) setWidth(next) }
measure()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(element)
return () => observer.disconnect()
}, [])

const plotWidth = Math.max(1, width - plotInset)
const ticks = new Map<number, AxisTick>()
if (start != null && end != null && Number.isFinite(start) && Number.isFinite(end)) {
const toTime = local ? localTime : utcTime, toDate = local ? localDate : utcDate
for (const tick of calendarTicks(toDate(start), toDate(end), plotWidth)) {
const time = toTime(tick.date)
const offset = end > start ? (time - start) / (end - start) * plotWidth : plotWidth / 2
ticks.set(time, { label: tick.label, major: tick.major, anchor: offset < 20 ? 'start' : plotWidth - offset < 20 ? 'end' : 'middle' })
}
}

const renderTick = ({ x, y, payload }: TickProps) => {
const tick = ticks.get(Number(payload?.value))
if (!tick) return <g />
return <text x={Number(x)} y={Number(y)} dy={12} textAnchor={tick.anchor} fontSize={10} data-axis-label="x" data-major={tick.major ? '' : undefined}
className={cn(tick.major ? 'fill-foreground font-semibold' : 'fill-muted-foreground')}>{tick.label}</text>
}

return { ref, axisProps: { ticks: [...ticks.keys()], interval: 0 as const, tick: renderTick } }
}
6 changes: 6 additions & 0 deletions client/src/components/common/calendar-ticks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ describe('calendar axis ticks', () => {
expect(labels('2016-01-01', '2026-10-27', 280)).toEqual(['2016', '2018', '2020', '2022', '2024', '2026'])
})

it('marks year labels as major only when they appear among month labels', () => {
expect(calendarTicks('2024-10-27', '2026-10-27', 280).map(tick => tick.major ?? false)).toEqual([true, false, true, false])
expect(calendarTicks('2026-01-01', '2026-10-27', 900).some(tick => tick.major)).toBe(false)
expect(calendarTicks('2016-01-01', '2026-10-27', 280).some(tick => tick.major)).toBe(false)
})

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'])
Expand Down
8 changes: 5 additions & 3 deletions client/src/components/common/calendar-ticks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 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 }
// A year label among month labels is `major`, so charts can emphasize it.
export type CalendarTick = { date: string; label: string; major?: boolean }

type Unit = 'day' | 'month' | 'year'
type Candidate = { unit: Unit; step: number; spacing: number; minimum: number }
Expand Down Expand Up @@ -56,10 +57,11 @@ export function calendarTicks(startDate: string, endDate: string, width: number)
if (!dates.length || dates.length > MAX_LABELS) continue
return dates.map(time => {
const date = new Date(time), month = date.getUTCMonth()
const yearStart = candidate.unit === 'month' && multiYear && month === 0
const label = candidate.unit === 'day' ? `${months[month]} ${date.getUTCDate()}`
: candidate.unit === 'year' || (multiYear && month === 0) ? String(date.getUTCFullYear())
: candidate.unit === 'year' || yearStart ? String(date.getUTCFullYear())
: months[month]
return { date: iso(time), label }
return yearStart ? { date: iso(time), label, major: true } : { date: iso(time), label }
})
}
return [{ date: endDate, label: `${months[new Date(end).getUTCMonth()]} ${new Date(end).getUTCDate()}` }]
Expand Down
9 changes: 9 additions & 0 deletions client/src/components/common/interactive-area-chart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,12 @@ it('does not invent percentage changes for an unknown, zero or negative opening
expect(container).not.toHaveTextContent('%')
expect(container).not.toHaveTextContent('EUR')
})

it('labels the axis on calendar boundaries and emphasizes years among months', () => {
const data = ['2024-10-27', '2025-06-01', '2026-10-27'].map((date, index) => ({ date, value: index }))
const { container } = render(<InteractiveAreaChart {...props} data={data} />)
const labels = [...container.querySelectorAll('[data-axis-label="x"]')]
expect(labels.map(label => label.textContent)).toEqual(['2025', 'Apr', 'Jul', 'Oct', '2026', 'Apr', 'Jul', 'Oct'])
expect(labels.filter(label => label.hasAttribute('data-major')).map(label => label.textContent)).toEqual(['2025', '2026'])
expect(labels.find(label => label.textContent === '2025')).toHaveClass('fill-foreground', 'font-semibold')
})
Loading
Loading