summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components/ClaudeReset.svelte
blob: eea8744520a9edf66fd43a8d0c41094de901bd8d (plain)
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
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
<script lang="ts">
import { onDestroy } from "svelte";
import { SnapshotSequencer } from "../snapshot-sequencer.js";

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;
	ok: boolean;
	error?: string;
}

interface LastWake {
	firedAt: number;
	ok: boolean;
	results: WakeResult[];
}

interface PendingRetry {
	retriesLeft: number;
	nextRetryAt: number;
	reason: string;
}

interface ScheduleSnapshot {
	/** hour (0-23) → { slotMinute → next fire ms }. */
	schedule: Record<string, Record<string, number>>;
	resetOffsetHours?: number;
	probeSlotMinutes?: number[];
	lastWake?: LastWake | null;
	pendingRetry?: PendingRetry | null;
}

// 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);

/**
 * Global mutation lock: the hour whose toggle POST is currently in flight,
 * or null if none. Disables ALL toggle buttons (not just this hour's) while
 * any mutation is pending.
 *
 * Why global, not per-hour: snapshot responses can be reordered on the wire,
 * but worse, requests themselves can be reordered. If two POSTs are in
 * flight and the SERVER processes them out of order, the snapshot the
 * SnapshotSequencer picks as "winner" (highest client-send seq) may not be
 * the snapshot reflecting the truest server state — the UI desyncs from
 * the server permanently. Serializing mutations on the client eliminates
 * the reorder window entirely. (The sequencer is still useful for the
 * GET-on-mount vs first-click race.)
 */
let pendingHour = $state<number | null>(null);

/**
 * Single global sequencer for ALL /models/wake-schedule responses (initial
 * GET + every toggle POST). Each response is dropped if a newer request has
 * already won. This protects against three races:
 *   1. Two toggles on different hours land out of order — older snapshot
 *      blindly overwrites the newer one, and the most-recent click vanishes
 *      from the UI.
 *   2. The initial loadFromServer is still in flight when the user clicks.
 *   3. Any future fan-out (e.g. polling) racing a user action.
 * A per-hour counter was insufficient because applySnapshot replaces the
 * whole `schedule` object, not just one hour's slot. See snapshot-sequencer.ts.
 */
const sequencer = new SnapshotSequencer();

/** Live "now" used for the current-hour ring + relative timestamps. */
let nowMs = $state<number>(Date.now());

const nowTimer = setInterval(() => {
	nowMs = Date.now();
}, 30_000);

onDestroy(() => {
	clearInterval(nowTimer);
});

function formatHour(h: number): string {
	const display = h % 12;
	return display === 0 ? "12" : String(display);
}

/**
 * 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);
	}
	return target.getTime();
}

function applySnapshot(data: ScheduleSnapshot): void {
	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;
}

async function loadFromServer(): Promise<void> {
	const mySeq = sequencer.begin();
	try {
		const res = await fetch(`${apiBase}/models/wake-schedule`);
		if (!res.ok) return;
		const data = (await res.json()) as ScheduleSnapshot;
		if (!sequencer.accept(mySeq)) return; // a newer response already won
		applySnapshot(data);
	} catch {
		// Network error — leave existing state
	}
}

function setPending(hour: number | null): void {
	pendingHour = hour;
}

async function postToggle(
	hour: number,
	action: "on" | "off",
	timestamps?: Record<number, number>,
): Promise<void> {
	const mySeq = sequencer.begin();
	setPending(hour);

	try {
		const body: {
			hour: number;
			action: "on" | "off";
			timestamps?: Record<string, number>;
		} = { hour, action };
		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),
		});
		if (!res.ok) return;
		const data = (await res.json()) as ScheduleSnapshot;
		// Drop stale snapshots — only the most-recent request wins for ALL
		// shared state (schedule, lastWake, pendingRetry). Even with the
		// global mutation lock, this still catches the GET-on-mount vs
		// first-click race.
		if (!sequencer.accept(mySeq)) return;
		applySnapshot(data);
	} catch {
		// Network error — leave local state alone; user can re-toggle.
	} finally {
		setPending(null);
	}
}

function toggleHour(hour: number): void {
	// Global lock: any pending mutation on ANY hour blocks new clicks. This
	// serializes POSTs on the wire so the server never has to choose between
	// two concurrent requests — eliminating the request-reorder failure mode
	// where the sequencer's "highest client seq wins" rule would discard the
	// snapshot reflecting the true server state.
	if (pendingHour !== null) return;
	if (schedule[hour] !== undefined) {
		// User intent: turn this hour OFF.
		void postToggle(hour, "off");
	} else {
		// User intent: turn this hour ON — compute first occurrence of HH:MM
		// for each probe slot, in the user's local timezone.
		const timestamps: Record<number, number> = {};
		for (const minute of probeSlotMinutes) {
			timestamps[minute] = nextOccurrenceAt(hour, minute);
		}
		void postToggle(hour, "on", timestamps);
	}
}

$effect(() => {
	void loadFromServer();
});

/**
 * Faded hours: the `resetOffsetHours - 1` hours immediately after each
 * marked hour (the "active session window"). Excludes hours that are
 * themselves marked.
 */
const fadedHours = $derived.by((): Set<number> => {
	const result = new Set<number>();
	const window = Math.max(0, resetOffsetHours - 1);
	for (const h of Object.keys(schedule).map(Number)) {
		for (let i = 1; i <= window; i++) {
			const faded = (h + i) % 24;
			if (schedule[faded] === undefined) {
				result.add(faded);
			}
		}
	}
	return result;
});

const currentHour = $derived(new Date(nowMs).getHours());

function blockClass(hour: number, faded: Set<number>): string {
	const isMarked = schedule[hour] !== undefined;
	const isCurrent = hour === currentHour;
	const isFaded = faded.has(hour);
	// Only the hour whose request is in flight shows the "wait" cursor;
	// other buttons are merely disabled (via the template `disabled={...}`).
	const isPending = pendingHour === hour;

	let base =
		"flex items-center justify-center rounded select-none text-[10px] font-mono transition-colors";

	if (isPending) {
		base += " opacity-60 cursor-wait";
	} else {
		base += " cursor-pointer";
	}

	if (isMarked) {
		base += " bg-primary text-primary-content";
	} else if (isFaded) {
		base += " bg-primary/25 text-base-content";
	} else {
		base += " bg-base-300 text-base-content/60 hover:bg-base-content/10";
	}

	if (isCurrent) {
		base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200";
	}

	return base;
}

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 + resetOffsetHours) % 24;
}

function formatRelative(ts: number, now: number): string {
	const diff = now - ts;
	if (diff < 0) {
		const ahead = -diff;
		if (ahead < 60_000) return "in <1 min";
		if (ahead < 3600_000) return `in ${Math.round(ahead / 60_000)} min`;
		return `in ${Math.round(ahead / 3600_000)} h`;
	}
	if (diff < 60_000) return "just now";
	if (diff < 3600_000) return `${Math.round(diff / 60_000)} min ago`;
	if (diff < 86_400_000) return `${Math.round(diff / 3600_000)} h ago`;
	return `${Math.round(diff / 86_400_000)} d ago`;
}

const markedHours = $derived(
	Object.keys(schedule)
		.map(Number)
		.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
const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23
</script>

<div class="flex flex-col gap-2">
	<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Claude Wake Schedule</div>

	<!-- AM rows -->
	<div class="flex items-center gap-1">
		<span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">AM</span>
		<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={pendingHour !== null} 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={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
						{formatHour(hour)}
					</button>
				{/each}
			</div>
		</div>
	</div>

	<!-- PM rows -->
	<div class="flex items-center gap-1">
		<span class="text-[10px] font-semibold text-base-content/40 w-5 shrink-0">PM</span>
		<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={pendingHour !== null} 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={pendingHour !== null} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
						{formatHour(hour)}
					</button>
				{/each}
			</div>
		</div>
	</div>

	<!-- Marked hours summary -->
	{#if markedHours.length > 0}
		<div class="flex flex-col gap-0.5 mt-1">
			{#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 whitespace-nowrap shrink-0">{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 hours marked. Click a block to probe at {probeLabels} that hour.</p>
	{/if}

	<!-- Status: last wake / pending retry -->
	{#if lastWake}
		<div class="flex items-center gap-1.5 text-xs mt-1" class:text-success={lastWake.ok} class:text-error={!lastWake.ok}>
			<span class="font-semibold">{lastWake.ok ? "✓" : "✗"}</span>
			<span>Last wake {formatRelative(lastWake.firedAt, nowMs)}{lastWake.ok ? "" : ` — ${lastWake.results.find((r) => !r.ok)?.error ?? "failed"}`}</span>
		</div>
	{/if}
	{#if pendingRetry}
		<div class="text-xs text-warning">
			Retrying ({pendingRetry.retriesLeft} left, next {formatRelative(pendingRetry.nextRetryAt, nowMs)})
		</div>
	{/if}
</div>