import React, { useEffect, useMemo, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, LineChart, Line, XAxis, YAxis, CartesianGrid, Legend } from "recharts"; import { Plus, Trash2, Pencil, Save, X, Download, Upload, Wallet, TrendingUp, PieChart as PieIcon } from "lucide-react"; /** * LocalStorage Portfolio Tracker * - No backend required * - Stores holdings + optional value history in localStorage * - Manual price input (you can add an API later) * * How to use in a Vite React app: * 1) `npm create vite@latest portfolio-tracker -- --template react` * 2) Replace src/App.jsx with this file * 3) Ensure Tailwind is set up (or convert classes to CSS) */ const LS_KEY = "portfolio_tracker_v1"; const DEFAULTS = { currency: "USD", holdings: [ { id: cryptoRandomId(), symbol: "AAPL", name: "Apple", quantity: 10, avgCost: 150, price: 190, category: "Stocks", notes: "" }, { id: cryptoRandomId(), symbol: "VTI", name: "Vanguard Total Stock Market", quantity: 5, avgCost: 210, price: 245, category: "ETFs", notes: "" }, ], history: [], // [{date: '2025-11-22', totalValue: 12345}] }; function cryptoRandomId() { if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); return Math.random().toString(36).slice(2); } function loadState() { try { if (typeof window === "undefined" || !window.localStorage) return DEFAULTS; const raw = window.localStorage.getItem(LS_KEY); if (!raw) return DEFAULTS; const parsed = JSON.parse(raw); return { ...DEFAULTS, ...parsed }; } catch { return DEFAULTS; } } function saveState(state) { try { if (typeof window === "undefined" || !window.localStorage) return; window.localStorage.setItem(LS_KEY, JSON.stringify(state)); } catch { /* ignore quota/private mode errors */ } } // (state) { localStorage.setItem(LS_KEY, JSON.stringify(state)); } function fmt(n, currency = "USD") { if (!Number.isFinite(n)) return "–"; return new Intl.NumberFormat(undefined, { style: "currency", currency, maximumFractionDigits: 2 }).format(n); } function pct(n) { if (!Number.isFinite(n)) return "–"; return `${(n * 100).toFixed(2)}%`; } const categories = ["Stocks", "ETFs", "Crypto", "Bonds", "Cash", "Other"]; export default function App() { const [state, setState] = useState(() => loadState()); const [query, setQuery] = useState(""); const [sortBy, setSortBy] = useState("value"); // value | symbol | pnl const [sortDir, setSortDir] = useState("desc"); const [editingId, setEditingId] = useState(null); const [isAddOpen, setIsAddOpen] = useState(false); useEffect(() => saveState(state), [state]); const totals = useMemo(() => { const rows = state.holdings.map(h => { const costBasis = h.quantity * h.avgCost; const value = h.quantity * h.price; const pnl = value - costBasis; const pnlPct = costBasis > 0 ? pnl / costBasis : 0; return { ...h, costBasis, value, pnl, pnlPct }; }); const totalValue = rows.reduce((a, r) => a + r.value, 0); const totalCost = rows.reduce((a, r) => a + r.costBasis, 0); const totalPnL = totalValue - totalCost; const totalPnLPct = totalCost > 0 ? totalPnL / totalCost : 0; return { rows, totalValue, totalCost, totalPnL, totalPnLPct }; }, [state.holdings]); const filteredSorted = useMemo(() => { const q = query.trim().toLowerCase(); let rows = totals.rows.filter(r => !q || [r.symbol, r.name, r.category].some(x => (x || "").toLowerCase().includes(q))); rows.sort((a, b) => { let av, bv; if (sortBy === "symbol") { av = a.symbol; bv = b.symbol; return av.localeCompare(bv); } if (sortBy === "pnl") { av = a.pnl; bv = b.pnl; } else { av = a.value; bv = b.value; } const diff = (av > bv ? 1 : av < bv ? -1 : 0); return sortDir === "asc" ? diff : -diff; }); return rows; }, [totals.rows, query, sortBy, sortDir]); const allocation = useMemo(() => { const byCat = new Map(); totals.rows.forEach(r => byCat.set(r.category, (byCat.get(r.category) || 0) + r.value)); return Array.from(byCat.entries()).map(([name, value]) => ({ name, value })); }, [totals.rows]); const bySymbol = useMemo(() => totals.rows .map(r => ({ name: r.symbol, value: r.value })) .sort((a, b) => b.value - a.value) .slice(0, 8), [totals.rows]); function upsertHolding(h) { setState(s => { const exists = s.holdings.some(x => x.id === h.id); const holdings = exists ? s.holdings.map(x => x.id === h.id ? h : x) : [h, ...s.holdings]; return { ...s, holdings }; }); } function deleteHolding(id) { setState(s => ({ ...s, holdings: s.holdings.filter(x => x.id !== id) })); if (editingId === id) setEditingId(null); } function snapshotToday() { const today = new Date().toISOString().slice(0, 10); setState(s => { const history = [...s.history.filter(h => h.date !== today), { date: today, totalValue: totals.totalValue }] .sort((a, b) => a.date.localeCompare(b.date)); return { ...s, history }; }); } function exportJson() { const blob = new Blob([JSON.stringify(state, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `portfolio-${new Date().toISOString().slice(0,10)}.json`; a.click(); URL.revokeObjectURL(url); } function importJson(file) { const reader = new FileReader(); reader.onload = () => { try { const parsed = JSON.parse(String(reader.result || "{}")); if (!parsed.holdings) throw new Error("Invalid file"); setState({ ...DEFAULTS, ...parsed }); } catch (e) { alert("Could not import: " + e.message); } }; reader.readAsText(file); } return (
setState(s => ({...s, currency}))} onAdd={() => setIsAddOpen(true)} onSnapshot={snapshotToday} onExport={exportJson} onImport={importJson} />
setQuery(e.target.value)} placeholder="Search holdings…" className="w-full rounded-xl bg-slate-900/70 px-4 py-2.5 text-sm outline-none ring-1 ring-slate-800 focus:ring-2 focus:ring-indigo-500" />
{filteredSorted.map((row) => ( setEditingId(row.id)} onCancel={() => setEditingId(null)} onSave={(h) => { upsertHolding(h); setEditingId(null);} } onDelete={() => deleteHolding(row.id)} /> ))}
Asset Qty Avg Cost Price Value P/L Category Actions
{filteredSorted.length === 0 && (
No holdings match your search.
)}

Allocation by Category

    {allocation.map(a => (
  • {a.name} {pct(a.value / (totals.totalValue || 1))}
  • ))}

Portfolio Value History

Click “Snapshot today” in the header to log your total value for charts.

Top Positions

    {bySymbol.map(a => (
  • {a.name} {fmt(a.value, state.currency)}
  • ))}
); } function Header({ currency, setCurrency, onAdd, onSnapshot, onExport, onImport }) { return (
PT
Portfolio Tracker
LocalStorage • Private • Fast
}>Snapshot today }>Add holding }>Export
); } function HeaderButton({ children, onClick, icon }) { return ( ); } function StatsBar({ totals, currency }) { const up = totals.totalPnL >= 0; return (
); } function StatCard({ label, value, sub, tone }) { const toneClass = tone === "good" ? "text-emerald-300" : tone === "bad" ? "text-rose-300" : "text-slate-100"; return (
{label}
{value}
{sub}
); } function HoldingRow({ row, currency, isEditing, onEdit, onCancel, onSave, onDelete }) { const [draft, setDraft] = useState(row); useEffect(() => { if (isEditing) setDraft(row); }, [isEditing, row]); const input = (key, type = "text", step = "any") => ( setDraft(d => ({ ...d, [key]: type === "number" ? Number(e.target.value) : e.target.value }))} className="w-full rounded-lg bg-slate-900/60 px-2 py-1 text-sm ring-1 ring-slate-800 focus:ring-2 focus:ring-indigo-500" /> ); return (
{row.symbol.slice(0, 4)}
{isEditing ? (
{input("symbol")} {input("name")}
) : ( <>
{row.symbol}
{row.name}
)}
{isEditing ? input("quantity", "number") : row.quantity} {isEditing ? input("avgCost", "number") : fmt(row.avgCost, currency)} {isEditing ? input("price", "number") : fmt(row.price, currency)} {fmt(row.value, currency)} = 0 ? "text-emerald-300" : "text-rose-300"}`}>
{fmt(row.pnl, currency)}
{pct(row.pnlPct)}
{isEditing ? ( ) : ( {row.category} )} {isEditing ? (
) : (
)}
); } function AddModal({ open, onClose, onSave }) { const empty = { id: cryptoRandomId(), symbol: "", name: "", quantity: 0, avgCost: 0, price: 0, category: "Stocks", notes: "" }; const [draft, setDraft] = useState(empty); useEffect(() => { if (open) setDraft({ ...empty, id: cryptoRandomId() }); }, [open]); if (!open) return null; return (

Add holding

setDraft(d => ({...d, symbol: v.toUpperCase()}))} placeholder="e.g., MSFT" /> setDraft(d => ({...d, name: v}))} placeholder="Microsoft" /> setDraft(d => ({...d, quantity: Number(v)}))} /> setDraft(d => ({...d, avgCost: Number(v)}))} /> setDraft(d => ({...d, price: Number(v)}))} />