summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:30:22 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:30:22 +0900
commit79a5b7d820530d7ed1f34cc10b11c140c67b5838 (patch)
tree5df98014ec7152090be5ebb7001435eb793b3b54
parent7128c624cf076d698bceb354526cf4cd3dfe5434 (diff)
downloaddispatch-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.svelte201
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>