This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
import React, { useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import {
LayoutDashboard,
ArrowLeftRight,
FolderTree,
Tags,
Store,
Wallet,
Sparkles,
Settings as SettingsIcon,
RefreshCw,
Menu,
X,
ShieldCheck,
CheckCircle2,
CircleHelp,
} from "lucide-react";
import type { State } from "./api";
import { APIError, emptyFilter, 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 { ErrorMessage } from "./ui";
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: "accounts", label: "Accounts", icon: Wallet },
{ 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({ ...emptyFilter });
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]);
useEffect(() => {
const connected = new URLSearchParams(window.location.search).get(
"connected",
);
if (connected === "1") {
setNotice(
"Bank authorization completed. Your connection is available in Accounts.",
);
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/sync",
"/api/rebuild",
].includes(path);
try {
acceptState(
await request<State>(
path,
revisionless ? body : { revision: state.revision, ...body },
),
message,
);
} 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">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path
d="M6 17c0-4 3-6 7-6V7c0-3 2-5 5-5s5 2 5 5v3h3v3h-5c0 7-4 11-10 11-5 0-8-3-8-7h3Z"
fill="currentColor"
/>
<circle cx="19" cy="7" r="1.2" fill="#102131" />
</svg>
</span>
<span>
finance<span className="brand-light">duck</span>
<small>YOUR MONEY, CLEARLY</small>
</span>
</a>
<span className="nav-label">WORKSPACE</span>
<nav aria-label="Main navigation">
{navigation.map(({ id, label, icon: Icon }, i) => (
<button
key={id}
className={`nav-item ${page === id ? "active" : ""} ${i === 7 ? "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>
)}
{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}`
: `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
key={state.revision}
data={state.data}
filter={filter}
setFilter={setFilter}
mutate={mutate}
/>
)}
{page === "categories" && (
<Registry
key={`categories-${state.revision}`}
entity="category"
data={state.data}
mutate={mutate}
/>
)}
{page === "tags" && (
<Registry
key={`tags-${state.revision}`}
entity="tag"
data={state.data}
mutate={mutate}
/>
)}
{page === "merchants" && (
<Registry
key={`merchants-${state.revision}`}
entity="merchant"
data={state.data}
mutate={mutate}
/>
)}
{page === "accounts" && (
<Accounts
key={state.revision}
state={state}
mutate={mutate}
acceptState={acceptState}
/>
)}
{page === "classification" && (
<Classification state={state} acceptState={acceptState} />
)}
{page === "settings" && (
<Settings
key={`${state.settings.model}-${state.settings.include_amount}`}
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>,
);