diff --git a/README.md b/README.md
index 9593b5e..010b657 100644
--- a/README.md
+++ b/README.md
@@ -67,13 +67,20 @@ See [Plugin releases](docs/plugin-releases.md) for the import and beta workflow.
Open **Settings → Plutonium updates & restarts** to check the latest published
revision, inspect each server's running revision, and queue a server or fleet
restart. Release and running-version checks also run hourly. The published
-revision comes from the [official changelog](https://plutonium.pw/docs/changelog/);
+revision comes from the [official production updater manifest](https://cdn.plutoniummod.com/updater/prod/info.json),
+which exposes the game revision directly and avoids the changelog website's browser challenge;
running revisions come from RCON `version`. Unsupported replies and failed
queries appear as unknown rather than being treated as up to date. A failed
-changelog request retains the last release with a visible error.
+manifest request retains the last release with a visible error.
-Daily fleet restarts are optional and disabled by default. Choose a **UTC** queue
-time and save the schedule; the next run is also shown in your display timezone.
+Daily fleet restarts are optional and disabled by default. Choose a queue time
+in your **selected timezone** and save the schedule. The timezone is saved with
+the schedule so the daily time follows daylight saving changes even when your
+browser is closed. Skipped times run at the first valid minute after the gap;
+repeated times run once on their first occurrence. Existing UTC schedules retain
+their original timing until saved again. The form displays their next occurrence
+in your timezone and identifies the saved zone if it differs. Changing a browser's
+display preference does not silently change a fleet schedule; save to apply it.
Every due run queues the current fleet, regardless of whether an update exists.
The schedule and jobs survive app restarts. Missed days become one run, and a
server can have only one pending maintenance restart. Disabling the schedule
diff --git a/src/lib/plutonium.ts b/src/lib/plutonium.ts
index f90efde..e7f986a 100644
--- a/src/lib/plutonium.ts
+++ b/src/lib/plutonium.ts
@@ -1,6 +1,9 @@
+import { validTimeZone } from './time-zone.js';
+
export interface MaintenanceSchedule {
enabled: boolean;
time: string;
+ timeZone: string;
nextAt: string | null;
}
export interface PlutoniumRelease {
@@ -37,19 +40,51 @@ export function parsePlutoniumRevision(text: string): number | null {
return match ? Number(match[1]) : null;
}
-export function parseChangelogRevision(html: string): number {
- const headings = [...html.matchAll(/
]*>([\s\S]*?)<\/h2>/gi)];
- const revisions = headings.map(h => parsePlutoniumRevision(h[1].replace(/<[^>]*>/g, ' ')))
- .filter((r): r is number => r !== null);
- if (!revisions.length) throw new Error('The Plutonium changelog did not contain a release heading.');
- return Math.max(...revisions);
+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()): string {
- if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)) throw new Error('Choose a valid UTC time (HH:mm).');
- const next = new Date(now);
+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);
- next.setUTCHours(hour, minute, 0, 0);
- if (next <= now) next.setUTCDate(next.getUTCDate() + 1);
- return next.toISOString();
+ 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));
}
diff --git a/src/lib/server/fleet/plutonium.ts b/src/lib/server/fleet/plutonium.ts
index 9822e48..ab38172 100644
--- a/src/lib/server/fleet/plutonium.ts
+++ b/src/lib/server/fleet/plutonium.ts
@@ -3,10 +3,9 @@ import { config, komodoConfigured, rconConfigured } from '../config.js';
import { getServer, listServers } from './servers.js';
import { rconGetPlayerCount, rconPlutoniumVersion } from '../integrations/rcon.js';
import { refreshRestartRollout, restartInRollout, rolloutHealth, rolloutWaitReason } from '../pipeline/restart-rollout.js';
-import { nextMaintenanceAt, parseChangelogRevision } from '../../plutonium.js';
+import { nextMaintenanceAt, parsePlutoniumManifest, PLUTONIUM_MANIFEST_URL } from '../../plutonium.js';
import type { MaintenanceJob, MaintenanceSchedule, PlutoniumRelease, PlutoniumStatus, PlutoniumVersion } from '../../plutonium.js';
-const CHANGELOG = 'https://plutonium.pw/docs/changelog/';
const HOUR = 60 * 60 * 1000;
const nowIso = () => new Date().toISOString();
function readSetting(key: string, fallback: T): T {
@@ -21,11 +20,12 @@ function release(): PlutoniumRelease {
return readSetting('plutonium_release', { revision: null, checkedAt: null, error: null });
}
function schedule(): MaintenanceSchedule {
- return readSetting('plutonium_schedule', { enabled: false, time: '06:00', nextAt: null });
+ // Legacy schedules were explicitly UTC. Preserve their timing until saved.
+ return { timeZone: 'UTC', ...readSetting('plutonium_schedule', { enabled: false, time: '06:00', nextAt: null }) };
}
-export function saveMaintenanceSchedule(enabled: boolean, time: string): void {
- const nextAt = nextMaintenanceAt(time);
- writeSetting('plutonium_schedule', { enabled, time, nextAt: enabled ? nextAt : null });
+export function saveMaintenanceSchedule(enabled: boolean, time: string, timeZone: string): void {
+ const nextAt = nextMaintenanceAt(time, new Date(), timeZone);
+ writeSetting('plutonium_schedule', { enabled, time, timeZone, nextAt: enabled ? nextAt : null });
}
export function plutoniumStatus(): PlutoniumStatus {
const versions = db().prepare('SELECT * FROM plutonium_versions').all() as PlutoniumVersion[];
@@ -58,11 +58,11 @@ export function checkPlutonium(): Promise {
async function check(): Promise {
const previous = release();
try {
- const response = await fetch(CHANGELOG, { signal: AbortSignal.timeout(config.httpTimeoutMs), redirect: 'error' });
- if (!response.ok) throw new Error(`Changelog request failed (HTTP ${response.status}).`);
- const html = await response.text();
- if (html.length > 2_000_000) throw new Error('Changelog response was too large.');
- writeSetting('plutonium_release', { revision: parseChangelogRevision(html), checkedAt: nowIso(), error: null });
+ const response = await fetch(PLUTONIUM_MANIFEST_URL, { signal: AbortSignal.timeout(config.httpTimeoutMs), redirect: 'error' });
+ if (!response.ok) throw new Error(`Updater manifest request failed (HTTP ${response.status}).`);
+ const body = await response.text();
+ if (body.length > 2_000_000) throw new Error('Updater manifest response was too large.');
+ writeSetting('plutonium_release', { revision: parsePlutoniumManifest(JSON.parse(body)), checkedAt: nowIso(), error: null });
} catch (err) {
writeSetting('plutonium_release', { ...previous, checkedAt: nowIso(), error: (err as Error).message });
}
@@ -122,7 +122,7 @@ async function runTick(): Promise {
const names = listServers().map(s => s.name);
if (names.length) queueMaintenance(names);
// Coalesce missed days after downtime into one run; no restart backlog.
- writeSetting('plutonium_schedule', { ...plan, nextAt: nextMaintenanceAt(plan.time) });
+ writeSetting('plutonium_schedule', { ...plan, nextAt: nextMaintenanceAt(plan.time, new Date(), plan.timeZone) });
}
})();
await refreshRestartRollout();
diff --git a/src/lib/ui/components/PlutoniumMaintenance.svelte b/src/lib/ui/components/PlutoniumMaintenance.svelte
index b54e8cc..46c16f0 100644
--- a/src/lib/ui/components/PlutoniumMaintenance.svelte
+++ b/src/lib/ui/components/PlutoniumMaintenance.svelte
@@ -1,7 +1,8 @@