156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
"use client";
|
|
import { useEffect, useState } from "react";
|
|
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
|
import { api, type WatchlistEntry, type Quote } from "@/lib/trpc";
|
|
|
|
/**
|
|
* Compact watchlist sidebar with mini sparklines and quick symbol switching.
|
|
* Shows portfolio summary at top. Replaces the full WatchlistPanel on the main page.
|
|
*/
|
|
|
|
interface MiniQuote extends Quote {
|
|
sparkline?: number[];
|
|
}
|
|
|
|
export function WatchlistSidebar() {
|
|
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
|
|
const setActive = useActiveSymbol((s) => s.setActive);
|
|
const [entries, setEntries] = useState<WatchlistEntry[]>([]);
|
|
const [quotes, setQuotes] = useState<Map<string, MiniQuote>>(new Map());
|
|
const [draft, setDraft] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Portfolio summary
|
|
const { data: holdings } = api.portfolio.holdings.useQuery();
|
|
const totalValue = (holdings ?? []).reduce((sum, h) => sum + h.shares * h.avg_cost, 0);
|
|
|
|
const load = async () => {
|
|
try {
|
|
setEntries(await api.watchlists.list());
|
|
} catch { /* ignore */ }
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
// Fetch quotes for watchlist symbols
|
|
useEffect(() => {
|
|
const fetchQuotes = async () => {
|
|
const quoteMap = new Map<string, MiniQuote>();
|
|
for (const entry of entries) {
|
|
try {
|
|
const snap = await api.market.snapshot(entry.symbol);
|
|
if (snap.quote && snap.candles) {
|
|
const sparkline = snap.candles.slice(-20).map(c => c.c);
|
|
quoteMap.set(entry.symbol, { ...snap.quote, symbol: entry.symbol, sparkline });
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
setQuotes(quoteMap);
|
|
};
|
|
if (entries.length > 0) fetchQuotes();
|
|
}, [entries]);
|
|
|
|
const add = async () => {
|
|
if (!draft.trim()) return;
|
|
const sym = draft.trim().toUpperCase();
|
|
await api.watchlists.addSymbol(sym);
|
|
setDraft("");
|
|
load();
|
|
};
|
|
|
|
const remove = async (symbol: string) => {
|
|
await api.watchlists.removeSymbol(symbol);
|
|
load();
|
|
};
|
|
|
|
return (
|
|
<aside className="w-56 border-r border-[#2a2b3a] bg-[#121318] flex-shrink-0 hidden lg:block">
|
|
<div className="p-3 space-y-3">
|
|
{/* Portfolio Summary */}
|
|
<div className="rounded-lg bg-[#1a1b24] border border-[#2a2b3a] p-3">
|
|
<p className="text-[10px] uppercase tracking-wider text-[#5a5b6a] mb-1">Portfolio</p>
|
|
<p className="text-sm font-bold text-[#e6e7ec]">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</p>
|
|
<div className="flex gap-2 mt-1">
|
|
<span className="text-[10px] text-[#5a5b6a]">{entries.length} symbols</span>
|
|
<span className="text-[10px] text-[#5a5b6a]">{holdings?.length ?? 0} holdings</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Add Symbol */}
|
|
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
|
<input
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
placeholder="Add symbol..."
|
|
className="flex-1 bg-[#0a0b0f] border border-[#2a2b3a] rounded px-2 py-1.5 text-xs text-[#e6e7ec] placeholder:text-[#5a5b6a] focus:outline-none focus:border-[#4f8cff]"
|
|
/>
|
|
<button type="submit" className="px-2 py-1.5 rounded text-xs bg-[#4f8cff] text-white">+</button>
|
|
</form>
|
|
|
|
{/* Watchlist */}
|
|
{loading ? (
|
|
<p className="text-xs text-[#5a5b6a]">Loading...</p>
|
|
) : entries.length === 0 ? (
|
|
<p className="text-xs text-[#5a5b6a]">No symbols tracked</p>
|
|
) : (
|
|
<div className="space-y-0.5 max-h-[calc(100vh-320px)] overflow-y-auto">
|
|
{entries.map((e) => {
|
|
const quote = quotes.get(e.symbol);
|
|
const isActive = e.symbol === activeSymbol;
|
|
const up = (quote?.change ?? 0) >= 0;
|
|
|
|
return (
|
|
<button
|
|
key={e.symbol}
|
|
onClick={() => setActive(e.symbol)}
|
|
className={`w-full flex items-center justify-between py-1.5 px-2 rounded-lg text-xs transition-colors ${
|
|
isActive
|
|
? "bg-[#4f8cff]/20 border-l-2 border-[#4f8cff]"
|
|
: "hover:bg-[#1a1b24]"
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`font-mono font-bold ${isActive ? "text-[#4f8cff]" : "text-[#e6e7ec]"}`}>
|
|
{e.symbol}
|
|
</span>
|
|
{/* Mini sparkline */}
|
|
{quote?.sparkline && quote.sparkline.length > 1 && (
|
|
<svg width="24" height="12" className="flex-shrink-0">
|
|
<polyline
|
|
points={quote.sparkline.map((v, i) => `${(i / (quote.sparkline!.length - 1)) * 24},${12 - ((v - Math.min(...quote.sparkline)) / (Math.max(...quote.sparkline) - Math.min(...quote.sparkline) || 1)) * 10}`).join(" ")}
|
|
fill="none"
|
|
stroke={up ? "#34d399" : "#fbbf24"}
|
|
strokeWidth="1"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{quote && (
|
|
<>
|
|
<span className={`font-mono ${isActive ? "text-[#e6e7ec]" : "text-[#8a8b9a]"}`}>
|
|
${quote.price?.toFixed(0)}
|
|
</span>
|
|
<span className={`text-[9px] font-mono ${up ? "text-[#34d399]" : "text-[#fbbf24]"}`}>
|
|
{up ? "+" : ""}{quote.changePercent?.toFixed(1)}%
|
|
</span>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={(ev) => { ev.stopPropagation(); remove(e.symbol); }}
|
|
className="text-[9px] text-[#5a5b6a] hover:text-[#ef4444]"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|