Schedule backups in local time instead of UTC (0.66.0)
Schedule times were UTC, so a nightly 03:00 backup had to be entered as 01:00 or 02:00 depending on the season, and moved by an hour twice a year. Each schedule now has an IANA time zone, preset in the form to the browser's (e.g. Europe/Berlin), and its hour/minute/weekday are wall-clock time there. compute_next_run builds each candidate day from its date in that zone rather than adding 24 hours, so the time stays put across daylight saving changes; a time skipped by the spring change runs an hour later that day. next_run is still stored as naive UTC. - Existing schedules default to UTC and keep their run times until edited. - The API validates the zone and hour/minute/weekday ranges (400) instead of silently wrapping them. - tzdata is a dependency: the slim base image has no system tz database. Tests cover Berlin winter and summer time, both 2026 change days, the skipped hour, a weekly run across the UTC date line, hourly at a +5:30 offset, the UTC fallback and the API checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a0d673798
commit
9a357d2b92
10 files changed
+192
-27
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.65.0",
|
||||
"version": "0.66.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface BackupSchedule {
|
||||
hour: number;
|
||||
minute: number;
|
||||
weekday: number;
|
||||
/** IANA zone the time is in, e.g. "Europe/Berlin". */
|
||||
timezone: string;
|
||||
include_volumes: boolean;
|
||||
stop_first: boolean;
|
||||
keep: number;
|
||||
@@ -34,6 +36,7 @@ export interface ScheduleInput {
|
||||
hour: number;
|
||||
minute: number;
|
||||
weekday: number;
|
||||
timezone: string;
|
||||
include_volumes: boolean;
|
||||
stop_first: boolean;
|
||||
keep: number;
|
||||
|
||||
@@ -77,8 +77,23 @@ export function Settings() {
|
||||
|
||||
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
/** The browser's zone: what "local time" means to whoever sets the schedule. */
|
||||
const BROWSER_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
||||
|
||||
/** Every zone the browser knows (ES2022), or a short fallback list. */
|
||||
const TIME_ZONES: string[] = (() => {
|
||||
const all = (
|
||||
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
|
||||
).supportedValuesOf?.("timeZone");
|
||||
const zones = all?.length
|
||||
? all
|
||||
: ["Europe/Berlin", "Europe/Vienna", "Europe/Zurich", "Europe/London", "America/New_York"];
|
||||
return Array.from(new Set(["UTC", BROWSER_TZ, ...zones]));
|
||||
})();
|
||||
|
||||
function describeSchedule(s: BackupSchedule): string {
|
||||
const t = `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")} UTC`;
|
||||
const tz = s.timezone || "UTC";
|
||||
const t = `${String(s.hour).padStart(2, "0")}:${String(s.minute).padStart(2, "0")} ${tz}`;
|
||||
if (s.frequency === "hourly") return `Hourly at :${String(s.minute).padStart(2, "0")}`;
|
||||
if (s.frequency === "weekly") return `Weekly · ${WEEKDAYS[s.weekday] ?? "?"} ${t}`;
|
||||
return `Daily · ${t}`;
|
||||
@@ -312,6 +327,8 @@ function ScheduleForm({
|
||||
hour: schedule?.hour ?? 3,
|
||||
minute: schedule?.minute ?? 0,
|
||||
weekday: schedule?.weekday ?? 0,
|
||||
// New schedules are in the browser's local time; existing ones keep theirs.
|
||||
timezone: schedule?.timezone ?? BROWSER_TZ,
|
||||
include_volumes: schedule?.include_volumes ?? true,
|
||||
stop_first: schedule?.stop_first ?? true,
|
||||
keep: schedule?.keep ?? 7,
|
||||
@@ -429,7 +446,7 @@ function ScheduleForm({
|
||||
)}
|
||||
{form.frequency !== "hourly" && (
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Hour (UTC)</span>
|
||||
<span className="text-xs font-medium text-slate-500">Hour</span>
|
||||
<Input type="number" min={0} max={23} value={form.hour} onChange={(e) => set("hour", Number(e.target.value))} />
|
||||
</label>
|
||||
)}
|
||||
@@ -437,6 +454,23 @@ function ScheduleForm({
|
||||
<span className="text-xs font-medium text-slate-500">Minute</span>
|
||||
<Input type="number" min={0} max={59} value={form.minute} onChange={(e) => set("minute", Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Time zone</span>
|
||||
<select
|
||||
className={selectClass}
|
||||
value={form.timezone}
|
||||
onChange={(e) => set("timezone", e.target.value)}
|
||||
>
|
||||
{!TIME_ZONES.includes(form.timezone) && (
|
||||
<option value={form.timezone}>{form.timezone}</option>
|
||||
)}
|
||||
{TIME_ZONES.map((z) => (
|
||||
<option key={z} value={z}>
|
||||
{z === BROWSER_TZ ? `${z} (this browser)` : z}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="space-y-1">
|
||||
|
||||
Reference in new issue
Block a user