import { validTimeZone } from './time-zone.js'; export interface MaintenanceSchedule { enabled: boolean; time: string; timeZone: string; nextAt: string | null; } export interface PlutoniumRelease { revision: number | null; checkedAt: string | null; error: string | null; } export interface PlutoniumVersion { server: string; revision: number | null; checked_at: string | null; error: string | null; stopped?: boolean; update_status?: 'outdated' | 'current' | 'ahead' | 'unknown' | 'stopped'; } export interface MaintenanceJob { id: number; server: string; server_deleted_at?: string | null; state: 'queued' | 'restarting' | 'done' | 'failed' | 'cancelled'; target_revision: number | null; requested_at: string; restarted_at: string | null; provisioning_id: string | null; finished_at: string | null; detail: string | null; kind: 'legacy' | 'update' | 'schedule'; origin: 'manual' | 'automatic' | 'schedule'; operation_stage?: string | null; operation_state?: string | null; } export interface PlutoniumStatus { release: PlutoniumRelease; schedule: MaintenanceSchedule; servers: PlutoniumVersion[]; jobs: MaintenanceJob[]; } /** Accept explicit revision tokens only, never engine versions or build dates. */ export function parsePlutoniumRevision(text: string): number | null { const match = text.replace(/\^[0-9]/g, '').match(/\br([1-9]\d{2,8})\b/i); return match ? Number(match[1]) : null; } export const PLUTONIUM_MANIFEST_URL = 'https://cdn.plutoniummod.com/updater/prod/info.json'; export function parsePlutoniumManifest(value: unknown): number { const manifest = value as { product?: unknown; revision?: unknown } | null; if (!manifest || manifest.product !== 'plutonium-core-prod' || typeof manifest.revision !== 'number' || !Number.isSafeInteger(manifest.revision) || manifest.revision < 1) { throw new Error('The production updater manifest did not contain a valid Plutonium revision.'); } return manifest.revision; } export function nextMaintenanceAt(time: string, now = new Date(), timeZone = 'UTC'): string { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) throw new Error('Choose a valid time (HH:mm).'); if (!validTimeZone(timeZone)) throw new Error('Choose a valid timezone.'); if (!Number.isFinite(now.getTime())) throw new Error('Invalid schedule date.'); const [hour, minute] = time.split(':').map(Number); const target = hour * 60 + minute; const formatter = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }); function local(at: number) { const parts = Object.fromEntries(formatter.formatToParts(at).map(p => [p.type, p.value])); return { day: `${parts.year}-${parts.month}-${parts.day}`, minute: Number(parts.hour) * 60 + Number(parts.minute) }; } const today = local(now.getTime()).day; const start = Math.floor(now.getTime() / 60_000) * 60_000; const days = new Map(); // Bounded minute search avoids assuming whole-hour offsets or 24-hour days. // A missing wall time runs at the first valid minute after the DST gap; // a repeated wall time runs only on its first occurrence that local day. for (let at = start - 26 * 3_600_000; at <= start + 50 * 3_600_000; at += 60_000) { const wall = local(at); if (wall.day < today || wall.minute < target) continue; const previous = days.get(wall.day); if (!previous || wall.minute < previous.minute) days.set(wall.day, { at, minute: wall.minute }); } const next = [...days.values()].map(d => d.at).filter(at => at > now.getTime()).sort((a, b) => a - b)[0]; if (next === undefined) throw new Error('Could not calculate the next maintenance time.'); return new Date(next).toISOString(); } /** Present an existing schedule in the viewing user's zone without rewriting it. */ export function maintenanceTimeInZone(schedule: MaintenanceSchedule, timeZone: string): string { if (schedule.timeZone === timeZone) return schedule.time; const at = schedule.nextAt ?? nextMaintenanceAt(schedule.time, new Date(), schedule.timeZone); return new Intl.DateTimeFormat('en-GB', { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(new Date(at)); }