Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -515,6 +516,10 @@ export const Routes = [
index: true,
element: withSuspense(<DashboardFinancialScreen />),
},
{
path: 'overview',
element: withSuspense(<DashboardFinancialOverviewScreen />),
},
{
path: 'live',
element: withSuspense(<DashboardFinancialLiveScreen />),
Expand Down
15 changes: 14 additions & 1 deletion src/__tests__/chart.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 32 additions & 0 deletions src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import {
formatUnits,
filenameDateFormat,
extractFilename,
formatChf,
formatChfOrDash,
formatCurrency,
FormatType,
deepEqual,
Expand Down Expand Up @@ -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);
Expand Down
29 changes: 29 additions & 0 deletions src/components/dashboard/age-badge.tsx
Original file line number Diff line number Diff line change
@@ -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)}</>;
}
2 changes: 1 addition & 1 deletion src/components/dashboard/balance-by-type-area-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
2 changes: 1 addition & 1 deletion src/components/dashboard/financial-changes-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
43 changes: 30 additions & 13 deletions src/components/dashboard/latest-balance-bar-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -25,29 +40,30 @@ export function BalanceBarChart({ title, data }: BalanceBarChartProps) {

if (data.length === 0) return null;

if (hasAssets) return <StackedBarChart title={title} data={sorted} />;
return <SimpleBarChart title={title} data={sorted} />;
if (hasAssets) return <StackedBarChart title={title} data={sorted} dark={dark} />;
return <SimpleBarChart title={title} data={sorted} dark={dark} />;
}

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)' },
labels: { formatter: (val: number) => Math.abs(val) >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0) },
},
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]);

Expand All @@ -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);

Expand Down Expand Up @@ -87,18 +103,19 @@ 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)' },
labels: { formatter: (val: number) => Math.abs(val) >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0) },
},
tooltip: { y: { formatter: (val: number) => `${val.toLocaleString('de-CH')} CHF` } },
legend: { position: 'bottom' },
}), [categories, assetNames]);
}), [categories, assetNames, dark]);

return (
<div>
Expand Down
24 changes: 24 additions & 0 deletions src/components/dashboard/summary-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`${cardBg} rounded-lg shadow p-4`}>
<div className="text-xs font-medium" style={{ color: labelColor }}>
{label}
</div>
<div className="text-xl font-bold mt-1" style={{ color: color ?? defaultValueColor }}>
{value}
</div>
</div>
);
}
10 changes: 6 additions & 4 deletions src/components/dashboard/total-balance-long-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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' },
Expand All @@ -48,7 +50,7 @@ export function TotalBalanceLongChart({ entries, timeRange }: TotalBalanceLongCh
},
legend: { position: 'bottom' },
};
}, [timeRange]);
}, [timeRange, dark]);

const chartSeries = useMemo(() => {
return [
Expand Down
2 changes: 1 addition & 1 deletion src/components/dashboard/total-balance-short-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/layout-config.hook.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -14,7 +14,7 @@ export function useLayoutOptions({
const { setConfig } = useLayoutConfigContext();
const prevConfig = useRef<LayoutConfig>();

useEffect(() => {
useLayoutEffect(() => {
const newConfig: LayoutConfig = {
title,
backButton,
Expand All @@ -36,7 +36,7 @@ export function useLayoutOptions({
}
}, [title, backButton, textStart, noPadding, smallMenu, onBack, setConfig]);

useEffect(() => {
useLayoutEffect(() => {
return () => setConfig({});
}, [setConfig]);
}
11 changes: 1 addition & 10 deletions src/screens/dashboard-financial-history.screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading
Loading