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
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"access": "public"
},
"dependencies": {
"@dfx.swiss/react": "^1.3.0-beta.256",
"@dfx.swiss/react-components": "^1.3.0-beta.256",
"@dfx.swiss/react": "^1.3.0-beta.261",
"@dfx.swiss/react-components": "^1.3.0-beta.261",
"@ledgerhq/hw-app-btc": "^6.24.1",
"@ledgerhq/hw-app-eth": "^6.33.7",
"@ledgerhq/hw-transport-webhid": "^6.27.19",
Expand Down
45 changes: 45 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ const RealunitTransactionDetailScreen = lazy(() => import('./screens/realunit-tr
const RealunitUserScreen = lazy(() => import('./screens/realunit-user.screen'));
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 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'));
const DashboardFinancialLiquidityScreen = lazy(() => import('./screens/dashboard-financial-liquidity.screen'));

setupLanguages();

Expand Down Expand Up @@ -406,6 +412,45 @@ export const Routes = [
},
],
},
{
path: 'dashboard',
children: [
{
index: true,
element: withSuspense(<DashboardScreen />),
},
{
path: 'financial',
children: [
{
index: true,
element: withSuspense(<DashboardFinancialScreen />),
},
{
path: 'live',
element: withSuspense(<DashboardFinancialLiveScreen />),
},
{
path: 'history',
children: [
{
index: true,
element: withSuspense(<DashboardFinancialHistoryScreen />),
},
{
path: 'expenses',
element: withSuspense(<DashboardFinancialExpensesScreen />),
},
],
},
{
path: 'liquidity',
element: withSuspense(<DashboardFinancialLiquidityScreen />),
},
],
},
],
},
],
},
];
Expand Down
120 changes: 120 additions & 0 deletions src/components/dashboard/balance-by-type-area-chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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';

interface Props {
entries: FinancialLogEntry[];
timeRange?: TimeRange;
}

const TYPE_COLORS: Record<string, string> = {
DEPS: '#3b82f6',
CHF: '#22c55e',
EUR: '#f59e0b',
USD: '#8b5cf6',
BTC: '#f97316',
};

const DEFAULT_COLOR = '#6b7280';

function useFinancialTypes(entries: FinancialLogEntry[]) {
return useMemo(() => {
const types = new Set<string>();
for (const entry of entries) {
for (const type of Object.keys(entry.balancesByType)) {
types.add(type);
}
}
return Array.from(types).sort();
}, [entries]);
}

function makeOptions(financialTypes: string[], timeRange?: TimeRange): ApexOptions {
return {
chart: {
type: 'area',
stacked: true,
toolbar: { show: true, offsetY: -5 },
zoom: { enabled: true },
background: '0',
},
stroke: { width: 1, curve: 'smooth' },
colors: financialTypes.map((t) => TYPE_COLORS[t] ?? DEFAULT_COLOR),
dataLabels: { enabled: false },
grid: { borderColor: '#e5e7eb' },
xaxis: {
type: 'datetime',
labels: { datetimeUTC: false, format: 'dd MMM yy' },
...(timeRange && { min: timeRange.min, max: timeRange.max }),
},
yaxis: {
title: { text: 'CHF' },
labels: {
formatter: (val: number) => val >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0),
},
},
tooltip: {
x: { format: 'dd MMM yyyy HH:mm' },
y: { formatter: (val: number) => `${val.toLocaleString('de-CH')} CHF` },
},
legend: { position: 'bottom' },
fill: { type: 'solid', opacity: 0.6 },
};
}

export function BalanceByTypePlusChart({ entries, timeRange }: Props) {
const financialTypes = useFinancialTypes(entries);
const options = useMemo(() => makeOptions(financialTypes, timeRange), [financialTypes, timeRange]);

const series = useMemo(() => financialTypes.map((type) => ({
name: type,
data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.balancesByType[type]?.plusBalanceChf ?? 0)]),
})), [entries, financialTypes]);

return (
<div>
<h3 className="text-sm font-semibold mb-2">Plus Balance by Type</h3>
<Chart type="area" height={300} options={options} series={series} />
</div>
);
}

export function BalanceByTypeMinusChart({ entries, timeRange }: Props) {
const financialTypes = useFinancialTypes(entries);
const options = useMemo(() => makeOptions(financialTypes, timeRange), [financialTypes, timeRange]);

const series = useMemo(() => financialTypes.map((type) => ({
name: type,
data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.balancesByType[type]?.minusBalanceChf ?? 0)]),
})), [entries, financialTypes]);

return (
<div>
<h3 className="text-sm font-semibold mb-2">Minus Balance by Type</h3>
<Chart type="area" height={300} options={options} series={series} />
</div>
);
}

export function BalanceByTypeTotalChart({ entries, timeRange }: Props) {
const financialTypes = useFinancialTypes(entries);
const options = useMemo(() => makeOptions(financialTypes, timeRange), [financialTypes, timeRange]);

const series = useMemo(() => financialTypes.map((type) => ({
name: type,
data: entries.map((e) => {
const b = e.balancesByType[type];
const net = b ? b.plusBalanceChf - b.minusBalanceChf : 0;
return [new Date(e.timestamp).getTime(), Math.round(net)];
}),
})), [entries, financialTypes]);

return (
<div>
<h3 className="text-sm font-semibold mb-2">Net Balance by Type</h3>
<Chart type="area" height={300} options={options} series={series} />
</div>
);
}
108 changes: 108 additions & 0 deletions src/components/dashboard/financial-changes-chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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';

interface FinancialChangesChartProps {
entries: FinancialChangesEntry[];
timeRange?: TimeRange;
}

const FOUR_DAYS_MS = 4 * 24 * 60 * 60 * 1000;

function makeBaseOptions(timeRange?: TimeRange): ApexOptions {
const isShortRange = timeRange && (timeRange.max - timeRange.min) < FOUR_DAYS_MS;

return {
chart: {
type: 'area',
toolbar: { show: true, offsetY: -5 },
zoom: { enabled: true },
background: '0',
},
stroke: { width: 2, curve: 'smooth' },
dataLabels: { enabled: false },
fill: { type: 'gradient', gradient: { opacityFrom: 0.3, opacityTo: 0.05 } },
grid: { borderColor: '#e5e7eb' },
xaxis: {
type: 'datetime',
labels: { datetimeUTC: false, format: isShortRange ? 'dd MMM HH:mm' : 'dd MMM yy' },
...(timeRange && { min: timeRange.min, max: timeRange.max }),
},
yaxis: {
title: { text: 'CHF (cumulative)' },
labels: {
formatter: (val: number) => val >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0),
},
},
tooltip: {
x: { format: 'dd MMM yyyy HH:mm' },
y: { formatter: (val: number) => `${val.toLocaleString('de-CH', { maximumFractionDigits: 0 })} CHF` },
},
legend: { position: 'bottom' },
};
}

export function FinancialChangesTotalChart({ entries, timeRange }: FinancialChangesChartProps) {
const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#22c55e'] }), [timeRange]);

const series = useMemo(() => [
{ name: 'Net Total', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.total)]) },
], [entries]);

return (
<div>
<h3 className="text-sm font-semibold mb-2">Net Total (cumulative)</h3>
<Chart type="area" height={250} options={options} series={series} />
</div>
);
}

export function FinancialChangesPlusChart({ entries, timeRange }: FinancialChangesChartProps) {
const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#3b82f6', '#f97316', '#8b5cf6', '#64748b'] }), [timeRange]);

const series = useMemo(() => [
{ name: 'BuyCrypto', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.plus.buyCrypto)]) },
{ name: 'BuyFiat', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.plus.buyFiat)]) },
{ name: 'PaymentLink', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.plus.paymentLink)]) },
{ name: 'Trading', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.plus.trading)]) },
], [entries]);

return (
<div>
<h3 className="text-sm font-semibold mb-2">Income / Plus (cumulative)</h3>
<Chart type="area" height={250} options={options} series={series} />
</div>
);
}

interface FinancialChangesMinusChartProps extends FinancialChangesChartProps {
onDetails?: () => void;
}

export function FinancialChangesMinusChart({ entries, timeRange, onDetails }: FinancialChangesMinusChartProps) {
const options = useMemo((): ApexOptions => ({ ...makeBaseOptions(timeRange), colors: ['#ef4444', '#f97316', '#64748b', '#6366f1', '#14b8a6'] }), [timeRange]);

const series = useMemo(() => [
{ name: 'Referral', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.ref.total)]) },
{ name: 'Binance', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.binance.total)]) },
{ name: 'Blockchain', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.blockchain.total)]) },
{ name: 'Bank', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.bank ?? 0)]) },
{ name: 'Kraken', data: entries.map((e) => [new Date(e.timestamp).getTime(), Math.round(e.minus.kraken?.total ?? 0)]) },
], [entries]);

return (
<div>
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm font-semibold">Expenses / Minus (cumulative)</h3>
{onDetails && (
<button onClick={onDetails} className="text-xs font-medium px-3 py-1 rounded" style={{ color: '#3b82f6', background: '#eff6ff' }}>
Details
</button>
)}
</div>
<Chart type="area" height={250} options={options} series={series} />
</div>
);
}
49 changes: 49 additions & 0 deletions src/components/dashboard/financial-log-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { FinancialLogEntry } from 'src/dto/dashboard.dto';

interface FinancialLogTableProps {
entries: FinancialLogEntry[];
}

function formatChf(value: number): string {
return value.toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}

function formatTimestamp(timestamp: string): string {
return new Date(timestamp).toLocaleString('de-CH', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
});
}

export function FinancialLogTable({ entries }: FinancialLogTableProps) {
const reversed = [...entries].reverse();

return (
<div className="overflow-auto max-h-[680px]">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-2 font-semibold">Timestamp (UTC)</th>
<th className="text-right py-2 px-2 font-semibold">Vermögen (CHF)</th>
<th className="text-right py-2 px-2 font-semibold">Plus (CHF)</th>
<th className="text-right py-2 px-2 font-semibold">Minus (CHF)</th>
</tr>
</thead>
<tbody>
{reversed.map((entry, i) => (
<tr key={i} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-1 px-2 whitespace-nowrap">{formatTimestamp(entry.timestamp)}</td>
<td className="py-1 px-2 text-right whitespace-nowrap">{formatChf(entry.totalBalanceChf)}</td>
<td className="py-1 px-2 text-right whitespace-nowrap text-green-600">{formatChf(entry.plusBalanceChf)}</td>
<td className="py-1 px-2 text-right whitespace-nowrap text-red-600">{formatChf(entry.minusBalanceChf)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Loading
Loading