From 41f681d23491f0ba090afae053f0232743703619 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 21 May 2026 00:07:03 +0900 Subject: fix: backend wake scheduler with atomic toggle API, American time display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaced POST /wake-schedule (full-replace) with POST /wake-schedule/toggle (atomic single-hour toggle) to eliminate race conditions between frontend and scheduler - Recursive setTimeout prevents overlapping wake executions - HMR-safe via global timer reference - Frontend now uses toggle endpoint instead of full schedule POST - Display shows reset time (wake hour + 5h) in American 12h format e.g. '8:15 → Reset at 1:00 PM' instead of European 24h --- packages/api/src/routes/models.ts | 79 +++++++++++- .../frontend/src/lib/components/ClaudeReset.svelte | 143 ++++++++------------- 2 files changed, 133 insertions(+), 89 deletions(-) diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 79f25cc..f29d236 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -336,11 +336,14 @@ modelsRoutes.get("/key-usage", async (c) => { } }); -// Wake all Claude accounts by sending "hi" to haiku -modelsRoutes.post("/wake", async (c) => { +// ─── Shared wake function ───────────────────────────────────── + +async function wakeAllClaudeAccounts(): Promise< + Array<{ label: string; ok: boolean; error?: string }> +> { const accounts = discoverClaudeAccounts(); if (accounts.length === 0) { - return c.json({ error: "no Claude accounts available" }, 502); + return [{ label: "(none)", ok: false, error: "no Claude accounts available" }]; } const results: Array<{ label: string; ok: boolean; error?: string }> = []; @@ -376,5 +379,75 @@ modelsRoutes.post("/wake", async (c) => { } } + return results; +} + +modelsRoutes.post("/wake", async (c) => { + const results = await wakeAllClaudeAccounts(); return c.json({ results }); }); + +// ─── Wake scheduler (runs on backend, survives frontend close) ─ + +type WakeSchedule = Record; // hour → target timestamp (ms) + +let wakeSchedule: WakeSchedule = {}; + +// HMR-safe: clear previous tick before starting a new one +(globalThis as Record)._dispatchWakeTimer ??= undefined; +const timerKey = "_dispatchWakeTimer"; + +async function schedulerTick(): Promise { + const now = Date.now(); + const hours = Object.keys(wakeSchedule).map(Number); + + for (const hour of hours) { + const ts = wakeSchedule[hour]; + if (ts !== undefined && ts <= now) { + // Delete BEFORE wake to prevent duplicate triggers + delete wakeSchedule[hour]; + wakeAllClaudeAccounts().catch(() => {}); + } + } + + // Schedule next tick + if (Object.keys(wakeSchedule).length > 0) { + (globalThis as Record)[timerKey] = setTimeout(schedulerTick, 30_000); + } +} + +export function startWakeScheduler(): void { + // Clear any previous interval (HMR-safe) + const prev = (globalThis as Record)[timerKey]; + if (typeof prev === "number") clearTimeout(prev); + schedulerTick(); +} + +modelsRoutes.post("/wake-schedule/toggle", async (c) => { + const body = await c.req.json<{ hour?: number; timestamp?: number }>(); + const hour = body.hour; + if (typeof hour !== "number" || !Number.isFinite(hour) || hour < 0 || hour > 23) { + return c.json({ error: "hour must be a number 0-23" }, 400); + } + + if (wakeSchedule[hour] !== undefined) { + // Delete + delete wakeSchedule[hour]; + } else { + // Add — require a future timestamp + const ts = body.timestamp; + if (typeof ts !== "number" || ts <= Date.now()) { + return c.json({ error: "timestamp must be a future Unix ms value" }, 400); + } + wakeSchedule[hour] = ts; + } + + // Restart the tick loop (handles empty → non-empty or vice versa) + startWakeScheduler(); + + return c.json({ schedule: wakeSchedule }); +}); + +modelsRoutes.get("/wake-schedule", (c) => { + return c.json({ schedule: wakeSchedule }); +}); diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte index 58150fe..bcefbcf 100644 --- a/packages/frontend/src/lib/components/ClaudeReset.svelte +++ b/packages/frontend/src/lib/components/ClaudeReset.svelte @@ -4,119 +4,84 @@ // Map of hour (0-23) → scheduled wake timestamp (ms) let schedule = $state>({}); - // Active timeout IDs keyed by hour - const timeoutIds: Record> = {}; - function formatHour(h: number): string { const display = h % 12; return display === 0 ? "12" : String(display); } - function loadSchedule(): Record { - try { - const raw = localStorage.getItem("claude-reset-schedule"); - if (!raw) return {}; - return JSON.parse(raw) as Record; - } catch { - return {}; - } - } - - function saveSchedule(s: Record): void { - try { - localStorage.setItem("claude-reset-schedule", JSON.stringify(s)); - } catch { - // localStorage unavailable — ignore + function nextOccurrenceAt15(hour: number): number { + const now = new Date(); + const target = new Date(now); + target.setHours(hour, 15, 0, 0); + if (target.getTime() <= Date.now()) { + target.setDate(target.getDate() + 1); } + return target.getTime(); } - async function triggerWake(hour: number): Promise { + async function loadFromServer(): Promise { try { - await fetch(`${apiBase}/models/wake`, { method: "POST" }); + const res = await fetch(`${apiBase}/models/wake-schedule`); + if (!res.ok) return; + const data = (await res.json()) as { schedule: Record }; + const parsed: Record = {}; + for (const [k, v] of Object.entries(data.schedule)) { + parsed[Number(k)] = v; + } + schedule = parsed; } catch { - // Ignore network errors + // Network error — leave schedule empty } - // Remove this hour from the schedule - const updated = { ...schedule }; - delete updated[hour]; - schedule = updated; - saveSchedule(schedule); } - function scheduleTimeout(hour: number, ts: number): void { - const delay = ts - Date.now(); - if (delay <= 0) { - // Already past — fire immediately - void triggerWake(hour); - return; + async function parseScheduleResponse(res: Response): Promise { + const data = (await res.json()) as { schedule: Record }; + const parsed: Record = {}; + for (const [k, v] of Object.entries(data.schedule)) { + parsed[Number(k)] = v; } - const id = setTimeout(() => { - void triggerWake(hour); - }, delay); - timeoutIds[hour] = id; + schedule = parsed; } - function clearHourTimeout(hour: number): void { - if (timeoutIds[hour] !== undefined) { - clearTimeout(timeoutIds[hour]); - delete timeoutIds[hour]; + async function toggleOnServer(hour: number, ts: number): Promise { + try { + const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hour, timestamp: ts }), + }); + if (!res.ok) return; + await parseScheduleResponse(res); + } catch { + // Network error — keep local state } } - function nextOccurrenceAt15(hour: number): number { - const now = new Date(); - const target = new Date(now); - target.setHours(hour, 15, 0, 0); - if (target.getTime() <= Date.now()) { - target.setDate(target.getDate() + 1); + async function removeFromServer(hour: number): Promise { + try { + const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hour }), + }); + if (!res.ok) return; + await parseScheduleResponse(res); + } catch { + // Network error — keep local state } - return target.getTime(); } function toggleHour(hour: number): void { if (schedule[hour] !== undefined) { - // Deschedule - clearHourTimeout(hour); - const updated = { ...schedule }; - delete updated[hour]; - schedule = updated; - saveSchedule(schedule); + void removeFromServer(hour); } else { - // Schedule const ts = nextOccurrenceAt15(hour); - schedule = { ...schedule, [hour]: ts }; - saveSchedule(schedule); - scheduleTimeout(hour, ts); + void toggleOnServer(hour, ts); } } $effect(() => { - // Load persisted schedule on mount - const loaded = loadSchedule(); - const now = Date.now(); - const cleaned: Record = {}; - - for (const [k, ts] of Object.entries(loaded)) { - const hour = Number(k); - if (ts >= now) { - cleaned[hour] = ts; - } - } - - schedule = cleaned; - saveSchedule(cleaned); - - // Register timeouts for all future entries - for (const [k, ts] of Object.entries(cleaned)) { - scheduleTimeout(Number(k), ts); - } - - // Cleanup on destroy - return () => { - for (const id of Object.values(timeoutIds)) { - clearTimeout(id); - } - }; + void loadFromServer(); }); // Compute "faded" hours: the 4 hours after each scheduled block @@ -161,8 +126,14 @@ return base; } - function formatWakeTime(ts: number): string { - return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + function formatAmPm(hour24: number): string { + const h = hour24 % 12; + const ampm = hour24 < 12 ? "AM" : "PM"; + return `${h === 0 ? "12" : String(h)}:00 ${ampm}`; + } + + function resetHour(wakeHour: number): number { + return (wakeHour + 5) % 24; } const scheduledHours = $derived( @@ -228,7 +199,7 @@ {#each scheduledHours as hour}
{formatHour(hour)}:15 - Wake scheduled for {formatWakeTime(schedule[hour] ?? 0)} + Reset at {formatAmPm(resetHour(hour))}
{/each} -- cgit v1.2.3