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 (
);
}
// ---------- 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 (
{statCards.map((s) => (
))}
Holdings Value
{fmtMoney(stats.holdingsValue)}
Unrealized
= 0 ? T.gain : T.loss }}>
{fmtMoney(stats.holdingsUPL)}
);
}
function EmptyState({ label, hint }) {
return (
);
}
// ---------- 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 })} />
{validCost && (
Result
)}
);
}
function HoldingSheet({ data, onClose, onSave, onDelete }) {
const [form, setForm] = useState(data);
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });
const preview = deriveHolding(form);
const valid = form.ticker && form.shares;
return (
onDelete(form.id) : null}>
setForm({ ...form, ticker: e.target.value.toUpperCase() })} placeholder="MSFT" />
{form.shares && form.avgCost && form.price && (
Unrealized P/L
)}
);
}