summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/frontend/src/lib/components/ClaudeReset.svelte72
1 files changed, 50 insertions, 22 deletions
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte
index 1bd0f55..fd71a10 100644
--- a/packages/frontend/src/lib/components/ClaudeReset.svelte
+++ b/packages/frontend/src/lib/components/ClaudeReset.svelte
@@ -46,8 +46,21 @@ let probeSlotMinutes = $state<readonly number[]>(DEFAULT_PROBE_SLOT_MINUTES);
let lastWake = $state<LastWake | null>(null);
let pendingRetry = $state<PendingRetry | null>(null);
-/** Hours with an in-flight toggle request — disables their buttons. */
-let pendingHours = $state<Set<number>>(new Set());
+/**
+ * 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
@@ -125,19 +138,24 @@ async function loadFromServer(): Promise<void> {
}
}
-function markPending(hour: number, isPending: boolean): void {
- const next = new Set(pendingHours);
- if (isPending) next.add(hour);
- else next.delete(hour);
- pendingHours = next;
+function setPending(hour: number | null): void {
+ pendingHour = hour;
}
-async function postToggle(hour: number, timestamps?: Record<number, number>): Promise<void> {
+async function postToggle(
+ hour: number,
+ action: "on" | "off",
+ timestamps?: Record<number, number>,
+): Promise<void> {
const mySeq = sequencer.begin();
- markPending(hour, true);
+ setPending(hour);
try {
- const body: { hour: number; timestamps?: Record<string, number> } = { hour };
+ 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;
@@ -151,28 +169,36 @@ async function postToggle(hour: number, timestamps?: Record<number, number>): Pr
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).
+ // 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 {
- markPending(hour, false);
+ setPending(null);
}
}
function toggleHour(hour: number): void {
- if (pendingHours.has(hour)) return;
+ // 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) {
- // Toggle off — backend deletes all 4 slots for this hour.
- void postToggle(hour);
+ // User intent: turn this hour OFF.
+ void postToggle(hour, "off");
} else {
- // Toggle on — compute first occurrence of HH:MM for each probe slot.
+ // 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, timestamps);
+ void postToggle(hour, "on", timestamps);
}
}
@@ -205,7 +231,9 @@ function blockClass(hour: number, faded: Set<number>): string {
const isMarked = schedule[hour] !== undefined;
const isCurrent = hour === currentHour;
const isFaded = faded.has(hour);
- const isPending = pendingHours.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";
@@ -282,14 +310,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)} AM — probes at {probeLabels}">
+ <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={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)} AM — probes at {probeLabels}">
+ <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}
@@ -303,14 +331,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)} PM — probes at {probeLabels}">
+ <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={pendingHours.has(hour)} onclick={() => toggleHour(hour)} title="{formatHour(hour)} PM — probes at {probeLabels}">
+ <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}