From ceff158f8d7875b41afc95cd90f821fa80d4b5d5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 19 May 2026 21:36:43 +0200 Subject: [PATCH] feat(dashboard): add financial overview screen (#1106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): add financial overview screen - New screen at /dashboard/financial/overview with summary cards (total/plus/minus balance + age of latest log entry) - Auto-refresh every 60s, age ticks every 1s in isolated subcomponent so chart re-renders are not triggered - Adds total balance card to liquidity screen for consistency - Registers route and hub entry in financial dashboard * refactor(dashboard): extract shared SummaryCard component Deduplicate SummaryCard, formatChf and AgeBadge across the financial live, liquidity and overview screens by extracting them into components/dashboard/summary-card.tsx. Also normalize missing-data handling to show '-' instead of '0 CHF' on all three screens. * refactor(dashboard): move TimeRange to util/chart and format helpers to util/utils TimeRange was exported from dashboard-financial-history.screen and imported by 5 chart components and 2 screens. Move it to src/util/chart.ts so chart components no longer depend on a screen module. Also move formatChf / formatChfOrDash from summary-card.tsx to util/utils.ts to keep the component file free of format helpers. * refactor(dashboard): split AgeBadge into its own file and use max-w-xs Move AgeBadge out of summary-card.tsx so summary-card.tsx only contains SummaryCard. Replace the inline-block wrapper on the liquidity screen's total balance card with max-w-xs for a cleaner constraint. * style(dashboard): sort imports and use named ReactNode/AgeBadgeProps - Sort imports alphabetically in dashboard-financial-overview.screen.tsx - Replace React.ReactNode with destructured ReactNode import in SummaryCard - Extract AgeBadgeProps interface for consistency with SummaryCardProps * chore(dashboard): remove dead getLatestChanges call and improve hub copy - Live screen called getLatestChanges() inside Promise.all but the destructured result discarded its payload, leaving an unused API call. Drop it and simplify to a single getLatestBalance() call. - Reword the Overview hub description: 'Summary cards, balance history and liquidity' is closer to what the screen actually shows. * feat(dashboard): dark theme on financial overview screen - Overview container uses dfxBlue-800 background with white text - SummaryCard supports optional dark variant (dfxBlue-700 card on dark page) - TotalBalanceLongChart and BalanceBarChart accept optional dark prop and switch ApexCharts theme.mode + grid color accordingly - Timeframe selector restyled for the dark surface Other screens (Live, Liquidity, History) continue to render in light mode. * fix(layout): apply layout config synchronously to avoid width flash useLayoutOptions used useEffect, which runs after paint. Pages with noMaxWidth: true rendered for one frame with max-w-screen-md before the config update came through, producing intermittent width flashes (especially noticeable on the Overview screen during auto-refresh). Switching to useLayoutEffect applies the config before the browser paints, so the wider layout is in effect from the first frame. Also add 1D and 3D timeframe options to the Overview screen (3D default) with hourly sampling for sub-week ranges. * feat(dashboard): semantic asset colors in liquidity bar chart Map asset names to semantic colors instead of palette-by-index: - BTC family (BTC, WBTC, cBTC) → orange - USD family (USDT, USDC, JUSD) → green - EUR family (EUR, dEURO, DEURO) → blue - CHF family (CHF, ZCHF) → red - everything else cycles a fallback palette Applies to BalanceBarChart's stacked variant (liquidity by provider + balance by type). Same palette for light and dark modes. * refactor(dashboard): centralize isDailySample helper + cover format/sample helpers with tests - Extract isDailySample(timeframe) into util/chart.ts. History screen had a local helper (WEEK/MONTH = daily), Overview had a local Set (DAY/THREE_DAYS = hourly) for the inverse predicate. Both now share the same logic. - Add unit tests for isDailySample, formatChf and formatChfOrDash next to the existing format helper tests. * polish(dashboard): age-badge robustness + theme-aligned chart grid color - AgeBadge: stop the 1s tick when no timestamp is bound, and guard against invalid date strings (NaN getTime) by falling back to '-'. - Chart grid border in dark mode now uses dfxBlue-500 (#0A355C) from the Tailwind theme instead of the ad-hoc #1f3a5c. --- src/App.tsx | 5 + src/__tests__/chart.test.ts | 15 +- src/__tests__/utils.test.ts | 32 +++++ src/components/dashboard/age-badge.tsx | 29 ++++ .../dashboard/balance-by-type-area-chart.tsx | 2 +- .../dashboard/financial-changes-chart.tsx | 2 +- .../dashboard/latest-balance-bar-chart.tsx | 43 ++++-- src/components/dashboard/summary-card.tsx | 24 ++++ .../dashboard/total-balance-long-chart.tsx | 10 +- .../dashboard/total-balance-short-chart.tsx | 2 +- src/hooks/layout-config.hook.tsx | 6 +- .../dashboard-financial-history.screen.tsx | 11 +- .../dashboard-financial-liquidity.screen.tsx | 16 ++- .../dashboard-financial-live.screen.tsx | 57 +++----- .../dashboard-financial-overview.screen.tsx | 129 ++++++++++++++++++ src/screens/dashboard-financial.screen.tsx | 1 + src/util/chart.ts | 9 ++ src/util/utils.ts | 8 ++ 18 files changed, 330 insertions(+), 71 deletions(-) create mode 100644 src/components/dashboard/age-badge.tsx create mode 100644 src/components/dashboard/summary-card.tsx create mode 100644 src/screens/dashboard-financial-overview.screen.tsx diff --git a/src/App.tsx b/src/App.tsx index 771812d4b..163c09d52 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -89,6 +89,7 @@ const PersonalIbanScreen = lazy(() => import('./screens/personal-iban.screen')); const BuyCryptoUpdateScreen = lazy(() => import('./screens/buy-crypto-update.screen')); const DashboardScreen = lazy(() => import('./screens/dashboard.screen')); const DashboardFinancialScreen = lazy(() => import('./screens/dashboard-financial.screen')); +const DashboardFinancialOverviewScreen = lazy(() => import('./screens/dashboard-financial-overview.screen')); const DashboardFinancialHistoryScreen = lazy(() => import('./screens/dashboard-financial-history.screen')); const DashboardFinancialLiveScreen = lazy(() => import('./screens/dashboard-financial-live.screen')); const DashboardFinancialExpensesScreen = lazy(() => import('./screens/dashboard-financial-expenses.screen')); @@ -515,6 +516,10 @@ export const Routes = [ index: true, element: withSuspense(), }, + { + path: 'overview', + element: withSuspense(), + }, { path: 'live', element: withSuspense(), diff --git a/src/__tests__/chart.test.ts b/src/__tests__/chart.test.ts index 0168cf50d..973a7bff2 100644 --- a/src/__tests__/chart.test.ts +++ b/src/__tests__/chart.test.ts @@ -1,4 +1,4 @@ -import { Timeframe, getFromDateByTimeframe } from '../util/chart'; +import { getFromDateByTimeframe, isDailySample, Timeframe } from '../util/chart'; describe('chart utils', () => { const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; @@ -55,6 +55,19 @@ describe('chart utils', () => { expect(getFromDateByTimeframe('unknown' as Timeframe)).toBe(0); }); + it('should return hourly sampling for DAY and THREE_DAYS', () => { + expect(isDailySample(Timeframe.DAY)).toBe(false); + expect(isDailySample(Timeframe.THREE_DAYS)).toBe(false); + }); + + it('should return daily sampling for WEEK and longer', () => { + expect(isDailySample(Timeframe.WEEK)).toBe(true); + expect(isDailySample(Timeframe.MONTH)).toBe(true); + expect(isDailySample(Timeframe.QUARTER)).toBe(true); + expect(isDailySample(Timeframe.YEAR)).toBe(true); + expect(isDailySample(Timeframe.ALL)).toBe(true); + }); + it('should return increasing values for longer timeframes', () => { const week = getFromDateByTimeframe(Timeframe.WEEK); const month = getFromDateByTimeframe(Timeframe.MONTH); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 374cbc1e9..4b5afbe10 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -28,6 +28,8 @@ import { formatUnits, filenameDateFormat, extractFilename, + formatChf, + formatChfOrDash, formatCurrency, FormatType, deepEqual, @@ -195,6 +197,36 @@ describe('utils', () => { }); }); + describe('formatChf', () => { + it('should format integer values with Swiss thousands separator', () => { + const result = formatChf(48515); + expect(result).toContain('48'); + expect(result).toContain('515'); + }); + + it('should round to zero decimals', () => { + const result = formatChf(1234.78); + expect(result).not.toContain(','); + expect(result).not.toContain('.'); + }); + }); + + describe('formatChfOrDash', () => { + it('should return dash for undefined', () => { + expect(formatChfOrDash(undefined)).toBe('-'); + }); + + it('should format zero as "0 CHF"', () => { + expect(formatChfOrDash(0)).toBe('0 CHF'); + }); + + it('should append CHF suffix to formatted value', () => { + const result = formatChfOrDash(48515); + expect(result.endsWith(' CHF')).toBe(true); + expect(result).toContain('48'); + }); + }); + describe('deepEqual', () => { it('should return true for identical primitives', () => { expect(deepEqual(1, 1)).toBe(true); diff --git a/src/components/dashboard/age-badge.tsx b/src/components/dashboard/age-badge.tsx new file mode 100644 index 000000000..821860e39 --- /dev/null +++ b/src/components/dashboard/age-badge.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; + +function formatAge(ms: number): string { + const s = Math.max(0, Math.floor(ms / 1000)); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${s % 60}s ago`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m ago`; +} + +interface AgeBadgeProps { + timestamp?: string; +} + +export function AgeBadge({ timestamp }: AgeBadgeProps): JSX.Element { + const [now, setNow] = useState(Date.now()); + + useEffect(() => { + if (!timestamp) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [timestamp]); + + if (!timestamp) return <>-; + const parsed = new Date(timestamp).getTime(); + if (Number.isNaN(parsed)) return <>-; + return <>{formatAge(now - parsed)}; +} diff --git a/src/components/dashboard/balance-by-type-area-chart.tsx b/src/components/dashboard/balance-by-type-area-chart.tsx index 0f82ba135..a103eeb95 100644 --- a/src/components/dashboard/balance-by-type-area-chart.tsx +++ b/src/components/dashboard/balance-by-type-area-chart.tsx @@ -2,7 +2,7 @@ import { ApexOptions } from 'apexcharts'; import { useMemo } from 'react'; import Chart from 'react-apexcharts'; import { FinancialLogEntry } from 'src/dto/dashboard.dto'; -import { TimeRange } from 'src/screens/dashboard-financial-history.screen'; +import { TimeRange } from 'src/util/chart'; interface Props { entries: FinancialLogEntry[]; diff --git a/src/components/dashboard/financial-changes-chart.tsx b/src/components/dashboard/financial-changes-chart.tsx index e84ce59b2..1a9de8448 100644 --- a/src/components/dashboard/financial-changes-chart.tsx +++ b/src/components/dashboard/financial-changes-chart.tsx @@ -2,7 +2,7 @@ import { ApexOptions } from 'apexcharts'; import { useMemo } from 'react'; import Chart from 'react-apexcharts'; import { FinancialChangesEntry } from 'src/dto/dashboard.dto'; -import { TimeRange } from 'src/screens/dashboard-financial-history.screen'; +import { TimeRange } from 'src/util/chart'; interface FinancialChangesChartProps { entries: FinancialChangesEntry[]; diff --git a/src/components/dashboard/latest-balance-bar-chart.tsx b/src/components/dashboard/latest-balance-bar-chart.tsx index 80512fd95..3574d02b3 100644 --- a/src/components/dashboard/latest-balance-bar-chart.tsx +++ b/src/components/dashboard/latest-balance-bar-chart.tsx @@ -6,15 +6,30 @@ import { BalanceByGroup } from 'src/dto/dashboard.dto'; interface BalanceBarChartProps { title: string; data: BalanceByGroup[]; + dark?: boolean; } -const ASSET_COLORS = [ - '#3b82f6', '#22c55e', '#f97316', '#8b5cf6', '#ef4444', - '#06b6d4', '#f59e0b', '#ec4899', '#14b8a6', '#6366f1', +const FALLBACK_ASSET_COLORS = [ + '#8b5cf6', '#06b6d4', '#f59e0b', '#ec4899', '#14b8a6', '#6366f1', '#84cc16', '#e11d48', '#0ea5e9', '#a855f7', '#64748b', ]; -export function BalanceBarChart({ title, data }: BalanceBarChartProps) { +const ASSET_COLOR_RULES: Array<{ test: (upper: string) => boolean; color: string }> = [ + { test: (u) => u.includes('BTC'), color: '#f97316' }, // orange — BTC family + { test: (u) => u.includes('USD'), color: '#22c55e' }, // green — USD family + { test: (u) => u.includes('EUR'), color: '#3b82f6' }, // blue — EUR family + { test: (u) => u.includes('CHF'), color: '#ef4444' }, // red — CHF family +]; + +function assetColor(name: string, fallbackIndex: number): string { + const upper = name.toUpperCase(); + for (const rule of ASSET_COLOR_RULES) { + if (rule.test(upper)) return rule.color; + } + return FALLBACK_ASSET_COLORS[fallbackIndex % FALLBACK_ASSET_COLORS.length]; +} + +export function BalanceBarChart({ title, data, dark }: BalanceBarChartProps) { const hasAssets = data.some((d) => d.assets && Object.keys(d.assets).length > 0); const sorted = useMemo(() => { @@ -25,21 +40,22 @@ export function BalanceBarChart({ title, data }: BalanceBarChartProps) { if (data.length === 0) return null; - if (hasAssets) return ; - return ; + if (hasAssets) return ; + return ; } -function SimpleBarChart({ title, data }: { title: string; data: BalanceByGroup[] }) { +function SimpleBarChart({ title, data, dark }: { title: string; data: BalanceByGroup[]; dark?: boolean }) { const categories = data.map((i) => i.name); const values = data.map((i) => Math.round(i.netBalanceChf)); const colors = data.map((i) => (i.netBalanceChf >= 0 ? '#22c55e' : '#ef4444')); const options = useMemo((): ApexOptions => ({ chart: { type: 'bar', toolbar: { show: false }, background: '0' }, + theme: { mode: dark ? 'dark' : 'light' }, plotOptions: { bar: { distributed: true, borderRadius: 4 } }, colors, dataLabels: { enabled: false }, - grid: { borderColor: '#e5e7eb' }, + grid: { borderColor: dark ? '#0A355C' : '#e5e7eb' }, xaxis: { categories }, yaxis: { title: { text: 'Net Balance (CHF)' }, @@ -47,7 +63,7 @@ function SimpleBarChart({ title, data }: { title: string; data: BalanceByGroup[] }, tooltip: { y: { formatter: (val: number) => `${val.toLocaleString('de-CH')} CHF` } }, legend: { show: false }, - }), [categories, colors]); + }), [categories, colors, dark]); const series = useMemo(() => [{ name: 'Net Balance', data: values }], [values]); @@ -59,7 +75,7 @@ function SimpleBarChart({ title, data }: { title: string; data: BalanceByGroup[] ); } -function StackedBarChart({ title, data }: { title: string; data: BalanceByGroup[] }) { +function StackedBarChart({ title, data, dark }: { title: string; data: BalanceByGroup[]; dark?: boolean }) { const { categories, assetNames, series } = useMemo(() => { const cats = data.map((d) => d.name); @@ -87,10 +103,11 @@ function StackedBarChart({ title, data }: { title: string; data: BalanceByGroup[ const options = useMemo((): ApexOptions => ({ chart: { type: 'bar', stacked: true, toolbar: { show: false }, background: '0' }, + theme: { mode: dark ? 'dark' : 'light' }, plotOptions: { bar: { borderRadius: 4, borderRadiusWhenStacked: 'last' as any } }, - colors: assetNames.map((_, i) => ASSET_COLORS[i % ASSET_COLORS.length]), + colors: assetNames.map((name, i) => assetColor(name, i)), dataLabels: { enabled: false }, - grid: { borderColor: '#e5e7eb' }, + grid: { borderColor: dark ? '#0A355C' : '#e5e7eb' }, xaxis: { categories }, yaxis: { title: { text: 'Balance (CHF)' }, @@ -98,7 +115,7 @@ function StackedBarChart({ title, data }: { title: string; data: BalanceByGroup[ }, tooltip: { y: { formatter: (val: number) => `${val.toLocaleString('de-CH')} CHF` } }, legend: { position: 'bottom' }, - }), [categories, assetNames]); + }), [categories, assetNames, dark]); return (
diff --git a/src/components/dashboard/summary-card.tsx b/src/components/dashboard/summary-card.tsx new file mode 100644 index 000000000..7a3487c13 --- /dev/null +++ b/src/components/dashboard/summary-card.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from 'react'; + +interface SummaryCardProps { + label: string; + value: ReactNode; + color?: string; + dark?: boolean; +} + +export function SummaryCard({ label, value, color, dark }: SummaryCardProps): JSX.Element { + const cardBg = dark ? 'bg-dfxBlue-700' : 'bg-white'; + const labelColor = dark ? '#9AA5B8' : '#6b7280'; + const defaultValueColor = dark ? '#ffffff' : '#111827'; + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} diff --git a/src/components/dashboard/total-balance-long-chart.tsx b/src/components/dashboard/total-balance-long-chart.tsx index a46a878a5..cdd1648c3 100644 --- a/src/components/dashboard/total-balance-long-chart.tsx +++ b/src/components/dashboard/total-balance-long-chart.tsx @@ -2,14 +2,15 @@ import { ApexOptions } from 'apexcharts'; import { useMemo } from 'react'; import Chart from 'react-apexcharts'; import { FinancialLogEntry } from 'src/dto/dashboard.dto'; -import { TimeRange } from 'src/screens/dashboard-financial-history.screen'; +import { TimeRange } from 'src/util/chart'; interface TotalBalanceLongChartProps { entries: FinancialLogEntry[]; timeRange?: TimeRange; + dark?: boolean; } -export function TotalBalanceLongChart({ entries, timeRange }: TotalBalanceLongChartProps) { +export function TotalBalanceLongChart({ entries, timeRange, dark }: TotalBalanceLongChartProps) { const chartOptions = useMemo((): ApexOptions => { return { chart: { @@ -18,10 +19,11 @@ export function TotalBalanceLongChart({ entries, timeRange }: TotalBalanceLongCh zoom: { enabled: true }, background: '0', }, + theme: { mode: dark ? 'dark' : 'light' }, stroke: { width: [3, 3], curve: 'smooth' }, colors: ['#22c55e', '#f97316'], dataLabels: { enabled: false }, - grid: { borderColor: '#e5e7eb' }, + grid: { borderColor: dark ? '#0A355C' : '#e5e7eb' }, xaxis: { type: 'datetime', labels: { datetimeUTC: false, format: 'dd MMM yy' }, @@ -48,7 +50,7 @@ export function TotalBalanceLongChart({ entries, timeRange }: TotalBalanceLongCh }, legend: { position: 'bottom' }, }; - }, [timeRange]); + }, [timeRange, dark]); const chartSeries = useMemo(() => { return [ diff --git a/src/components/dashboard/total-balance-short-chart.tsx b/src/components/dashboard/total-balance-short-chart.tsx index 28d7ed2ab..2e0dfa808 100644 --- a/src/components/dashboard/total-balance-short-chart.tsx +++ b/src/components/dashboard/total-balance-short-chart.tsx @@ -2,7 +2,7 @@ import { ApexOptions } from 'apexcharts'; import { useMemo } from 'react'; import Chart from 'react-apexcharts'; import { FinancialLogEntry } from 'src/dto/dashboard.dto'; -import { TimeRange } from 'src/screens/dashboard-financial-history.screen'; +import { TimeRange } from 'src/util/chart'; interface Props { entries: FinancialLogEntry[]; diff --git a/src/hooks/layout-config.hook.tsx b/src/hooks/layout-config.hook.tsx index 177bf0c34..4ffaaa3ae 100644 --- a/src/hooks/layout-config.hook.tsx +++ b/src/hooks/layout-config.hook.tsx @@ -1,5 +1,5 @@ import { isEqual } from 'lodash'; -import { useEffect, useRef } from 'react'; +import { useLayoutEffect, useRef } from 'react'; import { LayoutConfig, useLayoutConfigContext } from '../contexts/layout-config.context'; export function useLayoutOptions({ @@ -14,7 +14,7 @@ export function useLayoutOptions({ const { setConfig } = useLayoutConfigContext(); const prevConfig = useRef(); - useEffect(() => { + useLayoutEffect(() => { const newConfig: LayoutConfig = { title, backButton, @@ -36,7 +36,7 @@ export function useLayoutOptions({ } }, [title, backButton, textStart, noPadding, smallMenu, onBack, setConfig]); - useEffect(() => { + useLayoutEffect(() => { return () => setConfig({}); }, [setConfig]); } diff --git a/src/screens/dashboard-financial-history.screen.tsx b/src/screens/dashboard-financial-history.screen.tsx index f4948dc78..1468880b0 100644 --- a/src/screens/dashboard-financial-history.screen.tsx +++ b/src/screens/dashboard-financial-history.screen.tsx @@ -10,19 +10,10 @@ import { useDashboard } from 'src/hooks/dashboard.hook'; import { useAdminGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { useNavigation } from 'src/hooks/navigation.hook'; -import { getFromDateByTimeframe, Timeframe } from 'src/util/chart'; - -export interface TimeRange { - min: number; - max: number; -} +import { getFromDateByTimeframe, isDailySample, Timeframe, TimeRange } from 'src/util/chart'; const TIMEFRAME_OPTIONS = [Timeframe.DAY, Timeframe.THREE_DAYS, Timeframe.WEEK, Timeframe.MONTH] as const; -function isDailySample(timeframe: Timeframe): boolean { - return timeframe === Timeframe.WEEK || timeframe === Timeframe.MONTH; -} - export default function DashboardFinancialLogScreen(): JSX.Element { useAdminGuard(); useLayoutOptions({ title: 'Financial Log', noMaxWidth: true }); diff --git a/src/screens/dashboard-financial-liquidity.screen.tsx b/src/screens/dashboard-financial-liquidity.screen.tsx index e47c04acd..f35a0c6fd 100644 --- a/src/screens/dashboard-financial-liquidity.screen.tsx +++ b/src/screens/dashboard-financial-liquidity.screen.tsx @@ -1,11 +1,13 @@ import { useSessionContext } from '@dfx.swiss/react'; import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { BalanceBarChart } from 'src/components/dashboard/latest-balance-bar-chart'; +import { SummaryCard } from 'src/components/dashboard/summary-card'; import { LatestBalanceResponse } from 'src/dto/dashboard.dto'; import { useDashboard } from 'src/hooks/dashboard.hook'; import { useAdminGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { formatChfOrDash } from 'src/util/utils'; export default function DashboardFinancialLiquidityScreen(): JSX.Element { useAdminGuard(); @@ -22,9 +24,17 @@ export default function DashboardFinancialLiquidityScreen(): JSX.Element { getLatestBalance() .then(setLatestBalance) + .catch(() => undefined) .finally(() => setIsLoading(false)); }, [isLoggedIn]); + const totalBalance = useMemo(() => { + if (!latestBalance) return undefined; + let net = 0; + for (const t of latestBalance.byType) net += t.plusBalanceChf - t.minusBalanceChf; + return net; + }, [latestBalance]); + if (isLoading) { return (
@@ -35,6 +45,10 @@ export default function DashboardFinancialLiquidityScreen(): JSX.Element { return (
+
+ +
+
diff --git a/src/screens/dashboard-financial-live.screen.tsx b/src/screens/dashboard-financial-live.screen.tsx index c7efa7de1..7d996d14e 100644 --- a/src/screens/dashboard-financial-live.screen.tsx +++ b/src/screens/dashboard-financial-live.screen.tsx @@ -1,41 +1,20 @@ import { useSessionContext } from '@dfx.swiss/react'; import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { BalanceBarChart } from 'src/components/dashboard/latest-balance-bar-chart'; +import { SummaryCard } from 'src/components/dashboard/summary-card'; import { LatestBalanceResponse } from 'src/dto/dashboard.dto'; import { useDashboard } from 'src/hooks/dashboard.hook'; import { useAdminGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; - -function formatChf(value: number): string { - return value.toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); -} - -interface SummaryCardProps { - label: string; - value: string; - color?: string; -} - -function SummaryCard({ label, value, color }: SummaryCardProps) { - return ( -
-
- {label} -
-
- {value} -
-
- ); -} +import { formatChfOrDash } from 'src/util/utils'; export default function DashboardFinancialLiveScreen(): JSX.Element { useAdminGuard(); useLayoutOptions({ title: 'Financial Live', noMaxWidth: true }); const { isLoggedIn } = useSessionContext(); - const { getLatestChanges, getLatestBalance } = useDashboard(); + const { getLatestBalance } = useDashboard(); const [latestBalance, setLatestBalance] = useState(); const [isLoading, setIsLoading] = useState(true); @@ -43,13 +22,23 @@ export default function DashboardFinancialLiveScreen(): JSX.Element { useEffect(() => { if (!isLoggedIn) return; - Promise.all([getLatestBalance(), getLatestChanges()]) - .then(([balanceData]) => { - setLatestBalance(balanceData); - }) + getLatestBalance() + .then(setLatestBalance) + .catch(() => undefined) .finally(() => setIsLoading(false)); }, [isLoggedIn]); + const { totalPlus, totalMinus, totalBalance } = useMemo(() => { + if (!latestBalance) return { totalPlus: undefined, totalMinus: undefined, totalBalance: undefined }; + let plus = 0; + let minus = 0; + for (const t of latestBalance.byType) { + plus += t.plusBalanceChf; + minus += t.minusBalanceChf; + } + return { totalPlus: plus, totalMinus: minus, totalBalance: plus - minus }; + }, [latestBalance]); + if (isLoading) { return (
@@ -58,16 +47,12 @@ export default function DashboardFinancialLiveScreen(): JSX.Element { ); } - const totalPlus = latestBalance?.byType.reduce((s, t) => s + t.plusBalanceChf, 0) ?? 0; - const totalMinus = latestBalance?.byType.reduce((s, t) => s + t.minusBalanceChf, 0) ?? 0; - return (
- {/* Summary Cards */}
- - - + + + (Timeframe.THREE_DAYS); + const [latestBalance, setLatestBalance] = useState(); + const [logEntries, setLogEntries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (!isLoggedIn) return; + + const fromTimestamp = getFromDateByTimeframe(timeframe); + const from = fromTimestamp > 0 ? new Date(fromTimestamp).toISOString() : undefined; + const dailySample = isDailySample(timeframe); + + function load(initial: boolean) { + if (initial) setIsLoading(true); + getLatestBalance() + .then(setLatestBalance) + .catch(() => undefined); + getFinancialLog(from, dailySample) + .then((logData) => setLogEntries(logData.entries)) + .catch(() => undefined) + .finally(() => { + if (initial) setIsLoading(false); + }); + } + + load(true); + const interval = setInterval(() => load(false), REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [isLoggedIn, timeframe]); + + const timeRange = useMemo((): TimeRange | undefined => { + if (logEntries.length === 0) return undefined; + let min = Infinity; + let max = -Infinity; + for (const e of logEntries) { + const t = new Date(e.timestamp).getTime(); + if (t < min) min = t; + if (t > max) max = t; + } + return { min, max }; + }, [logEntries]); + + const { totalPlus, totalMinus, totalBalance } = useMemo(() => { + if (!latestBalance) return { totalPlus: undefined, totalMinus: undefined, totalBalance: undefined }; + let plus = 0; + let minus = 0; + for (const t of latestBalance.byType) { + plus += t.plusBalanceChf; + minus += t.minusBalanceChf; + } + return { totalPlus: plus, totalMinus: minus, totalBalance: plus - minus }; + }, [latestBalance]); + + return ( +
+
+ + + + } dark /> +
+ +
+ {TIMEFRAME_OPTIONS.map((tf) => ( + + ))} +
+ + {isLoading ? ( +
+ +
+ ) : ( + <> +
+ +
+ +
+ +
+ + )} +
+ ); +} diff --git a/src/screens/dashboard-financial.screen.tsx b/src/screens/dashboard-financial.screen.tsx index 4c558a054..84769b10e 100644 --- a/src/screens/dashboard-financial.screen.tsx +++ b/src/screens/dashboard-financial.screen.tsx @@ -3,6 +3,7 @@ import { useAdminGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; const pages = [ + { path: '/dashboard/financial/overview', title: 'Overview', description: 'Summary cards, balance history and liquidity' }, { path: '/dashboard/financial/live', title: 'Live', description: 'Current balances and latest changes' }, { path: '/dashboard/financial/history', title: 'History', description: 'Balance history over time' }, { path: '/dashboard/financial/liquidity', title: 'Liquidity', description: 'Balance breakdown by type' }, diff --git a/src/util/chart.ts b/src/util/chart.ts index e5b0f6047..95b6b09f2 100644 --- a/src/util/chart.ts +++ b/src/util/chart.ts @@ -1,3 +1,8 @@ +export interface TimeRange { + min: number; + max: number; +} + export enum Timeframe { DAY = '24h', THREE_DAYS = '3D', @@ -10,6 +15,10 @@ export enum Timeframe { const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; +export function isDailySample(timeframe: Timeframe): boolean { + return timeframe !== Timeframe.DAY && timeframe !== Timeframe.THREE_DAYS; +} + export function getFromDateByTimeframe(timeframe: Timeframe): number { switch (timeframe) { case Timeframe.ALL: diff --git a/src/util/utils.ts b/src/util/utils.ts index de06ff665..301f21343 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -312,6 +312,14 @@ export function formatAmountForDisplay(amount?: number): string { return Utils.formatAmount(amount).replace('.00', '.-').replace(' ', "'"); } +export function formatChf(value: number): string { + return value.toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); +} + +export function formatChfOrDash(value?: number): string { + return value !== undefined ? `${formatChf(value)} CHF` : '-'; +} + export function deepEqual(a: any, b: any): boolean { if (a === b) return true; if (a == null || b == null) return false;