Isolate Yahoo drain from SEC backlog and protect the guest account.
Yahoo and SEC drain on separate workers so hung EDGAR jobs cannot freeze watchlist prices. Queue health splits yahooHealthy from institutional backfill, watchlist snapshots only enqueue missing quotes, and first subscribe seeds quote/candles/symbol. Hide the anonymous system user from Admin so it cannot be deleted.
This commit is contained in:
@@ -31,6 +31,8 @@ interface QueueStatus {
|
||||
stoppedSources?: string[];
|
||||
pendingByKind?: Record<string, number>;
|
||||
demandSize?: number;
|
||||
yahooHealthy?: boolean;
|
||||
secHealthy?: boolean;
|
||||
dataPlaneHealthy?: boolean;
|
||||
dataPlaneNotes?: string[];
|
||||
}
|
||||
@@ -585,11 +587,19 @@ export default function QueuePage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status && status.dataPlaneHealthy === false && (status.dataPlaneNotes?.length ?? 0) > 0 && (
|
||||
{status && status.yahooHealthy === false && (
|
||||
<div className="mx-4 mt-3 flex items-center gap-2 rounded border border-red-500/30 bg-red-950/20 px-3 py-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-danger animate-pulse" />
|
||||
<span className="text-xs text-danger font-medium">
|
||||
Yahoo: {(status.dataPlaneNotes ?? []).filter((n) => !/institutional backfill/i.test(n)).join(" · ") || "quote lane unhealthy"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{status && status.yahooHealthy !== false && status.secHealthy === false && (
|
||||
<div className="mx-4 mt-3 flex items-center gap-2 rounded border border-amber-500/30 bg-amber-950/20 px-3 py-1.5">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#fbbf24] animate-pulse" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#fbbf24]" />
|
||||
<span className="text-xs text-[#fbbf24] font-medium">
|
||||
Data plane: {status.dataPlaneNotes!.join(" · ")}
|
||||
Institutional backfill: {(status.dataPlaneNotes ?? []).filter((n) => /institutional backfill/i.test(n)).join(" · ") || "SEC jobs queued"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -261,7 +261,11 @@ function UserActions({ user, onUpdated }: { user: UserRow; onUpdated: () => void
|
||||
|
||||
const isPending = user.status === "pending_approval";
|
||||
const isDisabled = user.status === "disabled";
|
||||
const isValidId = user.id && /^[0-9a-fA-F]{8}-/.test(user.id);
|
||||
const isSystemAccount =
|
||||
user.id === "anonymous" ||
|
||||
user.id === "system" ||
|
||||
(user.email ?? "").toLowerCase().startsWith("anonymous@");
|
||||
const isValidId = !isSystemAccount && !!user.id && /^[0-9a-fA-F]{8}-/.test(user.id);
|
||||
|
||||
async function doResetPassword() {
|
||||
setResetOpen(false);
|
||||
@@ -313,7 +317,10 @@ function UserActions({ user, onUpdated }: { user: UserRow; onUpdated: () => void
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1 w-48 rounded-md border border-line-strong bg-surface-raised shadow-xl z-10 py-1">
|
||||
{!isPending && (
|
||||
{isSystemAccount && (
|
||||
<span className="block px-4 py-2 text-xs text-fg-faint">Guest book owner. Not a login.</span>
|
||||
)}
|
||||
{!isPending && !isSystemAccount && (
|
||||
<>
|
||||
<button onClick={() => { setOpen(false); setResetOpen(true); }} className="block w-full text-left px-4 py-2 text-xs text-fg hover:bg-surface-overlay transition">Reset Password</button>
|
||||
<button onClick={() => { setOpen(false); setModulesOpen(true); }} className="block w-full text-left px-4 py-2 text-xs text-fg hover:bg-surface-overlay transition">Manage Modules</button>
|
||||
@@ -323,7 +330,9 @@ function UserActions({ user, onUpdated }: { user: UserRow; onUpdated: () => void
|
||||
) : (
|
||||
<button onClick={() => { setOpen(false); setDisableOpen(true); }} disabled={loading} className="block w-full text-left px-4 py-2 text-xs text-warn hover:bg-surface-overlay transition disabled:opacity-50">Disable</button>
|
||||
)}
|
||||
<button onClick={() => { setOpen(false); setDeleteOpen(true); }} disabled={loading} className="block w-full text-left px-4 py-2 text-xs text-error hover:bg-surface-overlay transition disabled:opacity-50">Delete</button>
|
||||
{isValidId && (
|
||||
<button onClick={() => { setOpen(false); setDeleteOpen(true); }} disabled={loading} className="block w-full text-left px-4 py-2 text-xs text-error hover:bg-surface-overlay transition disabled:opacity-50">Delete</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isPending && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { X, List } from "lucide-react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { useActiveWatchlist } from "@/stores/active-watchlist-store";
|
||||
import { api, type PortfolioHolding, type WatchlistEntry } from "@/lib/trpc";
|
||||
import { useVisibilityAwarePoll } from "@/lib/useVisibilityAwarePoll";
|
||||
|
||||
type QuoteSnap = { price: number; changePercent: number };
|
||||
|
||||
@@ -52,7 +53,7 @@ export function MobileWatchlistSheet({
|
||||
}
|
||||
}),
|
||||
);
|
||||
setQuotes(next);
|
||||
setQuotes((prev) => ({ ...prev, ...next }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,6 +83,24 @@ export function MobileWatchlistSheet({
|
||||
};
|
||||
}, [open, activeWatchlist, loadQuotes]);
|
||||
|
||||
const quoteSymbols = [
|
||||
...holdings.map((h) => h.symbol),
|
||||
...entries.map((e) => e.symbol),
|
||||
];
|
||||
const quotesMissing = quoteSymbols.some((s) => quotes[s] == null);
|
||||
const [fastPoll, setFastPoll] = useState(true);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setFastPoll(true);
|
||||
const t = setTimeout(() => setFastPoll(false), 45_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [open, activeWatchlist, entries]);
|
||||
useVisibilityAwarePoll(
|
||||
() => { void loadQuotes(quoteSymbols); },
|
||||
quotesMissing && fastPoll ? 2500 : 15_000,
|
||||
open && quoteSymbols.length > 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useActiveSymbol } from "@/stores/active-symbol-store";
|
||||
import { useActiveWatchlist } from "@/stores/active-watchlist-store";
|
||||
import { api, type WatchlistEntry, type Quote, type PortfolioHolding } from "@/lib/trpc";
|
||||
import { chart as CHART } from "@/lib/chart-theme";
|
||||
import { useVisibilityAwarePoll } from "@/lib/useVisibilityAwarePoll";
|
||||
import { SymbolAutocomplete } from "./SymbolAutocomplete";
|
||||
interface MiniQuote extends Quote {
|
||||
sparkline?: number[];
|
||||
@@ -149,58 +150,63 @@ export function WatchlistSidebar({ compact = false, collapsed = false, onToggle
|
||||
|
||||
// Fetch quotes for watchlist symbols. Quote alone is enough for the mark;
|
||||
// candles only improve the sparkline (queue may deliver quote first).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const fetchQuotes = async (): Promise<Set<string>> => {
|
||||
const got = new Set<string>();
|
||||
const updates = new Map<string, MiniQuote>();
|
||||
|
||||
try {
|
||||
// Use batched snapshots to avoid N+1 API calls
|
||||
const symbols = entries.map((e) => e.symbol);
|
||||
const results = await api.market.snapshots(symbols);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.quote) {
|
||||
const sparkline = result.candles?.length
|
||||
? result.candles.slice(-20).map((c) => c.c)
|
||||
: undefined;
|
||||
updates.set(result.symbol, { ...result.quote, symbol: result.symbol, sparkline });
|
||||
got.add(result.symbol);
|
||||
}
|
||||
const fetchQuotes = useCallback(async () => {
|
||||
if (entries.length === 0) return;
|
||||
const updates = new Map<string, MiniQuote>();
|
||||
try {
|
||||
const symbols = entries.map((e) => e.symbol);
|
||||
const results = await api.market.snapshots(symbols);
|
||||
for (const result of results) {
|
||||
if (result.quote) {
|
||||
const sparkline = result.candles?.length
|
||||
? result.candles.slice(-20).map((c) => c.c)
|
||||
: undefined;
|
||||
updates.set(result.symbol, { ...result.quote, symbol: result.symbol, sparkline });
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[watchlist] batched snapshot fail:`, (e as Error).message.slice(0, 50));
|
||||
}
|
||||
|
||||
if (!cancelled && updates.size > 0) {
|
||||
setQuotes((prev) => {
|
||||
const m = new Map(prev);
|
||||
for (const [k, v] of updates) m.set(k, v);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return got;
|
||||
};
|
||||
|
||||
if (entries.length === 0) return () => { cancelled = true; };
|
||||
|
||||
void (async () => {
|
||||
let got = await fetchQuotes();
|
||||
// Newly added symbols often land after a short queue delay - retry a few times.
|
||||
for (let attempt = 0; attempt < 6 && !cancelled; attempt++) {
|
||||
if (entries.every((e) => got.has(e.symbol))) break;
|
||||
await new Promise((r) => setTimeout(r, 2500));
|
||||
if (cancelled) break;
|
||||
const next = await fetchQuotes();
|
||||
got = new Set([...got, ...next]);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
} catch (e) {
|
||||
console.log(`[watchlist] batched snapshot fail:`, (e as Error).message.slice(0, 50));
|
||||
}
|
||||
if (updates.size > 0) {
|
||||
setQuotes((prev) => {
|
||||
const m = new Map(prev);
|
||||
for (const [k, v] of updates) m.set(k, v);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
}, [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchQuotes();
|
||||
}, [fetchQuotes]);
|
||||
|
||||
// Only the visible rail polls. Compact and desktop instances both mount;
|
||||
// CSS-hiding the other must not double the Yahoo enqueue path.
|
||||
const [pollEnabled, setPollEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = compact
|
||||
? window.matchMedia("(min-width: 768px) and (max-width: 1023px)")
|
||||
: window.matchMedia("(min-width: 1024px)");
|
||||
const sync = () => setPollEnabled(mq.matches);
|
||||
sync();
|
||||
mq.addEventListener("change", sync);
|
||||
return () => mq.removeEventListener("change", sync);
|
||||
}, [compact]);
|
||||
|
||||
// Burst-poll after a list change so a newly added name can land; then settle.
|
||||
const [fastPoll, setFastPoll] = useState(true);
|
||||
useEffect(() => {
|
||||
setFastPoll(true);
|
||||
const t = setTimeout(() => setFastPoll(false), 15_000);
|
||||
return () => clearTimeout(t);
|
||||
}, [entries]);
|
||||
const quotesMissing = entries.some((e) => !quotes.has(e.symbol));
|
||||
useVisibilityAwarePoll(
|
||||
fetchQuotes,
|
||||
quotesMissing && fastPoll ? 2500 : 15_000,
|
||||
pollEnabled && entries.length > 0,
|
||||
);
|
||||
|
||||
// Hybrid resolve (ADR-0011): known index symbol adds instantly; unmatched text
|
||||
// soft-blocks with an explicit "add anyway?" before adding (background hydration
|
||||
// then picks the row up via the existing demand pipeline).
|
||||
@@ -312,7 +318,7 @@ export function WatchlistSidebar({ compact = false, collapsed = false, onToggle
|
||||
|
||||
const containerClass = compact
|
||||
? "w-full border-b border-line bg-surface-raised flex-shrink-0"
|
||||
: `hidden lg:flex items-stretch transition-all duration-200 ${collapsed ? 'w-4' : 'w-72'}`;
|
||||
: `hidden lg:flex items-stretch transition-all duration-200 ${collapsed ? 'w-4' : 'w-80'}`;
|
||||
|
||||
return (
|
||||
<aside className={containerClass}>
|
||||
@@ -377,7 +383,7 @@ export function WatchlistSidebar({ compact = false, collapsed = false, onToggle
|
||||
{/* Portfolio snapshot — aligned rows, one clear CTA */}
|
||||
<div className="shrink-0 px-3 pt-3 pb-2">
|
||||
<div className="rounded-md border border-line bg-surface-sunken/80 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-line">
|
||||
<div className="flex items-center justify-between gap-2 px-3.5 py-2 border-b border-line">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-fg-muted">
|
||||
Portfolio
|
||||
</p>
|
||||
@@ -386,25 +392,25 @@ export function WatchlistSidebar({ compact = false, collapsed = false, onToggle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl className="px-3 py-2.5 space-y-1.5">
|
||||
<div className="grid grid-cols-[1fr_auto] items-baseline gap-x-3 gap-y-0">
|
||||
<dt className="text-[11px] text-fg-muted">Cost basis</dt>
|
||||
<dd className="text-[13px] font-semibold tabular-nums text-fg text-right">
|
||||
<dl className="px-3.5 py-2.5 space-y-1.5">
|
||||
<div className="flex items-baseline gap-3 min-w-0">
|
||||
<dt className="w-[6.75rem] shrink-0 text-[11px] text-fg-muted">Cost basis</dt>
|
||||
<dd className="m-0 min-w-0 text-[13px] font-semibold tabular-nums text-fg whitespace-nowrap">
|
||||
${fmtUsd(Math.round(costBasis))}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] items-baseline gap-x-3">
|
||||
<dt className="text-[11px] text-fg-muted">Market value</dt>
|
||||
<dd className="text-[13px] font-semibold tabular-nums text-fg text-right">
|
||||
<div className="flex items-baseline gap-3 min-w-0">
|
||||
<dt className="w-[6.75rem] shrink-0 text-[11px] text-fg-muted">Market value</dt>
|
||||
<dd className="m-0 min-w-0 text-[13px] font-semibold tabular-nums text-fg whitespace-nowrap">
|
||||
{marketDisplay}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] items-baseline gap-x-3">
|
||||
<dt className="text-[11px] text-fg-muted">Unrealized</dt>
|
||||
<dd className={`text-[13px] font-semibold tabular-nums text-right ${pnlTone}`}>
|
||||
{pnlDisplay}
|
||||
<div className="flex items-baseline gap-3 min-w-0">
|
||||
<dt className="w-[6.75rem] shrink-0 text-[11px] text-fg-muted">Unrealized</dt>
|
||||
<dd className={`m-0 min-w-0 flex flex-wrap items-baseline gap-x-1.5 text-[13px] font-semibold tabular-nums ${pnlTone}`}>
|
||||
<span className="whitespace-nowrap">{pnlDisplay}</span>
|
||||
{unrealizedPnl != null && unrealizedPnlPct != null && (
|
||||
<span className="ml-1.5 text-[11px] font-medium opacity-90">
|
||||
<span className="text-[11px] font-medium opacity-90 whitespace-nowrap">
|
||||
({unrealizedPnlPct >= 0 ? "+" : "−"}
|
||||
{Math.abs(unrealizedPnlPct).toFixed(1)}%)
|
||||
</span>
|
||||
@@ -419,7 +425,7 @@ export function WatchlistSidebar({ compact = false, collapsed = false, onToggle
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2 border-t border-line">
|
||||
<div className="px-3.5 py-2 border-t border-line">
|
||||
<a
|
||||
href="/portfolio"
|
||||
className="block w-full text-center rounded-md border border-line bg-surface px-2 py-1.5 text-[11px] font-medium text-fg hover:border-accent/40 hover:text-accent transition-colors"
|
||||
|
||||
@@ -1826,6 +1826,8 @@ export interface QueueStatus {
|
||||
stoppedSources?: string[];
|
||||
pendingByKind?: Record<string, number>;
|
||||
demandSize?: number;
|
||||
yahooHealthy?: boolean;
|
||||
secHealthy?: boolean;
|
||||
dataPlaneHealthy?: boolean;
|
||||
dataPlaneNotes?: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user