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;