import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Plus, X, Trash2, BookOpen, Briefcase, BarChart3, TrendingUp, TrendingDown, Minus, ChevronRight } from "lucide-react"; // ---------- design tokens ---------- const T = { ink: "#0F1524", inkDeep: "#0A0E1A", card: "#171F35", line: "#28324A", parchment: "#EDE7D9", dim: "#7C8699", brass: "#C9A24B", brassDim: "rgba(201,162,75,0.14)", gain: "#5FAE7C", gainDim: "rgba(95,174,124,0.14)", loss: "#C1584B", lossDim: "rgba(193,88,75,0.14)", }; const fmtMoney = (n) => { if (n === null || n === undefined || isNaN(n)) return "—"; const abs = Math.abs(n); const s = abs.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return `${n < 0 ? "-" : ""}$${s}`; }; const fmtPct = (n) => { if (n === null || n === undefined || isNaN(n)) return "—"; return `${(n * 100).toFixed(1)}%`; }; const uid = () => Math.random().toString(36).slice(2, 10); function deriveTrade(t) { const shares = parseFloat(t.shares) || 0; const entry = parseFloat(t.entry) || 0; const exit = parseFloat(t.exit) || 0; const fees = parseFloat(t.fees) || 0; const costBasis = shares * entry; const proceeds = shares * exit - fees; const pl = proceeds - costBasis; const plPct = costBasis ? pl / costBasis : 0; let outcome = "Breakeven"; if (pl > 0) outcome = "Win"; else if (pl < 0) outcome = "Loss"; return { ...t, costBasis, proceeds, pl, plPct, outcome }; } function deriveHolding(h) { const shares = parseFloat(h.shares) || 0; const avgCost = parseFloat(h.avgCost) || 0; const price = parseFloat(h.price) || 0; const costBasis = shares * avgCost; const marketValue = shares * price; const upl = marketValue - costBasis; const uplPct = costBasis ? upl / costBasis : 0; return { ...h, costBasis, marketValue, upl, uplPct }; } // ---------- tiny sparkline ---------- function Sparkline({ values, width = 280, height = 64 }) { if (!values.length) { return (
Log a trade to see your curve
); } const min = Math.min(0, ...values); const max = Math.max(0, ...values); const range = max - min || 1; const pad = 6; const step = values.length > 1 ? (width - pad * 2) / (values.length - 1) : 0; const pts = values.map((v, i) => { const x = pad + i * step; const y = height - pad - ((v - min) / range) * (height - pad * 2); return [x, y]; }); const zeroY = height - pad - ((0 - min) / range) * (height - pad * 2); const path = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" "); const last = values[values.length - 1]; const color = last >= 0 ? T.gain : T.loss; return ( {pts.length > 0 && ( )} ); } // ---------- reusable bits ---------- function Field({ label, children }) { return ( ); } const inputStyle = { width: "100%", background: T.ink, border: `1px solid ${T.line}`, borderRadius: 8, padding: "10px 12px", color: T.parchment, fontSize: 15, fontFamily: "IBM Plex Mono, monospace", outline: "none", boxSizing: "border-box", }; function Segmented({ options, value, onChange }) { return (
{options.map((opt) => ( ))}
); } function OutcomeBadge({ pl }) { const positive = pl > 0; const flat = pl === 0; const color = flat ? T.dim : positive ? T.gain : T.loss; const bg = flat ? "transparent" : positive ? T.gainDim : T.lossDim; const Icon = flat ? Minus : positive ? TrendingUp : TrendingDown; return ( {fmtMoney(pl)} ); } // ---------- main app ---------- export default function TradeLedger() { const [tab, setTab] = useState("log"); const [trades, setTrades] = useState([]); const [holdings, setHoldings] = useState([]); const [loaded, setLoaded] = useState(false); const [sheet, setSheet] = useState(null); // { type: 'trade'|'holding', data } useEffect(() => { (async () => { try { const t = await window.storage.get("ledger:trades", false); if (t?.value) setTrades(JSON.parse(t.value)); } catch (e) { /* no data yet */ } try { const h = await window.storage.get("ledger:holdings", false); if (h?.value) setHoldings(JSON.parse(h.value)); } catch (e) { /* no data yet */ } setLoaded(true); })(); }, []); const saveTrades = useCallback(async (next) => { setTrades(next); try { await window.storage.set("ledger:trades", JSON.stringify(next), false); } catch (e) { console.error("save trades failed", e); } }, []); const saveHoldings = useCallback(async (next) => { setHoldings(next); try { await window.storage.set("ledger:holdings", JSON.stringify(next), false); } catch (e) { console.error("save holdings failed", e); } }, []); const derivedTrades = useMemo(() => trades.map(deriveTrade).sort((a, b) => (b.date || "").localeCompare(a.date || "")), [trades] ); const derivedHoldings = useMemo(() => holdings.map(deriveHolding), [holdings]); const stats = useMemo(() => { const closed = derivedTrades; const wins = closed.filter((t) => t.outcome === "Win").length; const losses = closed.filter((t) => t.outcome === "Loss").length; const total = closed.length; const winRate = total ? wins / total : 0; const totalPL = closed.reduce((s, t) => s + t.pl, 0); const avgPL = total ? totalPL / total : 0; const best = total ? Math.max(...closed.map((t) => t.pl)) : 0; const worst = total ? Math.min(...closed.map((t) => t.pl)) : 0; const holdingsValue = derivedHoldings.reduce((s, h) => s + h.marketValue, 0); const holdingsUPL = derivedHoldings.reduce((s, h) => s + h.upl, 0); // cumulative curve in chronological order const chrono = [...closed].sort((a, b) => (a.date || "").localeCompare(b.date || "")); let running = 0; const curve = chrono.map((t) => (running += t.pl)); return { wins, losses, total, winRate, totalPL, avgPL, best, worst, holdingsValue, holdingsUPL, curve }; }, [derivedTrades, derivedHoldings]); const openNewTrade = () => setSheet({ type: "trade", data: { id: null, date: new Date().toISOString().slice(0, 10), ticker: "", side: "Sell", shares: "", entry: "", exit: "", fees: "0", notes: "" }, }); const openEditTrade = (t) => setSheet({ type: "trade", data: t }); const openNewHolding = () => setSheet({ type: "holding", data: { id: null, ticker: "", shares: "", avgCost: "", price: "", notes: "" }, }); const openEditHolding = (h) => setSheet({ type: "holding", data: h }); const handleSaveTrade = (data) => { if (!data.ticker || !data.shares || !data.entry) return; if (data.id) { saveTrades(trades.map((t) => (t.id === data.id ? data : t))); } else { saveTrades([...trades, { ...data, id: uid() }]); } setSheet(null); }; const handleDeleteTrade = (id) => { saveTrades(trades.filter((t) => t.id !== id)); setSheet(null); }; const handleSaveHolding = (data) => { if (!data.ticker || !data.shares) return; if (data.id) { saveHoldings(holdings.map((h) => (h.id === data.id ? data : h))); } else { saveHoldings([...holdings, { ...data, id: uid() }]); } setSheet(null); }; const handleDeleteHolding = (id) => { saveHoldings(holdings.filter((h) => h.id !== id)); setSheet(null); }; const tabMeta = { log: { title: "Trade Log", sub: "Closed positions & outcomes" }, holdings: { title: "Holdings", sub: "What you currently own" }, summary: { title: "Summary", sub: "Your track record" }, }; return (
{/* header */}
§

{tabMeta[tab].title}

{tabMeta[tab].sub}

{/* content */}
{!loaded ? (
Loading your ledger…
) : tab === "log" ? ( ) : tab === "holdings" ? ( ) : ( )}
{/* FAB */} {tab !== "summary" && ( )} {/* bottom nav */}
{[ { id: "log", label: "Log", icon: BookOpen }, { id: "holdings", label: "Holdings", icon: Briefcase }, { id: "summary", label: "Summary", icon: BarChart3 }, ].map(({ id, label, icon: Icon }) => ( ))}
{/* sheet */} {sheet?.type === "trade" && ( setSheet(null)} onSave={handleSaveTrade} onDelete={handleDeleteTrade} /> )} {sheet?.type === "holding" && ( setSheet(null)} onSave={handleSaveHolding} onDelete={handleDeleteHolding} /> )}
); } // ---------- log list ---------- function LogList({ trades, onEdit }) { if (!trades.length) { return ; } // group by month const groups = {}; trades.forEach((t) => { const key = (t.date || "Undated").slice(0, 7); groups[key] = groups[key] || []; groups[key].push(t); }); return (
{Object.entries(groups).map(([month, items]) => (
{monthLabel(month)}
{items.map((t, i) => ( ))}
))}
); } function monthLabel(key) { if (key === "Undated") return "Undated"; const [y, m] = key.split("-"); const d = new Date(parseInt(y), parseInt(m) - 1, 1); return d.toLocaleDateString("en-US", { month: "long", year: "numeric" }); } // ---------- holdings list ---------- function HoldingsList({ holdings, onEdit }) { if (!holdings.length) { return ; } const totalValue = holdings.reduce((s, h) => s + h.marketValue, 0); const totalUPL = holdings.reduce((s, h) => s + h.upl, 0); return (
Market Value
{fmtMoney(totalValue)}
Unrealized
= 0 ? T.gain : T.loss }}> {fmtMoney(totalUPL)}
{holdings.map((h, i) => ( ))}
); } // ---------- summary ---------- function SummaryView({ stats }) { const statCards = [ { label: "Total Realized P/L", value: fmtMoney(stats.totalPL), color: stats.totalPL >= 0 ? T.gain : T.loss }, { label: "Win Rate", value: fmtPct(stats.winRate), color: T.parchment }, { label: "Wins / Losses", value: `${stats.wins} / ${stats.losses}`, color: T.parchment }, { label: "Avg P/L per Trade", value: fmtMoney(stats.avgPL), color: stats.avgPL >= 0 ? T.gain : T.loss }, { label: "Best Trade", value: fmtMoney(stats.best), color: T.gain }, { label: "Worst Trade", value: fmtMoney(stats.worst), color: T.loss }, ]; return (
Cumulative Realized P/L
{statCards.map((s) => (
{s.label}
{s.value}
))}
Holdings Value
{fmtMoney(stats.holdingsValue)}
Unrealized
= 0 ? T.gain : T.loss }}> {fmtMoney(stats.holdingsUPL)}
); } function EmptyState({ label, hint }) { return (
{label}
{hint}
); } // ---------- sheets (bottom modal forms) ---------- function SheetShell({ title, onClose, onDelete, children }) { return (

{title}

{onDelete && ( )}
{children}
); } function TradeSheet({ data, onClose, onSave, onDelete }) { const [form, setForm] = useState(data); const set = (k) => (e) => setForm({ ...form, [k]: e.target.value }); const preview = deriveTrade(form); const validCost = preview.costBasis > 0; return ( onDelete(form.id) : null}>
setForm({ ...form, ticker: e.target.value.toUpperCase() })} placeholder="AAPL" />
setForm({ ...form, side: v })} />