Phase 5: alert subscriptions UI + multiple watchlists + queue fixes
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:
Investor Flow Build
2026-07-23 20:51:47 -04:00
parent f71987c119
commit 5b9f770aa4
23 changed files with 1567 additions and 146 deletions
+1
View File
@@ -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: "⚙️" },
+16 -1
View File
@@ -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>
);
})}
+114 -16
View File
@@ -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>