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} />
| Asset |
Qty |
Avg Cost |
Price |
Value |
P/L |
Category |
Actions |
{filteredSorted.map((row) => (
setEditingId(row.id)}
onCancel={() => setEditingId(null)}
onSave={(h) => { upsertHolding(h); setEditingId(null);} }
onDelete={() => deleteHolding(row.id)}
/>
))}
{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)}
))}
setIsAddOpen(false)} onSave={(h) => { upsertHolding(h); setIsAddOpen(false);} } />
);
}
function Header({ currency, setCurrency, onAdd, onSnapshot, onExport, onImport }) {
return (
);
}
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)}))} />
);
}
function Field({ label, value, onChange, type = "text", placeholder }) {
return (
onChange(e.target.value)}
className="w-full rounded-xl bg-slate-900/70 px-3 py-2 text-sm ring-1 ring-slate-800"
/>
);
}
function Donut({ data, currency }) {
const colors = ["#6366f1", "#22c55e", "#f59e0b", "#06b6d4", "#a855f7", "#ef4444", "#84cc16", "#e11d48"];
return (
{data.map((_, i) => | )}
fmt(Number(v), currency)} />
);
}
function HistoryChart({ history, currency }) {
if (!history || history.length === 0) {
return (
No snapshots yet.
);
}
return (
fmt(v, currency)} width={80} />
fmt(Number(v), currency)} />
);
}
function Card({ children, className = "" }) {
return (
{children}
);
}
function Footer() {
return (
);
}