Files
finance-duck/web/src/Overview.tsx
T
2026-09-10 12:30:42 +02:00

481 lines
16 KiB
TypeScript

import { useEffect, useState } from "react";
import {
ArrowDownLeft,
ArrowUpRight,
Wallet,
ArrowRight,
TrendingUp,
} from "lucide-react";
import type { Dashboard, Dataset, Filter, Group } from "./api";
import { money, request } from "./api";
import { Empty, ErrorMessage, Filters } from "./ui";
export function Overview({
data,
revision,
filter,
setFilter,
navigate,
}: {
data: Dataset;
revision: string;
filter: Filter;
setFilter: (f: Filter) => void;
navigate: (page: string) => void;
}) {
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [retry, setRetry] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter))
if (value) params.set(key, value);
request<Dashboard>(`/api/dashboard?${params}`, undefined, controller.signal)
.then((value) => {
for (const key of [
"totals",
"previous",
"monthly",
"categories",
"tags",
"merchants",
"accounts",
"recurring",
] as const) {
if (!(key in value))
throw new Error(`Dashboard response is missing ${key}.`);
if (value[key] === null) Object.assign(value, { [key]: [] });
}
setDashboard(value);
})
.catch((err) => {
if (!controller.signal.aborted) {
setError(err instanceof Error ? err.message : String(err));
setDashboard(null);
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [revision, filter, retry]);
return (
<>
<div className="section-heading">
<div>
<h2>Your financial picture</h2>
<p>A little clarity for the decisions ahead.</p>
</div>
<button
className="button secondary"
onClick={() => navigate("transactions")}
>
View transactions <ArrowRight size={16} />
</button>
</div>
<Filters data={data} value={filter} onChange={setFilter} />
<ErrorMessage error={error} />
{error && (
<button
className="button secondary"
onClick={() => setRetry(retry + 1)}
>
Retry analytics
</button>
)}
{loading ? (
<div className="loading-block" role="status">
<span className="spinner" />
Reading your financial picture
</div>
) : (
dashboard && (
<>
{!data.transactions.length && (
<section className="welcome panel">
<div className="welcome-copy">
<span className="eyebrow">A fresh start</span>
<h3>
All your finances.
<br />A space of your own.
</h3>
<p>
Your private journal is ready. Add an account and import
your first statement to see the bigger picture.
</p>
<button
className="button primary"
onClick={() => navigate("accounts")}
>
Set up your accounts <ArrowRight size={16} />
</button>
</div>
<div className="welcome-art" aria-hidden="true">
<Wallet size={70} />
<span>Your data. Your server.</span>
</div>
</section>
)}
{dashboard.totals.map((total) => {
const previous = dashboard.previous.find(
(t) => t.currency === total.currency,
);
return (
<div className="stat-grid" key={total.currency}>
{(
[
{
key: "income",
label: "Money in",
Icon: ArrowDownLeft,
className: "positive",
},
{
key: "expenses",
label: "Money out",
Icon: ArrowUpRight,
className: "",
},
{
key: "net",
label: "Net cash flow",
Icon: Wallet,
className: total.net.startsWith("-") ? "" : "positive",
},
] as const
).map(({ key, label, Icon, className }) => (
<section className="panel stat" key={key}>
<div className="stat-top">
<span>{label}</span>
<span className={`stat-icon ${key}`}>
<Icon size={19} />
</span>
</div>
<strong className={`stat-value ${className}`}>
{money(total[key], total.currency)}
</strong>
<small>
{previous
? `Previous period ${money(previous[key], previous.currency)}`
: "No previous-period activity"}
</small>
</section>
))}
</div>
);
})}
{data.transactions.length > 0 && !dashboard.totals.length && (
<section className="panel">
<Empty title="No activity in this view">
Adjust your filters to include more transactions.
</Empty>
</section>
)}
<div className="dashboard-grid">
<section className="panel chart-panel">
<div className="panel-heading">
<div>
<h3>Monthly cash flow</h3>
<p>Net movement over time · transfers excluded</p>
</div>
<TrendingUp size={20} />
</div>
<MonthlyChart groups={dashboard.monthly} />
</section>
<CategoryTree
data={data}
groups={dashboard.categories}
onSelect={(id) => {
setFilter({ ...filter, category_id: id });
navigate("transactions");
}}
/>
</div>
<div className="dashboard-grid thirds">
<GroupPanel
title="Merchants"
subtitle="Where your money goes"
groups={dashboard.merchants}
onSelect={(id) => {
setFilter({ ...filter, merchant_id: id });
navigate("transactions");
}}
/>
<GroupPanel
title="Accounts"
subtitle="Movement by account"
groups={dashboard.accounts}
onSelect={(id) => {
setFilter({ ...filter, account_id: id });
navigate("transactions");
}}
/>
<GroupPanel
title="Tags"
subtitle="Your custom perspective"
groups={dashboard.tags}
onSelect={(id) => {
setFilter({ ...filter, tag_id: id });
navigate("transactions");
}}
/>
</div>
<section className="panel">
<div className="panel-heading">
<div>
<h3>Recurring patterns</h3>
<p>Repeated payments detected in the selected period</p>
</div>
<span className="badge neutral">Observed, not forecast</span>
</div>
{dashboard.recurring.length ? (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Merchant / payment</th>
<th>Frequency</th>
<th>Occurrences</th>
<th className="numeric">Observed total</th>
</tr>
</thead>
<tbody>
{dashboard.recurring.map((g, i) => (
<tr key={`${g.id}-${g.currency}-${i}`}>
<td>{g.name}</td>
<td>{g.period}</td>
<td>{g.count}</td>
<td className="numeric money">
{money(g.amount, g.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="compact-empty">
No recurring patterns detected in this period.
</div>
)}
</section>
</>
)
)}
</>
);
}
function CategoryTree({
data,
groups,
onSelect,
}: {
data: Dataset;
groups: Group[];
onSelect: (id: string) => void;
}) {
const [expanded, setExpanded] = useState<string[]>(
data.categories.filter((c) => !c.parent_id).map((c) => c.id),
);
const [showAll, setShowAll] = useState(false);
const available = new Set(groups.map((g) => g.id));
const roots = data.categories.filter(
(c) => !c.parent_id && available.has(c.id),
);
const node = (id: string, depth: number): React.ReactNode => {
const category = data.categories.find((c) => c.id === id);
const children = data.categories.filter(
(c) => c.parent_id === id && available.has(c.id),
);
const rows = groups.filter((g) => g.id === id);
return (
<div key={id} className="category-node">
<div
className="category-line"
style={{ paddingLeft: `${depth * 17}px` }}
>
{children.length ? (
<button
className="icon-button"
aria-label={`${expanded.includes(id) ? "Collapse" : "Expand"} ${category?.name || id}`}
aria-expanded={expanded.includes(id)}
onClick={() =>
setExpanded(
expanded.includes(id)
? expanded.filter((v) => v !== id)
: [...expanded, id],
)
}
>
<ArrowRight
size={13}
style={{
transform: expanded.includes(id)
? "rotate(90deg)"
: undefined,
}}
/>
</button>
) : (
<span className="tree-spacer" />
)}
<button className="category-drill" onClick={() => onSelect(id)}>
<span>{category?.name || id}</span>
<span>
{rows.map((g) => (
<strong className="money" key={g.currency}>
{money(g.amount, g.currency)}
</strong>
))}
</span>
</button>
</div>
{expanded.includes(id) &&
(showAll ? children : children.slice(0, 6)).map((c) =>
node(c.id, depth + 1),
)}
{expanded.includes(id) && !showAll && children.length > 6 && (
<button className="button subtle" onClick={() => setShowAll(true)}>
Show remaining {children.length - 6} categories
</button>
)}
</div>
);
};
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>Category breakdown</h3>
<p>Expand the tree · parents include their descendants</p>
</div>
</div>
{groups.length ? (
<div className="category-tree">{roots.map((c) => node(c.id, 0))}</div>
) : (
<div className="compact-empty">
Your category breakdown appears after importing transactions.
</div>
)}
</section>
);
}
function MonthlyChart({ groups }: { groups: Group[] }) {
if (!groups.length)
return (
<Empty title="Room for a bigger picture">
Your monthly trend appears after importing transactions.
</Empty>
);
const currencies = Array.from(new Set(groups.map((g) => g.currency)));
return (
<div className="monthly-charts">
{currencies.map((currency) => {
const rows = groups
.filter((g) => g.currency === currency)
.sort((a, b) => a.period.localeCompare(b.period));
const max = Math.max(...rows.map((g) => Math.abs(Number(g.amount))), 1);
return (
<div key={currency}>
<span className="eyebrow">{currency}</span>
<div
className="bar-chart"
role="img"
aria-label={`Monthly net cash flow in ${currency}. ${rows.map((g) => `${g.period}: ${g.amount}`).join("; ")}`}
>
{rows.map((g, i) => (
<div className="bar-column" key={`${g.period}-${i}`}>
<span className="bar-value">{money(g.amount, currency)}</span>
<div className="bar-track">
<div
className={`bar ${g.amount.startsWith("-") ? "negative" : ""}`}
style={{
height: `${Math.max((Math.abs(Number(g.amount)) / max) * 100, 1)}%`,
}}
title={`${g.period}: ${money(g.amount, currency)}`}
/>
</div>
<span className="bar-label">{g.period}</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
function GroupPanel({
title,
subtitle,
groups,
onSelect,
}: {
title: string;
subtitle: string;
groups: Group[];
onSelect: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const maxima: Record<string, number> = {};
for (const g of groups)
maxima[g.currency] = Math.max(
maxima[g.currency] || 1,
Math.abs(Number(g.amount)),
);
const sorted = [...groups].sort(
(a, b) =>
a.currency.localeCompare(b.currency) ||
Math.abs(Number(b.amount)) - Math.abs(Number(a.amount)),
);
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>{title}</h3>
<p>{subtitle}</p>
</div>
</div>
{groups.length ? (
<div className="group-list">
{(expanded ? sorted : sorted.slice(0, 6)).map((g, i) => (
<button
className="group-row"
key={`${g.id}-${g.currency}-${i}`}
onClick={() => onSelect(g.id)}
>
<div>
<span className="group-name">{g.name || "Unassigned"}</span>
<strong className="money">{money(g.amount, g.currency)}</strong>
</div>
<div className="group-track">
<span
style={{
width: `${Math.max(1, (Math.abs(Number(g.amount)) / maxima[g.currency]) * 100)}%`,
}}
/>
</div>
<small>
{g.count} transaction{g.count === 1 ? "" : "s"}
</small>
</button>
))}
{groups.length > 6 && (
<button
className="button subtle"
onClick={() => setExpanded(!expanded)}
>
{expanded ? "Show less" : `Show all ${groups.length}`}
</button>
)}
</div>
) : (
<div className="compact-empty">No activity in this view.</div>
)}
</section>
);
}