import type { ReasoningEffort } from "@dispatch/transport-contract"; import { DEFAULT_REASONING_EFFORT, effectiveEffort, effortOptions, } from "../../chat/reasoning-effort"; import type { HeartbeatConfig, HeartbeatConfigPatch, HeartbeatRun, HeartbeatRunStatus, } from "./types"; /** * Pure view-models for the heartbeat feature — zero DOM, zero effects, zero * Svelte. Maps backend `HeartbeatConfig`/`HeartbeatRun` to display shapes * (badges, labels, formatted times) and holds the config-form helpers. * * The reasoning-effort ladder + resolution are SERVER-owned and shared with the * per-conversation knob, so they are REUSED from `features/chat/reasoning-effort` * (a sanctioned cross-feature import through its public exports) rather than * redefined — no drift. */ export type Badge = "success" | "warning" | "error" | "neutral"; /** A run shaped for display in the scrolling runs list. */ export interface HeartbeatRunView { readonly id: string; readonly conversationId: string; readonly status: HeartbeatRunStatus; readonly statusLabel: string; readonly badge: Badge; /** True while the run is in flight (show a spinner). */ readonly busy: boolean; /** A short absolute clock label, e.g. "14:30:05". */ readonly timeLabel: string; /** A relative label, e.g. "5m ago" / "just now". */ readonly relativeLabel: string; } const RUNNING_LABEL = "Running"; const COMPLETED_LABEL = "Completed"; const STOPPED_LABEL = "Stopped"; /** * Map a run's status to a display badge + busy flag. `running` → warning + * spinner, `completed` → success, `stopped` → neutral. Mirrors the LSP/MCP * status visual treatment. */ export function badgeForStatus(status: HeartbeatRunStatus): { badge: Badge; busy: boolean } { switch (status) { case "running": return { badge: "warning", busy: true }; case "completed": return { badge: "success", busy: false }; case "stopped": return { badge: "neutral", busy: false }; } } export function statusLabelFor(status: HeartbeatRunStatus): string { switch (status) { case "running": return RUNNING_LABEL; case "completed": return COMPLETED_LABEL; case "stopped": return STOPPED_LABEL; } } /** * Format an ISO timestamp as a short absolute clock label (HH:MM:SS) in the * viewer's locale. Returns "—" for an unparseable timestamp so the UI never * crashes on a malformed backend value. Pure (no `now` needed — an absolute * clock label doesn't depend on the current time). */ export function formatRunTime(triggeredAt: string): string { const t = parseTime(triggeredAt); if (t === null) return "—"; return clockLabel(t); } /** * A coarse relative label — "just now" (<1m), "Nm ago", "Nh ago", else the * absolute date+time (so an old run reads "Jun 24, 14:30"). Pure via `now`. */ export function relativeLabel(triggeredAt: string, now: number = Date.now()): string { const t = parseTime(triggeredAt); if (t === null) return "—"; const deltaMs = now - t; if (deltaMs < 0) return "just now"; const mins = Math.floor(deltaMs / 60000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; return dateLabel(t); } /** * Build a display view for a run. `now` is injectable for tests (defaults to * `Date.now()`); the composition-root component passes nothing in production. */ export function viewRun(run: HeartbeatRun, now: number = Date.now()): HeartbeatRunView { const { badge, busy } = badgeForStatus(run.status); return { id: run.id, conversationId: run.conversationId, status: run.status, statusLabel: statusLabelFor(run.status), badge, busy, timeLabel: formatRunTime(run.triggeredAt), relativeLabel: relativeLabel(run.triggeredAt, now), }; } export function viewRuns( runs: readonly HeartbeatRun[], now: number = Date.now(), ): readonly HeartbeatRunView[] { return runs.map((r) => viewRun(r, now)); } // ── Time formatting (pure: no `Date` mutation; injectable `now` for tests) ───── /** Parse an ISO timestamp to epoch ms, or null if unparseable. */ function parseTime(iso: string): number | null { if (typeof iso !== "string" || iso.length === 0) return null; const t = Date.parse(iso); return Number.isNaN(t) ? null : t; } /** `HH:MM:SS` in the viewer's locale (24h where the locale uses it). */ function clockLabel(epochMs: number): string { const d = new Date(epochMs); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); const ss = String(d.getSeconds()).padStart(2, "0"); return `${hh}:${mm}:${ss}`; } /** A short absolute date+time label for an old run, e.g. "Jun 24, 14:30". */ function dateLabel(epochMs: number): string { const d = new Date(epochMs); const month = d.toLocaleString(undefined, { month: "short" }); const day = d.getDate(); const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); 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 ─────────────────────────────────────────────────────────────── /** * The editable form state for the config panel — a mutable mirror of a loaded * `HeartbeatConfig` that the inputs bind to. `reasoningEffort` is resolved to * an effective level for the `