diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 19:52:47 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 19:52:47 +0900 |
| commit | 51da5df8fc70efc2febda238faeaeacb21713699 (patch) | |
| tree | 15727c2a4e38dccb4f44123c387f43abe117dbad /src/features/heartbeat/logic | |
| parent | f582642aeed9c79247e805545d434c4a261be781 (diff) | |
| download | dispatch-web-51da5df8fc70efc2febda238faeaeacb21713699.tar.gz dispatch-web-51da5df8fc70efc2febda238faeaeacb21713699.zip | |
feat(heartbeat): prompt editor modal + hours/minutes timer + backend handoff
Diffstat (limited to 'src/features/heartbeat/logic')
| -rw-r--r-- | src/features/heartbeat/logic/view-model.test.ts | 63 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.ts | 41 |
2 files changed, 93 insertions, 11 deletions
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts index fc43112..fe97496 100644 --- a/src/features/heartbeat/logic/view-model.test.ts +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -9,11 +9,13 @@ import { formatRunTime, formDiffers, formFromConfig, + joinInterval, normalizeHeartbeatConfig, normalizeHeartbeatRuns, normalizeInterval, patchFromForm, relativeLabel, + splitInterval, statusLabelFor, viewRun, viewRuns, @@ -120,10 +122,12 @@ describe("viewRun / viewRuns", () => { }); describe("config form", () => { - it("emptyForm has defaults (disabled, default interval, default effort)", () => { + it("emptyForm has defaults (disabled, default interval split, default effort)", () => { const f = emptyForm(); expect(f.enabled).toBe(false); - expect(f.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES); + // 30 min → 0h 30m + expect(f.intervalHours).toBe(0); + expect(f.intervalMinutes).toBe(30); expect(f.reasoningEffort).toBe("high"); // DEFAULT_REASONING_EFFORT expect(f.systemPrompt).toBe(""); expect(f.model).toBe(""); @@ -139,6 +143,25 @@ describe("config form", () => { expect(f.reasoningEffort).toBe("max"); }); + it("formFromConfig splits intervalMinutes into hours + minutes (0–59)", () => { + expect(formFromConfig(config({ intervalMinutes: 90 }))).toMatchObject({ + intervalHours: 1, + intervalMinutes: 30, + }); + expect(formFromConfig(config({ intervalMinutes: 60 }))).toMatchObject({ + intervalHours: 1, + intervalMinutes: 0, + }); + expect(formFromConfig(config({ intervalMinutes: 59 }))).toMatchObject({ + intervalHours: 0, + intervalMinutes: 59, + }); + expect(formFromConfig(config({ intervalMinutes: 1440 }))).toMatchObject({ + intervalHours: 24, + intervalMinutes: 0, + }); + }); + it("formFromConfig coerces malformed fields safely", () => { const f = formFromConfig( config({ @@ -149,7 +172,9 @@ describe("config form", () => { }), ); expect(f.enabled).toBe(false); // non-true → false - expect(f.intervalMinutes).toBe(1); // clamped + // -5 clamps to 1 → 0h 1m + expect(f.intervalHours).toBe(0); + expect(f.intervalMinutes).toBe(1); expect(f.model).toBe(""); // non-string → "" expect(f.systemPrompt).toBe(""); // undefined → "" }); @@ -164,8 +189,24 @@ describe("config form", () => { expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES); }); - it("patchFromForm clamps interval + carries every field", () => { + it("splitInterval / joinInterval round-trip (and clamp)", () => { + expect(splitInterval(90)).toEqual({ hours: 1, minutes: 30 }); + expect(splitInterval(0)).toEqual({ hours: 0, minutes: 1 }); // 0 → clamps to 1 + expect(splitInterval(1440)).toEqual({ hours: 24, minutes: 0 }); + expect(splitInterval(2000)).toEqual({ hours: 24, minutes: 0 }); // clamped + // join recomputes + clamps + expect(joinInterval(1, 30)).toBe(90); + expect(joinInterval(0, 0)).toBe(1); // 0 → clamps to 1 + expect(joinInterval(25, 0)).toBe(1440); // 1500 → clamps to 1440 + expect(joinInterval(-1, 30)).toBe(30); // negatives floored to 0 + expect(joinInterval("x" as unknown as number, 15)).toBe(15); // non-finite → 0h + }); + + it("patchFromForm recombines hours+minutes into intervalMinutes + carries every field", () => { const f = formFromConfig(config({ intervalMinutes: 2000 })); + // 2000 clamps to 1440 → 24h 0m in the form + expect(f.intervalHours).toBe(24); + expect(f.intervalMinutes).toBe(0); const patch = patchFromForm(f); expect(patch.intervalMinutes).toBe(1440); expect(patch.enabled).toBe(false); @@ -175,6 +216,13 @@ describe("config form", () => { expect(patch.taskPrompt).toBe("check status"); }); + it("patchFromForm recombines an arbitrary hours/minutes edit", () => { + const f = formFromConfig(config({ intervalMinutes: 15 })); + f.intervalHours = 2; + f.intervalMinutes = 45; + expect(patchFromForm(f).intervalMinutes).toBe(165); + }); + it("formDiffers is false for a form seeded from the config (no edits)", () => { const c = config({ reasoningEffort: "medium" }); const f = formFromConfig(c); @@ -188,6 +236,13 @@ describe("config form", () => { expect(formDiffers(f, c)).toBe(true); }); + it("formDiffers is true after an interval edit (hours or minutes)", () => { + const c = config({ intervalMinutes: 90 }); + const f = formFromConfig(c); + f.intervalMinutes = 45; // 1h45m vs 1h30m + expect(formDiffers(f, c)).toBe(true); + }); + it("formDiffers treats null config effort as the default (matches the resolved form)", () => { const c = config({ reasoningEffort: null }); const f = formFromConfig(c); diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts index f5b9f96..b79fff6 100644 --- a/src/features/heartbeat/logic/view-model.ts +++ b/src/features/heartbeat/logic/view-model.ts @@ -159,11 +159,16 @@ function dateLabel(epochMs: number): string { * `HeartbeatConfig` that the inputs bind to. `reasoningEffort` is resolved to * an effective level for the `<select>` (null ⇒ default `high`), exactly like * the per-conversation selector. + * + * The interval is split into `intervalHours` + `intervalMinutes` (0–59) for the + * UI (two inputs), and recombined to a total-minutes value at the patch seam + * (`patchFromForm`); the backend stores a single `intervalMinutes`. */ export interface HeartbeatFormState { enabled: boolean; systemPrompt: string; taskPrompt: string; + intervalHours: number; intervalMinutes: number; model: string; reasoningEffort: ReasoningEffort; @@ -172,16 +177,33 @@ export interface HeartbeatFormState { /** The default interval (minutes) shown for an empty/unset config. */ export const DEFAULT_INTERVAL_MINUTES = 30; +/** Split a total-minutes value into { hours, minutes (0–59) }. Pure. */ +export function splitInterval(totalMinutes: number): { hours: number; minutes: number } { + const total = normalizeInterval(totalMinutes); + const hours = Math.floor(total / 60); + const minutes = total - hours * 60; + return { hours, minutes }; +} + +/** Recombine hours + minutes into a clamped total-minutes value. Pure. */ +export function joinInterval(hours: number, minutes: number): number { + const h = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0; + const m = Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0; + return normalizeInterval(h * 60 + m); +} + /** * Seed the editable form state from a loaded config, applying safe defaults for * any malformed/absent backend field so the inputs are never `undefined`. */ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState { + const { hours, minutes } = splitInterval(config.intervalMinutes); return { enabled: config.enabled === true, systemPrompt: config.systemPrompt ?? "", taskPrompt: config.taskPrompt ?? "", - intervalMinutes: normalizeInterval(config.intervalMinutes), + intervalHours: hours, + intervalMinutes: minutes, model: typeof config.model === "string" ? config.model : "", reasoningEffort: effectiveEffort(config.reasoningEffort ?? null), }; @@ -189,11 +211,13 @@ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState { /** An empty form (before the config loads). */ export function emptyForm(): HeartbeatFormState { + const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES); return { enabled: false, systemPrompt: "", taskPrompt: "", - intervalMinutes: DEFAULT_INTERVAL_MINUTES, + intervalHours: hours, + intervalMinutes: minutes, model: "", reasoningEffort: DEFAULT_REASONING_EFFORT, }; @@ -209,16 +233,17 @@ export function normalizeInterval(value: unknown): number { } /** - * The patch to PUT when persisting the form. Only `intervalMinutes` is clamped; - * text fields are sent verbatim. `reasoningEffort` is always present (a resolved - * level) since the heartbeat has no per-run override — it persists the level. + * The patch to PUT when persisting the form. The split hours+minutes are + * recombined into a single `intervalMinutes` (clamped); text fields are sent + * verbatim. `reasoningEffort` is always present (a resolved level) since the + * heartbeat has no per-run override — it persists the level. */ export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch { return { enabled: form.enabled, systemPrompt: form.systemPrompt, taskPrompt: form.taskPrompt, - intervalMinutes: normalizeInterval(form.intervalMinutes), + intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes), model: form.model, reasoningEffort: form.reasoningEffort, }; @@ -226,11 +251,13 @@ export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch { /** Whether the form differs from the loaded config (drives the Save button). */ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): boolean { + const { hours, minutes } = splitInterval(config.intervalMinutes); return ( form.enabled !== config.enabled || form.systemPrompt !== (config.systemPrompt ?? "") || form.taskPrompt !== (config.taskPrompt ?? "") || - form.intervalMinutes !== normalizeInterval(config.intervalMinutes) || + form.intervalHours !== hours || + form.intervalMinutes !== minutes || form.model !== (typeof config.model === "string" ? config.model : "") || form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null) ); |
