diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 085fb13245..36e66880b7 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -497,7 +497,7 @@ export type RoutineTriggerKind = (typeof ROUTINE_TRIGGER_KINDS)[number];
export const ROUTINE_TRIGGER_SIGNING_MODES = ["bearer", "hmac_sha256", "github_hmac", "none"] as const;
export type RoutineTriggerSigningMode = (typeof ROUTINE_TRIGGER_SIGNING_MODES)[number];
-export const ROUTINE_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select"] as const;
+export const ROUTINE_VARIABLE_TYPES = ["text", "textarea", "number", "boolean", "select", "date"] as const;
export type RoutineVariableType = (typeof ROUTINE_VARIABLE_TYPES)[number];
export const ROUTINE_RUN_STATUSES = [
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 493d552b4b..c1ac1f1eb8 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -1439,6 +1439,8 @@ export {
getBuiltinRoutineVariableValues,
interpolateRoutineTemplate,
isBuiltinRoutineVariable,
+ isRoutineDateVariableName,
+ isValidRoutineDateString,
isValidRoutineVariableName,
stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate,
diff --git a/packages/shared/src/routine-variables.test.ts b/packages/shared/src/routine-variables.test.ts
index 15d63a652e..3cd4ea5e1a 100644
--- a/packages/shared/src/routine-variables.test.ts
+++ b/packages/shared/src/routine-variables.test.ts
@@ -5,6 +5,8 @@ import {
getBuiltinRoutineVariableValues,
interpolateRoutineTemplate,
isBuiltinRoutineVariable,
+ isRoutineDateVariableName,
+ isValidRoutineDateString,
syncRoutineVariablesWithTemplate,
} from "./routine-variables.js";
@@ -26,15 +28,43 @@ describe("routine variable helpers", () => {
it("preserves existing metadata when syncing variables from a template", () => {
expect(
- syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{priority}}"], [
+ syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{startDate}}"], [
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
+ { name: "startDate", label: "Start", type: "text", defaultValue: "soon", required: false, options: [] },
]),
).toEqual([
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
- { name: "priority", label: null, type: "text", defaultValue: null, required: true, options: [] },
+ { name: "startDate", label: "Start", type: "text", defaultValue: "soon", required: false, options: [] },
]);
});
+ it("identifies routine date variable names by strict capital-Date suffix", () => {
+ expect(isRoutineDateVariableName("startDate")).toBe(true);
+ expect(isRoutineDateVariableName("endDate")).toBe(true);
+ expect(isRoutineDateVariableName("fooDate")).toBe(true);
+ expect(isRoutineDateVariableName("date")).toBe(false);
+ expect(isRoutineDateVariableName("startdate")).toBe(false);
+ expect(isRoutineDateVariableName("candidate")).toBe(false);
+ expect(isRoutineDateVariableName("Date")).toBe(false);
+ });
+
+ it("defaults newly synced capital-Date variables to date type", () => {
+ expect(
+ syncRoutineVariablesWithTemplate("Compare {{startDate}} to {{endDate}} with {{date}}", []),
+ ).toEqual([
+ { name: "startDate", label: null, type: "date", defaultValue: null, required: true, options: [] },
+ { name: "endDate", label: null, type: "date", defaultValue: null, required: true, options: [] },
+ ]);
+ });
+
+ it("validates YYYY-MM-DD routine date strings as real calendar dates", () => {
+ expect(isValidRoutineDateString("2024-02-29")).toBe(true);
+ expect(isValidRoutineDateString("2024-02-30")).toBe(false);
+ expect(isValidRoutineDateString("2023-02-29")).toBe(false);
+ expect(isValidRoutineDateString("2024-13-01")).toBe(false);
+ expect(isValidRoutineDateString("2024-1-01")).toBe(false);
+ });
+
it("interpolates provided variable values into the routine template", () => {
expect(
interpolateRoutineTemplate("Review {{repo}} for {{priority}}", {
diff --git a/packages/shared/src/routine-variables.ts b/packages/shared/src/routine-variables.ts
index 84257188ab..43d57551aa 100644
--- a/packages/shared/src/routine-variables.ts
+++ b/packages/shared/src/routine-variables.ts
@@ -49,6 +49,37 @@ export function isValidRoutineVariableName(name: string): boolean {
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
}
+export function isRoutineDateVariableName(name: string): boolean {
+ return isValidRoutineVariableName(name) && name.length > "Date".length && name.endsWith("Date");
+}
+
+export function isValidRoutineDateString(value: string): boolean {
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
+ if (!match) return false;
+
+ const year = Number(match[1]);
+ const month = Number(match[2]);
+ const day = Number(match[3]);
+ if (month < 1 || month > 12) return false;
+
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
+ const daysInMonth = [
+ 31,
+ leapYear ? 29 : 28,
+ 31,
+ 30,
+ 31,
+ 30,
+ 31,
+ 31,
+ 30,
+ 31,
+ 30,
+ 31,
+ ][month - 1]!;
+ return day >= 1 && day <= daysInMonth;
+}
+
function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] {
const templates = Array.isArray(input) ? input : [input];
return templates.filter((template): template is string => typeof template === "string" && template.length > 0);
@@ -71,7 +102,7 @@ function defaultRoutineVariable(name: string): RoutineVariable {
return {
name,
label: null,
- type: "text",
+ type: isRoutineDateVariableName(name) ? "date" : "text",
defaultValue: null,
required: true,
options: [],
diff --git a/packages/shared/src/validators/routine.test.ts b/packages/shared/src/validators/routine.test.ts
index 97580179ac..82c921b9cc 100644
--- a/packages/shared/src/validators/routine.test.ts
+++ b/packages/shared/src/validators/routine.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
routineRevisionSnapshotV1Schema,
+ routineVariableSchema,
updateRoutineSchema,
} from "./routine.js";
@@ -83,4 +84,30 @@ describe("routine validators", () => {
baseRevisionId,
}).baseRevisionId).toBe(baseRevisionId);
});
+
+ it("accepts date variables with valid YYYY-MM-DD defaults", () => {
+ expect(routineVariableSchema.parse({
+ name: "startDate",
+ type: "date",
+ defaultValue: "2024-02-29",
+ })).toMatchObject({
+ name: "startDate",
+ type: "date",
+ defaultValue: "2024-02-29",
+ });
+ });
+
+ it("rejects date variables with non-calendar or non-string defaults", () => {
+ expect(() => routineVariableSchema.parse({
+ name: "startDate",
+ type: "date",
+ defaultValue: "2024-02-30",
+ })).toThrow(/YYYY-MM-DD/);
+
+ expect(() => routineVariableSchema.parse({
+ name: "startDate",
+ type: "date",
+ defaultValue: 20240229,
+ })).toThrow(/YYYY-MM-DD/);
+ });
});
diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts
index 2db48fbbf5..e9cea498a2 100644
--- a/packages/shared/src/validators/routine.ts
+++ b/packages/shared/src/validators/routine.ts
@@ -13,6 +13,7 @@ import {
issueExecutionWorkspaceSettingsSchema,
} from "./issue.js";
import { envConfigSchema } from "./secret.js";
+import { isValidRoutineDateString } from "../routine-variables.js";
const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]);
@@ -47,6 +48,15 @@ export const routineVariableSchema = z.object({
});
}
}
+ if (value.type === "date" && value.defaultValue != null) {
+ if (typeof value.defaultValue !== "string" || !isValidRoutineDateString(value.defaultValue)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ["defaultValue"],
+ message: "Date variable defaults must be valid YYYY-MM-DD calendar dates",
+ });
+ }
+ }
});
export const createRoutineSchema = z.object({
diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts
index c5f6a1a470..183913f227 100644
--- a/server/src/__tests__/routines-service.test.ts
+++ b/server/src/__tests__/routines-service.test.ts
@@ -1073,6 +1073,63 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
});
});
+ it("infers capital-Date variables, preserves builtin date, and validates submitted date values", async () => {
+ const { companyId, agentId, projectId, svc } = await seedFixture();
+ const dateRoutine = await svc.create(
+ companyId,
+ {
+ projectId,
+ goalId: null,
+ parentIssueId: null,
+ title: "date check {{startDate}} on {{date}}",
+ description: "Range {{startDate}} to {{endDate}}",
+ assigneeAgentId: agentId,
+ priority: "medium",
+ status: "active",
+ concurrencyPolicy: "coalesce_if_active",
+ catchUpPolicy: "skip_missed",
+ },
+ {},
+ );
+
+ expect(dateRoutine.variables).toEqual([
+ { name: "startDate", label: null, type: "date", defaultValue: null, required: true, options: [] },
+ { name: "endDate", label: null, type: "date", defaultValue: null, required: true, options: [] },
+ ]);
+
+ await expect(
+ svc.runRoutine(dateRoutine.id, {
+ source: "manual",
+ variables: { startDate: "2024-02-30", endDate: "2024-03-01" },
+ }),
+ ).rejects.toThrow(/valid YYYY-MM-DD date/i);
+
+ const run = await svc.runRoutine(dateRoutine.id, {
+ source: "manual",
+ variables: { startDate: "2024-02-29", endDate: "2024-03-01" },
+ });
+
+ const storedIssue = await db
+ .select({ title: issues.title, description: issues.description })
+ .from(issues)
+ .where(eq(issues.id, run.linkedIssueId!))
+ .then((rows) => rows[0] ?? null);
+ const storedRun = await db
+ .select({ triggerPayload: routineRuns.triggerPayload })
+ .from(routineRuns)
+ .where(eq(routineRuns.id, run.id))
+ .then((rows) => rows[0] ?? null);
+
+ expect(storedIssue?.title).toMatch(/^date check 2024-02-29 on \d{4}-\d{2}-\d{2}$/);
+ expect(storedIssue?.description).toBe("Range 2024-02-29 to 2024-03-01");
+ expect(storedRun?.triggerPayload).toEqual({
+ variables: {
+ startDate: "2024-02-29",
+ endDate: "2024-03-01",
+ },
+ });
+ });
+
it("attaches the selected execution workspace to manually triggered routine issues", async () => {
const { companyId, projectId, routine, svc } = await seedFixture();
const projectWorkspaceId = randomUUID();
@@ -1366,6 +1423,32 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
).rejects.toThrow(/require defaults for required variables/i);
});
+ it("rejects invalid date defaults before persisting routine variables", async () => {
+ const { companyId, agentId, projectId, svc } = await seedFixture();
+
+ await expect(
+ svc.create(
+ companyId,
+ {
+ projectId,
+ goalId: null,
+ parentIssueId: null,
+ title: "date check {{startDate}}",
+ description: null,
+ assigneeAgentId: agentId,
+ priority: "medium",
+ status: "active",
+ concurrencyPolicy: "coalesce_if_active",
+ catchUpPolicy: "skip_missed",
+ variables: [
+ { name: "startDate", label: null, type: "date", defaultValue: "2024-02-30", required: true, options: [] },
+ ],
+ },
+ {},
+ ),
+ ).rejects.toThrow(/valid YYYY-MM-DD date/i);
+ });
+
it("serializes concurrent dispatches until the first execution issue is linked to a queued run", async () => {
const { routine, svc } = await seedFixture({
wakeup: async (wakeupAgentId, wakeupOpts) => {
diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts
index 9ad737f3c5..d1eb9f795e 100644
--- a/server/src/services/routines.ts
+++ b/server/src/services/routines.ts
@@ -46,6 +46,7 @@ import {
getBuiltinRoutineVariableValues,
extractRoutineVariableNames,
interpolateRoutineTemplate,
+ isValidRoutineDateString,
pluginOperationIssueOriginKind,
stringifyRoutineVariableValue,
syncRoutineVariablesWithTemplate,
@@ -223,10 +224,22 @@ function parseNumberVariableValue(name: string, raw: unknown) {
throw unprocessable(`Variable "${name}" must be a number`);
}
+function parseDateVariableValue(name: string, raw: unknown) {
+ if (typeof raw !== "string") {
+ throw unprocessable(`Variable "${name}" must be a YYYY-MM-DD date`);
+ }
+ const normalized = raw.trim();
+ if (!isValidRoutineDateString(normalized)) {
+ throw unprocessable(`Variable "${name}" must be a valid YYYY-MM-DD date`);
+ }
+ return normalized;
+}
+
function normalizeRoutineVariableValue(variable: RoutineVariable, raw: unknown): string | number | boolean | null {
if (raw == null) return null;
if (variable.type === "boolean") return parseBooleanVariableValue(variable.name, raw);
if (variable.type === "number") return parseNumberVariableValue(variable.name, raw);
+ if (variable.type === "date") return parseDateVariableValue(variable.name, raw);
const normalized = stringifyRoutineVariableValue(raw);
if (variable.type === "select") {
diff --git a/ui/src/components/RoutineRunVariablesDialog.test.tsx b/ui/src/components/RoutineRunVariablesDialog.test.tsx
index f4d5e1c2dc..d2704b1a57 100644
--- a/ui/src/components/RoutineRunVariablesDialog.test.tsx
+++ b/ui/src/components/RoutineRunVariablesDialog.test.tsx
@@ -1,9 +1,9 @@
// @vitest-environment jsdom
-import { act } from "react";
+import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import type { Agent, ExecutionWorkspace, Project } from "@paperclipai/shared";
+import type { Agent, ExecutionWorkspace, Project, RoutineVariable } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RoutineRunVariablesDialog } from "./RoutineRunVariablesDialog";
@@ -56,6 +56,17 @@ vi.mock("./IssueWorkspaceCard", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+async function settleEffects() {
+ await Promise.resolve();
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+async function flushUi(callback: () => void) {
+ flushSync(callback);
+ await settleEffects();
+}
+
function createProject(): Project {
return {
id: "project-1",
@@ -162,6 +173,56 @@ function createExecutionWorkspace(): ExecutionWorkspace {
};
}
+function createQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+}
+
+async function renderRoutineRunDialog(container: HTMLDivElement, props: {
+ variables: RoutineVariable[];
+ onSubmit?: (data: unknown) => void;
+}) {
+ const root = createRoot(container);
+ const queryClient = createQueryClient();
+ const onSubmit = props.onSubmit ?? vi.fn();
+
+ await flushUi(() => {
+ root.render(
+
+ {}}
+ companyId="company-1"
+ projects={[]}
+ agents={[createAgent()]}
+ defaultAssigneeAgentId="agent-1"
+ variables={props.variables}
+ isPending={false}
+ onSubmit={onSubmit}
+ />
+ ,
+ );
+ });
+
+ return { root, onSubmit };
+}
+
+function setInputValue(input: HTMLInputElement, value: string) {
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
+ setter?.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+function findRunButton() {
+ return Array.from(document.querySelectorAll("button"))
+ .find((button) => button.textContent === "Run routine") as HTMLButtonElement | undefined;
+}
+
describe("RoutineRunVariablesDialog", () => {
let container: HTMLDivElement;
@@ -193,7 +254,7 @@ describe("RoutineRunVariablesDialog", () => {
},
});
- await act(async () => {
+ await flushUi(() => {
root.render(
{
/>
,
);
- await Promise.resolve();
- await Promise.resolve();
- await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(issueWorkspaceDraftCalls).toBeLessThanOrEqual(2);
@@ -220,7 +278,7 @@ describe("RoutineRunVariablesDialog", () => {
expect(document.body.textContent).not.toContain("Search agents...");
expect(document.body.textContent).not.toContain("Search projects...");
- await act(async () => {
+ await flushUi(() => {
root.unmount();
});
});
@@ -235,7 +293,7 @@ describe("RoutineRunVariablesDialog", () => {
},
});
- await act(async () => {
+ await flushUi(() => {
root.render(
{
/>
,
);
- await Promise.resolve();
- await Promise.resolve();
- await new Promise((resolve) => setTimeout(resolve, 0));
});
const dialogContent = Array.from(document.body.querySelectorAll("div")).find((element) =>
@@ -288,7 +343,7 @@ describe("RoutineRunVariablesDialog", () => {
expect(footer?.contains(formScrollRegion ?? null)).toBe(false);
expect(footer?.textContent).toContain("Run routine");
- await act(async () => {
+ await flushUi(() => {
root.unmount();
});
});
@@ -310,7 +365,7 @@ describe("RoutineRunVariablesDialog", () => {
},
});
- await act(async () => {
+ await flushUi(() => {
root.render(
{
/>
,
);
- await Promise.resolve();
- await Promise.resolve();
- await new Promise((resolve) => setTimeout(resolve, 0));
});
for (let i = 0; i < 10 && !document.querySelector('[data-testid="workspace-card"]'); i += 1) {
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 0));
- });
+ await settleEffects();
}
const branchInput = Array.from(document.querySelectorAll("input"))
@@ -356,7 +406,7 @@ describe("RoutineRunVariablesDialog", () => {
.find((button) => button.textContent === "Run routine");
expect(runButton).toBeTruthy();
- await act(async () => {
+ await flushUi(() => {
runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
@@ -371,7 +421,7 @@ describe("RoutineRunVariablesDialog", () => {
executionWorkspaceSettings: { mode: "isolated_workspace" },
});
- await act(async () => {
+ await flushUi(() => {
root.unmount();
});
});
@@ -394,7 +444,7 @@ describe("RoutineRunVariablesDialog", () => {
},
});
- await act(async () => {
+ await flushUi(() => {
root.render(
{
/>
,
);
- await Promise.resolve();
- await Promise.resolve();
- await new Promise((resolve) => setTimeout(resolve, 0));
});
for (let i = 0; i < 10 && latestWorkspaceIssue === null; i += 1) {
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 0));
- });
+ await settleEffects();
}
expect(latestWorkspaceIssue).toMatchObject({
@@ -430,7 +475,104 @@ describe("RoutineRunVariablesDialog", () => {
projectWorkspaceId: workspace.projectWorkspaceId,
});
- await act(async () => {
+ await flushUi(() => {
+ root.unmount();
+ });
+ });
+
+ it("respects explicit date and text variable types for Date-suffixed names", async () => {
+ const { root } = await renderRoutineRunDialog(container, {
+ variables: [
+ {
+ name: "startDate",
+ label: null,
+ type: "text",
+ defaultValue: "2026-06-26",
+ required: true,
+ options: [],
+ },
+ {
+ name: "releaseOn",
+ label: "Release on",
+ type: "date",
+ defaultValue: "2026-07-01",
+ required: false,
+ options: [],
+ },
+ ],
+ });
+
+ const dateInputs = Array.from(document.querySelectorAll('input[type="date"]'));
+ expect(dateInputs).toHaveLength(1);
+ expect(dateInputs[0]?.value).toBe("2026-07-01");
+
+ const textInput = Array.from(document.querySelectorAll('input[type="text"]'))
+ .find((input) => input.value === "2026-06-26");
+ expect(textInput).toBeTruthy();
+
+ await flushUi(() => {
+ root.unmount();
+ });
+ });
+
+ it("blocks empty required dates, submits date strings, and omits optional empty dates", async () => {
+ const onSubmit = vi.fn();
+ const { root } = await renderRoutineRunDialog(container, {
+ variables: [
+ {
+ name: "startDate",
+ label: null,
+ type: "date",
+ defaultValue: null,
+ required: true,
+ options: [],
+ },
+ {
+ name: "releaseOn",
+ label: "Release on",
+ type: "date",
+ defaultValue: null,
+ required: true,
+ options: [],
+ },
+ {
+ name: "endDate",
+ label: null,
+ type: "date",
+ defaultValue: null,
+ required: false,
+ options: [],
+ },
+ ],
+ onSubmit,
+ });
+
+ const runButton = findRunButton();
+ expect(runButton?.disabled).toBe(true);
+ expect(document.body.textContent).toContain("Missing: startDate, Release on");
+
+ const dateInputs = Array.from(document.querySelectorAll('input[type="date"]'));
+ await flushUi(() => {
+ setInputValue(dateInputs[0]!, "2026-07-04");
+ setInputValue(dateInputs[1]!, "2026-08-01");
+ });
+
+ expect(runButton?.disabled).toBe(false);
+
+ await flushUi(() => {
+ runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(onSubmit).toHaveBeenCalledWith({
+ variables: {
+ startDate: "2026-07-04",
+ releaseOn: "2026-08-01",
+ },
+ assigneeAgentId: "agent-1",
+ projectId: null,
+ });
+
+ await flushUi(() => {
root.unmount();
});
});
diff --git a/ui/src/components/RoutineRunVariablesDialog.tsx b/ui/src/components/RoutineRunVariablesDialog.tsx
index 1dfb94919d..25449ec732 100644
--- a/ui/src/components/RoutineRunVariablesDialog.tsx
+++ b/ui/src/components/RoutineRunVariablesDialog.tsx
@@ -153,6 +153,10 @@ function isMissingRequiredValue(value: unknown) {
return value == null || (typeof value === "string" && value.trim().length === 0);
}
+function shouldUseDateInput(variable: RoutineVariable) {
+ return variable.type === "date";
+}
+
function supportsRoutineRunWorkspaceSelection(
project: Project | null | undefined,
isolatedWorkspacesEnabled: boolean,
@@ -497,6 +501,12 @@ export function RoutineRunVariablesDialog({
))}
+ ) : shouldUseDateInput(variable) ? (
+ setValues((current) => ({ ...current, [variable.name]: event.target.value }))}
+ />
) : (
void) {
+ flushSync(callback);
+}
+
+describe("RoutineVariablesEditor", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ });
+
+ it("renders date variable defaults with a date input", () => {
+ const root = createRoot(container);
+ const variables: RoutineVariable[] = [
+ {
+ name: "startDate",
+ label: null,
+ type: "date",
+ defaultValue: "2026-06-26",
+ required: true,
+ options: [],
+ },
+ ];
+
+ flushUi(() => {
+ root.render(
+ ,
+ );
+ });
+
+ const dateInput = container.querySelector('input[type="date"]');
+ expect(dateInput?.value).toBe("2026-06-26");
+
+ flushUi(() => root.unmount());
+ });
+
+ it("documents capital-Date default type behavior", () => {
+ const root = createRoot(container);
+
+ flushUi(() => {
+ root.render();
+ });
+
+ const helpButton = document.querySelector('button[aria-label="Show variable help"]');
+ expect(helpButton).toBeTruthy();
+
+ flushUi(() => {
+ helpButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(document.body.textContent).toContain("Variable names ending in capital Date");
+ expect(document.body.textContent).toContain("startDate");
+
+ flushUi(() => root.unmount());
+ });
+});
diff --git a/ui/src/components/RoutineVariablesEditor.tsx b/ui/src/components/RoutineVariablesEditor.tsx
index cae1b0bc75..b93091c4b7 100644
--- a/ui/src/components/RoutineVariablesEditor.tsx
+++ b/ui/src/components/RoutineVariablesEditor.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, HelpCircle } from "lucide-react";
-import { syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
+import { isValidRoutineDateString, syncRoutineVariablesWithTemplate, type RoutineVariable } from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
@@ -21,7 +21,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
-const variableTypes: RoutineVariable["type"][] = ["text", "textarea", "number", "boolean", "select"];
+const variableTypes: RoutineVariable["type"][] = ["text", "textarea", "number", "boolean", "select", "date"];
function serializeVariables(value: RoutineVariable[]) {
return JSON.stringify(value);
@@ -42,6 +42,14 @@ function updateVariableList(
return variables.map((variable) => (variable.name === name ? mutate(variable) : variable));
}
+function defaultValueForType(type: RoutineVariable["type"], current: RoutineVariable["defaultValue"]) {
+ if (type === "boolean") return null;
+ if (type === "date") {
+ return typeof current === "string" && isValidRoutineDateString(current) ? current : null;
+ }
+ return current;
+}
+
export function RoutineVariablesEditor({
title,
description,
@@ -114,7 +122,7 @@ export function RoutineVariablesEditor({
onValueChange={(type) => onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
...current,
type: type as RoutineVariable["type"],
- defaultValue: type === "boolean" ? null : current.defaultValue,
+ defaultValue: defaultValueForType(type as RoutineVariable["type"], current.defaultValue),
options: type === "select" ? current.options : [],
})))}
>
@@ -212,6 +220,15 @@ export function RoutineVariablesEditor({
+ ) : variable.type === "date" ? (
+ onChange(updateVariableList(syncedVariables, variable.name, (current) => ({
+ ...current,
+ defaultValue: event.target.value || null,
+ })))}
+ />
) : (
- Names must start with a letter and may use letters, numbers, and underscores.
- - Pick a type (text, textarea, number, boolean, select), default value, and whether it is required.
+ - Pick a type (text, textarea, number, boolean, select, date), default value, and whether it is required.
+ - Variable names ending in capital Date, such as startDate, are created as date variables by default.
- The same name reused across the title and instructions is treated as one variable.