1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
/**
* Pure helpers for the Claude wake scheduler. Kept side-effect-free so the
* recovery & rescheduling logic can be unit-tested without spinning up the
* Hono app or touching SQLite.
*
* Semantics — read this before editing:
*
* 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 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. 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 slot whose `next_wake_at` is in the
* past is "recovered": if it was missed by ≤ MISSED_WAKE_GRACE_MS we
* 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. */
export const MISSED_WAKE_GRACE_MS = 2 * 60 * 60 * 1000; // 2 hours
/** Day length used when advancing recurring wakes. */
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.
*/
export function nextDailyAfter(previous: number, now: number): number {
if (previous > now) return previous;
const deltaMs = now - previous;
// Ceiling division so the result is strictly > now.
const stepsAhead = Math.floor(deltaMs / DAILY_INTERVAL_MS) + 1;
return previous + stepsAhead * DAILY_INTERVAL_MS;
}
export interface RecoveredEntry {
/** New `next_wake_at` to persist (always strictly in the future). */
nextWakeAt: number;
/** True if the caller should fire a wake *right now* before scheduling. */
shouldFireNow: boolean;
}
/**
* Compute the post-boot state for a single persisted schedule entry.
*
* - Entry still in the future → keep as-is, no fire.
* - Missed by ≤ grace window → fire now, then advance to next day.
* - Missed by > grace window → skip the fire, advance to next day.
*/
export function recoverScheduleEntry(
storedNextWakeAt: number,
now: number,
graceMs: number = MISSED_WAKE_GRACE_MS,
): RecoveredEntry {
if (storedNextWakeAt > now) {
return { nextWakeAt: storedNextWakeAt, shouldFireNow: false };
}
const overdueBy = now - storedNextWakeAt;
const shouldFireNow = overdueBy <= graceMs;
return {
nextWakeAt: nextDailyAfter(storedNextWakeAt, now),
shouldFireNow,
};
}
/** Display hour (0-23) for the "reset" label paired with a wake hour. */
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;
}
|