diff options
| author | Adam Malczewski <[email protected]> | 2026-05-21 00:07:03 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-21 00:07:03 +0900 |
| commit | 41f681d23491f0ba090afae053f0232743703619 (patch) | |
| tree | af2fdbf8cd0f9d6de9784f1ffb74c2f0522e9d28 | |
| parent | 1f4776e6891348d2dbdcbbf704c0a5901b008ecf (diff) | |
| download | dispatch-41f681d23491f0ba090afae053f0232743703619.tar.gz dispatch-41f681d23491f0ba090afae053f0232743703619.zip | |
fix: backend wake scheduler with atomic toggle API, American time display
- 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
| -rw-r--r-- | packages/api/src/routes/models.ts | 79 | ||||
| -rw-r--r-- | packages/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<number, number>; // hour → target timestamp (ms) + +let wakeSchedule: WakeSchedule = {}; + +// HMR-safe: clear previous tick before starting a new one +(globalThis as Record<string, unknown>)._dispatchWakeTimer ??= undefined; +const timerKey = "_dispatchWakeTimer"; + +async function schedulerTick(): Promise<void> { + 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<string, unknown>)[timerKey] = setTimeout(schedulerTick, 30_000); + } +} + +export function startWakeScheduler(): void { + // Clear any previous interval (HMR-safe) + const prev = (globalThis as Record<string, unknown>)[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<Record<number, number>>({}); - // Active timeout IDs keyed by hour - const timeoutIds: Record<number, ReturnType<typeof setTimeout>> = {}; - function formatHour(h: number): string { const display = h % 12; return display === 0 ? "12" : String(display); } - function loadSchedule(): Record<number, number> { - try { - const raw = localStorage.getItem("claude-reset-schedule"); - if (!raw) return {}; - return JSON.parse(raw) as Record<number, number>; - } catch { - return {}; - } - } - - function saveSchedule(s: Record<number, number>): 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<void> { + async function loadFromServer(): Promise<void> { 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<string, number> }; + const parsed: Record<number, number> = {}; + 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<void> { + const data = (await res.json()) as { schedule: Record<string, number> }; + const parsed: Record<number, number> = {}; + 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<void> { + 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<void> { + 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<number, number> = {}; - - 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} <div class="flex items-center gap-1.5 text-xs text-base-content/70"> <span class="badge badge-xs badge-primary">{formatHour(hour)}:15</span> - <span>Wake scheduled for {formatWakeTime(schedule[hour] ?? 0)}</span> + <span>Reset at {formatAmPm(resetHour(hour))}</span> </div> {/each} </div> |
