diff options
| -rw-r--r-- | packages/api/src/routes/models.ts | 167 | ||||
| -rw-r--r-- | packages/api/src/wake-scheduler.ts | 41 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 113 | ||||
| -rw-r--r-- | packages/core/src/db/index.ts | 23 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ClaudeReset.svelte | 101 |
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 --> |
