From 0cb4cc2dfb91355289cb04314284f1dbee6aa1c3 Mon Sep 17 00:00:00 2001 From: 31b4 Date: Wed, 21 Oct 2026 11:20:51 +0200 Subject: [PATCH 1/7] Refresh balance history with interactive SVG area charts --- CHANGELOG.md | 2 + .../NetWorthTrendChart.test.tsx | 8 +- .../analytics-module/NetWorthTrendChart.tsx | 91 ++-------- .../analytics-module/PerAccountTrendChart.tsx | 86 ++------- .../common/interactive-area-chart.test.tsx | 91 ++++++++++ .../common/interactive-area-chart.tsx | 163 ++++++++++++++++++ .../src/components/common/licenses/easyui.txt | 21 +++ .../PortfolioHistoryChart.tsx | 30 ++-- .../src/test/PortfolioHistoryChart.test.tsx | 27 ++- 9 files changed, 337 insertions(+), 182 deletions(-) create mode 100644 client/src/components/common/interactive-area-chart.test.tsx create mode 100644 client/src/components/common/interactive-area-chart.tsx create mode 100644 client/src/components/common/licenses/easyui.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b8598c..23edde11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Intermediate package/UI version bumps are grouped under the next published GitHu ## Unreleased +- Refreshed cash-balance, account and portfolio history charts with an EasyUI-inspired SVG area presentation, dated value readouts and keyboard/touch inspection, preserving 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) diff --git a/client/src/components/analytics-module/NetWorthTrendChart.test.tsx b/client/src/components/analytics-module/NetWorthTrendChart.test.tsx index dee0d00a..c5f505ce 100644 --- a/client/src/components/analytics-module/NetWorthTrendChart.test.tsx +++ b/client/src/components/analytics-module/NetWorthTrendChart.test.tsx @@ -38,11 +38,11 @@ describe('Cash Balance Trend comparison', () => { it('is opt-in, labels both months, and replaces the EMA only while comparing', () => { renderChart() - expect(screen.getByTestId('series-smoothed')).toHaveAttribute('data-dashes', '6 3') + expect(document.querySelector('[data-series=reference] path')).toHaveAttribute('stroke-dasharray', '6 3') 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() @@ -52,8 +52,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', () => { diff --git a/client/src/components/analytics-module/NetWorthTrendChart.tsx b/client/src/components/analytics-module/NetWorthTrendChart.tsx index 5155c12c..76e8c9de 100644 --- a/client/src/components/analytics-module/NetWorthTrendChart.tsx +++ b/client/src/components/analytics-module/NetWorthTrendChart.tsx @@ -1,10 +1,8 @@ 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 { 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' @@ -42,84 +40,15 @@ export const NetWorthTrendChart = memo(function NetWorthTrendChart({ data, maste {comparing ? : data.length > 1 ? ( -
- - ({ ...point, timestamp: parseISO(point.date).getTime() }))} margin={{ top: 10, right: 10, left: -10, bottom: 0 }}> - - - - - - - - format(new Date(value), 'MMM d')} - stroke="hsl(var(--muted-foreground))" - fontSize={10} - minTickGap={24} - tickLine={false} - axisLine={false} - interval="preserveStartEnd" - /> - { - 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} - /> - { - if (active && payload && payload.length) { - const balanceEntry = payload.find(p => p.dataKey === 'balance') - const value = balanceEntry?.value ?? payload[0].value - return ( -
-

{format(new Date(Number(label)), 'MMM d, yyyy')}

-

- {privacyMode === 'hidden' ? '••••••' : `${Number(value)?.toLocaleString('hu-HU', {minimumFractionDigits: 0, maximumFractionDigits: 0})} ${masterCurrency}`} -

-
- ) - } - return null - }} - /> - - -
-
-
+ ({ date: point.date, value: point.balance, reference: point.smoothed }))} + label="Cash balance" + formatValue={value => value === null ? 'Unavailable' : `${value.toLocaleString('hu-HU', { maximumFractionDigits: 0 })} ${masterCurrency}`} + reference={{ label: 'Trend', color: 'hsl(var(--muted-foreground))', dashed: true }} + hidden={privacyMode === 'hidden'} + showReadout + className="h-48 sm:h-64" + /> ) : (
Need more data points to show trend diff --git a/client/src/components/analytics-module/PerAccountTrendChart.tsx b/client/src/components/analytics-module/PerAccountTrendChart.tsx index 0d421629..06434165 100644 --- a/client/src/components/analytics-module/PerAccountTrendChart.tsx +++ b/client/src/components/analytics-module/PerAccountTrendChart.tsx @@ -1,8 +1,7 @@ import { memo } from 'react' import { Card, CardContent, CardHeader, CardTitle } from '../common/card' -import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' +import { InteractiveAreaChart } from '../common/interactive-area-chart' import { usePrivacy } from '../../context/PrivacyContext' -import { calculateYAxisDomain } from './utils' import { COLORS } from './constants' import type { Account, TrendDataPoint } from './types' @@ -29,79 +28,16 @@ export const PerAccountTrendChart = memo(function PerAccountTrendChart({ account {data.length > 0 ? ( -
- - - - - - - - - - - { - 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={45} - /> - { - if (active && payload && payload.length) { - const balanceEntry = payload.find(p => p.dataKey === 'balance') - const value = balanceEntry?.value ?? payload[0].value - return ( -
-

{label}

-

- {privacyMode === 'hidden' ? '••••••' : `${Number(value)?.toLocaleString('hu-HU', {minimumFractionDigits: 0, maximumFractionDigits: 0})} ${account.currency}`} -

-
- ) - } - return null - }} - /> - - -
-
-
+ ({ date: point.date, value: point.balance, reference: point.smoothed }))} + label={`${account.name} balance`} + color={COLORS[index % COLORS.length]} + formatValue={value => value === null ? 'Unavailable' : `${value.toLocaleString('hu-HU', { maximumFractionDigits: 0 })} ${account.currency}`} + reference={{ label: 'Trend', color: 'hsl(var(--muted-foreground))', dashed: true }} + hidden={privacyMode === 'hidden'} + showReadout + className="h-36 sm:h-48" + /> ) : (
No transaction data in this period diff --git a/client/src/components/common/interactive-area-chart.test.tsx b/client/src/components/common/interactive-area-chart.test.tsx new file mode 100644 index 00000000..5af97afb --- /dev/null +++ b/client/src/components/common/interactive-area-chart.test.tsx @@ -0,0 +1,91 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { InteractiveAreaChart } from './interactive-area-chart' +import type { AreaChartPoint } from './interactive-area-chart' + +const points: AreaChartPoint[] = [ + { date: '2026-10-01', value: -100, reference: -120 }, + { date: '2026-10-02', value: null, reference: null }, + { date: '2026-10-10', value: 0, reference: -50 }, + { date: '2026-10-11', value: 200, reference: 100 }, +] +const money = (value: number | null) => value === null ? 'Unavailable' : `${value} EUR` +const props = { data: points, label: 'Cash balance', formatValue: money, showReadout: true } + +describe('InteractiveAreaChart', () => { + it('reads original observations by keyboard, including gaps and zero, and resets on Escape', () => { + render() + const slider = screen.getByRole('slider') + expect(slider).toHaveAttribute('aria-valuetext', 'Oct 11, 2026 · 200 EUR') + fireEvent.keyDown(slider, { key: 'Home' }) + expect(slider).toHaveAttribute('aria-valuetext', 'Oct 1, 2026 · -100 EUR') + fireEvent.keyDown(slider, { key: 'ArrowRight' }) + expect(slider).toHaveAttribute('aria-valuetext', 'Oct 2, 2026 · Unavailable') + fireEvent.keyDown(slider, { key: 'ArrowRight' }) + expect(slider).toHaveAttribute('aria-valuetext', 'Oct 10, 2026 · 0 EUR') + fireEvent.keyDown(slider, { key: 'End' }) + fireEvent.keyDown(slider, { key: 'ArrowRight' }) + expect(slider).toHaveAttribute('aria-valuenow', '3') + fireEvent.keyDown(slider, { key: 'Escape' }) + expect(screen.getByText('Oct 11, 2026 · latest in period')).toBeVisible() + }) + + it('snaps pointer and touch inspection to the nearest real date on an irregular time axis', () => { + const inspect = vi.fn() + render() + const slider = screen.getByRole('slider') + vi.spyOn(slider, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, left: 0, top: 0, right: 640, bottom: 240, width: 640, height: 240, toJSON: () => ({}) }) + // jsdom does not implement PointerEvent; use MouseEvent's coordinate support. + const event = new MouseEvent('pointermove', { bubbles: true, clientX: 115 }) + fireEvent(slider, event) + expect(inspect).toHaveBeenLastCalledWith(1) + expect(slider).toHaveAttribute('aria-valuetext', 'Oct 2, 2026 · Unavailable') + fireEvent(slider, new MouseEvent('pointerdown', { bubbles: true, clientX: 628 })) + expect(inspect).toHaveBeenLastCalledWith(3) + fireEvent.pointerCancel(slider) + expect(inspect).toHaveBeenLastCalledWith(null) + }) + + it('breaks both paths at missing observations and keeps the reference stepped', () => { + const { container } = render() + const primary = container.querySelector('[data-series=value]')! + expect(primary.querySelectorAll('path[fill=none]')).toHaveLength(2) + expect(primary.querySelectorAll('circle')).toHaveLength(1) + const lines = container.querySelectorAll('[data-series=reference] path') + expect(lines).toHaveLength(2) + expect(lines[1].getAttribute('d')).toContain(' H') + expect(lines[1]).toHaveAttribute('stroke-dasharray', '6 3') + expect(container.innerHTML).not.toMatch(/NaN|Infinity/) + }) + + it('removes values, chart geometry and accessible financial labels in privacy mode', () => { + const { container, rerender } = render() + fireEvent.keyDown(screen.getByRole('slider'), { key: 'Home' }) + rerender(
{error&&

{data?'Showing saved history. Refresh failed.':'Portfolio history could not be loaded.'} {history.errorMessage}

} {loading&&

{data?'Refreshing history…':'Loading portfolio history…'}

} -
- {hidden?
Chart hidden for privacy
:!data?
{loading?'Loading daily values…':'History unavailable'}
:!known?
No known portfolio values in this range.
: - {if(s.activeIndex!=null&&Number.isInteger(Number(s.activeIndex)))readDay(Number(s.activeIndex))}} onClick={s=>{if(s.activeIndex!=null&&Number.isInteger(Number(s.activeIndex)))readDay(Number(s.activeIndex))}} onMouseLeave={()=>setSelection(null)} margin={{left:0,right:12,top:8,bottom:0}}> - - - String(d).slice(5)}/> - Intl.NumberFormat(undefined,{notation:'compact',maximumFractionDigits:1}).format(n)} domain={['auto','auto']}/> - { - const p=active&&payload?.[0]?.payload as PortfolioHistoryPoint|undefined - return p?} +
+ {hidden?
Chart hidden for privacy
:!data?
{loading?'Loading daily values…':'History unavailable'}
:!known?
No known portfolio values in this range.
: + ({date:point.date,value:point.value,reference:point.net_invested}))} + label="Portfolio value" formatValue={money} className="h-[220px] @min-[640px]:h-[300px]" + reference={{label:'Net invested',color:'hsl(var(--muted-foreground))',dashed:true,step:true}} + activeIndex={selected?selection!.index:null} onInspect={index=>index===null?setSelection(null):readDay(index)} + renderTooltip={index=>}/>}
-
Portfolio valueNet invested
- {!hidden&&data&&data.points.length>1&&} {data&&<>

{data.from} – {data.to} · {data.timezone} · read {new Date(data.as_of).toLocaleString()}{data.points.at(-1)?.provisional?' · today is provisional':''}

} diff --git a/client/src/test/PortfolioHistoryChart.test.tsx b/client/src/test/PortfolioHistoryChart.test.tsx index 0db6f44a..4b2d1e9c 100644 --- a/client/src/test/PortfolioHistoryChart.test.tsx +++ b/client/src/test/PortfolioHistoryChart.test.tsx @@ -35,7 +35,30 @@ it('retains saved data with an actionable retry after a failed refresh',()=>{ it('reads a selected day by keyboard/touch control without substituting a current value for a gap',()=>{ const history=fixture();history.data.points[0].value=null render(