fix: dedup fund_capture alerts for same-date sibling records and add clear-all

fund_capture alerts for a symbol with multiple capture records sharing one
as_of re-fired every batch cycle: the single comparison-state slot
ping-ponged between sibling evidence URLs, and the pre-check dedup key
never matched the stored buildDedupKey format. Track a seen-set of URLs in
comparison state and align dedup-key construction with buildDedupKey.
Also add alerts.clearAll (hard delete) with an immediate-refresh bell.
This commit is contained in:
Investor Flow Build
2026-08-12 22:39:19 -04:00
parent e5a7ff9f33
commit 0bc7b636e5
6 changed files with 185 additions and 21 deletions
+86 -11
View File
@@ -1,5 +1,5 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { ExternalLink as ExternalLinkIcon } from "lucide-react";
import { LayoutShell } from "@/components/LayoutShell";
import { FeatureGate } from "@/components/FeatureGate";
@@ -19,6 +19,10 @@ function AlertsPageInner() {
const [alertType, setAlertType] = useState("informed_buy");
const [symbol, setSymbol] = useState("");
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const [totalCount, setTotalCount] = useState<number | null>(null);
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadEvents = async () => {
try {
@@ -27,6 +31,13 @@ function AlertsPageInner() {
} catch { /* ignore */ }
};
const loadTotal = async () => {
try {
const data = await api.alerts.unackedCount();
setTotalCount(data.count);
} catch { /* ignore */ }
};
const loadSubscriptions = async () => {
try {
const data = await api.alerts.listSubscriptions();
@@ -42,7 +53,26 @@ function AlertsPageInner() {
};
useEffect(() => {
Promise.all([loadEvents(), loadSubscriptions(), loadTypeToggles()]).finally(() => setLoading(false));
let cancelled = false;
api.alerts.list(100).then(
(data) => { if (!cancelled) setEvents(data); },
() => {},
);
api.alerts.listSubscriptions().then(
(data) => { if (!cancelled) setSubscriptions(data); },
() => {},
);
api.alerts.listTypes().then(
(data) => { if (!cancelled) setTypeToggles(data); },
() => {},
);
api.alerts.unackedCount().then(
(data) => { if (!cancelled) setTotalCount(data.count); },
() => {},
).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const acknowledge = async (id: string) => {
@@ -53,6 +83,28 @@ function AlertsPageInner() {
const acknowledgeAll = async () => {
await api.alerts.acknowledgeAll();
loadEvents();
loadTotal();
window.dispatchEvent(new CustomEvent("alert-dismissed"));
};
const clearAll = async () => {
if (confirmTimer.current) clearTimeout(confirmTimer.current);
setClearing(true);
await api.alerts.clearAll();
setClearing(false);
setConfirmClear(false);
setEvents([]);
setTotalCount(0);
loadSubscriptions();
loadTypeToggles();
window.dispatchEvent(new CustomEvent("alert-dismissed"));
};
const requestClearAll = () => {
setConfirmClear(true);
confirmTimer.current = setTimeout(() => {
setConfirmClear(false);
}, 4000);
};
const addSubscription = async () => {
@@ -110,15 +162,38 @@ function AlertsPageInner() {
</div>
) : tab === "events" ? (
<div>
{unacknowledged.length > 0 && (
<button
type="button"
onClick={acknowledgeAll}
className="mb-4 min-h-[44px] rounded-lg border border-line px-4 py-2 text-sm font-medium text-fg-muted hover:text-fg active:bg-surface-sunken"
>
Acknowledge all ({unacknowledged.length})
</button>
)}
<div className="mb-4 flex flex-col sm:flex-row sm:items-center gap-2">
{unacknowledged.length > 0 && (
<button
type="button"
onClick={acknowledgeAll}
className="min-h-[44px] rounded-lg border border-line px-4 py-2 text-sm font-medium text-fg-muted hover:text-fg active:bg-surface-sunken"
>
Acknowledge all{totalCount != null ? ` (${totalCount.toLocaleString()})` : ""}
</button>
)}
{totalCount != null && totalCount > 0 && (
<>
<button
type="button"
onClick={confirmClear ? clearAll : requestClearAll}
disabled={clearing}
className={`min-h-[44px] rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
confirmClear
? "border-danger bg-danger text-white hover:bg-danger/80"
: "border-red-500/40 text-danger hover:bg-red-950/30"
} disabled:opacity-50`}
>
{confirmClear
? clearing ? "Deleting..." : "Confirm clear all?"
: `Clear all (${totalCount.toLocaleString()})`}
</button>
{confirmClear && (
<span className="text-xs text-fg-faint">This permanently deletes all alert events.</span>
)}
</>
)}
</div>
{events.length === 0 ? (
<p className="text-fg-faint text-sm">No alerts yet.</p>
) : (
+15 -2
View File
@@ -31,11 +31,24 @@ export function NotificationBell() {
}, []);
useEffect(() => {
void fetchAlerts();
}, [fetchAlerts]);
api.alerts.list(20).then(
(evts) => setAlerts(evts),
() => {},
);
api.alerts.unackedCount().then(
(r) => setUnackedCount(r.count),
() => {},
);
}, []);
useVisibilityAwarePoll(fetchAlerts, POLL_INTERVAL);
useEffect(() => {
const handler = () => void fetchAlerts();
window.addEventListener("alert-dismissed", handler);
return () => window.removeEventListener("alert-dismissed", handler);
}, [fetchAlerts]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
+1
View File
@@ -1247,6 +1247,7 @@ export const api = {
acknowledge: (alertId: string) => trpcMutate<{ ok: boolean }>("alerts.acknowledge", { alertId }),
unackedCount: () => trpcQuery<{ count: number }>("alerts.unackedCount"),
acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll", {}),
clearAll: () => trpcMutate<{ ok: boolean }>("alerts.clearAll", {}),
createSubscription: (input: { alertType: string; watchlistId?: string; symbol?: string; params?: string }) =>
trpcMutate<AlertSubscriptionRow>("alerts.createSubscription", input),
listSubscriptions: (symbol?: string) =>