diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index bc768b32ff875..971962bb8030c 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -109,6 +109,8 @@ const DAYS = [ function describeSchedule(task: ScheduledTask): string { if (task.schedule.type === "webhook") return "On webhook"; if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs); + if (task.schedule.type === "monthly") + return `Day ${task.schedule.dayOfMonth} of each month at ${formatTime(task.schedule.timeOfDay)}`; const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day"; return `${days} at ${formatTime(task.schedule.timeOfDay)}`; } @@ -651,6 +653,8 @@ function TaskForm({ ); const projects = useProjects().filter((project) => project.environmentId === environmentId); const config = useEnvironmentServerConfig(environmentId); + const supportsMonthly = config?.environment.capabilities.monthlyScheduledTasks === true; + const monthlyUnsupported = draft.schedule.mode === "monthly" && !supportsMonthly; const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); const canOperate = useAtomValue( serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), @@ -685,12 +689,20 @@ function TaskForm({ environmentUnavailable ) return; + if (monthlyUnsupported) { + Dialog.alert( + "Monthly scheduling unavailable", + "Update T3 Code on this environment to save monthly schedules.", + ); + return; + } // Preserve signatures edited on other clients by sending the task's current value. const liveTask = tasks.data?.tasks.find((task) => task.id === draft.task?.id); const schedule = scheduleFromDraft( draft.schedule.mode === "webhook" && liveTask?.schedule.type === "webhook" ? { ...draft.schedule, signature: liveTask.schedule.signature } : draft.schedule, + supportsMonthly, ); if ( draft.schedule.mode === "webhook" && @@ -936,7 +948,8 @@ function TaskForm({ - {draft.schedule.mode === "fixed_time" ? ( + {monthlyUnsupported ? ( + + Update T3 Code on this environment to save monthly schedules. + + ) : null} + {draft.schedule.mode === "fixed_time" || draft.schedule.mode === "monthly" ? ( <> ) : null} - ({ - id: String(day.index), - title: day.label, - attributes: { keepsMenuPresented: true }, - state: draft.schedule.weekdays.includes(day.index) - ? ("on" as const) - : undefined, - })), - ]} - onSelect={(id) => { - const weekdays = - id === "every_day" - ? DAYS.map((day) => day.index) - : id === "weekdays" - ? [1, 2, 3, 4, 5] - : (() => { - const day = DAYS.find((item) => String(item.index) === id); - if (!day) return draft.schedule.weekdays; - return draft.schedule.weekdays.includes(day.index) - ? draft.schedule.weekdays.filter((index) => index !== day.index) - : [...draft.schedule.weekdays, day.index]; - })(); - setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } }); - }} - /> + {draft.schedule.mode === "monthly" ? ( + ({ + id: String(index + 1), + title: String(index + 1), + state: draft.schedule.dayOfMonth === String(index + 1) ? "on" : undefined, + }))} + onSelect={(dayOfMonth) => + setDraft({ ...draft, schedule: { ...draft.schedule, dayOfMonth } }) + } + /> + ) : ( + ({ + id: String(day.index), + title: day.label, + attributes: { keepsMenuPresented: true }, + state: draft.schedule.weekdays.includes(day.index) + ? ("on" as const) + : undefined, + })), + ]} + onSelect={(id) => { + const weekdays = + id === "every_day" + ? DAYS.map((day) => day.index) + : id === "weekdays" + ? [1, 2, 3, 4, 5] + : (() => { + const day = DAYS.find((item) => String(item.index) === id); + if (!day) return draft.schedule.weekdays; + return draft.schedule.weekdays.includes(day.index) + ? draft.schedule.weekdays.filter((index) => index !== day.index) + : [...draft.schedule.weekdays, day.index]; + })(); + setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } }); + }} + /> + )} + {draft.schedule.mode === "monthly" ? ( + + Months without this day are skipped. + + ) : null} ) : draft.schedule.mode === "webhook" ? ( <> @@ -1127,7 +1166,7 @@ function TaskForm({ /> - {draft.schedule.mode === "fixed_time" ? ( + {draft.schedule.mode === "fixed_time" || draft.schedule.mode === "monthly" ? ( Time uses the environment's time zone, which may differ from your phone's. @@ -1137,7 +1176,13 @@ function TaskForm({ tone="primary" fullWidth loading={saving} - disabled={!canOperate || dictationPending || taskMissing || environmentUnavailable} + disabled={ + !canOperate || + dictationPending || + taskMissing || + environmentUnavailable || + monthlyUnsupported + } onPress={() => void save()} label={saving ? "Saving…" : draft.task ? "Save changes" : "Create task"} /> diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 8cf8bb5b2b72d..86f5d5943ea64 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -11,9 +11,43 @@ import { DEFAULT_SCHEDULE, scheduleDraftForTask, scheduleFromDraft, + createDraft, + hasScheduledTaskDraftChanges, } from "./scheduledTaskDraft"; describe("scheduleDraftForTask", () => { + it("refuses a monthly payload for an unsupported destination without changing the draft", () => { + const schedule = { type: "monthly", dayOfMonth: 31, timeOfDay: "17:45" } as const; + const draft = scheduleDraftForTask({ schedule }); + expect(scheduleFromDraft(draft, true)).toEqual(schedule); + expect(scheduleFromDraft(draft, false)).toBeNull(); + expect(scheduleFromDraft(draft)).toBeNull(); + expect(scheduleFromDraft(draft, true)).toEqual(schedule); + expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval" }, false)).toEqual({ + type: "interval", + everyMs: 900_000, + }); + }); + + it("round-trips monthly schedules and tracks edits to their day", () => { + const schedule = { type: "monthly", dayOfMonth: 31, timeOfDay: "17:45" } as const; + const draft = scheduleDraftForTask({ schedule }); + expect(draft.mode).toBe("monthly"); + expect(scheduleFromDraft(draft, true)).toEqual(schedule); + for (const dayOfMonth of ["", "0", "32", "1.5", "abc"]) { + expect(scheduleFromDraft({ ...draft, dayOfMonth }, true)).toBeNull(); + } + expect(scheduleFromDraft({ ...draft, timeOfDay: "24:00" }, true)).toBeNull(); + const initial = { ...createDraft(null, null), schedule: draft }; + expect(hasScheduledTaskDraftChanges(initial, initial)).toBe(false); + expect( + hasScheduledTaskDraftChanges(initial, { + ...initial, + schedule: { ...draft, dayOfMonth: "15" }, + }), + ).toBe(true); + }); + it("opens legacy sub-minute schedules at the writable minimum", () => { const draft = scheduleDraftForTask({ schedule: { type: "interval", everyMs: 30_000 } }); expect(draft.intervalMinutes).toBe("1"); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 3ef1a2f3faf6e..cb92ae1d8ee44 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -51,8 +51,9 @@ export function scheduledTaskDefaultModel( } export type ScheduleDraft = { - readonly mode: "fixed_time" | "interval" | "webhook"; + readonly mode: "fixed_time" | "monthly" | "interval" | "webhook"; readonly timeOfDay: string; + readonly dayOfMonth: string; readonly weekdays: ReadonlyArray; readonly intervalMinutes: string; readonly signature: ScheduledTaskWebhookSignature | null; @@ -68,6 +69,7 @@ export type ScheduleDraft = { export const DEFAULT_SCHEDULE: ScheduleDraft = { mode: "fixed_time", timeOfDay: "09:00", + dayOfMonth: "1", weekdays: [1, 2, 3, 4, 5], intervalMinutes: "15", signature: null, @@ -81,6 +83,13 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = { export function scheduleDraftForTask(task: Pick): ScheduleDraft { switch (task.schedule.type) { + case "monthly": + return { + ...DEFAULT_SCHEDULE, + mode: "monthly", + timeOfDay: task.schedule.timeOfDay, + dayOfMonth: String(task.schedule.dayOfMonth), + }; case "fixed_time": return { ...DEFAULT_SCHEDULE, @@ -116,7 +125,10 @@ export function scheduleDraftForTask(task: Pick): Sch } } -export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null { +export function scheduleFromDraft( + draft: ScheduleDraft, + supportsMonthly = false, +): ScheduledTaskUpsertSchedule | null { if (draft.mode === "webhook") { const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); if (maxDeliveryAgeMinutes === undefined) return null; @@ -167,6 +179,13 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche const everyMs = Math.round(minutes * 60_000); return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null; } + if (draft.mode === "monthly") { + if (!supportsMonthly) return null; + const dayOfMonth = Number(draft.dayOfMonth); + if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null; + if (!/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay)) return null; + return { type: "monthly", timeOfDay: draft.timeOfDay, dayOfMonth }; + } const weekdays = [...new Set(draft.weekdays)].sort((a, b) => a - b); if ( !/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay) || @@ -211,6 +230,7 @@ function draftSignature(draft: ScheduledTaskDraft): string { .map((option) => [option.id, option.value]), draft.schedule.mode, draft.schedule.timeOfDay, + draft.schedule.dayOfMonth, [...draft.schedule.weekdays].sort((a, b) => a - b), draft.schedule.intervalMinutes, draft.schedule.maxDeliveryAgeMinutes, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..d53858a3afd3e 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -213,6 +213,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.monthlyScheduledTasks).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 4678aff95b736..2ed57aec28d81 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -216,6 +216,7 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + monthlyScheduledTasks: true, attachmentUploads: true, questionAttachments: true, fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts index f5973969747f7..2bc9525967a33 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -1,9 +1,37 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; -import { isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; +import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; describe("scheduled task schedule calculation", () => { + it.each([ + ["2026-01-14T18:00:00Z", 15, "2026-01-15T14:00:00.000Z"], + ["2026-01-15T14:00:00Z", 15, "2026-02-15T14:00:00.000Z"], + ["2026-01-31T15:00:00Z", 31, "2026-03-31T13:00:00.000Z"], + ["2026-04-01T00:00:00Z", 31, "2026-05-31T13:00:00.000Z"], + ["2027-02-01T00:00:00Z", 29, "2027-03-29T13:00:00.000Z"], + ["2028-02-01T00:00:00Z", 29, "2028-02-29T14:00:00.000Z"], + ["2026-12-31T15:00:00Z", 1, "2027-01-01T14:00:00.000Z"], + ])("finds the monthly run after %s for day %s", (from, dayOfMonth, expected) => { + const next = nextScheduledRunAt( + { type: "monthly", dayOfMonth, timeOfDay: "09:00" }, + DateTime.makeZonedUnsafe(from, { timeZone: "America/New_York" }), + ); + expect(next && DateTime.formatIso(next)).toBe(expected); + }); + + it("compares monthly days and applies the fixed-time missed-run grace", () => { + const schedule = { type: "monthly", dayOfMonth: 15, timeOfDay: "09:00" } as const; + expect(isSameSchedule(schedule, { ...schedule, timeOfDay: "9:00" })).toBe(true); + expect(isSameSchedule(schedule, { ...schedule, dayOfMonth: 16 })).toBe(false); + expect(isSameSchedule(schedule, { ...schedule, timeOfDay: "10:00" })).toBe(false); + expect(isSameSchedule(schedule, { type: "fixed_time", timeOfDay: "09:00" })).toBe(false); + expect(isSameSchedule({ type: "fixed_time", timeOfDay: "09:00" }, schedule)).toBe(false); + const due = DateTime.makeUnsafe("2026-01-15T09:00:00Z"); + expect(isMissedFixedTimeRun(schedule, due, DateTime.add(due, { minutes: 10 }))).toBe(false); + expect(isMissedFixedTimeRun(schedule, due, DateTime.add(due, { minutes: 11 }))).toBe(true); + }); + it("skips to the next matching fixed-time weekday", () => { const next = nextScheduledRunAt( { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 2, 3, 4, 5] }, diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index bac32bb913146..003ec8c4c7df8 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -21,6 +21,22 @@ export function nextScheduledRunAt( const time = parseTimeOfDay(schedule.timeOfDay); if (time === null) return null; + if (schedule.type === "monthly") { + const monthStart = DateTime.setParts(from, { day: 1 }); + for (let offset = 0; offset <= 2; offset += 1) { + const candidate = DateTime.setParts(DateTime.add(monthStart, { months: offset }), { + day: schedule.dayOfMonth, + hour: time.hour, + minute: time.minute, + second: 0, + millisecond: 0, + }); + if (DateTime.toParts(candidate).day !== schedule.dayOfMonth) continue; + if (DateTime.toEpochMillis(candidate) <= DateTime.toEpochMillis(from)) continue; + return candidate; + } + return null; + } const weekdays = schedule.weekdays && schedule.weekdays.length > 0 ? new Set(schedule.weekdays) : null; for (let offset = 0; offset <= 7; offset += 1) { @@ -48,7 +64,7 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul return b.type === "interval" && a.everyMs === b.everyMs; } if (a.type === "webhook") return b.type === "webhook"; - if (b.type !== "fixed_time") return false; + if (b.type !== a.type) return false; // Compare parsed times so padded-hour edits do not reschedule pending runs. const aTime = parseTimeOfDay(a.timeOfDay); const bTime = parseTimeOfDay(b.timeOfDay); @@ -57,7 +73,9 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul bTime !== null && aTime.hour === bTime.hour && aTime.minute === bTime.minute && - weekdayKey(a.weekdays) === weekdayKey(b.weekdays) + (a.type === "monthly" + ? b.type === "monthly" && a.dayOfMonth === b.dayOfMonth + : b.type === "fixed_time" && weekdayKey(a.weekdays) === weekdayKey(b.weekdays)) ); } @@ -68,6 +86,6 @@ export function isMissedFixedTimeRun( dueAt: DateTime.DateTime, now: DateTime.DateTime, ): boolean { - if (schedule.type !== "fixed_time") return false; + if (schedule.type !== "fixed_time" && schedule.type !== "monthly") return false; return DateTime.toEpochMillis(now) - DateTime.toEpochMillis(dueAt) > MISSED_FIXED_TIME_GRACE_MS; } diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 09e1d7674044f..9822d0ae5abc9 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -17,6 +17,62 @@ import * as ScheduledTaskService from "./ScheduledTaskService.ts"; const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); +it.effect("persists monthly schedules and recalculates when their day changes", () => + Effect.gen(function* () { + const localDate = (month: number, day: number, hour: number) => + DateTime.makeZonedUnsafe( + { year: 2026, month, day, hour, minute: 0, second: 0, millisecond: 0 }, + { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true }, + ); + yield* TestClock.setTime(DateTime.toEpochMillis(localDate(1, 31, 10))); + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const input = yield* decodeUpsertInput({ + title: "Monthly review", + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "monthly", dayOfMonth: 31, timeOfDay: "09:00" }, + projectId: "project-monthly-schedule", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + const created = (yield* service.upsert(input)).task; + const expected = DateTime.formatIso(localDate(3, 31, 9)); + expect(created.schedule).toEqual(input.schedule); + expect(created.nextRunAt).toBe(expected); + expect((yield* service.list()).tasks[0]?.schedule).toEqual(input.schedule); + expect( + (yield* service.setEnabled({ id: created.id, enabled: false })).task.nextRunAt, + ).toBeNull(); + expect((yield* service.setEnabled({ id: created.id, enabled: true })).task.nextRunAt).toBe( + expected, + ); + + const edited = yield* service.upsert({ ...input, id: created.id, title: "Edited" }); + expect(edited.task.nextRunAt).toBe(expected); + const rescheduled = yield* service.upsert({ + ...input, + id: created.id, + schedule: { type: "monthly", dayOfMonth: 15, timeOfDay: "09:00" }, + }); + expect(rescheduled.task.nextRunAt).toBe(DateTime.formatIso(localDate(2, 15, 9))); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + it.effect("rejects a stale form save after deletion while preserving explicit-id creates", () => Effect.gen(function* () { const layerDependencies = Layer.mergeAll( diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index e42242f01093e..0f5a076ff6369 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; +import { createContext, useContext } from "react"; import "~/index.css"; import { render } from "../../../test/render"; @@ -39,13 +40,27 @@ const task: ScheduledTask = { }; const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true })); +const MonthlySchedulingContext = createContext(undefined); -function environment() { +function setup() { + state.connected = true; + state.tasksLoaded = true; +} + +function environment(monthlySupported?: boolean) { return { environmentId, label: "Laptop", connection: { phase: state.connected ? "connected" : "disconnected" }, - serverConfig: state.connected ? { environment: { platform: "linux" } } : null, + serverConfig: state.connected + ? { + environment: { + platform: "linux", + capabilities: + monthlySupported === undefined ? {} : { monthlyScheduledTasks: monthlySupported }, + }, + } + : null, }; } @@ -93,7 +108,7 @@ vi.mock(import("../../state/query"), async (original) => ({ })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), - useEnvironment: (() => environment()) as never, + useEnvironment: (() => environment(useContext(MonthlySchedulingContext))) as never, })); vi.mock(import("../../state/entities"), async (original) => ({ ...(await original()), @@ -106,8 +121,7 @@ vi.mock(import("../../hooks/useSettings"), async (original) => ({ describe("ScheduledTasksSettings", () => { it("keeps a linked task's unsaved edits across a disconnect and reconnect", async () => { - state.connected = true; - state.tasksLoaded = true; + setup(); await using screen = await render(); const name = page.getByRole("dialog").getByRole("textbox", { name: "Name" }); await expect.element(name).toHaveValue("Review issues"); @@ -124,7 +138,7 @@ describe("ScheduledTasksSettings", () => { }); it("keeps an editor the user opened when the linked task loads later", async () => { - state.connected = true; + setup(); state.tasksLoaded = false; await using screen = await render(); await userEvent.click(page.getByRole("button", { name: "New task" })); @@ -138,4 +152,45 @@ describe("ScheduledTasksSettings", () => { await expect.element(dialog.getByRole("heading", { name: "New task" })).toBeInTheDocument(); await expect.element(name).toHaveValue("My draft"); }); + + it("offers monthly scheduling only when supported and preserves a draft when support is lost", async () => { + setup(); + await using screen = await render( + + + , + ); + const dialog = page.getByRole("dialog"); + const monthly = dialog.getByRole("button", { name: "Monthly", exact: true }); + await expect.element(monthly).not.toBeInTheDocument(); + + await screen.rerender( + + + , + ); + await userEvent.click(monthly); + const day = dialog.getByRole("combobox", { name: "Day of month" }); + await userEvent.click(day); + await userEvent.click(page.getByRole("option", { name: "31", exact: true })); + const name = dialog.getByRole("textbox", { name: "Name" }); + await userEvent.fill(name, "Monthly review"); + + await screen.rerender( + + + , + ); + await expect.element(monthly).toBeDisabled(); + await expect.element(day).toHaveTextContent("31"); + await expect.element(name).toHaveValue("Monthly review"); + await expect + .element(dialog.getByText("Update T3 Code on this environment to save monthly schedules.")) + .toBeVisible(); + const save = dialog.getByRole("button", { name: "Save task" }); + await expect.element(save).toBeDisabled(); + + await userEvent.click(dialog.getByRole("button", { name: "Weekly", exact: true })); + await expect.element(save).toBeEnabled(); + }); }); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 3b842367ab4c1..c36bb70ba325e 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -138,6 +138,7 @@ const EMPTY_DRAFT: DraftState = { scheduleMode: "fixed", intervalMinutes: "15", timeOfDay: "09:00", + dayOfMonth: "1", weekdays: new Set([1, 2, 3, 4, 5]), projectId: "", threadId: "", @@ -288,6 +289,8 @@ function splitModelKey(value: string): ModelSelection | null { export function scheduleLabel(schedule: ScheduledTaskSchedule): string { if (schedule.type === "webhook") return "On webhook"; + if (schedule.type === "monthly") + return `Day ${schedule.dayOfMonth} of each month at ${schedule.timeOfDay}`; if (schedule.type === "interval") { const minutes = schedule.everyMs / 60_000; return Number.isInteger(minutes) @@ -1120,6 +1123,8 @@ function ScheduledTaskEditorForm({ const environment = useEnvironment(environmentId); const connected = environment?.connection.phase === "connected" && environment.serverConfig !== null; + const supportsMonthly = + environment?.serverConfig?.environment.capabilities.monthlyScheduledTasks === true; const tasksQuery = useEnvironmentQuery( connected ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null, ); @@ -1141,6 +1146,7 @@ function ScheduledTaskEditorForm({ const [draft, setDraft] = useState(() => task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" }, ); + const monthlyUnsupported = draft.scheduleMode === "monthly" && !supportsMonthly; const submissionPending = useRef(false); const editingTaskMissing = draft.editingId !== null && @@ -1211,8 +1217,17 @@ function ScheduledTaskEditorForm({ reportFailure("Invalid filter", parsedFilters.error); return; } - const schedule = scheduleFromDraft(draft); + if (monthlyUnsupported) { + reportFailure( + "Monthly scheduling unavailable", + "Update T3 Code on this environment to save monthly schedules.", + ); + return; + } + const schedule = scheduleFromDraft(draft, supportsMonthly); if (schedule === null) { + if (draft.scheduleMode === "monthly") + reportFailure("Invalid monthly schedule", "Choose a day from 1 to 31 and a valid time."); for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description); return; } @@ -1510,7 +1525,12 @@ function ScheduledTaskEditorForm({ value={[draft.scheduleMode]} onValueChange={(values) => { const mode = values[0] as ScheduleMode | undefined; - if (mode === "fixed" || mode === "interval" || mode === "webhook") + if ( + mode === "fixed" || + (mode === "monthly" && supportsMonthly) || + mode === "interval" || + mode === "webhook" + ) setDraft((current) => ({ ...current, scheduleMode: mode, @@ -1521,12 +1541,23 @@ function ScheduledTaskEditorForm({ })); }} > - At a time + Weekly + {supportsMonthly || draft.scheduleMode === "monthly" ? ( + + Monthly + + ) : null} Every interval On webhook + {monthlyUnsupported ? ( +

+ Update T3 Code on this environment to save monthly schedules. +

+ ) : null} + {draft.scheduleMode === "webhook" ? (
@@ -1697,42 +1728,75 @@ function ScheduledTaskEditorForm({
) : null} - ) : draft.scheduleMode === "fixed" ? ( -
-
- - - setDraft((current) => ({ ...current, timeOfDay: event.target.value })) - } - /> - on + ) : draft.scheduleMode === "fixed" || draft.scheduleMode === "monthly" ? ( +
+
+
+ + + setDraft((current) => ({ ...current, timeOfDay: event.target.value })) + } + /> + {draft.scheduleMode === "fixed" ? ( + on + ) : null} +
+ {draft.scheduleMode === "monthly" ? ( +
+ + +
+ ) : ( + { + if (values.length === 0) return; + setDraft((current) => ({ + ...current, + weekdays: new Set(values.map(Number)), + })); + }} + > + {WEEKDAY_ORDER.map((day) => ( + + {WEEKDAY_SHORT[day]} + + ))} + + )}
- { - if (values.length === 0) return; - setDraft((current) => ({ - ...current, - weekdays: new Set(values.map(Number)), - })); - }} - > - {WEEKDAY_ORDER.map((day) => ( - - {WEEKDAY_SHORT[day]} - - ))} - + {draft.scheduleMode === "monthly" ? ( +

+ Uses the environment's time zone. Months without this day are skipped. +

+ ) : null}
) : (
@@ -1777,7 +1841,14 @@ function ScheduledTaskEditorForm({