From 2617e99e9b14f64586ed59c0e615149947846158 Mon Sep 17 00:00:00 2001 From: 31b4 Date: Wed, 28 Oct 2026 10:43:46 +0100 Subject: [PATCH] Use calendar-aligned axes on every date chart Per-account trends, portfolio history, the investment detail chart, manual valuations and the AI cash outlook now share the calendar ticks from #183, and year labels among months are emphasized. Per-account balances are rebuilt from the whole ledger and clipped to the period, so past months are correct and 1M/1Y open on the first day. --- .../analytics-module/AICashOutlookChart.tsx | 11 +-- .../analytics-module/Analytics.test.tsx | 26 +++++++ .../components/analytics-module/Analytics.tsx | 77 +++++-------------- .../analytics-module/NetWorthTrendChart.tsx | 2 +- .../components/common/calendar-axis.test.tsx | 26 +++++++ .../src/components/common/calendar-axis.tsx | 51 ++++++++++++ .../components/common/calendar-ticks.test.ts | 6 ++ .../src/components/common/calendar-ticks.ts | 8 +- .../common/interactive-area-chart.test.tsx | 9 +++ .../common/interactive-area-chart.tsx | 22 +++--- .../investments-module/InvestmentChart.tsx | 9 +-- .../investments-module/ManualValuations.tsx | 6 +- 12 files changed, 167 insertions(+), 86 deletions(-) create mode 100644 client/src/components/common/calendar-axis.test.tsx create mode 100644 client/src/components/common/calendar-axis.tsx diff --git a/client/src/components/analytics-module/AICashOutlookChart.tsx b/client/src/components/analytics-module/AICashOutlookChart.tsx index ea370d39..3a8c4990 100644 --- a/client/src/components/analytics-module/AICashOutlookChart.tsx +++ b/client/src/components/analytics-module/AICashOutlookChart.tsx @@ -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' @@ -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 (
@@ -208,7 +209,7 @@ export function AICashOutlookChart({ snapshot, transactions, accounts, convertTo {usesReconstruction &&

Earlier history is reconstructed using current transactions, account settings, and exchange rates, so it may change.

} {hasMissingFx &&

Some account currencies have no exchange rate; their earlier movements are excluded.

} {hasMonthlyHistory &&

History before {format(dateFromHistory(dailyHistoryStart!), 'MMM d, yyyy')} uses saved monthly balances; the past year uses daily balances.

} -
+
@@ -218,7 +219,7 @@ export function AICashOutlookChart({ snapshot, transactions, accounts, convertTo - format(new Date(value), historyRange === 'all' ? 'MMM d, yy' : 'MMM d')} stroke="hsl(var(--muted-foreground))" fontSize={10} tickLine={false} axisLine={false} /> + hidden ? '••••' : Math.abs(value) >= 1_000_000 ? `${(value / 1_000_000).toFixed(1)}M` : `${Math.round(value / 1_000)}K`} /> { const point = payload?.[0]?.payload as ChartPoint | undefined diff --git a/client/src/components/analytics-module/Analytics.test.tsx b/client/src/components/analytics-module/Analytics.test.tsx index f184da97..95d61700 100644 --- a/client/src/components/analytics-module/Analytics.test.tsx +++ b/client/src/components/analytics-module/Analytics.test.tsx @@ -24,6 +24,12 @@ vi.mock('./NetWorthTrendChart', () => ({ ), })) +vi.mock('./PerAccountTrendChart', () => ({ + PerAccountTrendChart: ({ account, data }: { account: Account; data: Array<{ date: string; balance: number }> }) => ( +
({ 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' }, @@ -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() + 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() + 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( { + 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 = {} accountTransactions.forEach(tx => { transactionsByDate[tx.date] = (transactionsByDate[tx.date] || 0) + tx.amount }) - - const dateBalances: Record = {} + + const dateBalances: Record = { [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 @@ -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]) diff --git a/client/src/components/analytics-module/NetWorthTrendChart.tsx b/client/src/components/analytics-module/NetWorthTrendChart.tsx index 29c70066..6e5f0f5b 100644 --- a/client/src/components/analytics-module/NetWorthTrendChart.tsx +++ b/client/src/components/analytics-module/NetWorthTrendChart.tsx @@ -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} /> : <>

{title}

Need more data points to show trend
} diff --git a/client/src/components/common/calendar-axis.test.tsx b/client/src/components/common/calendar-axis.test.tsx new file mode 100644 index 00000000..b6d15420 --- /dev/null +++ b/client/src/components/common/calendar-axis.test.tsx @@ -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({ticks.map(time => {tick({ x: 10, y: 10, payload: { value: time } })})}) + 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([]) + }) +}) diff --git a/client/src/components/common/calendar-axis.tsx b/client/src/components/common/calendar-axis.tsx new file mode 100644 index 00000000..3acd0a7a --- /dev/null +++ b/client/src/components/common/calendar-axis.tsx @@ -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(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() + 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 + return {tick.label} + } + + return { ref, axisProps: { ticks: [...ticks.keys()], interval: 0 as const, tick: renderTick } } +} diff --git a/client/src/components/common/calendar-ticks.test.ts b/client/src/components/common/calendar-ticks.test.ts index 3c95b6f5..f23cca63 100644 --- a/client/src/components/common/calendar-ticks.test.ts +++ b/client/src/components/common/calendar-ticks.test.ts @@ -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']) diff --git a/client/src/components/common/calendar-ticks.ts b/client/src/components/common/calendar-ticks.ts index 15ad0a0a..1f1bdc4e 100644 --- a/client/src/components/common/calendar-ticks.ts +++ b/client/src/components/common/calendar-ticks.ts @@ -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 } @@ -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()}` }] diff --git a/client/src/components/common/interactive-area-chart.test.tsx b/client/src/components/common/interactive-area-chart.test.tsx index 93d251ef..bdc4e878 100644 --- a/client/src/components/common/interactive-area-chart.test.tsx +++ b/client/src/components/common/interactive-area-chart.test.tsx @@ -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() + 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') +}) diff --git a/client/src/components/common/interactive-area-chart.tsx b/client/src/components/common/interactive-area-chart.tsx index 8e62df04..6b76ed45 100644 --- a/client/src/components/common/interactive-area-chart.tsx +++ b/client/src/components/common/interactive-area-chart.tsx @@ -28,16 +28,13 @@ 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) const finite = (value: number | null | undefined): value is number => value != null && Number.isFinite(value) -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, calendarAxis = false }: 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 }: InteractiveAreaChartProps) { const uid = useId().replaceAll(':', '') const reduce = useReducedMotion() const wrapRef = useRef(null) @@ -87,11 +84,9 @@ export function InteractiveAreaChart({ data, label, formatValue, color = 'hsl(va const first = data[0]?.value 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 = !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) })) + // Calendar boundaries (days, months, years) placed by time, not by point index. + const labels = !data.length ? [] : 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, major: tick.major })) const paths = (key: 'value' | 'reference', step = false) => { const segments: { line: string; area: string; single: number | null }[] = [] @@ -174,9 +169,12 @@ export function InteractiveAreaChart({ data, label, formatValue, color = 'hsl(va {Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(value)} )} - {labels.map((tick, j) => {tick.text})} + {labels.map(tick => + {tick.major && } + {tick.text} + )} {reference && {paths('reference', reference.step).map((path, i) => {path.single !== null && } diff --git a/client/src/components/investments-module/InvestmentChart.tsx b/client/src/components/investments-module/InvestmentChart.tsx index 53cdd22a..b5d3f4e0 100644 --- a/client/src/components/investments-module/InvestmentChart.tsx +++ b/client/src/components/investments-module/InvestmentChart.tsx @@ -12,6 +12,7 @@ import { XAxis, YAxis, } from 'recharts' +import { useCalendarAxis } from '../common/calendar-axis' import type { PriceHistory } from '../../../../shared/market-data' import { API_BASE_URL, apiFetch } from '../../config' import { unitPrice } from './overview' @@ -168,6 +169,7 @@ export function InvestmentChart({ Math.min(...points.map((p) => p.time), ...markers.map((t) => t.time)), Math.max(...points.map((p) => p.time), ...markers.map((t) => t.time)), ] + const { ref: axisRef, axisProps } = useCalendarAxis(points.length ? domain[0] : undefined, points.length ? domain[1] : undefined, { plotInset: 12 }) return (
@@ -204,7 +206,7 @@ export function InvestmentChart({ {history ? 'Refreshing price history…' : 'Loading price history…'}

)} -
+
{!points.length ? (

{loading ? 'Loading daily observations…' : 'No known daily closes in this range.'} @@ -241,12 +243,9 @@ export function InvestmentChart({ type="number" domain={domain} scale="time" - tickFormatter={t=>new Intl.DateTimeFormat(undefined,{month:'short',...(history&&Date.parse(history.to)-Date.parse(history.from)>200*86400000?{year:'numeric' as const}:{day:'numeric' as const}),timeZone:'UTC'}).format(new Date(Number(t)))} + {...axisProps} tickLine={false} axisLine={false} - tickCount={4} - minTickGap={50} - tick={{ fontSize: 10 }} /> ({date:v.effective_date,time:Date.parse(`${v.effective_date}T00:00:00Z`),value:v.amount})) const through = history?.at ?? localAccountDate() if (points.length && points[points.length-1].date < through) points.push({date:through,time:Date.parse(`${through}T00:00:00Z`),value:points[points.length-1].value}) + const { ref: axisRef, axisProps } = useCalendarAxis(points[0]?.time, points.at(-1)?.time, { plotInset: 82 }) const status = account.manual_valuation return

@@ -126,8 +128,8 @@ export function ManualValuations({ account, hidden, onSaved, referenceCost, onBu } {saving&&

Saving valuation…

} {error &&
{error}
} - {!hidden && points.length>1 &&
- new Date(Number(value)).toISOString().slice(0,10)} tick={{fontSize:11,fill:'hsl(var(--muted-foreground))'}} minTickGap={50} /> + {!hidden && points.length>1 &&
+ new Date(Number(value)).toISOString().slice(0,10)} formatter={value => [`${amountText(Number(value))} ${currency}`,total?'Total value':'Unit price']} contentStyle={{background:'hsl(var(--card))',borderColor:'hsl(var(--border))',color:'hsl(var(--foreground))'}} /> {referenceCost!=null&&}