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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+56
-2
@@ -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", {}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user