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(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("/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, 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( 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 (
Skip to content {mobileNav && ( ))}
Private by nature Self-hosted. In your hands.
Workspace / {navigation.find((n) => n.id === page)?.label}
Local workspace
{notice && (
{notice}
)} {conflict && (
Your journal has changed.

The conflicting change was not applied. Reloading closes stale edit forms so you can edit the latest version.

)} {reconnects.length > 0 && (
{Array.from(new Set(reconnects.map((c) => c.institution))).join( ", ", )}{" "} needs reconnection. Your existing transactions are safe.
)} {connectError && (
Bank connection failed: {connectError}
)} {state && (state.status.sync_error || state.status.index_error) && page !== "settings" && (
{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}`}
)} {!state ? (
{refreshing ? ( <> Opening your workspace… ) : ( )}
) : ( <> {page === "overview" && ( )} {page === "transactions" && ( )} {page === "categories" && ( )} {page === "tags" && ( )} {page === "merchants" && ( )} {page === "instruments" && ( )} {page === "accounts" && ( )} {page === "wealth" && ( )} {page === "classification" && ( )} {page === "settings" && ( )} )}
Finance Duck Clarity without compromise.
); } 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 ? (

We couldn't display this workspace

The server response or application needs attention. Your journal has not been modified by this display error.

) : ( this.props.children ); } } createRoot(document.getElementById("root")!).render( , );