1
0
Fork 0
polaris-task-force/src/components/frontend/intelligence/MissionReminderToasts.tsx
Z8MB1E 7180b0aa4c feat(missions): make code name optional, add UGC cover images
- Missions.codeName is now optional (still unique when set); migration included
- Campaign and side-op dialogs accept an optional cover image (uploadUgcImage)
- Reminder toasts, evaluation aggregates, calendar, cards and mission headers
  handle a null code name
- Mission read filter no longer hides drafts by status; visibility follows
  ownershipAndStatus.visibility only
2026-09-10 20:02:47 -04:00

234 lines
7.6 KiB
TypeScript

"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<Promise<void> | null>(null);
const expiryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pumpScheduledRef = useRef(false);
const pumpQueueRef = useRef<() => void>(() => undefined);
const dismissedRef = useRef<Dismissals>({});
const currentRef = useRef(new Map<string, ReminderEntry>());
const queueRef = useRef<ReminderEntry[]>([]);
const queuedKeysRef = useRef(new Set<string>());
const shownKeysRef = useRef(new Set<string>());
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;
}