diff options
Diffstat (limited to 'src/features/cache-warming/logic/view-model.ts')
| -rw-r--r-- | src/features/cache-warming/logic/view-model.ts | 244 |
1 files changed, 122 insertions, 122 deletions
diff --git a/src/features/cache-warming/logic/view-model.ts b/src/features/cache-warming/logic/view-model.ts index eb105f6..bc8cf2e 100644 --- a/src/features/cache-warming/logic/view-model.ts +++ b/src/features/cache-warming/logic/view-model.ts @@ -15,37 +15,37 @@ import type { SurfaceSpec } from "@dispatch/ui-contract"; // ── Manual-warm port (consumer-defines-port; the composition root adapts the // store's `POST /chat/warm` result to this shape). ────────────────────────── export type WarmFeedback = - | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } - | { readonly ok: false; readonly error: string }; + | { readonly ok: true; readonly cachePct: number; readonly expectedCacheRate: number } + | { readonly ok: false; readonly error: string }; export type WarmNow = () => Promise<WarmFeedback | null>; // ── Parsed surface controls ─────────────────────────────────────────────────── export interface ParsedControls { - readonly enabled: boolean; - readonly toggleActionId: string | null; - readonly intervalSeconds: number; - readonly setIntervalActionId: string | null; - /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */ - readonly lastPct: number | null; - /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */ - readonly retentionPct: number | null; - /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ - readonly nextWarmAt: number | null; - /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ - readonly lastWarmAt: number | null; + readonly enabled: boolean; + readonly toggleActionId: string | null; + readonly intervalSeconds: number; + readonly setIntervalActionId: string | null; + /** Most recent warm's cache-hit %, from the "last cache rate" stat (`null` when "—"/absent). */ + readonly lastPct: number | null; + /** Cross-turn retention %, from the "cache retention" stat (`null` when "—"/absent). */ + readonly retentionPct: number | null; + /** Authoritative epoch-ms the next AUTOMATIC warm fires, or `null` when not scheduled. */ + readonly nextWarmAt: number | null; + /** Authoritative epoch-ms of the most recent completed warm, or `null` if none. */ + readonly lastWarmAt: number | null; } const EMPTY_CONTROLS: ParsedControls = { - enabled: false, - toggleActionId: null, - intervalSeconds: 0, - setIntervalActionId: null, - lastPct: null, - retentionPct: null, - nextWarmAt: null, - lastWarmAt: null, + enabled: false, + toggleActionId: null, + intervalSeconds: 0, + setIntervalActionId: null, + lastPct: null, + retentionPct: null, + nextWarmAt: null, + lastWarmAt: null, }; /** The `cache-warming-timer` custom field's renderer id (this feature owns it). */ @@ -53,24 +53,24 @@ const TIMER_RENDERER_ID = "cache-warming-timer"; /** Parse a stat's display string (e.g. "100%", "93 %", "—") into a number or null. */ export function parsePct(value: string): number | null { - const match = value.match(/-?\d+(?:\.\d+)?/); - if (match === null) return null; - const n = Number(match[0]); - return Number.isFinite(n) ? n : null; + const match = value.match(/-?\d+(?:\.\d+)?/); + if (match === null) return null; + const n = Number(match[0]); + return Number.isFinite(n) ? n : null; } /** A finite number, else null. */ function numOrNull(v: unknown): number | null { - return typeof v === "number" && Number.isFinite(v) ? v : null; + return typeof v === "number" && Number.isFinite(v) ? v : null; } /** Pull the authoritative `nextWarmAt`/`lastWarmAt` out of the timer custom payload. */ function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: number | null } { - if (typeof payload !== "object" || payload === null) { - return { nextWarmAt: null, lastWarmAt: null }; - } - const p = payload as Record<string, unknown>; - return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; + if (typeof payload !== "object" || payload === null) { + return { nextWarmAt: null, lastWarmAt: null }; + } + const p = payload as Record<string, unknown>; + return { nextWarmAt: numOrNull(p.nextWarmAt), lastWarmAt: numOrNull(p.lastWarmAt) }; } /** @@ -80,79 +80,79 @@ function parseTimer(payload: unknown): { nextWarmAt: number | null; lastWarmAt: * absent. */ export function parseControls(spec: SurfaceSpec | null): ParsedControls { - if (spec === null) return EMPTY_CONTROLS; - let enabled = false; - let toggleActionId: string | null = null; - let intervalSeconds = 0; - let setIntervalActionId: string | null = null; - let lastPct: number | null = null; - let retentionPct: number | null = null; - let nextWarmAt: number | null = null; - let lastWarmAt: number | null = null; - let seenToggle = false; - let seenNumber = false; - let seenRateStat = false; - for (const field of spec.fields) { - if (field.kind === "toggle" && !seenToggle) { - enabled = field.value; - toggleActionId = field.action.actionId; - seenToggle = true; - } else if (field.kind === "number" && !seenNumber) { - intervalSeconds = field.value; - setIntervalActionId = field.action.actionId; - seenNumber = true; - } else if (field.kind === "stat") { - // Retention is told apart by its label; everything else is the cache rate - // (first one wins, so a stray later stat can't clobber it). - if (/retention/i.test(field.label)) { - retentionPct = parsePct(field.value); - } else if (!seenRateStat) { - lastPct = parsePct(field.value); - seenRateStat = true; - } - } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { - const timer = parseTimer(field.payload); - nextWarmAt = timer.nextWarmAt; - lastWarmAt = timer.lastWarmAt; - } - } - return { - enabled, - toggleActionId, - intervalSeconds, - setIntervalActionId, - lastPct, - retentionPct, - nextWarmAt, - lastWarmAt, - }; + if (spec === null) return EMPTY_CONTROLS; + let enabled = false; + let toggleActionId: string | null = null; + let intervalSeconds = 0; + let setIntervalActionId: string | null = null; + let lastPct: number | null = null; + let retentionPct: number | null = null; + let nextWarmAt: number | null = null; + let lastWarmAt: number | null = null; + let seenToggle = false; + let seenNumber = false; + let seenRateStat = false; + for (const field of spec.fields) { + if (field.kind === "toggle" && !seenToggle) { + enabled = field.value; + toggleActionId = field.action.actionId; + seenToggle = true; + } else if (field.kind === "number" && !seenNumber) { + intervalSeconds = field.value; + setIntervalActionId = field.action.actionId; + seenNumber = true; + } else if (field.kind === "stat") { + // Retention is told apart by its label; everything else is the cache rate + // (first one wins, so a stray later stat can't clobber it). + if (/retention/i.test(field.label)) { + retentionPct = parsePct(field.value); + } else if (!seenRateStat) { + lastPct = parsePct(field.value); + seenRateStat = true; + } + } else if (field.kind === "custom" && field.rendererId === TIMER_RENDERER_ID) { + const timer = parseTimer(field.payload); + nextWarmAt = timer.nextWarmAt; + lastWarmAt = timer.lastWarmAt; + } + } + return { + enabled, + toggleActionId, + intervalSeconds, + setIntervalActionId, + lastPct, + retentionPct, + nextWarmAt, + lastWarmAt, + }; } // ── Interval ↔ minutes/seconds (seconds capped at 59) ───────────────────────── export interface MinSec { - readonly minutes: number; - readonly seconds: number; + readonly minutes: number; + readonly seconds: number; } export function clampSeconds(n: number): number { - if (!Number.isFinite(n)) return 0; - return Math.min(59, Math.max(0, Math.floor(n))); + if (!Number.isFinite(n)) return 0; + return Math.min(59, Math.max(0, Math.floor(n))); } export function clampMinutes(n: number): number { - if (!Number.isFinite(n)) return 0; - return Math.max(0, Math.floor(n)); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.floor(n)); } export function toMinSec(totalSeconds: number): MinSec { - const total = Math.max(0, Math.floor(totalSeconds)); - return { minutes: Math.floor(total / 60), seconds: total % 60 }; + const total = Math.max(0, Math.floor(totalSeconds)); + return { minutes: Math.floor(total / 60), seconds: total % 60 }; } /** Combine a minutes + seconds pair (each clamped) into total seconds. */ export function fromMinSec(minutes: number, seconds: number): number { - return clampMinutes(minutes) * 60 + clampSeconds(seconds); + return clampMinutes(minutes) * 60 + clampSeconds(seconds); } // ── Status + formatting ─────────────────────────────────────────────────────── @@ -161,56 +161,56 @@ export type WarmStatus = "success" | "warning" | "error"; /** Cache-hit % → semantic status (green high, yellow mid, red low). */ export function statusForPct(pct: number): WarmStatus { - if (pct >= 80) return "success"; - if (pct >= 40) return "warning"; - return "error"; + if (pct >= 80) return "success"; + if (pct >= 40) return "warning"; + return "error"; } /** A status → its DaisyUI text-colour class (full literal so Tailwind keeps it). */ export function colorClass(status: WarmStatus): string { - switch (status) { - case "success": - return "text-success"; - case "warning": - return "text-warning"; - case "error": - return "text-error"; - } + switch (status) { + case "success": + return "text-success"; + case "warning": + return "text-warning"; + case "error": + return "text-error"; + } } /** The status line for a warm, matching the manual-warm feedback phrasing. */ export function formatWarmLabel(pct: number): string { - return `Warmed — ${Math.round(pct)}% cache hit`; + return `Warmed — ${Math.round(pct)}% cache hit`; } /** Seconds → a short countdown string (e.g. "3:05", "9s"). */ export function formatCountdown(seconds: number): string { - const s = Math.max(0, Math.floor(seconds)); - if (s < 60) return `${s}s`; - const m = Math.floor(s / 60); - const rem = s % 60; - return `${m}:${String(rem).padStart(2, "0")}`; + const s = Math.max(0, Math.floor(seconds)); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return `${m}:${String(rem).padStart(2, "0")}`; } // ── Warming history reducer (keyed off the authoritative `lastWarmAt`) ───────── export interface WarmEntry { - readonly pct: number; - /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ - readonly at: number; + readonly pct: number; + /** Authoritative epoch-ms of this warm (the surface's `lastWarmAt`). */ + readonly at: number; } export interface WarmingViewState { - /** Warmings, MOST RECENT FIRST. */ - readonly history: readonly WarmEntry[]; - /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ - readonly lastWarmAt: number | null; + /** Warmings, MOST RECENT FIRST. */ + readonly history: readonly WarmEntry[]; + /** The last authoritative `lastWarmAt` recorded, for change-detection (de-dup key). */ + readonly lastWarmAt: number | null; } const MAX_HISTORY = 50; export function initialWarmingState(): WarmingViewState { - return { history: [], lastWarmAt: null }; + return { history: [], lastWarmAt: null }; } /** @@ -221,14 +221,14 @@ export function initialWarmingState(): WarmingViewState { * ignored; a null pct advances the de-dup key without adding an entry. */ export function observeWarm( - state: WarmingViewState, - lastWarmAt: number | null, - pct: number | null, + state: WarmingViewState, + lastWarmAt: number | null, + pct: number | null, ): WarmingViewState { - if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; - if (pct === null) return { ...state, lastWarmAt }; - const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); - return { history, lastWarmAt }; + if (lastWarmAt === null || lastWarmAt === state.lastWarmAt) return state; + if (pct === null) return { ...state, lastWarmAt }; + const history = [{ pct, at: lastWarmAt }, ...state.history].slice(0, MAX_HISTORY); + return { history, lastWarmAt }; } /** @@ -248,7 +248,7 @@ const STALE_NEXT_WARM_MS = 3000; * when `nextWarmAt` is stale (further than the grace into the past). */ export function secondsUntilNext(nextWarmAt: number | null, now: number): number | null { - if (nextWarmAt === null) return null; - if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null; - return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); + if (nextWarmAt === null) return null; + if (now - nextWarmAt > STALE_NEXT_WARM_MS) return null; + return Math.max(0, Math.ceil((nextWarmAt - now) / 1000)); } |
