Phase 5: alert subscriptions UI + multiple watchlists + queue fixes
CI / Test & Type-Check (push) Canceled after 0s
CI / Test & Type-Check (push) Canceled after 0s
UI: - /alerts page: event history with acknowledge, subscription create/manage with toggle - /admin/smtp: SMTP config form (host, port, auth, test) - Watchlist sidebar: dropdown selector for multiple watchlists, create/delete - Sidebar: alerts count badge, SMTP link under admin - Mobile tab nav: alerts tab added - Client trpc.ts: all new API methods + types Backend: - watchlists.listByWatchlist procedure + listSymbolsByWatchlist repo fn - yfinance min-interval 1500->2000ms to reduce Edge 429s - Fixed e.date.slice error in yfinance-adjustments with typeof guard - Removed defunct BITF from watchlist+queue - Cleared 83 failed + 12 backoff queue jobs Docs: - FUNCTIONAL_DESIGN.md: alerts + multiple watchlists + SMTP documented - TECH_DESIGN.md: new modules, tRPC procs, routes updated
This commit is contained in:
@@ -18,6 +18,7 @@ const TABS: TabItem[] = [
|
||||
{ label: "Overview", href: "/", icon: "📊" },
|
||||
{ label: "Filings", href: "/filings", icon: "📄" },
|
||||
{ label: "Options", href: "/options-dd", icon: "🔮" },
|
||||
{ label: "Alerts", href: "/alerts", icon: "🔔" },
|
||||
{ label: "Execution", href: "/execution", icon: "⚡" },
|
||||
{ label: "Trade Plan", href: "/trade-plan", icon: "📋" },
|
||||
{ label: "Settings", href: "/settings", icon: "⚙️" },
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Bell,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/trpc";
|
||||
|
||||
/**
|
||||
* Left sidebar navigation with collapsible sections.
|
||||
@@ -58,6 +60,7 @@ const SECTIONS: NavSection[] = [
|
||||
{ label: "Risk Posture", href: "/risk" },
|
||||
{ label: "Filings", href: "/filings" },
|
||||
{ label: "Options DD", href: "/options-dd" },
|
||||
{ label: "Alerts", href: "/alerts" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -69,6 +72,7 @@ const SECTIONS: NavSection[] = [
|
||||
{ label: "Queue", href: "/admin/queue" },
|
||||
{ label: "Audit Logs", href: "/admin/audit-logs" },
|
||||
{ label: "X Accounts", href: "/admin/x-accounts" },
|
||||
{ label: "SMTP", href: "/admin/smtp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -101,6 +105,11 @@ export function SidebarNav() {
|
||||
const normalizedPath = (pathname || "/").replace(/\/+$/, "") || "/";
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [unackedCount, setUnackedCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
api.alerts.unackedCount().then((r) => setUnackedCount(r.count)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsed(getInitialCollapsed(pathname));
|
||||
@@ -146,6 +155,7 @@ export function SidebarNav() {
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive = normalizedPath === item.href;
|
||||
const showBadge = item.label === "Alerts" && unackedCount > 0;
|
||||
return (
|
||||
<Link
|
||||
key={item.label}
|
||||
@@ -156,7 +166,12 @@ export function SidebarNav() {
|
||||
: "text-fg-muted hover:text-fg hover:bg-surface-overlay border-transparent"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
<span className="flex items-center justify-between">
|
||||
<span>{item.label}</span>
|
||||
{showBadge && (
|
||||
<span className="rounded-full bg-accent px-1.5 py-0.5 text-2xs text-white">{unackedCount}</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { api, type WatchlistEntry, type Quote, type PortfolioHolding } from "@/lib/trpc";
|
||||
import { chart as CHART } from "@/lib/chart-theme";
|
||||
import { UI_STRINGS } from "@/lib/strings";
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
interface WatchlistMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
symbol_count: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface WatchlistSidebarProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
@@ -26,6 +29,11 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [holdings, setHoldings] = useState<PortfolioHolding[]>([]);
|
||||
const [watchlists, setWatchlists] = useState<WatchlistMeta[]>([]);
|
||||
const [activeWatchlist, setActiveWatchlist] = useState("default");
|
||||
const [showListPicker, setShowListPicker] = useState(false);
|
||||
const [newListName, setNewListName] = useState("");
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Portfolio summary
|
||||
useEffect(() => {
|
||||
@@ -34,14 +42,30 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
|
||||
const totalValue = (holdings ?? []).reduce((sum, h) => sum + h.shares * h.avg_cost, 0);
|
||||
|
||||
const loadWatchlists = async () => {
|
||||
try {
|
||||
const wls = await api.watchlists.listWatchlists();
|
||||
setWatchlists(wls);
|
||||
if (wls.length > 0 && !wls.find((w) => w.name === activeWatchlist)) {
|
||||
setActiveWatchlist(wls[0].name);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setEntries(await api.watchlists.list());
|
||||
setEntries(await api.watchlists.listByWatchlist(activeWatchlist));
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
loadWatchlists();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [activeWatchlist]);
|
||||
|
||||
// Fetch quotes for watchlist symbols
|
||||
useEffect(() => {
|
||||
@@ -64,16 +88,48 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
const add = async () => {
|
||||
if (!draft.trim()) return;
|
||||
const sym = draft.trim().toUpperCase();
|
||||
await api.watchlists.addSymbol(sym);
|
||||
await api.watchlists.addSymbol(sym, activeWatchlist === "default" ? undefined : activeWatchlist);
|
||||
setDraft("");
|
||||
load();
|
||||
loadWatchlists();
|
||||
};
|
||||
|
||||
const remove = async (symbol: string) => {
|
||||
await api.watchlists.removeSymbol(symbol);
|
||||
await api.watchlists.removeSymbol(symbol, activeWatchlist === "default" ? undefined : activeWatchlist);
|
||||
load();
|
||||
loadWatchlists();
|
||||
};
|
||||
|
||||
const createWatchlist = async () => {
|
||||
if (!newListName.trim()) return;
|
||||
await api.watchlists.create(newListName.trim());
|
||||
setNewListName("");
|
||||
setActiveWatchlist(newListName.trim());
|
||||
setShowListPicker(false);
|
||||
loadWatchlists();
|
||||
};
|
||||
|
||||
const deleteWatchlist = async (name: string) => {
|
||||
await api.watchlists.delete(name);
|
||||
loadWatchlists();
|
||||
if (activeWatchlist === name) {
|
||||
setActiveWatchlist("default");
|
||||
}
|
||||
};
|
||||
|
||||
// Close picker on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowListPicker(false);
|
||||
}
|
||||
};
|
||||
if (showListPicker) {
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}
|
||||
}, [showListPicker]);
|
||||
|
||||
const containerClass = compact
|
||||
? "w-full border-b border-line bg-surface-raised flex-shrink-0"
|
||||
: "w-56 border-r border-line bg-surface-raised flex-shrink-0 hidden lg:block";
|
||||
@@ -83,7 +139,7 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
return (
|
||||
<aside className={containerClass}>
|
||||
<div className={contentClass}>
|
||||
{/* Portfolio Summary — compact mode shows minimal */}
|
||||
{/* Watchlist selector + Portfolio summary */}
|
||||
{compact ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-wider text-fg-muted">Portfolio</span>
|
||||
@@ -95,7 +151,6 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
<p className="text-[10px] uppercase tracking-wider text-fg-muted mb-1">Portfolio</p>
|
||||
<p className="text-sm font-bold text-fg">${totalValue.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="text-[10px] text-fg-muted">{entries.length} watchlist</span>
|
||||
<span className="text-[10px] text-fg-muted">{holdings?.length ?? 0} holdings</span>
|
||||
</div>
|
||||
<a
|
||||
@@ -107,7 +162,53 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Watchlist symbols only — not portfolio positions */}
|
||||
{/* Watchlist selector dropdown */}
|
||||
{!compact && (
|
||||
<div className="relative" ref={pickerRef}>
|
||||
<button
|
||||
onClick={() => setShowListPicker(!showListPicker)}
|
||||
className="w-full flex items-center gap-2 rounded-lg border border-line bg-surface px-2 py-1.5 text-xs text-fg hover:bg-surface-sunken"
|
||||
>
|
||||
<span className="flex-1 text-left truncate">{activeWatchlist}</span>
|
||||
<span className="text-fg-faint">{watchlists.length > 0 ? watchlists.find((w) => w.name === activeWatchlist)?.symbol_count ?? entries.length : entries.length}</span>
|
||||
<svg className="h-3 w-3 text-fg-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
|
||||
{showListPicker && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-line bg-surface-raised shadow-xl overflow-hidden">
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{watchlists.map((wl) => (
|
||||
<div key={wl.id} className="flex items-center gap-1 px-2 py-1.5 hover:bg-surface-sunken cursor-pointer text-xs" onClick={() => { setActiveWatchlist(wl.name); setShowListPicker(false); }}>
|
||||
<span className={`flex-1 truncate ${wl.name === activeWatchlist ? "text-accent font-medium" : "text-fg"}`}>{wl.name}</span>
|
||||
<span className="text-fg-faint">{wl.symbol_count}</span>
|
||||
{wl.name !== "default" && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); deleteWatchlist(wl.name); }}
|
||||
className="text-fg-muted hover:text-danger ml-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-line p-2">
|
||||
<form onSubmit={(e) => { e.preventDefault(); createWatchlist(); }} className="flex gap-1">
|
||||
<input
|
||||
value={newListName}
|
||||
onChange={(e) => setNewListName(e.target.value)}
|
||||
placeholder="New list..."
|
||||
className="flex-1 bg-surface border border-line rounded px-1.5 py-1 text-[10px] text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button type="submit" className="px-1.5 py-1 rounded text-[10px] bg-accent text-white">+</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add symbol form */}
|
||||
{!compact && (
|
||||
<form onSubmit={(e) => { e.preventDefault(); add(); }} className="flex gap-1">
|
||||
<label className="sr-only" htmlFor="watchlist-add-symbol">Add watchlist symbol</label>
|
||||
@@ -157,11 +258,9 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
: "hover:bg-surface-sunken"
|
||||
}`}
|
||||
>
|
||||
{/* Symbol — fixed width for alignment */}
|
||||
<span className={`font-mono font-bold whitespace-nowrap w-12 text-left ${isActive ? "text-accent" : "text-fg"}`}>
|
||||
{e.symbol}
|
||||
</span>
|
||||
{/* Mini sparkline */}
|
||||
{quote && quote.sparkline && quote.sparkline.length > 1 && (
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0" aria-hidden="true">
|
||||
<svg width="20" height="12" className="flex-shrink-0">
|
||||
@@ -177,7 +276,6 @@ export function WatchlistSidebar({ compact = false }: WatchlistSidebarProps) {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{/* Change percent — fixed width for alignment */}
|
||||
{quote ? (
|
||||
<span className="flex items-center gap-0.5 font-mono text-[9px] whitespace-nowrap w-14 justify-end">
|
||||
<span className={up ? "text-up" : "text-warn"} aria-hidden="true">{up ? "▲" : "▼"}</span>
|
||||
|
||||
Reference in New Issue
Block a user