summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 00:03:21 +0900
committerAdam Malczewski <[email protected]>2026-06-27 00:03:21 +0900
commit80f99665034a0e510300793205c162fc7a46769f (patch)
treefcff9e54903d107449b29b77a0ac6f70094e100b
parent121fd7280bc3f77c61da7025cca06480798038b7 (diff)
downloaddispatch-web-80f99665034a0e510300793205c162fc7a46769f.tar.gz
dispatch-web-80f99665034a0e510300793205c162fc7a46769f.zip
feat(heartbeat): show next heartbeat timer + backend handoff for timestamp endpoint
-rw-r--r--backend-handoff.md77
-rw-r--r--src/app/App.svelte6
-rw-r--r--src/app/store.svelte.ts39
-rw-r--r--src/features/heartbeat/index.ts5
-rw-r--r--src/features/heartbeat/logic/types.ts14
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts71
-rw-r--r--src/features/heartbeat/logic/view-model.ts54
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte134
8 files changed, 365 insertions, 35 deletions
diff --git a/backend-handoff.md b/backend-handoff.md
index faf429d..ffc5b45 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,10 +5,10 @@
> **From:** dispatch-web orchestrator · **To:** `../dispatch-backend` orchestrator · **Courier:** the user.
> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here.
-_Last updated: 2026-06-26 (§2h ADDED — Heartbeat system-prompt default + reset button: the heartbeat `systemPrompt` is
-now an override (empty = inherit the GLOBAL system prompt); FE pre-fills + resets to the default; opens 1 backend ask
-CR-HB-2: resolve an empty heartbeat `systemPrompt` to the global system prompt at run time (composes with CR-HB-1).
-typecheck 0/0, 849 tests green, biome clean, build OK. §2g/§2f unchanged.)_
+_Last updated: 2026-06-26 (§2i ADDED — Heartbeat next-run countdown timer: FE shows a live "Next run in Xm Ys" countdown
+from a 1s clock; opens 1 backend ask CR-HB-3: new `GET /workspaces/:id/heartbeat/next-run` → `{ nextRunAt: ISO|null }`.
+FE falls back to an approximation (latest run + interval) until the endpoint ships. typecheck 0/0, 865 tests green, biome
+clean, build OK. §2h/§2g/§2f unchanged.)_
**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9**
(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). The SSH-divergence
(§2d) is RESOLVED.
@@ -647,6 +647,75 @@ empty heartbeat `systemPrompt` would run with no system prompt — the FE flags
---
+## 2i. Heartbeat next-run countdown timer → **FE BUILT; 1 BACKEND ASK (CR-HB-3)**
+
+A follow-up to §2f/§2g/§2h (branch `feature/heartbeat`). The Heartbeat sidebar view now shows a live countdown to the
+next scheduled run ("Next run in 4m 32s") beneath the Enabled/Disabled status. The FE computes it from a server-
+authoritative next-run timestamp + a 1s ticking clock.
+
+### CR-HB-3 — `GET /workspaces/:id/heartbeat/next-run` → **ASK (new endpoint)**
+
+**New endpoint:**
+```
+GET /workspaces/:id/heartbeat/next-run
+```
+**Response shape:**
+```json
+{ "nextRunAt": "2026-06-25T14:05:00Z" }
+```
+or `null` when the heartbeat is **disabled** or **no run is scheduled**:
+```json
+{ "nextRunAt": null }
+```
+
+**Semantics:**
+- `nextRunAt` is the server-authoritative timestamp of the NEXT scheduled heartbeat run (the moment the scheduler will
+ fire it), as an ISO 8601 string. It is DERIVED server-side from the scheduler state (the last run's start + the
+ configured `intervalMinutes`, or the moment `enabled` was toggled on + `intervalMinutes` for the first run) — the FE
+ cannot derive it accurately (it doesn't know when the last run fired relative to "now" + scheduling jitter, paused-
+ while-running, etc.).
+- `null` when the heartbeat is disabled, or when no run is currently scheduled (e.g. a run is in flight and the next
+ hasn't been queued yet — the FE then shows no countdown, not a fabricated one).
+- Recompute on each call (a cheap read of the scheduler's next-fire time). The FE polls it on the same 4s cadence as
+ the runs list, so a run completing (→ next run scheduled) reflects within ~4s.
+
+**Why a dedicated endpoint (not a field on the config/runs response):** the user explicitly asked for "a timestamp
+endpoint." It's also the most efficient for polling (a lightweight read of just the next-fire time, vs. re-fetching the
+full config or runs). The FE polls it alongside the runs list every 4s.
+
+**No wire/transport-contract/ui-contract change** — the response is a plain JSON object (the heartbeat API is a plain
+REST surface, not a transport-contract type; the FE owns the `HeartbeatNextRunResult` type locally in
+`src/features/heartbeat/logic/types.ts` and coerces the untyped body at the network seam).
+
+### FE behavior (this slice) — works BEFORE the backend ships the endpoint
+
+- The store's `heartbeatNextRun()` calls `GET /workspaces/:id/heartbeat/next-run`; on **404/error** it returns
+ `ok: false` (non-fatal). The FE then sets a `nextRunEndpointFailed` flag and **stops polling the endpoint** (no 404
+ spam) and falls back to an APPROXIMATION: the latest run's `triggeredAt` + the configured `intervalMinutes`
+ (`approximateNextRunEpoch`, pure + tested). So the countdown shows (approximate) immediately, and becomes ACCURATE
+ once the backend ships CR-HB-3 (the FE prefers the server value when available).
+- A 1s ticking clock (`now` state) recomputes the countdown locally from `effectiveNextRun` (server value, else the
+ approximation) — no per-second network churn. Pure `formatCountdown(remainingMs)` → "4m 32s" / "32s" / "1h 05m" /
+ "due" (≤0) / "—" (unknown).
+- The countdown only renders when the heartbeat is **enabled** AND a next-run time is known (`effectiveNextRun !==
+ null`); disabled → no countdown (just "Disabled").
+- Edge: when the countdown reaches "due" (≤0), a run should be firing; the next 4s poll refreshes `nextRunAt` to the
+ newly-scheduled run. The approximation similarly refreshes when a new run appears in the runs list.
+
+### FE summary (this slice)
+- `src/features/heartbeat/logic/types.ts`: `HeartbeatNextRunResult` + `LoadHeartbeatNextRun` port.
+- `src/features/heartbeat/logic/view-model.ts`: pure `nextRunEpoch` (parse ISO), `formatCountdown`, `approximateNextRunEpoch` (+12 tests).
+- `src/app/store.svelte.ts`: `heartbeatNextRun()` (GET `.../heartbeat/next-run`; graceful 404 → `ok:false`).
+- `src/features/heartbeat/ui/HeartbeatView.svelte`: polls `nextRun` (stop-on-fail + fallback), 1s countdown clock, "Next run in …" under the status.
+- `src/app/App.svelte`: `loadHeartbeatNextRun` adapter → `HeartbeatView`.
+
+**Verification:** typecheck 0/0, 865 tests green (+12 next-run helpers), biome clean, build OK. The accurate countdown
+can only be confirmed against a running backend with CR-HB-3 shipped (enable the heartbeat, watch "Next run in …" tick
+down, confirm it matches when a run actually fires). Until CR-HB-3 ships, the FE shows the APPROXIMATE countdown
+(latest run + interval) — flagged as the known gap.
+
+---
+
## 3. Likely NEXT backend asks (heads-up, not yet requested)
- **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 7ad8733..09be947 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -64,6 +64,7 @@
manifest as heartbeatManifest,
RunModal,
type HeartbeatConfigResult,
+ type HeartbeatNextRunResult,
type HeartbeatRunView,
type HeartbeatRunsResult,
type HeartbeatStopResult,
@@ -399,6 +400,10 @@
return store.stopHeartbeatRun(runId);
}
+ async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ return store.heartbeatNextRun();
+ }
+
// Run-chat modal: open a live watch on the run's conversation (the store owns
// the ChatStore + the `chat.subscribe` stream), and tear it down on close.
function openRunChat(conversationId: string): ChatStore {
@@ -682,6 +687,7 @@
stopRun={stopHeartbeatRun}
loadVariables={loadSystemPromptVariablesPrompt}
loadDefaultPrompt={loadSystemPromptPrompt}
+ loadNextRun={loadHeartbeatNextRun}
onOpenRun={(run) => (heartbeatRun = run)}
/>
{/if}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 1ad0c08..0e2d112 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -59,6 +59,7 @@ import type {
HeartbeatConfig,
HeartbeatConfigPatch,
HeartbeatConfigResult,
+ HeartbeatNextRunResult,
HeartbeatRun,
HeartbeatRunsResult,
HeartbeatStopResult,
@@ -341,6 +342,15 @@ export interface AppStore {
*/
stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>;
/**
+ * Fetch the server-authoritative next-run timestamp
+ * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run
+ * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows
+ * a live countdown from this. When the endpoint is absent (404 — backend
+ * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to
+ * an approximation from the runs + config.
+ */
+ heartbeatNextRun(): Promise<HeartbeatNextRunResult>;
+ /**
* Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
* modal): ensures a live {@link ChatStore} for the conversation, subscribing
* to its turn stream (`chat.subscribe`) + loading history. Reuses the open
@@ -1649,6 +1659,35 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async heartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`,
+ );
+ if (!res.ok) {
+ // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to
+ // an approximation. Surface as ok:false (non-fatal).
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null;
+ // `null` (disabled / no run scheduled) passes through; anything non-string
+ // also becomes null so a malformed body can't crash the countdown.
+ const raw = data?.nextRunAt;
+ const nextRunAt = typeof raw === "string" ? raw : null;
+ return { ok: true, nextRunAt };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat next-run request failed",
+ };
+ }
+ },
+
watchConversation(conversationId: string): ChatStore {
return watchConversation(conversationId);
},
diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts
index 0789ea6..fd1cf8d 100644
--- a/src/features/heartbeat/index.ts
+++ b/src/features/heartbeat/index.ts
@@ -2,27 +2,32 @@ export type {
HeartbeatConfig,
HeartbeatConfigPatch,
HeartbeatConfigResult,
+ HeartbeatNextRunResult,
HeartbeatRun,
HeartbeatRunStatus,
HeartbeatRunsResult,
HeartbeatStopResult,
LoadHeartbeatConfig,
+ LoadHeartbeatNextRun,
LoadHeartbeatRuns,
SaveHeartbeatConfig,
StopHeartbeatRun,
} from "./logic/types";
export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-model";
export {
+ approximateNextRunEpoch,
badgeForStatus,
DEFAULT_INTERVAL_MINUTES,
effectiveSystemPrompt,
effortOptions,
emptyForm,
+ formatCountdown,
formatRunTime,
formDiffers,
formFromConfig,
isInheritingSystemPrompt,
joinInterval,
+ nextRunEpoch,
normalizeHeartbeatConfig,
normalizeHeartbeatRuns,
normalizeInterval,
diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts
index b3871c9..ede22a6 100644
--- a/src/features/heartbeat/logic/types.ts
+++ b/src/features/heartbeat/logic/types.ts
@@ -87,3 +87,17 @@ export type SaveHeartbeatConfig = (
) => Promise<HeartbeatConfigResult | null>;
export type LoadHeartbeatRuns = () => Promise<HeartbeatRunsResult | null>;
export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult | null>;
+
+/**
+ * Outcome of `GET /workspaces/:id/heartbeat/next-run` — the server-authoritative
+ * timestamp of the next scheduled heartbeat run (ISO 8601 string), or `null`
+ * when the heartbeat is disabled or no run is scheduled. The FE computes a live
+ * countdown from this + a 1s clock (see `formatCountdown`). When the endpoint is
+ * unavailable (404 — backend hasn't shipped it yet), the FE falls back to an
+ * approximation from the runs + config (see `approximateNextRunEpoch`).
+ */
+export type HeartbeatNextRunResult =
+ | { readonly ok: true; readonly nextRunAt: string | null }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadHeartbeatNextRun = () => Promise<HeartbeatNextRunResult | null>;
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
index 63bbf16..d237817 100644
--- a/src/features/heartbeat/logic/view-model.test.ts
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -2,16 +2,19 @@ import type { ReasoningEffort } from "@dispatch/transport-contract";
import { describe, expect, it } from "vitest";
import type { HeartbeatConfig, HeartbeatRun } from "./types";
import {
+ approximateNextRunEpoch,
badgeForStatus,
DEFAULT_INTERVAL_MINUTES,
effectiveSystemPrompt,
effortOptions,
emptyForm,
+ formatCountdown,
formatRunTime,
formDiffers,
formFromConfig,
isInheritingSystemPrompt,
joinInterval,
+ nextRunEpoch,
normalizeHeartbeatConfig,
normalizeHeartbeatRuns,
normalizeInterval,
@@ -397,3 +400,71 @@ describe("normalizeHeartbeatRuns", () => {
expect(runs[1]?.id).toBe("r4");
});
});
+
+describe("next-run countdown", () => {
+ const ISO_AT = "2026-06-25T14:05:00Z"; // 5 min past the hour
+
+ describe("nextRunEpoch", () => {
+ it("parses an ISO timestamp to epoch-ms", () => {
+ expect(nextRunEpoch(ISO_AT)).toBe(Date.parse(ISO_AT));
+ });
+ it("returns null for unparseable / empty / non-string", () => {
+ expect(nextRunEpoch("not-a-date")).toBeNull();
+ expect(nextRunEpoch("")).toBeNull();
+ expect(nextRunEpoch(null)).toBeNull();
+ expect(nextRunEpoch(undefined)).toBeNull();
+ });
+ });
+
+ describe("formatCountdown", () => {
+ it("null → —", () => {
+ expect(formatCountdown(null)).toBe("—");
+ });
+ it("≤ 0 → due", () => {
+ expect(formatCountdown(0)).toBe("due");
+ expect(formatCountdown(-5000)).toBe("due");
+ });
+ it("seconds only (< 1m)", () => {
+ expect(formatCountdown(32_000)).toBe("32s");
+ expect(formatCountdown(1_000)).toBe("1s");
+ });
+ it("minutes + seconds (1m–1h)", () => {
+ expect(formatCountdown(4 * 60_000 + 32_000)).toBe("4m 32s");
+ expect(formatCountdown(59 * 60_000 + 5_000)).toBe("59m 05s");
+ });
+ it("hours + minutes (≥ 1h)", () => {
+ expect(formatCountdown(3_600_000 + 5 * 60_000)).toBe("1h 05m");
+ expect(formatCountdown(2 * 3_600_000 + 30 * 60_000)).toBe("2h 30m");
+ });
+ });
+
+ describe("approximateNextRunEpoch", () => {
+ const runs = (times: string[]): HeartbeatRun[] =>
+ times.map((t, i) => ({
+ id: `r${i}`,
+ conversationId: "c",
+ triggeredAt: t,
+ status: "completed",
+ }));
+
+ it("disabled → null", () => {
+ expect(approximateNextRunEpoch(runs([ISO_AT]), 15, false)).toBeNull();
+ });
+ it("no runs → null (no fabricated countdown)", () => {
+ expect(approximateNextRunEpoch([], 15, true)).toBeNull();
+ });
+ it("latest run + interval (minutes)", () => {
+ // latest is the max triggeredAt (runs need not be ordered)
+ const unordered = runs(["2026-06-25T13:00:00Z", "2026-06-25T13:50:00Z"]);
+ // 13:50 + 15 min = 14:05
+ expect(approximateNextRunEpoch(unordered, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("ignores unparseable triggeredAt values", () => {
+ const mixed = runs(["not-a-date", "2026-06-25T13:50:00Z"]);
+ expect(approximateNextRunEpoch(mixed, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("all-unparseable → null", () => {
+ expect(approximateNextRunEpoch(runs(["nope", "also-nope"]), 15, true)).toBeNull();
+ });
+ });
+});
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
index 9f25eb9..93c0e55 100644
--- a/src/features/heartbeat/logic/view-model.ts
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -152,6 +152,60 @@ function dateLabel(epochMs: number): string {
return `${month} ${day}, ${hh}:${mm}`;
}
+// ── Next-run countdown (timer of when the next heartbeat fires) ───────────────
+//
+// The authoritative next-run time comes from the backend
+// (`GET /workspaces/:id/heartbeat/next-run` → `nextRunAt` ISO string); the FE
+// computes a live countdown from it + a 1s clock. When that endpoint is absent,
+// the FE falls back to an approximation (`approximateNextRunEpoch`) from the
+// latest run + the configured interval.
+
+/** Parse an ISO timestamp to epoch-ms, or null if unparseable. */
+export function nextRunEpoch(iso: string | null | undefined): number | null {
+ if (typeof iso !== "string" || iso.length === 0) return null;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
+}
+
+/**
+ * Format a remaining-ms delta as a short countdown: "4m 32s", "32s", "1h 05m",
+ * "due" (≤ 0), or "—" (unknown/null). Pure via the injected `remainingMs`.
+ */
+export function formatCountdown(remainingMs: number | null): string {
+ if (remainingMs === null) return "—";
+ if (remainingMs <= 0) return "due";
+ const totalSec = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSec / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const secs = totalSec % 60;
+ if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
+ if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
+ return `${secs}s`;
+}
+
+/**
+ * Approximate the next-run epoch-ms when the backend's `next-run` endpoint is
+ * unavailable: the LATEST run's `triggeredAt` + `intervalMinutes` (only when the
+ * heartbeat is enabled AND at least one run exists). Returns null otherwise (the
+ * FE then shows no countdown — never a fabricated one). The latest run is the
+ * max `triggeredAt` (runs need not be ordered). Pure (no `now` needed — the next
+ * run is latest + interval, independent of the current time).
+ */
+export function approximateNextRunEpoch(
+ runs: readonly HeartbeatRun[],
+ intervalMinutes: number,
+ enabled: boolean,
+): number | null {
+ if (!enabled) return null;
+ let latest: number | null = null;
+ for (const r of runs) {
+ const t = Date.parse(r.triggeredAt);
+ if (!Number.isNaN(t) && (latest === null || t > latest)) latest = t;
+ }
+ if (latest === null) return null;
+ return latest + intervalMinutes * 60_000;
+}
+
// ── Config form ───────────────────────────────────────────────────────────────
/**
diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte
index d2a7e96..5f262f8 100644
--- a/src/features/heartbeat/ui/HeartbeatView.svelte
+++ b/src/features/heartbeat/ui/HeartbeatView.svelte
@@ -3,19 +3,25 @@
import type { ReasoningEffort } from "@dispatch/transport-contract";
import { isReasoningEffort } from "../../chat/reasoning-effort";
import {
+ approximateNextRunEpoch,
badgeForStatus,
type Badge,
emptyForm,
effortOptions,
+ formatCountdown,
formDiffers,
formFromConfig,
+ joinInterval,
+ nextRunEpoch,
patchFromForm,
viewRuns,
type HeartbeatFormState,
type HeartbeatRunView,
} from "../logic/view-model";
import type {
+ HeartbeatRun,
LoadHeartbeatConfig,
+ LoadHeartbeatNextRun,
LoadHeartbeatRuns,
SaveHeartbeatConfig,
StopHeartbeatRun,
@@ -34,6 +40,7 @@
stopRun,
loadVariables,
loadDefaultPrompt,
+ loadNextRun,
onOpenRun,
}: {
/** The available model names (for the config's model dropdown). */
@@ -47,6 +54,8 @@
/** Load the global system prompt — the default the heartbeat inherits when
* its `systemPrompt` is empty (the workspace's regular prompt). */
loadDefaultPrompt: LoadSystemPrompt;
+ /** Load the server-authoritative next-run timestamp (the countdown source). */
+ loadNextRun: LoadHeartbeatNextRun;
/** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */
onOpenRun: (run: HeartbeatRunView) => void;
} = $props();
@@ -133,6 +142,9 @@
// ── Runs list (polls while mounted) ───────────────────────────────────────
let runs = $state<readonly HeartbeatRunView[]>([]);
+ /** The raw backend runs (carry `triggeredAt`), kept for the next-run
+ * approximation fallback (the view drops `triggeredAt` for display labels). */
+ let rawRuns = $state<readonly HeartbeatRun[]>([]);
/** True after the first successful load (gates the "No runs yet" empty state
* WITHOUT flashing it before the initial fetch resolves). The per-poll
* loading is intentionally INVISIBLE — it's near-instant and a visible
@@ -145,6 +157,40 @@
/** Re-entrancy guard for background polling (no UI — prevents overlapping fetches). */
let refreshInFlight = false;
+ // ── Next-run countdown ───────────────────────────────────────────────────
+ /** Epoch-ms of the next scheduled run, or null (no countdown shown). Sourced
+ * from the backend's `next-run` endpoint; falls back to an approximation
+ * (latest run + interval) when the endpoint is unavailable (404 — pre-CR-HB-3). */
+ let nextRunAt = $state<number | null>(null);
+ /** Once the next-run endpoint fails (404), stop polling it (avoid 404 spam) and
+ * rely on the approximation. Reset only on remount. */
+ let nextRunEndpointFailed = $state(false);
+
+ async function refreshNextRun(): Promise<void> {
+ if (nextRunEndpointFailed) return;
+ const result = await loadNextRun();
+ if (result === null) return;
+ if (result.ok) {
+ nextRunAt = nextRunEpoch(result.nextRunAt);
+ } else {
+ // Endpoint absent / errored → stop polling it + use the approximation.
+ nextRunEndpointFailed = true;
+ }
+ }
+
+ /** The fallback countdown source: latest run + interval (only when enabled +
+ * ≥1 run). Recomputed reactively from the loaded config + raw runs. */
+ const approxNextRun = $derived(
+ approximateNextRunEpoch(
+ rawRuns,
+ joinInterval(loadedConfig.intervalHours, loadedConfig.intervalMinutes),
+ loadedConfig.enabled,
+ ),
+ );
+ /** The effective next-run epoch: the server value if available, else the
+ * approximation. Drives the countdown. */
+ const effectiveNextRun = $derived(nextRunEndpointFailed ? approxNextRun : nextRunAt);
+
const RUN_POLL_MS = 4000;
async function refreshRuns(): Promise<void> {
@@ -154,6 +200,7 @@
refreshInFlight = false;
if (result === null) return;
if (result.ok) {
+ rawRuns = result.runs;
runs = viewRuns(result.runs);
// Clear the error only on success so it stays visible (stable, no
// flicker) during an in-flight retry rather than vanishing mid-poll.
@@ -178,15 +225,18 @@
}
}
- // Load config + runs on mount, and poll runs while the view is alive so a
- // running run's completion/stopped transition shows without a manual refresh.
+ // Load config + runs + next-run on mount, and poll them while the view is
+ // alive so a running run's completion/stopped transition + the next-run timer
+ // stay fresh without a manual refresh.
$effect(() => {
untrack(() => {
void refreshConfig();
void refreshRuns();
+ void refreshNextRun();
});
pollHandle = setInterval(() => {
void refreshRuns();
+ void refreshNextRun();
}, RUN_POLL_MS);
return () => {
if (pollHandle !== null) clearInterval(pollHandle);
@@ -207,45 +257,67 @@
void tick; // depend on the ticker
return runs;
});
+
+ // The countdown clock: ticks every second so the "next run in Xm Ys" stays
+ // live. Pure countdown math is in `formatCountdown` (view-model); this only
+ // advances `now`.
+ let now = $state(Date.now());
+ $effect(() => {
+ const h = setInterval(() => {
+ now = Date.now();
+ }, 1000);
+ return () => clearInterval(h);
+ });
+ const countdownMs = $derived(
+ effectiveNextRun !== null ? effectiveNextRun - now : null,
+ );
+ const countdownLabel = $derived(formatCountdown(countdownMs));
</script>
<div class="flex flex-col gap-3">
<!-- Enable / status header -->
- <section class="flex items-center justify-between gap-2">
- <div class="flex items-center gap-2">
+ <section class="flex flex-col gap-1">
+ <div class="flex items-center justify-between gap-2">
+ <div class="flex items-center gap-2">
+ <button
+ type="button"
+ role="switch"
+ aria-checked={form.enabled}
+ aria-label="Toggle heartbeat"
+ class="toggle toggle-sm"
+ class:toggle-primary={form.enabled}
+ disabled={saving || configLoading}
+ onclick={handleToggleEnabled}
+ ></button>
+ <span class="text-xs font-semibold uppercase opacity-60">
+ {#if configLoading}
+ Loading…
+ {:else if form.enabled}
+ Enabled
+ {:else}
+ Disabled
+ {/if}
+ </span>
+ </div>
<button
type="button"
- role="switch"
- aria-checked={form.enabled}
- aria-label="Toggle heartbeat"
- class="toggle toggle-sm"
- class:toggle-primary={form.enabled}
- disabled={saving || configLoading}
- onclick={handleToggleEnabled}
- ></button>
- <span class="text-xs font-semibold uppercase opacity-60">
+ class="btn btn-ghost btn-xs"
+ disabled={configLoading}
+ onclick={() => refreshConfig()}
+ aria-label="Refresh heartbeat config"
+ >
{#if configLoading}
- Loading…
- {:else if form.enabled}
- Enabled
+ <span class="loading loading-spinner loading-xs"></span>
{:else}
- Disabled
+ Refresh
{/if}
- </span>
+ </button>
</div>
- <button
- type="button"
- class="btn btn-ghost btn-xs"
- disabled={configLoading}
- onclick={() => refreshConfig()}
- aria-label="Refresh heartbeat config"
- >
- {#if configLoading}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Refresh
- {/if}
- </button>
+ {#if form.enabled && effectiveNextRun !== null}
+ <p class="text-xs opacity-60" title="When the next heartbeat run fires">
+ Next run in {countdownLabel}
+ </p>
+ {/if}
</section>
{#if configError}