"use client"; import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from "react"; import "./transaction-ledger.css"; export type LedgerEntry = { id: string; /** ISO timestamp. */ at: string; merchant: string; /** Minor units; negative for money out. */ amount: number; kind: "card" | "transfer" | "fee"; category: string; status: "posted" | "pending"; card?: string; receipt?: boolean; memo?: string; }; export type TransactionLedgerProps = { entries: LedgerEntry[]; /** Save a memo; reject to keep the draft and show an error. */ onMemo?: (id: string, memo: string) => Promise; /** "Today" and "Yesterday" are measured from this date (ISO); defaults to the viewer's clock. */ today?: string; currency?: string; locale?: string; timeZone?: string; theme?: "dark" | "light"; className?: string; }; type Filter = "all" | "card" | "transfer" | "pending"; const HUES = [210, 260, 160, 20, 330, 45, 190]; const hue = (s: string) => HUES[[...s].reduce((a, c) => a + c.charCodeAt(0), 0) % HUES.length]; const initials = (s: string) => s.replace(/[^A-Za-z0-9 ]/g, "").split(/\s+/).filter(Boolean).map((w) => w[0]).slice(0, 2).join("").toUpperCase(); /** * Transaction Ledger * Account activity grouped by day, each day with its net total. Money in is * marked, pending items say so, and any row opens in place to show its * details and take a memo. */ export function TransactionLedger({ entries, onMemo, today, currency = "USD", locale, timeZone, theme = "dark", className = "" }: TransactionLedgerProps) { const id = useId(); const [filter, setFilter] = useState("all"); const [query, setQuery] = useState(""); const [open, setOpen] = useState(null); const [memos, setMemos] = useState>(() => Object.fromEntries(entries.map((e) => [e.id, e.memo ?? ""]))); const [memoState, setMemoState] = useState>({}); const [now, setNow] = useState(today ?? null); const rows = useRef(new Map()); useEffect(() => { if (!today) setNow(new Date().toISOString()); }, [today]); const money = new Intl.NumberFormat(locale, { style: "currency", currency }); const dayKey = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone }); const dayLabel = new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short", timeZone }); const time = new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit", timeZone }); const rel = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); const visible = useMemo(() => { const q = query.trim().toLowerCase(); return [...entries] .sort((a, b) => b.at.localeCompare(a.at)) .filter((e) => filter === "all" || (filter === "pending" ? e.status === "pending" : e.kind === filter)) .filter((e) => !q || `${e.merchant} ${e.category} ${memos[e.id] ?? ""} ${e.card ?? ""}`.toLowerCase().includes(q)); }, [entries, filter, query, memos]); const groups = useMemo(() => { const out: { key: string; items: LedgerEntry[] }[] = []; for (const e of visible) { const key = dayKey.format(new Date(e.at)); const last = out[out.length - 1]; if (last?.key === key) last.items.push(e); else out.push({ key, items: [e] }); } return out; }, [visible, dayKey]); const heading = (key: string) => { if (now) { const diff = Math.round((Date.parse(dayKey.format(new Date(now))) - Date.parse(key)) / 86_400_000); if (diff === 0 || diff === 1) { const word = rel.format(-diff, "day"); return word[0].toUpperCase() + word.slice(1); } } return dayLabel.format(new Date(`${key}T12:00:00Z`)); }; const saveMemo = async (entryId: string) => { if (!onMemo || memoState[entryId] === "saving") return; const original = entries.find((e) => e.id === entryId)?.memo ?? ""; setMemoState((s) => ({ ...s, [entryId]: "saving" })); try { await onMemo(entryId, memos[entryId] ?? original); setMemoState((s) => ({ ...s, [entryId]: "saved" })); } catch { setMemoState((s) => ({ ...s, [entryId]: "error" })); } }; const onRowKey = (e: KeyboardEvent, entryId: string) => { if (e.key !== "j" && e.key !== "k" && e.key !== "ArrowDown" && e.key !== "ArrowUp") return; e.preventDefault(); const i = visible.findIndex((v) => v.id === entryId); const next = visible[i + (e.key === "j" || e.key === "ArrowDown" ? 1 : -1)]; if (next) rows.current.get(next.id)?.focus(); }; const FILTERS: { id: Filter; label: string }[] = [{ id: "all", label: "All" }, { id: "card", label: "Card" }, { id: "transfer", label: "Transfers" }, { id: "pending", label: "Pending" }]; return (
{FILTERS.map((f) => )}

{`${visible.length} ${visible.length === 1 ? "transaction" : "transactions"}`}

{groups.length === 0 &&

Nothing matches{query ? ` “${query}”` : ""}.

} {groups.map((g) => { const net = g.items.reduce((a, e) => a + e.amount, 0); return (

{heading(g.key)}{net > 0 ? "+" : ""}{money.format(net / 100)}

    {g.items.map((e) => { const isOpen = open === e.id; const credit = e.amount > 0; return (
  • ); })}
); })}
); }