1
0
Fork 0
polaris-task-force/src/components/frontend/intelligence/missionReminderClient.ts
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

122 lines
3.6 KiB
TypeScript

const REMINDER_EXPIRY_MS = 72 * 60 * 60 * 1000;
const DISMISSALS_STORAGE_KEY = "ptf:mission-reminder:dismissals";
export type ReminderLeader = {
readonly id: number;
readonly username: string;
readonly displayName: string;
};
export type MissionReminder = {
readonly missionId: number;
readonly missionName: string;
readonly missionCodeName: string | null;
readonly completedAt: string;
readonly leaders: readonly ReminderLeader[];
};
export type ReminderEntry = MissionReminder & {
readonly key: string;
readonly leader: ReminderLeader;
readonly expiresAt: number;
};
export type Dismissals = Record<string, number>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isReminderLeader(value: unknown): value is ReminderLeader {
return (
isRecord(value) &&
typeof value.id === "number" &&
typeof value.username === "string" &&
typeof value.displayName === "string"
);
}
function isMissionReminder(value: unknown): value is MissionReminder {
return (
isRecord(value) &&
typeof value.missionId === "number" &&
typeof value.missionName === "string" &&
(value.missionCodeName === null || typeof value.missionCodeName === "string") &&
typeof value.completedAt === "string" &&
Array.isArray(value.leaders) &&
value.leaders.every(isReminderLeader)
);
}
export function parseMissionReminders(value: unknown): readonly MissionReminder[] {
if (!isRecord(value) || !Array.isArray(value.reminders)) return [];
return value.reminders.filter(isMissionReminder);
}
export function missionReminderKey(missionId: number, leaderId: number): string {
return `${missionId}:${leaderId}`;
}
export function buildReminderEntries(
reminders: readonly MissionReminder[],
now: number,
): readonly ReminderEntry[] {
const entries: ReminderEntry[] = [];
const seen = new Set<string>();
for (const reminder of reminders) {
const completedAt = Date.parse(reminder.completedAt);
if (!Number.isFinite(completedAt)) continue;
const expiresAt = completedAt + REMINDER_EXPIRY_MS;
if (expiresAt <= now) continue;
for (const leader of reminder.leaders) {
const key = missionReminderKey(reminder.missionId, leader.id);
if (seen.has(key)) continue;
seen.add(key);
entries.push({ ...reminder, key, leader, expiresAt });
}
}
return entries;
}
// Browser storage is read during mount and written during mount or the
// explicit dismissal handler; it is never accessed during render.
export function readReminderDismissals(now: number): Dismissals {
if (typeof window === "undefined") return {};
try {
const raw = window.localStorage.getItem(DISMISSALS_STORAGE_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) return {};
const dismissals: Dismissals = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "number" && Number.isFinite(value) && value > now) {
dismissals[key] = value;
}
}
return dismissals;
} catch (error) {
if (error instanceof Error) return {};
throw error;
}
}
export function writeReminderDismissals(dismissals: Dismissals, now: number): void {
if (typeof window === "undefined") return;
const activeDismissals: Dismissals = {};
for (const [key, expiresAt] of Object.entries(dismissals)) {
if (expiresAt > now) activeDismissals[key] = expiresAt;
}
try {
window.localStorage.setItem(DISMISSALS_STORAGE_KEY, JSON.stringify(activeDismissals));
} catch (error) {
if (error instanceof Error) return;
throw error;
}
}