Files
finance-duck/web/src/main.tsx
T
Lars Nolden 1b3d7b22bb Create categories and tags in place from every assignment picker
Category and tag inputs across the transaction editor, Analyse
corrections, and merchant defaults now mint missing entries without a
detour through the registry pages. A bare name lands under the kind's
root, "Parent / Name" targets that parent, and typing an existing name
selects it instead of duplicating. Enter only creates when nothing
matches, server rejections surface inline in the dropdown, and
assignment pickers offer leaf categories only — the shape the server
validates.

Mutations now return the accepted state so callers can select the id
the server just minted, and the revision-keyed remounts on Transactions
and the registry pages are gone: they closed the open modal and threw
away pending edits the moment any in-modal creation committed.
2026-09-14 11:50:50 +02:00

458 lines
15 KiB
TypeScript

import React, { useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import {
LayoutDashboard,
ArrowLeftRight,
FolderTree,
Tags,
Store,
CandlestickChart,
Wallet,
PiggyBank,
Sparkles,
Settings as SettingsIcon,
RefreshCw,
Menu,
X,
ShieldCheck,
CheckCircle2,
CircleHelp,
} from "lucide-react";
import type { State } from "./api";
import {
APIError,
defaultFilter,
localInstant,
normalizeState,
request,
} from "./api";
import { Overview } from "./Overview";
import { Transactions } from "./Transactions";
import { Registry } from "./Registry";
import { Accounts } from "./Accounts";
import { Classification } from "./Classification";
import { Settings } from "./Settings";
import Wealth from "./Wealth";
import { ErrorMessage } from "./ui";
// Montserrat carries the wordmark. The subsets are bundled rather than fetched
// from Google Fonts: the Content-Security-Policy serves fonts from 'self' only,
// and a self-hosted workspace must not phone home to render its own brand.
import "@fontsource/montserrat/latin-500.css";
import "@fontsource/montserrat/latin-700.css";
import "./styles.css";
const navigation = [
{ id: "overview", label: "Overview", icon: LayoutDashboard },
{ id: "transactions", label: "Transactions", icon: ArrowLeftRight },
{ id: "categories", label: "Categories", icon: FolderTree },
{ id: "tags", label: "Tags", icon: Tags },
{ id: "merchants", label: "Merchants", icon: Store },
{ id: "instruments", label: "Instruments", icon: CandlestickChart },
{ id: "accounts", label: "Accounts", icon: Wallet },
{ id: "wealth", label: "Wealth", icon: PiggyBank },
{ id: "classification", label: "AI classification", icon: Sparkles },
{ id: "settings", label: "Settings", icon: SettingsIcon },
];
function App() {
const [page, setPage] = useState(() =>
navigation.some((n) => n.id === window.location.hash.slice(1))
? window.location.hash.slice(1)
: "overview",
);
const [state, setState] = useState<State | null>(null);
const [error, setError] = useState("");
const [conflict, setConflict] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [notice, setNotice] = useState("");
const [mobileNav, setMobileNav] = useState(false);
const [filter, setFilter] = useState(defaultFilter);
const acceptState = useCallback((value: State, message?: string) => {
setState(normalizeState(value));
setConflict(false);
if (message) setNotice(message);
}, []);
const reload = useCallback(async () => {
setRefreshing(true);
setError("");
try {
acceptState(await request<State>("/api/state"));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRefreshing(false);
}
}, [acceptState]);
useEffect(() => {
void reload();
const change = () => {
const next = window.location.hash.slice(1);
if (navigation.some((n) => n.id === next)) setPage(next);
};
window.addEventListener("hashchange", change);
return () => window.removeEventListener("hashchange", change);
}, [reload]);
useEffect(() => {
if (!notice) return;
const timeout = window.setTimeout(() => setNotice(""), 7000);
return () => window.clearTimeout(timeout);
}, [notice]);
const [connectError, setConnectError] = useState("");
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("connected") === "1") {
const unlinkable = Number(params.get("unlinkable") ?? "0");
setNotice(
unlinkable > 0
? `Bank authorization completed. ${unlinkable} shared account${unlinkable === 1 ? "" : "s"} could not be linked (no IBAN or stable identification, or an unsupported currency); the rest are available in Accounts.`
: "Bank authorization completed. Your connection is available in Accounts.",
);
}
const failed = params.get("connect_error");
if (failed) setConnectError(failed);
if (params.get("connected") || failed) {
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.hash}`,
);
}
}, []);
const navigate = (next: string) => {
setPage(next);
window.location.hash = next;
setMobileNav(false);
window.scrollTo({ top: 0, behavior: "instant" });
};
const mutate = async (
path: string,
body: Record<string, unknown>,
message?: string,
) => {
if (!state) throw new Error("Load the journal before making changes.");
const revisionless = [
"/api/settings",
"/api/settings/openrouter",
"/api/settings/enablebanking",
"/api/sync",
"/api/rebuild",
].includes(path);
try {
const next = await request<State>(
path,
revisionless ? body : { revision: state.revision, ...body },
);
acceptState(next, message);
return next;
} catch (err) {
if (err instanceof APIError && err.status === 409) setConflict(true);
throw err;
}
};
const reconnects =
state?.connections.filter(
(connection) => connection.status === "reconnect_required",
) || [];
return (
<div className="app">
<a className="skip-link" href="#main-content">
Skip to content
</a>
{mobileNav && (
<button
className="nav-backdrop"
aria-label="Close navigation"
onClick={() => setMobileNav(false)}
/>
)}
<aside className={`sidebar ${mobileNav ? "open" : ""}`}>
<a
href="#overview"
className="brand"
onClick={(e) => {
e.preventDefault();
navigate("overview");
}}
>
<span className="brand-mark" aria-hidden="true">
<img src="/finance-duck-icon.svg" alt="" width={34} height={34} />
</span>
<span>
finance<span className="brand-light">duck</span>
<small>YOUR MONEY</small>
</span>
</a>
<span className="nav-label">WORKSPACE</span>
<nav aria-label="Main navigation">
{navigation.map(({ id, label, icon: Icon }) => (
<button
key={id}
className={`nav-item ${page === id ? "active" : ""} ${id === "settings" ? "nav-settings" : ""}`}
aria-current={page === id ? "page" : undefined}
onClick={() => navigate(id)}
>
<Icon size={19} />
<span>{label}</span>
{id === "classification" && <span className="nav-ai">AI</span>}
</button>
))}
</nav>
<div className="sidebar-bottom">
<ShieldCheck size={19} />
<div>
<strong>Private by nature</strong>
<span>Self-hosted. In your hands.</span>
</div>
</div>
</aside>
<div className="main-shell">
<header className="topbar">
<div className="breadcrumb">
<button
className="icon-button mobile-toggle"
aria-label="Open navigation"
onClick={() => setMobileNav(true)}
>
<Menu size={21} />
</button>
<span>Workspace</span>
<span className="breadcrumb-divider">/</span>
<strong>{navigation.find((n) => n.id === page)?.label}</strong>
</div>
<div className="topbar-actions">
<span className="local-status">
<span />
Local workspace
</span>
<button
className="icon-button"
title="Refresh journal and revision"
aria-label="Refresh journal and revision"
disabled={refreshing}
onClick={() => void reload()}
>
<RefreshCw size={18} className={refreshing ? "spin" : ""} />
</button>
<button
className="avatar"
title="Open privacy settings"
aria-label="Open privacy settings"
onClick={() => navigate("settings")}
>
<ShieldCheck size={18} />
</button>
</div>
</header>
<main id="main-content" tabIndex={-1}>
{notice && (
<div className="toast" role="status">
<CheckCircle2 size={18} />
<span>{notice}</span>
<button
className="icon-button"
aria-label="Dismiss notification"
onClick={() => setNotice("")}
>
<X size={16} />
</button>
</div>
)}
<ErrorMessage error={error} />
{conflict && (
<div className="alert error">
<div>
<strong>Your journal has changed.</strong>
<p>
The conflicting change was not applied. Reloading closes stale
edit forms so you can edit the latest version.
</p>
</div>
<button
className="button secondary"
onClick={() => void reload()}
>
Reload latest revision
</button>
</div>
)}
{reconnects.length > 0 && (
<div className="alert warning">
<CircleHelp size={19} />
<span>
{Array.from(new Set(reconnects.map((c) => c.institution))).join(
", ",
)}{" "}
needs reconnection. Your existing transactions are safe.
</span>
<button
className="button secondary"
onClick={() => navigate("accounts")}
>
Reconnect bank
</button>
</div>
)}
{connectError && (
<div className="alert error">
<CircleHelp size={19} />
<span>Bank connection failed: {connectError}</span>
<button
className="button subtle"
onClick={() => setConnectError("")}
>
Dismiss
</button>
</div>
)}
{state &&
(state.status.sync_error || state.status.index_error) &&
page !== "settings" && (
<div className="alert warning">
<CircleHelp size={19} />
<span>
{state.status.index_error
? `Analytics needs attention: ${state.status.index_error}`
: state.status.sync_retry_at
? `Bank sync is waiting for your bank's rate limit and retries by itself after ${localInstant(state.status.sync_retry_at)}${state.status.sync_error}`
: `Bank sync needs attention: ${state.status.sync_error}`}
</span>
<button
className="button subtle"
onClick={() => navigate("settings")}
>
View status
</button>
</div>
)}
{!state ? (
<div className="loading-block">
{refreshing ? (
<>
<span className="spinner" />
<span role="status">Opening your workspace</span>
</>
) : (
<button
className="button primary"
onClick={() => void reload()}
>
Retry loading workspace
</button>
)}
</div>
) : (
<>
{page === "overview" && (
<Overview
data={state.data}
revision={state.revision}
filter={filter}
setFilter={setFilter}
navigate={navigate}
/>
)}
{page === "transactions" && (
<Transactions
data={state.data}
filter={filter}
setFilter={setFilter}
mutate={mutate}
/>
)}
{page === "categories" && (
<Registry
entity="category"
data={state.data}
mutate={mutate}
acceptState={acceptState}
revision={state.revision}
model={state.settings.model}
/>
)}
{page === "tags" && (
<Registry entity="tag" data={state.data} mutate={mutate} />
)}
{page === "merchants" && (
<Registry entity="merchant" data={state.data} mutate={mutate} />
)}
{page === "instruments" && (
<Registry
entity="instrument"
data={state.data}
mutate={mutate}
/>
)}
{page === "accounts" && (
<Accounts
state={state}
mutate={mutate}
acceptState={acceptState}
/>
)}
{page === "wealth" && (
<Wealth
revision={state.revision}
acceptState={acceptState}
mutate={mutate}
/>
)}
{page === "classification" && (
<Classification
state={state}
acceptState={acceptState}
mutate={mutate}
/>
)}
{page === "settings" && (
<Settings
key={`${state.settings.model}-${state.settings.classify_on_import}-${state.settings.private_names.join(",")}`}
state={state}
mutate={mutate}
/>
)}
</>
)}
<footer>
<span>Finance Duck</span>
<span>Clarity without compromise.</span>
</footer>
</main>
</div>
</div>
);
}
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ message: string }
> {
state = { message: "" };
static getDerivedStateFromError(error: Error) {
return { message: error.message };
}
render() {
return this.state.message ? (
<div className="fatal">
<h1>We couldn't display this workspace</h1>
<p>
The server response or application needs attention. Your journal has
not been modified by this display error.
</p>
<ErrorMessage error={this.state.message} />
<button
className="button primary"
onClick={() => window.location.reload()}
>
Reload workspace
</button>
</div>
) : (
this.props.children
);
}
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);