summaryrefslogtreecommitdiffhomepage
path: root/src
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 /src
parent121fd7280bc3f77c61da7025cca06480798038b7 (diff)
downloaddispatch-web-80f99665034a0e510300793205c162fc7a46769f.tar.gz
dispatch-web-80f99665034a0e510300793205c162fc7a46769f.zip
feat(heartbeat): show next heartbeat timer + backend handoff for timestamp endpoint
Diffstat (limited to 'src')
-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
7 files changed, 292 insertions, 31 deletions
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}