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
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import { AdminLayout } from "@/components/AdminLayout";
import { api } from "@/lib/trpc";
export default function SmtpAdminPage() {
const [config, setConfig] = useState<{ host: string; port: number; secure: boolean; user: string | null; passEnc: string | null; fromName: string; fromEmail: string; enabled: boolean } | null>(null);
const [host, setHost] = useState("smtp.mail.me.com");
const [port, setPort] = useState(587);
const [secure, setSecure] = useState(false);
const [user, setUser] = useState("");
const [pass, setPass] = useState("");
const [fromName, setFromName] = useState("Investor Flow");
const [fromEmail, setFromEmail] = useState("");
const [enabled, setEnabled] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
useEffect(() => {
api.admin.smtpConfig().then((c) => {
setConfig(c as any);
setHost((c as any).host ?? "smtp.mail.me.com");
setPort((c as any).port ?? 587);
setSecure((c as any).secure ?? false);
setUser((c as any).user ?? "");
setFromName((c as any).fromName ?? "Investor Flow");
setFromEmail((c as any).fromEmail ?? "");
setEnabled((c as any).enabled ?? false);
}).catch(() => {});
}, []);
const save = async () => {
setSaving(true);
setMsg(null);
try {
await api.admin.smtpConfigUpdate({ host, port, secure, user: user || null, pass: pass || undefined, fromName, fromEmail, enabled });
setMsg({ type: "success", text: "SMTP config saved." });
} catch (e) {
setMsg({ type: "error", text: e instanceof Error ? e.message : "Failed to save." });
}
setSaving(false);
};
const test = async () => {
setTesting(true);
setMsg(null);
try {
await api.admin.smtpConfigTest();
setMsg({ type: "success", text: "Test email sent! Check your inbox." });
} catch (e) {
setMsg({ type: "error", text: e instanceof Error ? e.message : "Test failed." });
}
setTesting(false);
};
return (
<AdminLayout>
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">SMTP Configuration</h1>
{msg && (
<div className={`mb-4 rounded-lg border px-4 py-3 text-sm ${msg.type === "success" ? "border-green-500/30 bg-green-950/20 text-up" : "border-red-500/30 bg-red-950/20 text-danger"}`}>
{msg.text}
</div>
)}
<div className="space-y-4">
<label className="block">
<span className="text-sm text-fg-faint">Host</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" value={host} onChange={(e) => setHost(e.target.value)} />
</label>
<label className="block">
<span className="text-sm text-fg-faint">Port</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" type="number" value={port} onChange={(e) => setPort(Number(e.target.value))} />
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} className="rounded border-border bg-surface-dark" />
<span className="text-sm text-fg-faint">Use TLS/SSL (secure)</span>
</label>
<label className="block">
<span className="text-sm text-fg-faint">Username</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" value={user} onChange={(e) => setUser(e.target.value)} />
</label>
<label className="block">
<span className="text-sm text-fg-faint">Password</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" type="password" value={pass} onChange={(e) => setPass(e.target.value)} placeholder="Leave blank to keep existing" />
</label>
<label className="block">
<span className="text-sm text-fg-faint">From Name</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" value={fromName} onChange={(e) => setFromName(e.target.value)} />
</label>
<label className="block">
<span className="text-sm text-fg-faint">From Email</span>
<input className="mt-1 w-full rounded-lg border border-border bg-surface-dark px-3 py-2 text-fg" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} />
</label>
<label className="flex items-center gap-2">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="rounded border-border bg-surface-dark" />
<span className="text-sm text-fg-faint">Enable email alerts</span>
</label>
<div className="flex gap-3 pt-4">
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/80 disabled:opacity-50">
{saving ? "Saving..." : "Save"}
</button>
<button onClick={test} disabled={testing || !enabled} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-fg hover:bg-surface-dark disabled:opacity-50">
{testing ? "Sending..." : "Send Test Email"}
</button>
</div>
</div>
</div>
</AdminLayout>
);
}
+172
View File
@@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
import { api, type AlertEventRow, type AlertSubscriptionRow } from "@/lib/trpc";
const SEVERITY_COLORS: Record<string, string> = {
info: "text-blue-400 bg-blue-950/30 border-blue-500/30",
warning: "text-amber-400 bg-amber-950/30 border-amber-500/30",
critical: "text-red-400 bg-red-950/30 border-red-500/30",
};
export default function AlertsPage() {
const [tab, setTab] = useState<"events" | "subscriptions">("events");
const [events, setEvents] = useState<AlertEventRow[]>([]);
const [subscriptions, setSubscriptions] = useState<AlertSubscriptionRow[]>([]);
const [alertType, setAlertType] = useState("informed_buy");
const [symbol, setSymbol] = useState("");
const [loading, setLoading] = useState(true);
const loadEvents = async () => {
try {
const data = await api.alerts.list(100);
setEvents(data);
} catch { /* ignore */ }
};
const loadSubscriptions = async () => {
try {
const data = await api.alerts.listSubscriptions();
setSubscriptions(data);
} catch { /* ignore */ }
};
useEffect(() => {
Promise.all([loadEvents(), loadSubscriptions()]).finally(() => setLoading(false));
}, []);
const acknowledge = async (id: string) => {
await api.alerts.acknowledge(id);
loadEvents();
};
const acknowledgeAll = async () => {
await api.alerts.acknowledgeAll();
loadEvents();
};
const addSubscription = async () => {
const sym = symbol.trim().toUpperCase();
await api.alerts.createSubscription({ alertType, symbol: sym || undefined });
setSymbol("");
loadSubscriptions();
};
const toggleSubscription = async (id: string, enabled: boolean) => {
await api.alerts.updateSubscription(id, { enabled: !enabled });
loadSubscriptions();
};
const deleteSubscription = async (id: string) => {
await api.alerts.deleteSubscription(id);
loadSubscriptions();
};
const unacknowledged = events.filter((e) => !e.acknowledged);
return (
<div className="min-h-screen bg-surface text-fg p-6">
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-4">Alerts</h1>
<div className="flex gap-1 mb-6 border-b border-border">
<button onClick={() => setTab("events")} className={`px-4 py-2 text-sm font-medium ${tab === "events" ? "border-b-2 border-accent text-fg" : "text-fg-faint hover:text-fg"}`}>
Events {unacknowledged.length > 0 && <span className="ml-1.5 rounded-full bg-accent px-1.5 py-0.5 text-xs text-white">{unacknowledged.length}</span>}
</button>
<button onClick={() => setTab("subscriptions")} className={`px-4 py-2 text-sm font-medium ${tab === "subscriptions" ? "border-b-2 border-accent text-fg" : "text-fg-faint hover:text-fg"}`}>
Subscriptions
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
) : tab === "events" ? (
<div>
{unacknowledged.length > 0 && (
<button onClick={acknowledgeAll} className="mb-4 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-fg-faint hover:text-fg">
Acknowledge all ({unacknowledged.length})
</button>
)}
{events.length === 0 ? (
<p className="text-fg-faint text-sm">No alerts yet.</p>
) : (
<div className="space-y-2">
{events.map((e) => (
<div key={e.id} className={`rounded-lg border p-4 ${e.acknowledged ? "border-border opacity-50" : "border-border"}`}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`rounded border px-1.5 py-0.5 text-xs font-medium ${SEVERITY_COLORS[e.severity] ?? ""}`}>{e.severity}</span>
<span className="text-sm font-medium text-fg">{e.title}</span>
{e.symbol && <span className="rounded bg-surface-dark px-1.5 py-0.5 text-xs text-fg-faint">{e.symbol}</span>}
</div>
<p className="text-sm text-fg-faint whitespace-pre-wrap">{e.description}</p>
<p className="mt-1 text-xs text-fg-faint/60">{new Date(e.createdAt).toLocaleString()}</p>
</div>
{!e.acknowledged && (
<button onClick={() => acknowledge(e.id)} className="shrink-0 rounded border border-border px-2 py-1 text-xs text-fg-faint hover:text-fg">
Dismiss
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
) : (
<div>
<div className="mb-6 rounded-lg border border-border p-4">
<h3 className="text-sm font-medium mb-3">New Subscription</h3>
<div className="flex flex-wrap gap-3">
<select value={alertType} onChange={(e) => setAlertType(e.target.value)} className="rounded-lg border border-border bg-surface-dark px-3 py-2 text-sm text-fg">
<option value="informed_buy">Insider Buy</option>
<option value="informed_sell">Insider Sell</option>
<option value="new_13da">New 13D/G Filing</option>
<option value="rotation_incipient">Rotation Signal</option>
<option value="regime_shift">Regime Shift</option>
<option value="conviction_unlock">Conviction Unlock</option>
<option value="thesis_broken">Thesis Broken</option>
<option value="thesis_weakening">Thesis Weakening</option>
<option value="cluster_breach">Cluster Breach</option>
<option value="drawdown_halt">Drawdown Halt</option>
<option value="asymmetry_warning">Asymmetry Warning</option>
</select>
<input value={symbol} onChange={(e) => setSymbol(e.target.value)} placeholder="Symbol (optional)" className="rounded-lg border border-border bg-surface-dark px-3 py-2 text-sm text-fg w-32" />
<button onClick={addSubscription} disabled={!alertType} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/80 disabled:opacity-50">
Add
</button>
</div>
</div>
{subscriptions.length === 0 ? (
<p className="text-fg-faint text-sm">No subscriptions configured.</p>
) : (
<div className="space-y-2">
{subscriptions.map((s) => (
<div key={s.id} className="flex items-center justify-between rounded-lg border border-border p-3">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-fg">{s.alert_type.replace(/_/g, " ")}</span>
{s.symbol && <span className="rounded bg-surface-dark px-1.5 py-0.5 text-xs text-fg-faint">{s.symbol}</span>}
{!s.symbol && !s.watchlist_id && <span className="text-xs text-fg-faint">global</span>}
</div>
<div className="flex items-center gap-2">
<label className="relative inline-flex cursor-pointer items-center">
<input type="checkbox" checked={s.enabled} onChange={() => toggleSubscription(s.id, s.enabled)} className="peer sr-only" />
<div className="h-5 w-9 rounded-full bg-surface-dark after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-fg-faint after:transition-all peer-checked:bg-accent peer-checked:after:translate-x-full peer-checked:after:bg-white" />
</label>
<button onClick={() => deleteSubscription(s.id)} className="rounded border border-border px-2 py-1 text-xs text-danger hover:bg-red-950/20">
Remove
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
+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>
+56 -2
View File
@@ -103,6 +103,40 @@ export interface AlertEventRow {
payload: Record<string, unknown>;
}
export interface AlertSubscriptionRow {
id: string;
owner_id: string;
watchlist_id: string | null;
symbol: string | null;
alert_type: string;
enabled: boolean;
params: string;
last_fired: string | null;
created_at: string;
}
export interface SmtpConfigRow {
host: string;
port: number;
secure: boolean;
user: string | null;
passEnc: string | null;
fromName: string;
fromEmail: string;
enabled: boolean;
}
export interface SmtpConfigUpdate {
host?: string;
port?: number;
secure?: boolean;
user?: string | null;
pass?: string | null;
fromName?: string;
fromEmail?: string;
enabled?: boolean;
}
export interface RotationData {
sectors: SectorRotationItem[];
signal: {
@@ -505,8 +539,16 @@ export const api = {
},
watchlists: {
list: () => trpcQuery<WatchlistEntry[]>("watchlists.list"),
addSymbol: (symbol: string) => trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol }),
removeSymbol: (symbol: string) => trpcMutate<{ removed: boolean }>("watchlists.removeSymbol", { symbol }),
listByWatchlist: (name: string) => trpcQuery<WatchlistEntry[]>("watchlists.listByWatchlist", { name }),
listWatchlists: () => trpcQuery<Array<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string }>>("watchlists.listWatchlists"),
create: (name: string) => trpcMutate<{ id: string; name: string; symbol_count: number; sort_order: number; created_at: string }>("watchlists.create", { name }),
delete: (name: string) => trpcMutate<{ deleted: boolean }>("watchlists.delete", { name }),
rename: (oldName: string, newName: string) => trpcMutate<{ renamed: boolean }>("watchlists.rename", { oldName, newName }),
reorder: (orders: { id: string; sort_order: number }[]) => trpcMutate<{ ok: boolean }>("watchlists.reorder", { orders }),
addSymbol: (symbol: string, watchlistName?: string, notes?: string) =>
trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol, ...(watchlistName ? { watchlistName } : {}), ...(notes ? { notes } : {}) }),
removeSymbol: (symbol: string, watchlistName?: string) =>
trpcMutate<{ removed: boolean }>("watchlists.removeSymbol", { symbol, ...(watchlistName ? { watchlistName } : {}) }),
},
options: {
chain: (symbol: string, opts?: { expiry?: string }) =>
@@ -581,6 +623,14 @@ export const api = {
acknowledge: (alertId: string) => trpcMutate<{ ok: boolean }>("alerts.acknowledge", { alertId }),
unackedCount: () => trpcQuery<{ count: number }>("alerts.unackedCount"),
acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll", {}),
createSubscription: (input: { alertType: string; watchlistId?: string; symbol?: string; params?: string }) =>
trpcMutate<AlertSubscriptionRow>("alerts.createSubscription", input),
listSubscriptions: (symbol?: string) =>
trpcQuery<AlertSubscriptionRow[]>("alerts.listSubscriptions", symbol ? { symbol } : {}),
updateSubscription: (id: string, updates: { enabled?: boolean; params?: string }) =>
trpcMutate<AlertSubscriptionRow>("alerts.updateSubscription", { id, ...updates }),
deleteSubscription: (id: string) =>
trpcMutate<{ ok: boolean }>("alerts.deleteSubscription", { id }),
},
x: {
cashtag_search: (symbol: string, limit = 30) => trpcQuery<{ posts: XPost[]; configured: boolean }>("x.cashtag_search", { symbol, limit }),
@@ -625,6 +675,10 @@ export const api = {
trpcQuery<DataQualityRow[]>("admin.dataQualityList", input ?? {}),
serverRestart: (target: 'backend' | 'frontend' | 'all' = 'all') =>
trpcMutate<{ ok: boolean; backend: string; frontend: string }>("admin.serverRestart", { target }),
smtpConfig: () => trpcQuery<SmtpConfigRow>("admin.smtpConfig"),
smtpConfigUpdate: (input: SmtpConfigUpdate) =>
trpcMutate<{ ok: boolean }>("admin.smtpConfigUpdate", input),
smtpConfigTest: () => trpcMutate<{ ok: boolean }>("admin.smtpConfigTest", {}),
},
};