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
6 changes: 5 additions & 1 deletion client/src/components/analytics-module/Analytics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))
Expand Down
41 changes: 14 additions & 27 deletions client/src/components/analytics-module/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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}`
Expand All @@ -28,15 +30,25 @@ function ComparisonReadout({ point, currency }: { point: CashBalanceComparisonPo
<span className="text-muted-foreground">Difference: </span>
<span className="font-semibold">{amount(delta.amount, true)}{!hidden && delta.percentage !== null ? ` (${delta.percentage > 0 ? '+' : ''}${delta.percentage.toFixed(1)}%)` : ''}</span>
</p>}
{point.selected === null && point.previous !== null && <p className="text-muted-foreground">Only the previous month has an actual balance on this day.</p>}
{point.previous === null && point.selected !== null && <p className="text-muted-foreground">The previous month has no matching calendar day.</p>}
{point.selected === null && point.previous !== null && <p className="text-muted-foreground">Only the previous {unit} has an actual balance on this day.</p>}
{point.previous === null && point.selected !== null && <p className="text-muted-foreground">The previous {unit} has no matching calendar day.</p>}
{point.selected === null && point.previous === null && <p className="text-muted-foreground">No actual balance is available on this day yet.</p>}
</div>
)
}

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]
Expand All @@ -45,41 +57,66 @@ 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 (
<div className="space-y-3">
<div className="flex flex-wrap gap-x-5 gap-y-1 px-2 sm:px-0 text-xs" aria-label="Cash balance comparison legend">
<span className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="h-2.5 w-2.5 rounded-full bg-primary" />{selectedLabel}</span>
<span className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="h-2.5 w-2.5 rounded-full border-2 border-muted-foreground" />{previousLabel}</span>
<div className="min-w-0">
<div className="mb-4 flex flex-col items-start justify-between gap-3 min-[480px]:flex-row min-[480px]:gap-4">
<div className="min-w-0" aria-live="polite" aria-atomic="true">
<h3 className="text-[13px] font-normal text-muted-foreground">{title}</h3>
<p className="mt-0.5 text-2xl font-semibold tracking-tight tabular-nums sm:text-3xl">
{!delta ? '–' : hidden ? '••••••' : `${delta.amount > 0 ? '+' : ''}${delta.amount.toLocaleString('hu-HU', { maximumFractionDigits: 0 })} ${currency}`}
</p>
{/* One line, like the balance readout, so the card height never changes. */}
<p className="mt-1 truncate text-xs tabular-nums sm:text-[13px]">
{delta && !hidden && delta.percentage !== null && <span className={delta.percentage >= 0 ? 'easyui-chart-positive' : 'easyui-chart-negative'}>{delta.percentage > 0 ? '+' : ''}{delta.percentage.toFixed(1)}% </span>}
<span className="text-muted-foreground">{delta && latestCommon
? `${format(parseISO(latestCommon.selectedDate!), 'MMM d')} vs ${format(parseISO(latestCommon.previousDate!), 'MMM d')} · latest shared day`
: 'No shared day to compare yet'}</span>
</p>
</div>
{controls}
</div>
{delta && latestCommon && <div className="px-2 sm:px-0">
<p className="text-lg font-semibold tabular-nums">
{privacyMode === 'hidden' ? '••••••' : `${delta.amount > 0 ? '+' : ''}${delta.amount.toLocaleString('hu-HU', { maximumFractionDigits: 0 })} ${currency}${delta.percentage !== null ? ` (${delta.percentage > 0 ? '+' : ''}${delta.percentage.toFixed(1)}%)` : ''}`}
</p>
<p className="text-xs text-muted-foreground">{format(parseISO(latestCommon.selectedDate!), 'MMM d, yyyy')} vs {format(parseISO(latestCommon.previousDate!), 'MMM d, yyyy')} · latest shared day</p>
</div>}
<div className="h-48 sm:h-64">
<div className={cn('h-[280px] min-w-0', className)}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart accessibilityLayer data={comparison.points} margin={{ top: 10, right: 12, left: -10, bottom: 0 }}>
<defs><linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient></defs>
<CartesianGrid vertical={false} stroke="hsl(var(--border))" strokeDasharray="3 3" opacity={0.5} />
<XAxis dataKey="day" type="number" domain={[1, comparison.points.length]} ticks={[1, 5, 10, 15, 20, 25, comparison.points.length]} tickFormatter={day => `${day}`} stroke="hsl(var(--muted-foreground))" fontSize={10} tickLine={false} axisLine={false} />
<XAxis dataKey="day" type="number" domain={[1, comparison.points.length]} ticks={ticks} tickFormatter={tickLabel} stroke="hsl(var(--muted-foreground))" fontSize={10} tickLine={false} axisLine={false} />
<YAxis domain={[min - padding, max + padding]} width={56} stroke="hsl(var(--muted-foreground))" fontSize={10} tickLine={false} axisLine={false}
tickFormatter={value => 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)} />
<Tooltip content={({ active, payload }) => {
const activePoint = payload?.[0]?.payload as CashBalanceComparisonPoint | undefined
return active && activePoint ? <div className="min-w-48 rounded-lg border border-border bg-card p-3 shadow-lg"><ComparisonReadout point={activePoint} currency={currency} /></div> : null
return active && activePoint ? <div className="min-w-48 rounded-lg border border-border bg-card p-3 shadow-lg"><ComparisonReadout point={activePoint} currency={currency} unit={comparison.unit} /></div> : null
}} />
<Area type="monotone" dataKey="selected" name={selectedLabel} stroke="hsl(var(--primary))" strokeWidth={2.5} fill={`url(#${gradientId})`} dot={{ r: 2, fill: 'hsl(var(--primary))', strokeWidth: 0 }} connectNulls={false} isAnimationActive={false} />
<Line type="monotone" dataKey="previous" name={previousLabel} stroke="hsl(var(--muted-foreground))" strokeWidth={1.5} dot={{ r: 2, fill: 'hsl(var(--card))', strokeWidth: 1.5 }} connectNulls={false} isAnimationActive={false} />
{/* A year has too many days for point markers. */}
<Area type="monotone" dataKey="selected" name={selectedLabel} stroke="hsl(var(--primary))" strokeWidth={2.5} fill={`url(#${gradientId})`} dot={yearly ? false : { r: 2, fill: 'hsl(var(--primary))', strokeWidth: 0 }} connectNulls={false} isAnimationActive={false} />
<Line type="monotone" dataKey="previous" name={previousLabel} stroke="hsl(var(--muted-foreground))" strokeWidth={1.5} dot={yearly ? false : { r: 2, fill: 'hsl(var(--card))', strokeWidth: 1.5 }} connectNulls={false} isAnimationActive={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap gap-x-5 gap-y-1 px-2 text-xs text-muted-foreground sm:px-0" aria-label="Cash balance comparison legend">
<span className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="h-0.5 w-4 bg-primary" />{selectedLabel}</span>
<span className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="w-4 border-t border-muted-foreground" />{previousLabel}</span>
</div>
{footer && <div className="ml-auto flex flex-wrap items-center justify-end gap-2">{footer}</div>}
</div>
</div>
)
}
Loading
Loading