diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 09:30:22 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 09:30:22 +0900 |
| commit | 79a5b7d820530d7ed1f34cc10b11c140c67b5838 (patch) | |
| tree | 5df98014ec7152090be5ebb7001435eb793b3b54 | |
| parent | 7128c624cf076d698bceb354526cf4cd3dfe5434 (diff) | |
| download | dispatch-79a5b7d820530d7ed1f34cc10b11c140c67b5838.tar.gz dispatch-79a5b7d820530d7ed1f34cc10b11c140c67b5838.zip | |
fix(frontend): ClaudeReset — live currentHour, real Set derived, in-flight tracking, status row
Bugs fixed
- fadedHours was $derived((): Set => {...}) — returned a *function*, not
a Set. blockClass() then called fadedHours() once per of the 24
buttons, defeating Svelte's memoization. Now uses $derived.by(() =>
Set), passed in to blockClass as a value.
- currentHour was $derived(new Date().getHours()) which is computed once
on mount and never updates. After midnight (or any hour boundary) the
'now' ring stayed on the wrong block. Now driven by a nowMs $state
bumped by a 30s setInterval, cleaned up on destroy.
- Rapid double-clicks could land out of order ('last response wins, not
last click'). Now tracks an in-flight Set + per-hour sequence counter;
stale responses are dropped and pending buttons are disabled.
- No feedback on wake success/failure. Snapshot now includes lastWake +
pendingRetry, surfaced as a colored status row.
Cleanups
- resetOffsetHours pulled from the server snapshot (was hardcoded +5).
- fadedHours window is now resetOffsetHours - 1 (was hardcoded 4).
- onclick handler short-circuits when the hour is already pending.
| -rw-r--r-- | packages/frontend/src/lib/components/ClaudeReset.svelte | 201 |
1 files changed, 151 insertions, 50 deletions
diff --git a/packages/frontend/src/lib/components/ClaudeReset.svelte b/packages/frontend/src/lib/components/ClaudeReset.svelte index baddb73..43cd55d 100644 --- a/packages/frontend/src/lib/components/ClaudeReset.svelte +++ b/packages/frontend/src/lib/components/ClaudeReset.svelte @@ -1,8 +1,64 @@ <script lang="ts"> +import { onDestroy } from "svelte"; + 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; + +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 { + schedule: Record<string, number>; + resetOffsetHours?: number; + lastWake?: LastWake | null; + pendingRetry?: PendingRetry | null; +} + // Map of hour (0-23) → scheduled wake timestamp (ms) let schedule = $state<Record<number, number>>({}); +let resetOffsetHours = $state<number>(DEFAULT_RESET_OFFSET_HOURS); +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()); + +/** + * Per-hour sequence numbers. Each toggle bumps the hour's counter; when a + * response comes back we only apply it if it matches the latest counter, + * so rapid double-clicks can't let an older response overwrite a newer one. + */ +const inFlightSeq: Record<number, number> = {}; + +/** Live "now" used for the current-hour ring. Bumped by an interval. */ +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); + +onDestroy(() => { + clearInterval(nowTimer); +}); function formatHour(h: number): string { const display = h % 12; @@ -19,64 +75,73 @@ function nextOccurrenceAt15(hour: number): number { return target.getTime(); } -async function loadFromServer(): Promise<void> { - try { - const res = await fetch(`${apiBase}/models/wake-schedule`); - if (!res.ok) return; - const data = (await res.json()) as { schedule: Record<string, number> }; - const parsed: Record<number, number> = {}; - for (const [k, v] of Object.entries(data.schedule)) { - parsed[Number(k)] = v; - } - schedule = parsed; - } catch { - // Network error — leave schedule empty - } -} - -async function parseScheduleResponse(res: Response): Promise<void> { - const data = (await res.json()) as { schedule: Record<string, number> }; +function applySnapshot(data: ScheduleSnapshot): void { const parsed: Record<number, number> = {}; - for (const [k, v] of Object.entries(data.schedule)) { + for (const [k, v] of Object.entries(data.schedule ?? {})) { parsed[Number(k)] = v; } schedule = parsed; + if (typeof data.resetOffsetHours === "number") { + resetOffsetHours = data.resetOffsetHours; + } + lastWake = data.lastWake ?? null; + pendingRetry = data.pendingRetry ?? null; } -async function toggleOnServer(hour: number, ts: number): Promise<void> { +async function loadFromServer(): Promise<void> { try { - const res = await fetch(`${apiBase}/models/wake-schedule/toggle`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hour, timestamp: ts }), - }); + const res = await fetch(`${apiBase}/models/wake-schedule`); if (!res.ok) return; - await parseScheduleResponse(res); + const data = (await res.json()) as ScheduleSnapshot; + applySnapshot(data); } catch { - // Network error — keep local state + // Network error — leave existing state } } -async function removeFromServer(hour: number): 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; +} + +async function postToggle(hour: number, ts?: number): Promise<void> { + // Bump the per-hour sequence; only the most-recent response wins. + 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 res = await fetch(`${apiBase}/models/wake-schedule/toggle`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hour }), + body: JSON.stringify(body), }); + // Drop stale responses (newer click in flight or completed). + if (inFlightSeq[hour] !== mySeq) return; if (!res.ok) return; - await parseScheduleResponse(res); + const data = (await res.json()) as ScheduleSnapshot; + applySnapshot(data); } catch { - // Network error — keep local state + // Network error — leave local state alone; user can re-toggle. + } finally { + if (inFlightSeq[hour] === mySeq) { + markPending(hour, false); + } } } function toggleHour(hour: number): void { + if (pendingHours.has(hour)) return; if (schedule[hour] !== undefined) { - void removeFromServer(hour); + // Toggle off + void postToggle(hour); } else { - const ts = nextOccurrenceAt15(hour); - void toggleOnServer(hour, ts); + // Toggle on + void postToggle(hour, nextOccurrenceAt15(hour)); } } @@ -84,11 +149,17 @@ $effect(() => { void loadFromServer(); }); -// Compute "faded" hours: the 4 hours after each scheduled block -const fadedHours = $derived((): Set<number> => { +/** + * 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. + */ +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 <= 4; i++) { + for (let i = 1; i <= window; i++) { const faded = (h + i) % 24; if (schedule[faded] === undefined) { result.add(faded); @@ -98,15 +169,22 @@ const fadedHours = $derived((): Set<number> => { return result; }); -const currentHour = $derived(new Date().getHours()); +const currentHour = $derived(new Date(nowMs).getHours()); -function blockClass(hour: number): string { +function blockClass(hour: number, faded: Set<number>): string { const isScheduled = schedule[hour] !== undefined; const isCurrent = hour === currentHour; - const isFaded = fadedHours().has(hour); + const isFaded = faded.has(hour); + const isPending = pendingHours.has(hour); let base = - "flex items-center justify-center rounded cursor-pointer select-none text-[10px] font-mono transition-colors"; + "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 (isScheduled) { base += " bg-primary text-primary-content"; @@ -117,11 +195,7 @@ function blockClass(hour: number): string { } if (isCurrent) { - if (isScheduled) { - base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200"; - } else { - base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200"; - } + base += " ring-2 ring-accent ring-offset-1 ring-offset-base-200"; } return base; @@ -134,7 +208,21 @@ function formatAmPm(hour24: number): string { } function resetHour(wakeHour: number): number { - return (wakeHour + 5) % 24; + 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 scheduledHours = $derived( @@ -158,14 +246,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)} w-[22px] h-[24px]" 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)}:15 AM"> {formatHour(hour)} </button> {/each} </div> <div class="flex gap-0.5"> {#each amRow2 as hour} - <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" 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)}:15 AM"> {formatHour(hour)} </button> {/each} @@ -179,14 +267,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)} w-[22px] h-[24px]" 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)}:15 PM"> {formatHour(hour)} </button> {/each} </div> <div class="flex gap-0.5"> {#each pmRow2 as hour} - <button type="button" class="{blockClass(hour)} w-[22px] h-[24px]" 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)}:15 PM"> {formatHour(hour)} </button> {/each} @@ -207,4 +295,17 @@ const pmRow2 = Array.from({ length: 6 }, (_, i) => i + 18); // 18–23 {:else} <p class="text-xs text-base-content/40 italic">No wake times scheduled. Click a block to schedule.</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> |
