"use client"; import { useCallback, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { useGameTick } from "@/hooks/useGameTick"; import { buildReminderEntries, parseMissionReminders, readReminderDismissals, writeReminderDismissals, type Dismissals, type MissionReminder, type ReminderEntry, } from "@/components/frontend/intelligence/missionReminderClient"; const POLL_MS = 60_000; export function MissionReminderToasts() { const router = useRouter(); const mountedRef = useRef(false); const fetchInFlightRef = useRef | null>(null); const expiryTimerRef = useRef | null>(null); const pumpScheduledRef = useRef(false); const pumpQueueRef = useRef<() => void>(() => undefined); const dismissedRef = useRef({}); const currentRef = useRef(new Map()); const queueRef = useRef([]); const queuedKeysRef = useRef(new Set()); const shownKeysRef = useRef(new Set()); const activeToastRef = useRef<{ key: string; toastId: string } | null>(null); const schedulePump = useCallback(() => { if (!mountedRef.current || pumpScheduledRef.current) return; pumpScheduledRef.current = true; setTimeout(() => { pumpScheduledRef.current = false; pumpQueueRef.current(); }, 0); }, []); const finishToast = useCallback( (key: string, toastId: string) => { const active = activeToastRef.current; if (!active || active.key !== key || active.toastId !== toastId) return; activeToastRef.current = null; schedulePump(); }, [schedulePump], ); const scheduleExpiryCheck = useCallback(() => { if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current); const now = Date.now(); const nextExpiry = [...currentRef.current.values()] .map((entry) => entry.expiresAt) .filter((expiresAt) => expiresAt > now) .sort((first, second) => first - second)[0]; if (nextExpiry === undefined) return; expiryTimerRef.current = setTimeout( () => { expiryTimerRef.current = null; const currentNow = Date.now(); for (const [key, entry] of currentRef.current) { if (entry.expiresAt <= currentNow) currentRef.current.delete(key); } queueRef.current = queueRef.current.filter((entry) => { const current = currentRef.current.get(entry.key); const keep = current?.expiresAt === entry.expiresAt && entry.expiresAt > currentNow; if (!keep) queuedKeysRef.current.delete(entry.key); return keep; }); const active = activeToastRef.current; if (active && !currentRef.current.has(active.key)) { activeToastRef.current = null; toast.dismiss(active.toastId); } scheduleExpiryCheck(); schedulePump(); }, Math.max(0, nextExpiry - now + 1), ); }, [schedulePump]); const applyReminders = useCallback( (reminders: readonly MissionReminder[]) => { const now = Date.now(); const entries = buildReminderEntries(reminders, now); const next = new Map(entries.map((entry) => [entry.key, entry])); const active = activeToastRef.current; if (active && !next.has(active.key)) { activeToastRef.current = null; toast.dismiss(active.toastId); } currentRef.current = next; queueRef.current = queueRef.current.filter((entry) => { const current = next.get(entry.key); const keep = current?.expiresAt === entry.expiresAt; if (!keep) queuedKeysRef.current.delete(entry.key); return keep; }); for (const entry of entries) { if ( !shownKeysRef.current.has(entry.key) && !queuedKeysRef.current.has(entry.key) && dismissedRef.current[entry.key] !== undefined ) { continue; } if (!shownKeysRef.current.has(entry.key) && !queuedKeysRef.current.has(entry.key)) { queueRef.current.push(entry); queuedKeysRef.current.add(entry.key); } } scheduleExpiryCheck(); schedulePump(); }, [scheduleExpiryCheck, schedulePump], ); const fetchReminders = useCallback(async () => { if (fetchInFlightRef.current) return fetchInFlightRef.current; const request = (async () => { try { const response = await fetch("/api/mission-reminder", { cache: "no-store" }); if (!response.ok || !mountedRef.current) return; const body: unknown = await response.json(); if (mountedRef.current) applyReminders(parseMissionReminders(body)); } catch (error) { if (error instanceof Error) return; throw error; } })(); fetchInFlightRef.current = request; try { await request; } finally { if (fetchInFlightRef.current === request) fetchInFlightRef.current = null; } }, [applyReminders]); const pumpQueue = useCallback(() => { if (!mountedRef.current || activeToastRef.current) return; const now = Date.now(); let entry: ReminderEntry | undefined; while (queueRef.current.length > 0) { const candidate = queueRef.current.shift(); if (!candidate) break; queuedKeysRef.current.delete(candidate.key); const current = currentRef.current.get(candidate.key); if ( !current || current.expiresAt !== candidate.expiresAt || candidate.expiresAt <= now || shownKeysRef.current.has(candidate.key) || dismissedRef.current[candidate.key] !== undefined ) { continue; } entry = candidate; break; } if (!entry) return; const toastId = `mission-reminder:${entry.key}`; const leaderName = entry.leader.displayName || entry.leader.username; const missionName = entry.missionName || entry.missionCodeName || "the mission"; shownKeysRef.current.add(entry.key); activeToastRef.current = { key: entry.key, toastId }; toast("Mission feedback", { id: toastId, description: `Rate ${leaderName}'s leadership on ${missionName}.`, action: { label: "Rate leader", onClick: () => { toast.dismiss(toastId); finishToast(entry.key, toastId); router.push(`/profile/${encodeURIComponent(entry.leader.username)}`); }, }, cancel: { label: "Dismiss reminder", onClick: () => { dismissedRef.current[entry.key] = entry.expiresAt; writeReminderDismissals(dismissedRef.current, Date.now()); toast.dismiss(toastId); finishToast(entry.key, toastId); }, }, onAutoClose: () => finishToast(entry.key, toastId), onDismiss: () => finishToast(entry.key, toastId), }); }, [finishToast, router]); pumpQueueRef.current = pumpQueue; useEffect(() => { mountedRef.current = true; const now = Date.now(); dismissedRef.current = readReminderDismissals(now); writeReminderDismissals(dismissedRef.current, now); void fetchReminders(); const interval = setInterval(() => void fetchReminders(), POLL_MS); return () => { mountedRef.current = false; clearInterval(interval); if (expiryTimerRef.current) clearTimeout(expiryTimerRef.current); const active = activeToastRef.current; activeToastRef.current = null; if (active) toast.dismiss(active.toastId); queueRef.current = []; queuedKeysRef.current.clear(); currentRef.current.clear(); }; }, [fetchReminders]); useGameTick(() => { void fetchReminders(); }); return null; }