summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 19:52:47 +0900
committerAdam Malczewski <[email protected]>2026-06-26 19:52:47 +0900
commit51da5df8fc70efc2febda238faeaeacb21713699 (patch)
tree15727c2a4e38dccb4f44123c387f43abe117dbad /src/features/heartbeat/logic
parentf582642aeed9c79247e805545d434c4a261be781 (diff)
downloaddispatch-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.ts63
-rw-r--r--src/features/heartbeat/logic/view-model.ts41
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)
);