summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/api/src/routes/models.ts167
-rw-r--r--packages/api/src/wake-scheduler.ts41
-rw-r--r--packages/api/tests/routes.test.ts113
-rw-r--r--packages/core/src/db/index.ts23
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte101
5 files changed, 319 insertions, 126 deletions
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 4fc89eb..5f37a1f 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -23,7 +23,10 @@ import {
import { Hono } from "hono";
import {
CLAUDE_RESET_OFFSET_HOURS,
+ isProbeSlotMinute,
nextDailyAfter,
+ PROBE_SLOT_MINUTES,
+ type ProbeSlotMinute,
recoverScheduleEntry,
} from "../wake-scheduler.js";
@@ -609,8 +612,15 @@ modelsRoutes.post("/wake", async (c) => {
});
// ─── Wake scheduler (runs on backend, survives frontend close) ─
+//
+// A "marked hour" expands to 4 probe slots inside that hour: :00, :15, :30,
+// :45. Each slot is its own (hour, slot_minute) row in `wake_schedule` with
+// its own `next_wake_at`. When multiple slots come due in the same tick we
+// coalesce into a single upstream wake — no point hitting Anthropic 4× in
+// the same 30-second window.
-type WakeSchedule = Record<number, number>; // hour → next wake timestamp (ms)
+/** Schedule: hour (0-23) → slot minute (0/15/30/45) → next fire ms. */
+type WakeSchedule = Record<number, Partial<Record<ProbeSlotMinute, number>>>;
interface PendingRetry {
/** Remaining attempts. Starts at MAX_RETRIES (e.g. 6 → 30 min of retries). */
@@ -631,33 +641,43 @@ const MAX_RETRIES = 6;
const RETRY_INTERVAL_MS = 5 * 60 * 1000;
const TICK_INTERVAL_MS = 30_000;
+function setSlot(schedule: WakeSchedule, hour: number, minute: ProbeSlotMinute, ts: number): void {
+ const hourEntry = schedule[hour] ?? {};
+ hourEntry[minute] = ts;
+ schedule[hour] = hourEntry;
+}
+
+function deleteHour(schedule: WakeSchedule, hour: number): void {
+ delete schedule[hour];
+}
+
+function countSlots(schedule: WakeSchedule): number {
+ let n = 0;
+ for (const slots of Object.values(schedule)) {
+ n += Object.keys(slots).length;
+ }
+ return n;
+}
+
function loadScheduleFromDB(): WakeSchedule {
try {
const db = getDatabase();
- const rows = db.query("SELECT hour, next_wake_at FROM wake_schedule").all() as Array<{
- hour: number;
- next_wake_at: number;
- }>;
+ const rows = db
+ .query("SELECT hour, slot_minute, next_wake_at FROM wake_schedule")
+ .all() as Array<{ hour: number; slot_minute: number; next_wake_at: number }>;
const schedule: WakeSchedule = {};
const now = Date.now();
let needsPersist = false;
+ let anyShouldFire = false;
for (const row of rows) {
+ if (!isProbeSlotMinute(row.slot_minute)) continue; // defensive — schema CHECKs it
const recovered = recoverScheduleEntry(row.next_wake_at, now);
- schedule[row.hour] = recovered.nextWakeAt;
- if (recovered.nextWakeAt !== row.next_wake_at) {
- needsPersist = true;
- }
- if (recovered.shouldFireNow) {
- // Mark the entry as "due immediately" so the next tick fires it.
- // We accomplish this by setting next_wake_at = now; the tick will
- // see ts <= now, fire, and advance via nextDailyAfter().
- schedule[row.hour] = now;
- needsPersist = true;
- }
- }
- if (needsPersist) {
- persistSchedule(schedule);
+ setSlot(schedule, row.hour, row.slot_minute, recovered.nextWakeAt);
+ if (recovered.nextWakeAt !== row.next_wake_at) needsPersist = true;
+ if (recovered.shouldFireNow) anyShouldFire = true;
}
+ if (needsPersist) persistSchedule(schedule);
+ if (anyShouldFire) needsBootFire = true;
return schedule;
} catch {
return {};
@@ -670,16 +690,25 @@ function persistSchedule(scheduleToSave?: WakeSchedule): void {
const data = scheduleToSave ?? wakeSchedule;
db.run("DELETE FROM wake_schedule");
const insert = db.query(
- "INSERT INTO wake_schedule (hour, next_wake_at) VALUES ($hour, $nextWakeAt)",
+ "INSERT INTO wake_schedule (hour, slot_minute, next_wake_at) VALUES ($hour, $slot, $nextWakeAt)",
);
- for (const [hour, nextWakeAt] of Object.entries(data)) {
- insert.run({ $hour: Number(hour), $nextWakeAt: nextWakeAt });
+ for (const [hour, slots] of Object.entries(data)) {
+ for (const [slotMinute, nextWakeAt] of Object.entries(slots)) {
+ if (nextWakeAt === undefined) continue;
+ insert.run({
+ $hour: Number(hour),
+ $slot: Number(slotMinute),
+ $nextWakeAt: nextWakeAt,
+ });
+ }
}
} catch {
// Ignore DB errors — schedule still lives in-memory for this process.
}
}
+/** Set to true by loadScheduleFromDB when one or more slots need a boot fire. */
+let needsBootFire = false;
const wakeSchedule: WakeSchedule = loadScheduleFromDB();
/**
@@ -765,6 +794,27 @@ async function processPendingRetry(now: number): Promise<void> {
}
}
+interface DueSlot {
+ hour: number;
+ minute: ProbeSlotMinute;
+ ts: number;
+}
+
+/** Collect every slot whose next_wake_at is at or before `now`. */
+function collectDueSlots(now: number): DueSlot[] {
+ const due: DueSlot[] = [];
+ for (const [hourStr, slots] of Object.entries(wakeSchedule)) {
+ const hour = Number(hourStr);
+ for (const [slotStr, ts] of Object.entries(slots)) {
+ if (ts === undefined) continue;
+ const slotMinute = Number(slotStr);
+ if (!isProbeSlotMinute(slotMinute)) continue;
+ if (ts <= now) due.push({ hour, minute: slotMinute, ts });
+ }
+ }
+ return due;
+}
+
async function schedulerTick(): Promise<void> {
// Prevent concurrent tick execution (e.g. toggle called mid-tick).
if (isTickRunning) return;
@@ -772,30 +822,35 @@ async function schedulerTick(): Promise<void> {
try {
const now = Date.now();
- // Snapshot hours so we don't iterate while mutating (toggle can race).
- const hours = Object.keys(wakeSchedule).map(Number);
- let anyFiredThisTick = false;
-
- for (const hour of hours) {
- const ts = wakeSchedule[hour];
- if (ts === undefined || ts > now) continue;
-
- // Advance the next fire to strictly > now (skips any missed days,
- // e.g. if the tick was paused for hours).
- wakeSchedule[hour] = nextDailyAfter(ts, now);
+ const due = collectDueSlots(now);
+
+ let firedThisTick = false;
+ if (due.length > 0 || needsBootFire) {
+ needsBootFire = false;
+ // Advance every due slot before firing — so a slow upstream call
+ // can't cause us to re-fire the same slot on the next tick.
+ for (const slot of due) {
+ const next = nextDailyAfter(slot.ts, now);
+ setSlot(wakeSchedule, slot.hour, slot.minute, next);
+ }
persistSchedule();
- anyFiredThisTick = true;
- await fireWake(`scheduled wake at hour ${hour}`);
+
+ const reasonParts = due.map((d) => `${d.hour}:${String(d.minute).padStart(2, "0")}`);
+ const reason =
+ reasonParts.length > 0 ? `scheduled probe(s) ${reasonParts.join(", ")}` : "boot recovery";
+ firedThisTick = true;
+ // COALESCED: one upstream call covers all slots due this tick.
+ await fireWake(reason);
}
// Only attempt a retry on ticks that didn't *just* fire — otherwise we'd
// race the retry against a fresh attempt within the same loop iteration.
- if (!anyFiredThisTick) {
+ if (!firedThisTick) {
await processPendingRetry(Date.now());
}
// Keep ticking while there's anything to monitor.
- if (Object.keys(wakeSchedule).length > 0 || pendingRetry !== null) {
+ if (countSlots(wakeSchedule) > 0 || pendingRetry !== null) {
(globalThis as Record<string, unknown>)[timerKey] = setTimeout(
schedulerTick,
TICK_INTERVAL_MS,
@@ -817,40 +872,56 @@ export function startWakeScheduler(): void {
function scheduleSnapshot(): {
schedule: WakeSchedule;
resetOffsetHours: number;
+ probeSlotMinutes: readonly number[];
lastWake: LastWake | null;
pendingRetry: PendingRetry | null;
} {
return {
schedule: wakeSchedule,
resetOffsetHours: CLAUDE_RESET_OFFSET_HOURS,
+ probeSlotMinutes: PROBE_SLOT_MINUTES,
lastWake,
pendingRetry,
};
}
modelsRoutes.post("/wake-schedule/toggle", async (c) => {
- const body = await c.req.json<{ hour?: number; timestamp?: number }>();
+ const body = await c.req.json<{
+ hour?: unknown;
+ timestamps?: unknown;
+ }>();
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);
}
- // Integer-only; reject 4.7, NaN coercions, etc.
if (!Number.isInteger(hour)) {
return c.json({ error: "hour must be an integer 0-23" }, 400);
}
if (wakeSchedule[hour] !== undefined) {
- // Toggle off — remove the entry.
- delete wakeSchedule[hour];
+ // Toggle off — remove all 4 slots for this hour.
+ deleteHour(wakeSchedule, hour);
} else {
- // Toggle on — require a future absolute timestamp from the client. The
- // client is the source of truth for *local* wall-clock intent; the
- // server just stores the ms and rolls it forward by 24h cycles.
- const ts = body.timestamp;
- if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= Date.now()) {
- return c.json({ error: "timestamp must be a future Unix ms value" }, 400);
+ // Toggle on — require a `timestamps` object with one absolute Unix ms
+ // per probe slot (0, 15, 30, 45). The client is the source of truth
+ // for the *local* wall-clock intent of each probe.
+ const timestamps = body.timestamps;
+ if (timestamps === null || typeof timestamps !== "object") {
+ return c.json(
+ { error: "timestamps must be an object { '0': ms, '15': ms, '30': ms, '45': ms }" },
+ 400,
+ );
+ }
+ const now = Date.now();
+ const parsed: Partial<Record<ProbeSlotMinute, number>> = {};
+ for (const slot of PROBE_SLOT_MINUTES) {
+ const raw = (timestamps as Record<string, unknown>)[String(slot)];
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= now) {
+ return c.json({ error: `timestamps['${slot}'] must be a future Unix ms value` }, 400);
+ }
+ parsed[slot] = raw;
}
- wakeSchedule[hour] = ts;
+ wakeSchedule[hour] = parsed;
}
persistSchedule();
diff --git a/packages/api/src/wake-scheduler.ts b/packages/api/src/wake-scheduler.ts
index 7bd2fad..8953e9f 100644
--- a/packages/api/src/wake-scheduler.ts
+++ b/packages/api/src/wake-scheduler.ts
@@ -5,24 +5,30 @@
*
* Semantics — read this before editing:
*
- * 1. The user picks an hour (0-23) on the frontend. The frontend computes
- * the *first* fire timestamp in **its** local timezone (target HH:15
- * tomorrow, or today if still future) and sends absolute Unix ms to
- * the backend. That absolute ms is the source of truth.
+ * 1. The user marks an hour (0-23) on the frontend. Marking the hour
+ * schedules FOUR probes inside that hour, one per quarter-hour slot
+ * (:00, :15, :30, :45). Each slot is its own persisted row keyed by
+ * (hour, slot_minute). The frontend computes the *first* fire ms for
+ * each slot in **its** local timezone and sends them; that absolute
+ * ms is the source of truth.
*
- * 2. After each successful (or attempted) fire we advance by exactly
+ * 2. After each fire (successful or not) we advance the slot by exactly
* 24h from the previous `next_wake_at`. This preserves the user's
* original local wall-clock intent regardless of the *server*'s
- * timezone (Docker is often UTC, the user is often not). DST can
- * drift the fire by ±1h on transition day; it self-corrects the next
- * time the user toggles the hour.
+ * timezone. DST can drift the fire by ±1h on transition day; it
+ * self-corrects the next time the user toggles the hour.
*
- * 3. On server boot, any persisted entry whose `next_wake_at` is in the
+ * 3. On server boot, any persisted slot whose `next_wake_at` is in the
* past is "recovered": if it was missed by ≤ MISSED_WAKE_GRACE_MS we
- * fire it *now* (signal: `shouldFireNow = true`) and then jump
- * forward 24h at a time until the next slot is in the future. If
- * missed by more than the grace window we silently skip and jump
- * forward without firing. Either way the entry stays scheduled.
+ * fire it on the next tick (signal: `shouldFireNow = true`) and
+ * advance to the next future occurrence. If missed by more than the
+ * grace window we silently skip and advance. Either way the slot
+ * stays scheduled.
+ *
+ * 4. Multiple slots that come due in the same tick (or recover at
+ * boot) coalesce into a SINGLE upstream wake call. Probing four
+ * times in 15 minutes is fine; probing four times within the same
+ * 30s tick is wasteful and pointless.
*/
/** How long after a missed fire we still consider it worth running. */
@@ -34,6 +40,10 @@ export const DAILY_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Fixed offset (hours) from a wake to the "Claude session reset" display. */
export const CLAUDE_RESET_OFFSET_HOURS = 5;
+/** Minute offsets inside a marked hour where a probe fires. */
+export const PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const;
+export type ProbeSlotMinute = (typeof PROBE_SLOT_MINUTES)[number];
+
/**
* Advance `previous` by 24-hour increments until strictly after `now`.
* Pure: only does math on the given numbers.
@@ -80,3 +90,8 @@ export function recoverScheduleEntry(
export function resetHourFor(wakeHour: number): number {
return (wakeHour + CLAUDE_RESET_OFFSET_HOURS) % 24;
}
+
+/** Type guard: is this number a valid probe slot minute? */
+export function isProbeSlotMinute(n: unknown): n is ProbeSlotMinute {
+ return n === 0 || n === 15 || n === 30 || n === 45;
+}
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index e3dff3d..090131c 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -402,8 +402,9 @@ describe("Wake schedule routes", () => {
const res = await app.request("/models/wake-schedule");
expect(res.status).toBe(200);
return (await res.json()) as {
- schedule: Record<string, number>;
+ schedule: Record<string, Record<string, number>>;
resetOffsetHours: number;
+ probeSlotMinutes: number[];
lastWake: unknown;
pendingRetry: unknown;
};
@@ -417,75 +418,135 @@ describe("Wake schedule routes", () => {
});
}
- it("GET returns the full snapshot shape with resetOffsetHours, lastWake, pendingRetry", async () => {
+ /** Build a `timestamps` payload with all 4 probe slots set to absolute ms. */
+ function buildTimestamps(base: number): Record<string, number> {
+ return { "0": base, "15": base + 60_000, "30": base + 120_000, "45": base + 180_000 };
+ }
+
+ it("GET returns the full snapshot shape with probeSlotMinutes, resetOffsetHours, lastWake, pendingRetry", async () => {
const snap = await getSchedule();
expect(snap.schedule).toBeDefined();
- // CLAUDE_RESET_OFFSET_HOURS is currently 5; keep this loose in case the
- // product changes the constant, but verify it's a positive integer.
expect(Number.isInteger(snap.resetOffsetHours)).toBe(true);
expect(snap.resetOffsetHours).toBeGreaterThan(0);
+ expect(snap.probeSlotMinutes).toEqual([0, 15, 30, 45]);
expect(snap.lastWake).toBeNull();
expect(snap.pendingRetry).toBeNull();
});
- it("POST toggle adds and removes a wake hour", async () => {
- const future = Date.now() + 60 * 60 * 1000; // 1 h ahead
+ it("POST toggle adds an hour as 4 probe slots and removes them all together", async () => {
+ const base = Date.now() + 60 * 60 * 1000; // 1 h ahead
+ const timestamps = buildTimestamps(base);
- const addRes = await toggle({ hour: 9, timestamp: future });
+ const addRes = await toggle({ hour: 9, timestamps });
expect(addRes.status).toBe(200);
- const addBody = (await addRes.json()) as { schedule: Record<string, number> };
- expect(addBody.schedule["9"]).toBe(future);
+ const addBody = (await addRes.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
+ expect(addBody.schedule["9"]).toEqual({
+ "0": base,
+ "15": base + 60_000,
+ "30": base + 120_000,
+ "45": base + 180_000,
+ });
const removeRes = await toggle({ hour: 9 });
expect(removeRes.status).toBe(200);
- const removeBody = (await removeRes.json()) as { schedule: Record<string, number> };
+ const removeBody = (await removeRes.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
expect(removeBody.schedule["9"]).toBeUndefined();
});
it("POST toggle rejects out-of-range hour", async () => {
- const res = await toggle({ hour: 24, timestamp: Date.now() + 60_000 });
+ const res = await toggle({
+ hour: 24,
+ timestamps: buildTimestamps(Date.now() + 60_000),
+ });
expect(res.status).toBe(400);
});
it("POST toggle rejects negative hour", async () => {
- const res = await toggle({ hour: -1, timestamp: Date.now() + 60_000 });
+ const res = await toggle({
+ hour: -1,
+ timestamps: buildTimestamps(Date.now() + 60_000),
+ });
expect(res.status).toBe(400);
});
it("POST toggle rejects non-integer hour", async () => {
- const res = await toggle({ hour: 4.5, timestamp: Date.now() + 60_000 });
+ const res = await toggle({
+ hour: 4.5,
+ timestamps: buildTimestamps(Date.now() + 60_000),
+ });
expect(res.status).toBe(400);
});
- it("POST toggle rejects past timestamp on add", async () => {
- const res = await toggle({ hour: 7, timestamp: Date.now() - 1000 });
+ it("POST toggle rejects past timestamp in any slot", async () => {
+ const future = Date.now() + 60_000;
+ const badTimestamps: Record<string, number> = {
+ "0": future,
+ "15": Date.now() - 1000, // past
+ "30": future + 60_000,
+ "45": future + 120_000,
+ };
+ const res = await toggle({ hour: 7, timestamps: badTimestamps });
expect(res.status).toBe(400);
});
- it("POST toggle rejects missing timestamp on add", async () => {
+ it("POST toggle rejects missing timestamps on add", async () => {
const res = await toggle({ hour: 8 });
expect(res.status).toBe(400);
});
- it("POST toggle: a delete does NOT require a timestamp", async () => {
- const future = Date.now() + 60 * 60 * 1000;
- const addRes = await toggle({ hour: 11, timestamp: future });
+ it("POST toggle rejects missing slot in timestamps object", async () => {
+ const res = await toggle({
+ hour: 8,
+ timestamps: { "0": Date.now() + 60_000, "15": Date.now() + 120_000 },
+ });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle rejects non-object timestamps", async () => {
+ const res = await toggle({ hour: 8, timestamps: 12345 });
+ expect(res.status).toBe(400);
+ });
+
+ it("POST toggle: a delete does NOT require timestamps", async () => {
+ const base = Date.now() + 60 * 60 * 1000;
+ const addRes = await toggle({ hour: 11, timestamps: buildTimestamps(base) });
expect(addRes.status).toBe(200);
const delRes = await toggle({ hour: 11 });
expect(delRes.status).toBe(200);
- const body = (await delRes.json()) as { schedule: Record<string, number> };
+ const body = (await delRes.json()) as { schedule: Record<string, unknown> };
expect(body.schedule["11"]).toBeUndefined();
});
- it("snapshot reflects multiple scheduled hours independently", async () => {
- const future = Date.now() + 2 * 60 * 60 * 1000;
- await toggle({ hour: 14, timestamp: future });
- await toggle({ hour: 19, timestamp: future + 60_000 });
+ it("snapshot reflects multiple marked hours independently with all 4 slots each", async () => {
+ const base1 = Date.now() + 2 * 60 * 60 * 1000;
+ const base2 = base1 + 60 * 60 * 1000;
+ await toggle({ hour: 14, timestamps: buildTimestamps(base1) });
+ await toggle({ hour: 19, timestamps: buildTimestamps(base2) });
const snap = await getSchedule();
- expect(snap.schedule["14"]).toBe(future);
- expect(snap.schedule["19"]).toBe(future + 60_000);
+ expect(Object.keys(snap.schedule["14"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
+ expect(Object.keys(snap.schedule["19"] ?? {}).sort()).toEqual(["0", "15", "30", "45"]);
+ expect(snap.schedule["14"]?.["0"]).toBe(base1);
+ expect(snap.schedule["19"]?.["0"]).toBe(base2);
// Cleanup so later tests start clean.
await toggle({ hour: 14 });
await toggle({ hour: 19 });
});
+
+ it("re-toggling the same hour replaces all 4 slot timestamps", async () => {
+ const base1 = Date.now() + 60 * 60 * 1000;
+ const base2 = base1 + 30 * 60 * 1000;
+ await toggle({ hour: 5, timestamps: buildTimestamps(base1) });
+ await toggle({ hour: 5 }); // remove
+ const addRes = await toggle({ hour: 5, timestamps: buildTimestamps(base2) });
+ const body = (await addRes.json()) as {
+ schedule: Record<string, Record<string, number>>;
+ };
+ expect(body.schedule["5"]?.["0"]).toBe(base2);
+ expect(body.schedule["5"]?.["45"]).toBe(base2 + 180_000);
+ await toggle({ hour: 5 });
+ });
});
diff --git a/packages/core/src/db/index.ts b/packages/core/src/db/index.ts
index 29448bc..93ec1f9 100644
--- a/packages/core/src/db/index.ts
+++ b/packages/core/src/db/index.ts
@@ -54,9 +54,28 @@ export function getDatabase(): Database {
updated_at INTEGER NOT NULL
)`);
+ // Wake schedule: 4 rows per marked hour (one per :00 / :15 / :30 / :45 probe
+ // slot). The PK is (hour, slot_minute). Destructive migration off the legacy
+ // single-row-per-hour schema: detect by absence of the `slot_minute` column
+ // and drop the old table. Other tables (credentials, api_keys, usage_cache,
+ // settings, tabs, chunks) are NOT touched.
+ const legacyWakeSchema = (() => {
+ try {
+ const cols = _db.query("PRAGMA table_info(wake_schedule)").all() as Array<{ name: string }>;
+ if (cols.length === 0) return false; // table doesn't exist yet
+ return !cols.some((c) => c.name === "slot_minute");
+ } catch {
+ return false;
+ }
+ })();
+ if (legacyWakeSchema) {
+ _db.run("DROP TABLE IF EXISTS wake_schedule");
+ }
_db.run(`CREATE TABLE IF NOT EXISTS wake_schedule (
- hour INTEGER PRIMARY KEY CHECK (hour BETWEEN 0 AND 23),
- next_wake_at INTEGER NOT NULL
+ hour INTEGER NOT NULL CHECK (hour BETWEEN 0 AND 23),
+ slot_minute INTEGER NOT NULL CHECK (slot_minute IN (0, 15, 30, 45)),
+ next_wake_at INTEGER NOT NULL,
+ PRIMARY KEY (hour, slot_minute)
)`);
_db.run(`CREATE TABLE IF NOT EXISTS usage_cache (
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte
index 43cd55d..14ffa9e 100644
--- a/packages/frontend/src/lib/components/ClaudeReset.svelte
+++ b/packages/frontend/src/lib/components/ClaudeReset.svelte
@@ -6,6 +6,10 @@ const { apiBase = "" }: { apiBase?: string } = $props();
/** Fixed offset (hours) from a wake to the "Claude session reset" display.
* Mirrors the backend constant — kept in sync via the GET response. */
const DEFAULT_RESET_OFFSET_HOURS = 5;
+const DEFAULT_PROBE_SLOT_MINUTES = [0, 15, 30, 45] as const;
+
+type ProbeMinute = number; // 0 | 15 | 30 | 45 in practice
+type HourSlots = Record<ProbeMinute, number>; // slot minute → next fire ms
interface WakeResult {
label: string;
@@ -26,15 +30,18 @@ interface PendingRetry {
}
interface ScheduleSnapshot {
- schedule: Record<string, number>;
+ /** hour (0-23) → { slotMinute → next fire ms }. */
+ schedule: Record<string, Record<string, number>>;
resetOffsetHours?: number;
+ probeSlotMinutes?: number[];
lastWake?: LastWake | null;
pendingRetry?: PendingRetry | null;
}
-// Map of hour (0-23) → scheduled wake timestamp (ms)
-let schedule = $state<Record<number, number>>({});
+// Marked hours: hour → { slotMinute → next fire ms }. Empty inner record = not marked.
+let schedule = $state<Record<number, HourSlots>>({});
let resetOffsetHours = $state<number>(DEFAULT_RESET_OFFSET_HOURS);
+let probeSlotMinutes = $state<readonly number[]>(DEFAULT_PROBE_SLOT_MINUTES);
let lastWake = $state<LastWake | null>(null);
let pendingRetry = $state<PendingRetry | null>(null);
@@ -48,10 +55,9 @@ let pendingHours = $state<Set<number>>(new Set());
*/
const inFlightSeq: Record<number, number> = {};
-/** Live "now" used for the current-hour ring. Bumped by an interval. */
+/** Live "now" used for the current-hour ring + relative timestamps. */
let nowMs = $state<number>(Date.now());
-// Re-derive current hour every minute (cheap; we don't need second-precision).
const nowTimer = setInterval(() => {
nowMs = Date.now();
}, 30_000);
@@ -65,10 +71,13 @@ function formatHour(h: number): string {
return display === 0 ? "12" : String(display);
}
-function nextOccurrenceAt15(hour: number): number {
- const now = new Date();
- const target = new Date(now);
- target.setHours(hour, 15, 0, 0);
+/**
+ * Compute the next occurrence of HH:MM in the user's local timezone.
+ * Today if still future, else tomorrow.
+ */
+function nextOccurrenceAt(hour: number, minute: number): number {
+ const target = new Date();
+ target.setHours(hour, minute, 0, 0);
if (target.getTime() <= Date.now()) {
target.setDate(target.getDate() + 1);
}
@@ -76,14 +85,21 @@ function nextOccurrenceAt15(hour: number): number {
}
function applySnapshot(data: ScheduleSnapshot): void {
- const parsed: Record<number, number> = {};
- for (const [k, v] of Object.entries(data.schedule ?? {})) {
- parsed[Number(k)] = v;
+ const parsed: Record<number, HourSlots> = {};
+ for (const [hourStr, slots] of Object.entries(data.schedule ?? {})) {
+ const inner: HourSlots = {};
+ for (const [slotStr, ts] of Object.entries(slots ?? {})) {
+ inner[Number(slotStr)] = ts;
+ }
+ parsed[Number(hourStr)] = inner;
}
schedule = parsed;
if (typeof data.resetOffsetHours === "number") {
resetOffsetHours = data.resetOffsetHours;
}
+ if (Array.isArray(data.probeSlotMinutes) && data.probeSlotMinutes.length > 0) {
+ probeSlotMinutes = data.probeSlotMinutes;
+ }
lastWake = data.lastWake ?? null;
pendingRetry = data.pendingRetry ?? null;
}
@@ -106,21 +122,23 @@ function markPending(hour: number, isPending: boolean): void {
pendingHours = next;
}
-async function postToggle(hour: number, ts?: number): Promise<void> {
- // Bump the per-hour sequence; only the most-recent response wins.
+async function postToggle(hour: number, timestamps?: Record<number, number>): Promise<void> {
const mySeq = (inFlightSeq[hour] ?? 0) + 1;
inFlightSeq[hour] = mySeq;
markPending(hour, true);
try {
- const body: { hour: number; timestamp?: number } = { hour };
- if (typeof ts === "number") body.timestamp = ts;
+ const body: { hour: number; timestamps?: Record<string, number> } = { hour };
+ if (timestamps) {
+ const stringKeyed: Record<string, number> = {};
+ for (const [k, v] of Object.entries(timestamps)) stringKeyed[k] = v;
+ body.timestamps = stringKeyed;
+ }
const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
- // Drop stale responses (newer click in flight or completed).
if (inFlightSeq[hour] !== mySeq) return;
if (!res.ok) return;
const data = (await res.json()) as ScheduleSnapshot;
@@ -137,11 +155,15 @@ async function postToggle(hour: number, ts?: number): Promise<void> {
function toggleHour(hour: number): void {
if (pendingHours.has(hour)) return;
if (schedule[hour] !== undefined) {
- // Toggle off
+ // Toggle off — backend deletes all 4 slots for this hour.
void postToggle(hour);
} else {
- // Toggle on
- void postToggle(hour, nextOccurrenceAt15(hour));
+ // Toggle on — compute first occurrence of HH:MM for each probe slot.
+ const timestamps: Record<number, number> = {};
+ for (const minute of probeSlotMinutes) {
+ timestamps[minute] = nextOccurrenceAt(hour, minute);
+ }
+ void postToggle(hour, timestamps);
}
}
@@ -151,9 +173,8 @@ $effect(() => {
/**
* Faded hours: the `resetOffsetHours - 1` hours immediately after each
- * scheduled wake (the "active session window"). Excludes hours that have
- * their own scheduled wake. Stored as a real Set, not a getter — the old
- * code accidentally wrapped this in a function and called it 24× per render.
+ * marked hour (the "active session window"). Excludes hours that are
+ * themselves marked.
*/
const fadedHours = $derived.by((): Set<number> => {
const result = new Set<number>();
@@ -172,7 +193,7 @@ const fadedHours = $derived.by((): Set<number> => {
const currentHour = $derived(new Date(nowMs).getHours());
function blockClass(hour: number, faded: Set<number>): string {
- const isScheduled = schedule[hour] !== undefined;
+ const isMarked = schedule[hour] !== undefined;
const isCurrent = hour === currentHour;
const isFaded = faded.has(hour);
const isPending = pendingHours.has(hour);
@@ -186,7 +207,7 @@ function blockClass(hour: number, faded: Set<number>): string {
base += " cursor-pointer";
}
- if (isScheduled) {
+ if (isMarked) {
base += " bg-primary text-primary-content";
} else if (isFaded) {
base += " bg-primary/25 text-base-content";
@@ -225,12 +246,18 @@ function formatRelative(ts: number, now: number): string {
return `${Math.round(diff / 86_400_000)} d ago`;
}
-const scheduledHours = $derived(
+const markedHours = $derived(
Object.keys(schedule)
.map(Number)
- .sort((a, b) => (schedule[a] ?? 0) - (schedule[b] ?? 0)),
+ .sort((a, b) => a - b),
);
+function probeLabel(minute: number): string {
+ return `:${String(minute).padStart(2, "0")}`;
+}
+
+const probeLabels = $derived(probeSlotMinutes.map(probeLabel).join(" "));
+
const amRow1 = Array.from({ length: 6 }, (_, i) => i); // 0–5
const amRow2 = Array.from({ length: 6 }, (_, i) => i + 6); // 6–11
const pmRow1 = Array.from({ length: 6 }, (_, i) => i + 12); // 12–17
@@ -246,14 +273,14 @@ const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
<div class="flex flex-col gap-0.5">
<div class="flex gap-0.5">
{#each amRow1 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 AM">
+ <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
{formatHour(hour)}
</button>
{/each}
</div>
<div class="flex gap-0.5">
{#each amRow2 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 AM">
+ <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
{formatHour(hour)}
</button>
{/each}
@@ -267,14 +294,14 @@ const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
<div class="flex flex-col gap-0.5">
<div class="flex gap-0.5">
{#each pmRow1 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 PM">
+ <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
{formatHour(hour)}
</button>
{/each}
</div>
<div class="flex gap-0.5">
{#each pmRow2 as hour}
- <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)}:15 PM">
+ <button type="button" class="{blockClass(hour, fadedHours)} w-[22px] h-[24px]" disabled={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
{formatHour(hour)}
</button>
{/each}
@@ -282,18 +309,18 @@ const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
</div>
</div>
- <!-- Scheduled summary -->
- {#if scheduledHours.length > 0}
+ <!-- Marked hours summary -->
+ {#if markedHours.length > 0}
<div class="flex flex-col gap-0.5 mt-1">
- {#each scheduledHours as hour}
+ {#each markedHours 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>Reset at {formatAmPm(resetHour(hour))}</span>
+ <span class="badge badge-xs badge-primary">{formatHour(hour)} {hour < 12 ? "AM" : "PM"}</span>
+ <span>Probes {probeLabels} → reset by {formatAmPm(resetHour(hour))}</span>
</div>
{/each}
</div>
{:else}
- <p class="text-xs text-base-content/40 italic">No wake times scheduled. Click a block to schedule.</p>
+ <p class="text-xs text-base-content/40 italic">No wake hours marked. Click a block to probe at {probeLabels} that hour.</p>
{/if}
<!-- Status: last wake / pending retry -->