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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Intermediate package/UI version bumps are grouped under the next published GitHu

## Unreleased

- Monochrome now uses a dedicated neutral light/dark palette for surfaces, controls and primary balance charts instead of a page-wide filter, retaining meaningful colors for income/expenses, gains/losses, statuses, forecasts and categories. Area charts follow the selected palette, with blue accents in Original and neutral accents in Monochrome. Red Filter also replaces its page filter with native red surfaces, controls and primary charts in light/dark modes, retaining the same meaningful financial/status and category colors.

- Refreshed cash-balance, account and portfolio history charts with EasyUI-style cards, smooth area curves, animated reveals and range pills. Dated value readouts and keyboard/touch inspection preserve native currencies, history gaps and privacy.

## v4.0 — 2026-10-20

[GitHub release](https://github.com/apptrackit/finance/releases/tag/v4.0) · [Compare v3.3…v4.0](https://github.com/apptrackit/finance/compare/v3.3...v4.0)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ The API and MCP Worker share the deployed D1 database but have separate authenti

### Accounts, search, and archive

The Accounts page groups cash/bank accounts, investments, and archived accounts. Desktop navigation includes collapsible Cash/Investments shortcuts showing all accounts sorted by converted value, independent account scrolling, compact balances, and Search everything; mobile uses bottom navigation, More, and a header search button. The sidebar and Settings expose light/dark mode independently of the existing color themes.
The Accounts page groups cash/bank accounts, investments, and archived accounts. Desktop navigation includes collapsible Cash/Investments shortcuts showing all accounts sorted by converted value, independent account scrolling, compact balances, and Search everything; mobile uses bottom navigation, More, and a header search button. The sidebar and Settings expose light/dark mode independently of the color theme. Original uses blue accents; Monochrome has native neutral surfaces, controls and primary balance charts in both display modes, with color retained for income/expenses, gains/losses, statuses, forecasts and category distinctions. Area charts inherit the selected palette. Red Filter also uses a native palette, with deep red surfaces in dark mode, warm pale surfaces in light mode, and matching red controls and primary charts. Both themes retain semantic financial/status colors and distinct category colors. Neither palette uses a page filter.

Initial Dashboard, Analytics, and Investments reads use text-free skeletons shaped like their widgets, with a left-to-right gradient shimmer. Analytics respects saved widget visibility; reduced-motion preferences disable the shimmer. Once data has loaded, background refreshes keep the page visible with a small fixed refresh indicator. Investment portfolios and the first page of saved forecast reports stay in memory across navigation and refresh when reopened; a browser reload starts a new session. Failed refreshes retain the previous complete snapshot with retry feedback.

Expand Down
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"clsx": "^2.1.1",
"date-fns": "^2.30.0",
"framer-motion": "^14.5.0",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
Expand Down
2 changes: 1 addition & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ function App() {
<aside className="finance-sidebar fixed inset-y-0 left-0 z-40 hidden flex-col border-r border-border bg-card px-3.5 py-5 lg:flex overflow-hidden">
<div className="mb-5 flex shrink-0 items-center gap-2">
<NavLink to="/dashboard" className="flex min-w-0 flex-1 items-center gap-2.5 px-1.5" aria-label="Finance home">
<span className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-primary text-white"><Wallet className="h-[17px] w-[17px]" /></span>
<span className="flex h-8 w-8 items-center justify-center rounded-[9px] bg-primary text-primary-foreground"><Wallet className="h-[17px] w-[17px]" /></span>
<span><span className="block text-[15px] font-semibold">Finance</span><span className="block text-[11px] text-muted-foreground">Self-hosted</span></span>
</NavLink>
{privacyControl}
Expand Down
16 changes: 13 additions & 3 deletions client/src/components/analytics-module/Analytics.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { ReactNode } from 'react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Analytics } from './Analytics'
import { WIDGET_DEFS } from './widgetConfig'
Expand All @@ -18,8 +19,8 @@ vi.mock('./IncomeExpensesTrendChart', () => ({
),
}))
vi.mock('./NetWorthTrendChart', () => ({
NetWorthTrendChart: ({ data, comparison, comparisonDisabledReason }: { data: Array<{ date: string; balance: number }>; comparison?: CashBalanceComparison; comparisonDisabledReason?: string }) => (
<div data-testid="cash-trend" data-points={JSON.stringify(data)} data-comparison={JSON.stringify(comparison)} data-disabled-reason={comparisonDisabledReason} />
NetWorthTrendChart: ({ data, comparison, comparisonDisabledReason, controls }: { controls?: ReactNode; data: Array<{ date: string; balance: number }>; comparison?: CashBalanceComparison; comparisonDisabledReason?: string }) => (
<div data-testid="cash-trend" data-points={JSON.stringify(data)} data-comparison={JSON.stringify(comparison)} data-disabled-reason={comparisonDisabledReason}>{controls}</div>
),
}))

Expand Down Expand Up @@ -60,6 +61,15 @@ describe('Analytics Projected mode', () => {
vi.useRealTimers()
})

it('applies the chart range pill to the page filters and returns projected data to Actual in one update', () => {
localStorage.setItem('analytics-widget-visibility', JSON.stringify({ 'cash-balance-trend': true }))
const onFiltersChange = vi.fn()
render(<Analytics transactions={[posted]} upcomingTransactions={[upcoming('future', '2026-09-20')]} accounts={accounts} categories={[]}
filters={{ period: 'month', date: '2026-09-01', pinned: false, mode: 'projected', expenseCategory: 'all', incomeCategory: 'all', resolution: 'quarter' }} onFiltersChange={onFiltersChange} />)
fireEvent.click(within(screen.getByRole('group', { name: 'Cash balance range' })).getByRole('button', { name: '1Y' }))
expect(onFiltersChange).toHaveBeenCalledExactlyOnceWith({ period: 'year', mode: 'actual', resolution: 'default' }, false)
})

it('hides the switch and keeps Actual values when only other periods or ineligible rows exist', () => {
renderAnalytics([
upcoming('october', '2026-10-01'),
Expand Down
5 changes: 4 additions & 1 deletion client/src/components/analytics-module/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { convertToMasterCurrency as convertUtil } from './utils'
import { SummaryCards } from './SummaryCards'
import { FinancialOutlook } from './FinancialOutlook'
import { NetWorthTrendChart } from './NetWorthTrendChart'
import { AreaChartRanges } from '../common/area-chart-ranges'
import { IncomeChart } from './IncomeChart'
import { ExpensesChart } from './ExpensesChart'
import {
Expand Down Expand Up @@ -923,7 +924,9 @@ export function Analytics({

const cashTrendChart = show('cash-balance-trend') && accounts.some(account => account.type !== 'investment' && !account.exclude_from_cash_balance) ? (
<NetWorthTrendChart data={displayedCashBalanceData} masterCurrency={masterCurrency}
title="Cash Balance Trend" comparison={cashBalanceComparison} comparisonDisabledReason={cashComparisonDisabledReason} />
title="Cash Balance Trend" periodLabel={periodDisplayLabel}
controls={<AreaChartRanges label="Cash balance range" options={[['month', '1M'], ['year', '1Y'], ['allTime', 'All']] as const} value={period} onChange={next => changeFilters({ period: next, mode: 'actual', resolution: 'default' })} />}
comparison={cashBalanceComparison} comparisonDisabledReason={cashComparisonDisabledReason} />
) : null

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const CategoryBreakdownChart = memo(function CategoryBreakdownChart({ dat
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
fill={COLORS[0]}
dataKey="value"
>
{data.map((_entry, index) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const IncomeBreakdownChart = memo(function IncomeBreakdownChart({ data, m
cy="50%"
labelLine={false}
outerRadius={80}
fill="#8884d8"
fill={COLORS[0]}
dataKey="value"
>
{data.map((_entry, index) => (
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/analytics-module/MoneyMapChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const COLORS: Record<FlowKind, string> = {
'cash-flow': 'hsl(var(--primary))',
expense: 'hsl(var(--destructive))',
surplus: 'hsl(var(--success))',
deficit: 'hsl(38 92% 50%)',
deficit: 'hsl(var(--warning))',
}

function consolidateCategories(data: CategoryData[], maxItems: number) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,17 @@ const toggle = () => fireEvent.click(screen.getByRole('button', { name: /^(Compa
describe('Cash Balance Trend comparison', () => {
beforeEach(() => { state.privacyMode = 'visible'; state.tooltipDay = 2; state.points = [] })

it('is opt-in, labels both months, and replaces the EMA only while comparing', () => {
it('shows the trend by default, lets it be hidden, and keeps comparison opt-in', () => {
renderChart()
expect(screen.getByTestId('series-smoothed')).toHaveAttribute('data-dashes', '6 3')
expect(screen.getByRole('button', { name: 'Trend' })).toHaveAttribute('aria-pressed', 'true')
expect(document.querySelector('[data-series=reference] path')).toHaveAttribute('stroke-dasharray', '6 3')
fireEvent.click(screen.getByRole('button', { name: 'Trend' }))
expect(document.querySelector('[data-series=reference]')).toBeNull()
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.queryByTestId('series-smoothed')).not.toBeInTheDocument()
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()
Expand All @@ -52,8 +56,8 @@ describe('Cash Balance Trend comparison', () => {
expect(screen.queryByText(/Inspect day/)).not.toBeInTheDocument()
toggle()
expect(screen.getByRole('button', { name: 'Compare previous month' })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByTestId('series-smoothed')).toBeInTheDocument()
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
expect(document.querySelector('[data-series=reference]')).toBeInTheDocument()
expect(screen.getByRole('slider', { name: /Cash balance/ })).toBeInTheDocument()
})

it('shows only the prior actual value on current-month future days in the tooltip', () => {
Expand Down
138 changes: 28 additions & 110 deletions client/src/components/analytics-module/NetWorthTrendChart.tsx
Original file line number Diff line number Diff line change
@@ -1,131 +1,49 @@
import { memo, useId, useState } from 'react'
import { format, parseISO } from 'date-fns'
import { Card, CardContent, CardHeader, CardTitle } from '../common/card'
import { TrendingUp } from 'lucide-react'
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import type { ReactNode } from 'react'
import { InteractiveAreaChart } from '../common/interactive-area-chart'
import { usePrivacy } from '../../context/PrivacyContext'
import { calculateYAxisDomain } from './utils'
import type { TrendDataPoint } from './types'
import { Button } from '../common/button'
import { CashBalanceComparisonChart } from './CashBalanceComparisonChart'
import type { CashBalanceComparison } from './cash-balance-comparison'

type NetWorthTrendChartProps = {
data: TrendDataPoint[]
masterCurrency: string
title?: string
controls?: ReactNode
periodLabel?: string
comparison?: CashBalanceComparison
comparisonDisabledReason?: string
}

export const NetWorthTrendChart = memo(function NetWorthTrendChart({ data, masterCurrency, title = 'Cash Balance Trend', comparison, comparisonDisabledReason }: NetWorthTrendChartProps) {
export const NetWorthTrendChart = memo(function NetWorthTrendChart({ data, masterCurrency, title = 'Cash Balance Trend', controls, periodLabel, comparison, comparisonDisabledReason }: NetWorthTrendChartProps) {
const { privacyMode } = usePrivacy()
const [compare, setCompare] = useState(false)
const [showTrend, setShowTrend] = useState(true)
const reasonId = useId()
const unavailableReason = comparisonDisabledReason || comparison?.unavailableReason
const comparing = compare && comparison !== undefined && !unavailableReason
const hidden = privacyMode === 'hidden'

return (
<Card className="lg:col-span-2">
<CardHeader className="pb-2 px-4 sm:px-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm sm:text-base">{title}</CardTitle>
</div>
{comparison && <Button type="button" size="sm" variant={comparing ? 'default' : 'outline'} className="self-start enabled:cursor-pointer sm:self-auto"
aria-pressed={comparing} disabled={Boolean(unavailableReason)} aria-describedby={unavailableReason ? reasonId : undefined} onClick={() => setCompare(previous => !previous)}>
{comparing ? 'Hide comparison' : 'Compare previous month'}
</Button>}
</div>
{comparison && unavailableReason && <p id={reasonId} className="text-xs text-muted-foreground">{unavailableReason}</p>}
</CardHeader>
<CardContent className="px-2 sm:px-6">
{comparing ? <CashBalanceComparisonChart key={comparison.selectedMonth} comparison={comparison} currency={masterCurrency} /> : data.length > 1 ? (
<div className="h-48 sm:h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data.map(point => ({ ...point, timestamp: parseISO(point.date).getTime() }))} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}>
<defs>
<linearGradient id="netWorthGradient" 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 strokeDasharray="3 3" stroke="hsl(var(--border))" opacity={0.5} />
<XAxis
dataKey="timestamp"
type="number"
scale="time"
domain={['dataMin', 'dataMax']}
tickFormatter={value => format(new Date(value), 'MMM d')}
stroke="hsl(var(--muted-foreground))"
fontSize={10}
minTickGap={24}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
stroke="hsl(var(--muted-foreground))"
fontSize={10}
tickLine={false}
axisLine={false}
domain={calculateYAxisDomain(data)}
tickFormatter={(value) => {
if (privacyMode === 'hidden') return '••••'
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
if (value >= 1000) return `${(value / 1000).toFixed(0)}K`
return value.toFixed(0)
}}
width={50}
/>
<Tooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
const balanceEntry = payload.find(p => p.dataKey === 'balance')
const value = balanceEntry?.value ?? payload[0].value
return (
<div className="bg-card border border-border rounded-lg p-2 shadow-lg">
<p className="text-xs text-muted-foreground mb-1">{format(new Date(Number(label)), 'MMM d, yyyy')}</p>
<p className={`text-sm font-bold text-primary ${privacyMode === 'hidden' ? 'select-none' : ''}`}>
{privacyMode === 'hidden' ? '••••••' : `${Number(value)?.toLocaleString('hu-HU', {minimumFractionDigits: 0, maximumFractionDigits: 0})} ${masterCurrency}`}
</p>
</div>
)
}
return null
}}
/>
<Area
type="monotone"
dataKey="smoothed"
name="Trend"
stroke="hsl(var(--muted-foreground))"
strokeWidth={1.5}
strokeDasharray="6 3"
fillOpacity={0}
fill="none"
dot={false}
activeDot={false}
/>
<Area
type="linear"
dataKey="balance"
name="Cash balance"
stroke="hsl(var(--primary))"
strokeWidth={2}
fillOpacity={1}
fill="url(#netWorthGradient)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-48 sm:h-64 flex items-center justify-center text-muted-foreground text-sm">
Need more data points to show trend
</div>
)}
</CardContent>
</Card>
)
return <section className="easyui-area-card lg:col-span-2" aria-label={title}>
{comparing ? <>
<div className="mb-5 flex flex-wrap items-start justify-between gap-3"><h3 className="text-[13px] text-muted-foreground">{title}</h3>{controls}</div>
<CashBalanceComparisonChart key={comparison.selectedMonth} comparison={comparison} currency={masterCurrency} />
</> : data.length > 1 ? <InteractiveAreaChart
data={data.map(point => ({ 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]"
/> : <><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></>}
<div className="mt-3 flex flex-wrap items-center justify-end gap-2">
{!comparing && !hidden && <button type="button" aria-pressed={showTrend} className="easyui-chart-control" onClick={() => setShowTrend(previous => !previous)}>Trend</button>}
{comparison && <button type="button" className="easyui-chart-control" aria-pressed={comparing}
disabled={Boolean(unavailableReason)} aria-describedby={unavailableReason ? reasonId : undefined} onClick={() => setCompare(previous => !previous)}>
{comparing ? 'Hide comparison' : 'Compare previous month'}
</button>}
</div>
{comparison && unavailableReason && <p id={reasonId} className="mt-1 text-xs text-muted-foreground">{unavailableReason}</p>}
</section>
})
Loading
Loading