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(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(`/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 ( <>

Your financial picture

A little clarity for the decisions ahead.

{error && ( )} {loading ? (
Reading your financial picture…
) : ( dashboard && ( <> {!data.transactions.length && (
A fresh start

All your finances.
A space of your own.

Your private journal is ready. Add an account and import your first statement to see the bigger picture.

)} {dashboard.totals.map((total) => { const previous = dashboard.previous.find( (t) => t.currency === total.currency, ); return (
{( [ { 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 }) => (
{label}
{money(total[key], total.currency)} {previous ? `Previous period ${money(previous[key], previous.currency)}` : "No previous-period activity"}
))}
); })} {data.transactions.length > 0 && !dashboard.totals.length && (
Adjust your filters to include more transactions.
)}

Monthly cash flow

Net movement over time · transfers excluded

{ setFilter({ ...filter, category_id: id }); navigate("transactions"); }} />
{ setFilter({ ...filter, merchant_id: id }); navigate("transactions"); }} /> { setFilter({ ...filter, account_id: id }); navigate("transactions"); }} /> { setFilter({ ...filter, tag_id: id }); navigate("transactions"); }} />

Recurring patterns

Repeated payments detected in the selected period

Observed, not forecast
{dashboard.recurring.length ? (
{dashboard.recurring.map((g, i) => ( ))}
Merchant / payment Frequency Occurrences Observed total
{g.name} {g.period} {g.count} {money(g.amount, g.currency)}
) : (
No recurring patterns detected in this period.
)}
) )} ); } function CategoryTree({ data, groups, onSelect, }: { data: Dataset; groups: Group[]; onSelect: (id: string) => void; }) { const [expanded, setExpanded] = useState( 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 (
{children.length ? ( ) : ( )}
{expanded.includes(id) && (showAll ? children : children.slice(0, 6)).map((c) => node(c.id, depth + 1), )} {expanded.includes(id) && !showAll && children.length > 6 && ( )}
); }; return (

Category breakdown

Expand the tree · parents include their descendants

{groups.length ? (
{roots.map((c) => node(c.id, 0))}
) : (
Your category breakdown appears after importing transactions.
)}
); } function MonthlyChart({ groups }: { groups: Group[] }) { if (!groups.length) return ( Your monthly trend appears after importing transactions. ); const currencies = Array.from(new Set(groups.map((g) => g.currency))); return (
{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 (
{currency}
`${g.period}: ${g.amount}`).join("; ")}`} > {rows.map((g, i) => (
{money(g.amount, currency)}
{g.period}
))}
); })}
); } function GroupPanel({ title, subtitle, groups, onSelect, }: { title: string; subtitle: string; groups: Group[]; onSelect: (id: string) => void; }) { const [expanded, setExpanded] = useState(false); const maxima: Record = {}; 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 (

{title}

{subtitle}

{groups.length ? (
{(expanded ? sorted : sorted.slice(0, 6)).map((g, i) => ( ))} {groups.length > 6 && ( )}
) : (
No activity in this view.
)}
); }