- 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
122 lines
3.6 KiB
TypeScript
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;
|
|
}
|
|
}
|