summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 00:07:03 +0900
committerAdam Malczewski <[email protected]>2026-05-21 00:07:03 +0900
commit41f681d23491f0ba090afae053f0232743703619 (patch)
treeaf2fdbf8cd0f9d6de9784f1ffb74c2f0522e9d28
parent1f4776e6891348d2dbdcbbf704c0a5901b008ecf (diff)
downloaddispatch-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.ts79
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte143
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>