import { useEffect, useId, useRef, useState } from "react"; import type { ReactNode } from "react"; import { X, Inbox, AlertCircle, CalendarDays, ChevronLeft, ChevronRight, Plus, } from "lucide-react"; import type { Category, Dataset, Filter, State, VerifiedModel } from "./api"; import { categoryPath, DEFAULT_MONTHS, defaultFilter, monthStart, request, yearStart, } from "./api"; export function Modal({ title, children, close, wide = false, }: { title: string; children: ReactNode; close: () => void; wide?: boolean; }) { const ref = useRef(null); const titleID = useId(); useEffect(() => { const dialog = ref.current; dialog?.showModal(); return () => dialog?.close(); }, []); return ( { e.preventDefault(); close(); }} >

{title}

{children}
); } // ModelOptions loads the server-verified model list once and renders it as a // datalist: the input stays free text so an unlisted model is still usable // when the catalog is unreachable. export function ModelOptions({ id }: { id: string }) { const [models, setModels] = useState([]); useEffect(() => { request("/api/models") .then(setModels) .catch(() => {}); }, []); return ( {models.map((m) => ( ))} ); } export function Field({ label, children, hint, }: { label: string; children: ReactNode; hint?: string; }) { return ( ); } export interface ComboOption { value: string; label: string; icon?: ReactNode; } // ComboCreate is one "create it now" row a Combobox offers when the typed // text matches nothing: running it is expected to persist the new entity and // select it through the caller's own onChange. export interface ComboCreate { key: string; label: string; run: () => Promise | void; } // Combobox is a free-text input that autocompletes against a fixed option // list: typing filters by label, Enter takes the exact or only match, and // picking an option reports its value. The caller keeps working with stable // ids while the user only ever sees names. export function Combobox({ options, value, onChange, placeholder, disabled = false, required = false, adornment, emptyText = "No matches.", create, }: { options: ComboOption[]; value: string; onChange: (value: string) => void; placeholder?: string; disabled?: boolean; required?: boolean; adornment?: ReactNode; emptyText?: string; create?: (text: string) => ComboCreate[]; }) { const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(""); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); // Index into the interactive rows (matches first, then create rows); -1 // means no row is armed and Enter falls back to exact/single-match logic. const [active, setActive] = useState(-1); const listID = useId(); const filter = query.trim().toLowerCase(); const matches = options.filter((o) => o.label.toLowerCase().includes(filter)); const exact = filter ? matches.find((o) => o.label.toLowerCase() === filter) : undefined; const shown = exact ? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)] : matches.slice(0, 60); const selected = options.find((o) => o.value === value); const creations = create && filter && !exact && !disabled ? create(query.trim()) : []; const total = shown.length + creations.length; const cursor = active < total ? active : -1; // The dropdown scrolls at 264px; keep the armed row visible while // arrowing through a long category list. useEffect(() => { if (cursor < 0) return; document .getElementById(`${listID}-${cursor}`) ?.scrollIntoView({ block: "nearest" }); }, [cursor, listID]); const pick = (v: string) => { onChange(v); setOpen(false); }; const runCreate = async (c: ComboCreate) => { if (creating) return; setCreating(true); setCreateError(""); try { await c.run(); setOpen(false); } catch (err) { setCreateError(err instanceof Error ? err.message : String(err)); } finally { setCreating(false); } }; return (
= 0 ? `${listID}-${cursor}` : undefined } disabled={disabled} value={open ? query : (selected?.label ?? value)} placeholder={placeholder} onFocus={() => { setQuery(""); setActive(-1); setOpen(true); }} onChange={(e) => { setQuery(e.target.value); setCreateError(""); setActive(-1); setOpen(true); }} onBlur={() => setOpen(false)} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); if ((e.key === "ArrowDown" || e.key === "ArrowUp") && open && total) { e.preventDefault(); setActive( e.key === "ArrowDown" ? (cursor + 1) % total : (cursor <= 0 ? total : cursor) - 1, ); } if (e.key === "Enter" && open) { e.preventDefault(); if (cursor >= 0 && cursor < shown.length) pick(shown[cursor].value); else if (cursor >= shown.length) void runCreate(creations[cursor - shown.length]); else { const hit = exact ?? (shown.length === 1 ? shown[0] : undefined); if (hit) pick(hit.value); // Without an armed row, Enter creates only when nothing // matches at all: minting from a half-typed name is too easy. else if (!shown.length && creations.length === 1) void runCreate(creations[0]); } } }} /> {adornment && !open && ( {adornment} )} {open && (
    {shown.map((o, i) => (
  • ))} {creations.map((c, i) => (
  • ))} {createError && (
  • {createError}
  • )} {shown.length === 0 && creations.length === 0 && !createError && (
  • {emptyText}
  • )} {matches.length > shown.length && (
  • {matches.length - shown.length} more — keep typing to narrow down.
  • )}
)}
); } // Dates are handled as calendar days, never as instants: every helper works on // the ISO string's integer parts so a browser time zone can never shift a // booking date. "Sept" follows the four-letter form used in the journal UI. const MONTHS = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec", ]; const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; interface Month { year: number; month: number; } function dayParts(iso: string): (Month & { day: number }) | null { if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null; const [year, month, day] = iso.split("-").map(Number); if (month < 1 || month > 12 || day < 1 || day > daysInMonth({ year, month })) return null; return { year, month, day }; } function isoDay({ year, month }: Month, day: number) { return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } function daysInMonth({ year, month }: Month) { return new Date(Date.UTC(year, month, 0)).getUTCDate(); } // Monday-first column index of the first day of the month. function firstWeekday({ year, month }: Month) { return (new Date(Date.UTC(year, month - 1, 1)).getUTCDay() + 6) % 7; } function shiftMonth({ year, month }: Month, by: number): Month { const zero = year * 12 + (month - 1) + by; return { year: Math.floor(zero / 12), month: (zero % 12) + 1 }; } // formatDay renders an ISO calendar day as "09 Sept 2026". Unparseable input is // returned unchanged so an unexpected stored value stays visible instead of // being replaced by an invented date. function formatDay(iso: string): string { const parts = dayParts(iso); if (!parts) return iso; return `${String(parts.day).padStart(2, "0")} ${MONTHS[parts.month - 1]} ${parts.year}`; } // DateField picks a calendar day by month name instead of the browser's // numeric date control, which renders day and month ambiguously across // locales. The value stays an ISO day, so filters and API payloads are // unchanged. min/max are inclusive ISO days. export function DateField({ label, value, onChange, min, max, hint, clearable = false, }: { label: string; value: string; onChange: (day: string) => void; min?: string; max?: string; hint?: string; clearable?: boolean; }) { const [open, setOpen] = useState(false); const clock = new Date(); const now = isoDay( { year: clock.getFullYear(), month: clock.getMonth() + 1 }, clock.getDate(), ); const selected = dayParts(value); const [view, setView] = useState( () => selected ?? dayParts(now) ?? { year: clock.getFullYear(), month: 1 }, ); const blocked = (day: string) => (!!min && day < min) || (!!max && day > max); const choose = (day: string) => { onChange(day); setOpen(false); }; const years = []; const firstYear = (min ? dayParts(min)?.year : undefined) ?? view.year - 12; const lastYear = (max ? dayParts(max)?.year : undefined) ?? view.year + 2; for ( let year = Math.min(firstYear, view.year); year <= Math.max(lastYear, view.year); year++ ) years.push(year); const days = []; for (let day = 1; day <= daysInMonth(view); day++) days.push(day); // Dismissal must not depend on focus: a click does not focus a button on // every platform, and Escape then never reaches the popover. preventDefault // keeps an enclosing dialog open when Escape only closes this picker. const box = useRef(null); useEffect(() => { if (!open) return; const outside = (e: PointerEvent) => { if (!box.current?.contains(e.target as Node)) setOpen(false); }; const escape = (e: KeyboardEvent) => { if (e.key !== "Escape") return; e.preventDefault(); setOpen(false); }; document.addEventListener("pointerdown", outside); document.addEventListener("keydown", escape); return () => { document.removeEventListener("pointerdown", outside); document.removeEventListener("keydown", escape); }; }, [open]); return (
{open && (
{WEEKDAYS.map((weekday) => ( {weekday} ))} {Array.from({ length: firstWeekday(view) }, (_, i) => ( ))} {days.map((day) => { const iso = isoDay(view, day); return ( ); })}
{clearable && value && ( )}
)}
); } export function ErrorMessage({ error }: { error: string }) { return error ? (
{error}
) : null; } export function Empty({ title, children, }: { title: string; children?: ReactNode; }) { return (

{title}

{children}
); } // createTag persists a new tag and returns its server-minted id, found by // diffing the returned state against the dataset the caller rendered with. export async function createTag( mutate: Mutate, data: Dataset, name: string, ): Promise { const next = await mutate( "/api/tags", { tag: { id: "", name, hint: "" } }, `Tag "${name}" created`, ); const created = next.data.tags.find( (t) => !data.tags.some((o) => o.id === t.id), ); if (!created) throw new Error(`The server did not return the new tag "${name}".`); return created.id; } export async function createCategory( mutate: Mutate, data: Dataset, category: { name: string; parent_id: string; kind: string }, ): Promise { const next = await mutate( "/api/categories", { category: { id: "", hint: "", ...category } }, `Category "${category.name}" created`, ); const created = next.data.categories.find( (c) => !data.categories.some((o) => o.id === c.id), ); if (!created) throw new Error( `The server did not return the new category "${category.name}".`, ); return created.id; } export function TagPicker({ data, value, onChange, mutate, }: { data: Dataset; value: string[]; onChange: (ids: string[]) => void; mutate?: Mutate; }) { const [draft, setDraft] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const add = async () => { const name = draft.trim(); if (!name || busy || !mutate) return; // An existing tag of the same name is checked instead of duplicated. const existing = data.tags.find( (t) => t.name.toLowerCase() === name.toLowerCase(), ); if (existing) { if (!value.includes(existing.id)) onChange([...value, existing.id]); setDraft(""); return; } setBusy(true); setError(""); try { const id = await createTag(mutate, data, name); onChange([...value, id]); setDraft(""); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; return (
Tags {data.tags.map((tag) => ( ))} {!data.tags.length && !mutate && ( No tags yet. Create them in Tags. )} {mutate && ( { setDraft(e.target.value); setError(""); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void add(); } }} /> )} {error && {error}}
); } export function CategoryOptions({ data, kind, exclude = [], }: { data: Dataset; kind?: string; exclude?: string[]; }) { return ( <> {data.categories .filter((c) => (!kind || c.kind === kind) && !exclude.includes(c.id)) .map((c) => ( ))} ); } // CategoryCombobox is the one category picker: options are full paths, and // with a mutate handle an unmatched name can be created in place. A bare name // lands under the kind's root; "Parent / Name" creates under that parent. // leavesOnly matches the server rule that assigned categories must be leaves; // a freshly created category is always a leaf. export function CategoryCombobox({ data, value, onChange, mutate, kind, leavesOnly = false, exclude = [], emptyLabel, required = false, disabled = false, placeholder = "Search categories", }: { data: Dataset; value: string; onChange: (id: string) => void; mutate?: Mutate; kind?: string; leavesOnly?: boolean; exclude?: string[]; emptyLabel?: string; required?: boolean; disabled?: boolean; placeholder?: string; }) { const parents = new Set( data.categories.map((c) => c.parent_id).filter(Boolean), ); const eligible = (c: Category) => (!kind || c.kind === kind) && !exclude.includes(c.id); const options: ComboOption[] = data.categories .filter((c) => eligible(c) && (!leavesOnly || !parents.has(c.id))) .map((c) => ({ value: c.id, label: categoryPath(data, c.id) })); if (emptyLabel) options.unshift({ value: "", label: emptyLabel }); const pathOf = (id: string) => categoryPath(data, id).toLowerCase(); const taken = (parentID: string, name: string) => { const full = `${parentID ? pathOf(parentID) + " / " : ""}${name.toLowerCase()}`; return data.categories.some((c) => pathOf(c.id) === full); }; const create = (text: string): ComboCreate[] => { // Text aiming at the empty option ("No parent…") is a selection, not a // new category name. if (!mutate || emptyLabel?.toLowerCase().includes(text.toLowerCase())) return []; const segments = text .split("/") .map((s) => s.trim()) .filter(Boolean); if (!segments.length) return []; const name = segments[segments.length - 1]; const row = (parent: Category): ComboCreate => ({ key: parent.id, label: `Create "${name}" in ${categoryPath(data, parent.id)}`, run: async () => onChange( await createCategory(mutate, data, { name, parent_id: parent.id, kind: parent.kind, }), ), }); if (segments.length > 1) { const prefix = segments.slice(0, -1).join(" / ").toLowerCase(); const parent = data.categories.find( (c) => eligible(c) && pathOf(c.id) === prefix, ); return parent && !taken(parent.id, name) ? [row(parent)] : []; } return data.categories .filter((c) => !c.parent_id && eligible(c) && !taken(c.id, name)) .map(row); }; return ( ); } export function Filters({ data, value, onChange, }: { data: Dataset; value: Filter; onChange: (filter: Filter) => void; }) { const update = (key: keyof Filter, text: string) => onChange({ ...value, [key]: text }); const currencies = Array.from( new Set([ ...data.accounts.map((a) => a.currency), ...data.transactions.map((t) => t.facts.currency), ]), ).sort(); // Presets leave `to` open so the window always reaches today; the explicit // date fields below stay authoritative for anything narrower. const ranges = [ ...[1, 3, DEFAULT_MONTHS, 12].map((months) => ({ label: `${months}M`, title: months === 1 ? "This month" : `Last ${months} months`, from: monthStart(months - 1), to: "", })), { label: "YTD", title: "Year to date", from: yearStart(), to: "" }, { label: "All", title: "All time", from: "", to: "" }, ]; return (
Period
{ranges.map((range) => { const active = value.from === range.from && value.to === range.to; return ( ); })}
update("from", day)} /> update("to", day)} />
); } export function FormActions({ busy, close, label = "Save changes", }: { busy: boolean; close: () => void; label?: string; }) { return (
); } // Mutate posts a revisioned change and returns the accepted state, so a // caller can find ids the server just minted. export type Mutate = ( path: string, body: Record, message?: string, ) => Promise;