From 34c9543d54b1fd02b6481b18b562bc286a46f4e1 Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Mon, 12 Oct 2026 07:46:52 -0400 Subject: [PATCH 1/5] fix(web): scheduled task times name the server's timezone Fixed-time schedules run in the server's timezone, but the settings row, the Run at picker and the thread's automations panel showed the bare time. A browser in another zone could not tell when a task would run. The scheduled task list now carries the server's IANA zone, and those three places name it ("Daily at 16:31 UTC"). Older servers send no zone, and their labels stay as they were. Refs #18632 Co-Authored-By: Claude Opus 5.5 --- .../ScheduledTaskService.schedule.test.ts | 19 ++++++++ .../scheduledTasks/ScheduledTaskService.ts | 6 ++- apps/server/src/ws.ts | 1 + .../chat/ThreadAutomationsPanel.tsx | 45 +++++++++++++------ .../settings/ScheduledTasksSettings.tsx | 19 ++++++-- .../scheduledTasksSettings.logic.test.ts | 13 ++++++ .../settings/scheduledTasksSettings.logic.ts | 9 ++++ packages/contracts/src/scheduledTask.ts | 2 + 8 files changed, 96 insertions(+), 18 deletions(-) diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index c562134f0d8b..b1d8373687ec 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -53,6 +53,25 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); +it.effect("lists the zone fixed-time schedules run in, before any task exists", () => + Effect.gen(function* () { + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + expect(yield* service.list()).toEqual({ + tasks: [], + timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), + }); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + it.effect("preserves a due run when a save only pads the scheduled hour", () => Effect.gen(function* () { const dueAt = DateTime.makeZonedUnsafe( diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 82899bba752d..f15c1201200d 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -916,7 +916,11 @@ export const layer = Layer.effect( const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( - Effect.map((tasks) => ({ tasks })), + // Fixed-time schedules run in the server's zone, which a client cannot see on its own. + Effect.map((tasks) => ({ + tasks, + timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), + })), Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ade826e53f38..166b3d9f7f92 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1309,6 +1309,7 @@ const layerWsRpc = ( currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result : { + ...result, tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task), }; const authorizeEffect = ( diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 94f6ff5b6635..c3cad727cf71 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -30,6 +30,24 @@ import { THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, } from "./threadDetailsPanelStyles"; +function scheduleSummary(task: ScheduledTask, timeZone: string | undefined) { + return ( + <> + {scheduleLabel(task.schedule, timeZone)} + {task.enabled && task.nextRunAt !== null ? ( + <> + {" · next "} + + + ) : task.enabled ? ( + "" + ) : ( + " · paused" + )} + + ); +} + const STATUS_DOT_CLASS: Record = { never: "bg-muted-foreground/40", running: "bg-sky-500", @@ -166,19 +184,20 @@ export function ThreadAutomationsPanel(props: { {task.title} -

- {scheduleLabel(task.schedule)} - {task.enabled && task.nextRunAt !== null ? ( - <> - {" · next "} - - - ) : task.enabled ? ( - "" - ) : ( - " · paused" - )} -

+ {/* The card is narrow, so the line can cut off the zone; hover or focus shows all of it. */} + + + } + > + {scheduleSummary(task, tasksQuery.data?.timeZone)} + + {scheduleSummary(task, tasksQuery.data?.timeZone)} + day >= 1 && day <= 5) ? "Weekdays" : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - return `${days} at ${schedule.timeOfDay}`; + const zone = scheduleTimeZoneLabel(timeZone); + return `${days} at ${schedule.timeOfDay}${zone === null ? "" : ` ${zone}`}`; } /** @@ -364,6 +366,7 @@ function ScheduledTaskEnvironmentList({ return { environment, connected: index >= 0, + timeZone: data?.timeZone, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), @@ -405,7 +408,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks }) => { + {populated.map(({ environment, tasks, timeZone }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); const transport = environmentTransportLabel(environment); return ( @@ -435,6 +438,7 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -550,6 +554,7 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -563,10 +568,12 @@ function ScheduledTaskEnvironmentSection({ function ScheduledTaskRow({ environmentId, task, + timeZone, onEdit, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; + readonly timeZone: string | undefined; readonly onEdit: () => void; }) { const canOperate = useAtomValue( @@ -611,7 +618,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule)} ·{" "} + {scheduleLabel(task.schedule, timeZone)} ·{" "} {!task.enabled ? ( "Paused" ) : isWebhook ? ( @@ -998,6 +1005,7 @@ function ScheduledTaskEditorForm({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); + const runAtZone = scheduleTimeZoneLabel(tasksQuery.data?.timeZone); const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; @@ -1501,6 +1509,9 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, timeOfDay: event.target.value })) } /> + {runAtZone === null ? null : ( + {runAtZone} + )} on
{ ).toBeNull(); }); }); + +describe("scheduleTimeZoneLabel", () => { + it("names the server's zone, with UTC shown as UTC", () => { + expect(scheduleTimeZoneLabel("Etc/UTC")).toBe("UTC"); + expect(scheduleTimeZoneLabel("UTC")).toBe("UTC"); + expect(scheduleTimeZoneLabel("Asia/Kolkata")).toBe("Asia/Kolkata"); + }); + + it("names nothing for an older server that does not report its zone", () => { + expect(scheduleTimeZoneLabel(undefined)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 0767f99190c9..a90c9248fbb8 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -19,6 +19,15 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; import type { ResolvedSettingsScope } from "./settingsScope"; +/** + * The zone to name after a fixed-time schedule, from the one the server reports. Older + * servers report none, so their labels stay as they were. + */ +export function scheduleTimeZoneLabel(timeZone: string | undefined): string | null { + if (timeZone === undefined) return null; + return timeZone === "Etc/UTC" || timeZone === "UTC" ? "UTC" : timeZone; +} + export function matchesScheduledTaskScope( scope: ResolvedSettingsScope, environmentId: EnvironmentId, diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 9a24e1b8abe3..56fad4f5d177 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -196,6 +196,8 @@ export const ScheduledTaskListResult = Schema.Struct({ // Trigger types grow over time; a client must not lose the whole list over // one task it cannot decode. tasks: ForwardCompatibleArray(ScheduledTask), + /** The IANA zone fixed-time schedules run in. Absent from older servers. */ + timeZone: Schema.optional(TrimmedNonEmptyString), }); export type ScheduledTaskListResult = typeof ScheduledTaskListResult.Type; From 858dfe1519395e591bdcf09dc9b3b982bbb40bc3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 16:55:33 +0000 Subject: [PATCH 2/5] refactor(scheduler): reset timezone proof of concept for takeover --- .../scheduledTasks/ScheduledTaskService.ts | 6 +-- apps/server/src/ws.ts | 1 - .../chat/ThreadAutomationsPanel.tsx | 45 ++++++------------- .../settings/ScheduledTasksSettings.tsx | 19 ++------ .../settings/scheduledTasksSettings.logic.ts | 9 ---- packages/contracts/src/scheduledTask.ts | 2 - 6 files changed, 18 insertions(+), 64 deletions(-) diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index f15c1201200d..82899bba752d 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -916,11 +916,7 @@ export const layer = Layer.effect( const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( - // Fixed-time schedules run in the server's zone, which a client cannot see on its own. - Effect.map((tasks) => ({ - tasks, - timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), - })), + Effect.map((tasks) => ({ tasks })), Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 166b3d9f7f92..ade826e53f38 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1309,7 +1309,6 @@ const layerWsRpc = ( currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result : { - ...result, tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task), }; const authorizeEffect = ( diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index c3cad727cf71..94f6ff5b6635 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -30,24 +30,6 @@ import { THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, } from "./threadDetailsPanelStyles"; -function scheduleSummary(task: ScheduledTask, timeZone: string | undefined) { - return ( - <> - {scheduleLabel(task.schedule, timeZone)} - {task.enabled && task.nextRunAt !== null ? ( - <> - {" · next "} - - - ) : task.enabled ? ( - "" - ) : ( - " · paused" - )} - - ); -} - const STATUS_DOT_CLASS: Record = { never: "bg-muted-foreground/40", running: "bg-sky-500", @@ -184,20 +166,19 @@ export function ThreadAutomationsPanel(props: { {task.title} - {/* The card is narrow, so the line can cut off the zone; hover or focus shows all of it. */} - - - } - > - {scheduleSummary(task, tasksQuery.data?.timeZone)} - - {scheduleSummary(task, tasksQuery.data?.timeZone)} - +

+ {scheduleLabel(task.schedule)} + {task.enabled && task.nextRunAt !== null ? ( + <> + {" · next "} + + + ) : task.enabled ? ( + "" + ) : ( + " · paused" + )} +

day >= 1 && day <= 5) ? "Weekdays" : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - const zone = scheduleTimeZoneLabel(timeZone); - return `${days} at ${schedule.timeOfDay}${zone === null ? "" : ` ${zone}`}`; + return `${days} at ${schedule.timeOfDay}`; } /** @@ -366,7 +364,6 @@ function ScheduledTaskEnvironmentList({ return { environment, connected: index >= 0, - timeZone: data?.timeZone, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), @@ -408,7 +405,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks, timeZone }) => { + {populated.map(({ environment, tasks }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); const transport = environmentTransportLabel(environment); return ( @@ -438,7 +435,6 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} - timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -554,7 +550,6 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} - timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -568,12 +563,10 @@ function ScheduledTaskEnvironmentSection({ function ScheduledTaskRow({ environmentId, task, - timeZone, onEdit, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; - readonly timeZone: string | undefined; readonly onEdit: () => void; }) { const canOperate = useAtomValue( @@ -618,7 +611,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule, timeZone)} ·{" "} + {scheduleLabel(task.schedule)} ·{" "} {!task.enabled ? ( "Paused" ) : isWebhook ? ( @@ -1005,7 +998,6 @@ function ScheduledTaskEditorForm({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); - const runAtZone = scheduleTimeZoneLabel(tasksQuery.data?.timeZone); const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; @@ -1509,9 +1501,6 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, timeOfDay: event.target.value })) } /> - {runAtZone === null ? null : ( - {runAtZone} - )} on
Date: Mon, 19 Oct 2026 17:00:09 +0000 Subject: [PATCH 3/5] test(scheduler): cover server timezone metadata and labels --- .../ScheduledTaskService.schedule.test.ts | 32 ++++++++++++-- ...omationsPanel.permissions.browser.test.tsx | 43 ++++++++++++++++++- .../scheduledTasksSettings.logic.test.ts | 13 ------ 3 files changed, 69 insertions(+), 19 deletions(-) diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index b1d8373687ec..35e18f665109 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -6,6 +6,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts"; @@ -53,7 +54,7 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); -it.effect("lists the zone fixed-time schedules run in, before any task exists", () => +it.effect("includes the scheduler zone in empty lists and subscription updates", () => Effect.gen(function* () { const layerDependencies = Layer.mergeAll( NodeCrypto.layer, @@ -64,10 +65,33 @@ it.effect("lists the zone fixed-time schedules run in, before any task exists", ); yield* Effect.gen(function* () { const service = yield* ScheduledTaskService.ScheduledTaskService; - expect(yield* service.list()).toEqual({ - tasks: [], - timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), + const timeZone = DateTime.zoneToString(DateTime.zoneMakeLocal()); + expect(yield* service.list()).toEqual({ tasks: [], timeZone }); + + const input = yield* decodeUpsertInput({ + title: "Morning review", + prompt: "Review the open pull requests.", + enabled: false, + schedule: { type: "fixed_time", timeOfDay: "09:00" }, + projectId: "project-schedule-zone", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + const snapshots = yield* service.subscribeList().pipe( + Stream.tap((snapshot) => + snapshot.tasks.length === 0 ? service.upsert(input) : Effect.void, + ), + Stream.take(2), + Stream.runCollect, + ); + const populated = yield* service.list(); + expect(populated).toMatchObject({ + timeZone, + tasks: [{ title: "Morning review", schedule: input.schedule }], }); + expect(snapshots).toEqual([{ tasks: [], timeZone }, populated]); }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx index e780a6350756..0d38057c6545 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx @@ -1,4 +1,10 @@ -import { EnvironmentId, ThreadId, ScheduledTaskId, type ScheduledTask } from "@t3tools/contracts"; +import { + EnvironmentId, + ThreadId, + ScheduledTaskId, + type ScheduledTask, + type ScheduledTaskListResult, +} from "@t3tools/contracts"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; @@ -28,7 +34,7 @@ vi.mock(import("../../state/use-atom-command"), async (importOriginal) => { }); vi.mock(import("../../state/query"), async (importOriginal) => ({ ...(await importOriginal()), - useEnvironmentQuery: (() => ({ data: { tasks: [task] }, error: null })) as never, + useEnvironmentQuery: (() => ({ data: listResult, error: null })) as never, })); import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; @@ -41,12 +47,15 @@ const task = { enabled: true, schedule: { type: "interval", everyMs: 60000 }, lastRunStatus: "never", + nextRunAt: null, } as ScheduledTask; +let listResult: ScheduledTaskListResult & { timeZone?: string }; let root: Root; let container: HTMLDivElement; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); state.allowed = true; + listResult = { tasks: [task] }; state.toggle.mockReset().mockResolvedValue({ _tag: "Success" }); state.run.mockReset().mockResolvedValue({ _tag: "Success" }); container = document.createElement("div"); @@ -84,3 +93,33 @@ it("rejects a retained toggle after task permission is revoked", async () => { await act(async () => byLabel("Pause Daily review").click()); expect(state.toggle).toHaveBeenCalledOnce(); }); + +it("labels fixed times with the server zone and preserves labels from older servers", async () => { + const tasks: ScheduledTask[] = [ + { ...task, schedule: { type: "fixed_time", timeOfDay: "09:00" } }, + { ...task, id: ScheduledTaskId.make("interval"), title: "Interval review" }, + { + ...task, + id: ScheduledTaskId.make("webhook"), + title: "Webhook review", + schedule: { type: "webhook", signature: null }, + }, + ]; + const labels = () => Array.from(container.querySelectorAll("li p"), (row) => row.textContent); + + listResult = { tasks, timeZone: "Asia/Kolkata" }; + await renderPanel(); + expect(labels()).toEqual(["Daily at 09:00 Asia/Kolkata", "Every 1 min", "On webhook"]); + + listResult = { tasks, timeZone: "Etc/UTC" }; + await renderPanel(); + expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); + + listResult = { tasks, timeZone: "UTC" }; + await renderPanel(); + expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); + + listResult = { tasks }; + await renderPanel(); + expect(labels()).toEqual(["Daily at 09:00", "Every 1 min", "On webhook"]); +}); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 9ac88c4648af..9c67ec46e8e0 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -20,7 +20,6 @@ import { scheduledTaskDefaultModel, matchesScheduledTaskScope, scheduleFromDraft, - scheduleTimeZoneLabel, taskToDraft, } from "./scheduledTasksSettings.logic"; @@ -315,15 +314,3 @@ describe("scheduled task model defaults", () => { ).toBeNull(); }); }); - -describe("scheduleTimeZoneLabel", () => { - it("names the server's zone, with UTC shown as UTC", () => { - expect(scheduleTimeZoneLabel("Etc/UTC")).toBe("UTC"); - expect(scheduleTimeZoneLabel("UTC")).toBe("UTC"); - expect(scheduleTimeZoneLabel("Asia/Kolkata")).toBe("Asia/Kolkata"); - }); - - it("names nothing for an older server that does not report its zone", () => { - expect(scheduleTimeZoneLabel(undefined)).toBeNull(); - }); -}); From 2c490d090011daf5af0d5c464b1b729611d124f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 17:04:21 +0000 Subject: [PATCH 4/5] fix(scheduler): label fixed-time tasks with the server timezone --- .../scheduledTasks/ScheduledTaskService.ts | 5 ++- apps/server/src/ws.ts | 1 + .../chat/ThreadAutomationsPanel.tsx | 38 ++++++++++++------- .../settings/ScheduledTasksSettings.tsx | 22 +++++++++-- packages/contracts/src/scheduledTask.ts | 2 + 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 82899bba752d..e79b5ae5f5b7 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -916,7 +916,10 @@ export const layer = Layer.effect( const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( - Effect.map((tasks) => ({ tasks })), + Effect.map((tasks) => ({ + tasks, + timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), + })), Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ade826e53f38..166b3d9f7f92 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1309,6 +1309,7 @@ const layerWsRpc = ( currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result : { + ...result, tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task), }; const authorizeEffect = ( diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 94f6ff5b6635..48fd8d16db48 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -166,19 +166,31 @@ export function ThreadAutomationsPanel(props: { {task.title} -

- {scheduleLabel(task.schedule)} - {task.enabled && task.nextRunAt !== null ? ( - <> - {" · next "} - - - ) : task.enabled ? ( - "" - ) : ( - " · paused" - )} -

+ + + } + > + {scheduleLabel(task.schedule, tasksQuery.data?.timeZone)} + {task.enabled && task.nextRunAt !== null ? ( + <> + {" · next "} + + + ) : task.enabled ? ( + "" + ) : ( + " · paused" + )} + + + {scheduleLabel(task.schedule, tasksQuery.data?.timeZone)} + + day >= 1 && day <= 5) ? "Weekdays" : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - return `${days} at ${schedule.timeOfDay}`; + const zone = scheduleTimeZoneLabel(timeZone); + return `${days} at ${schedule.timeOfDay}${zone === undefined ? "" : ` ${zone}`}`; } /** @@ -364,6 +369,7 @@ function ScheduledTaskEnvironmentList({ return { environment, connected: index >= 0, + timeZone: data?.timeZone, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), @@ -405,7 +411,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks }) => { + {populated.map(({ environment, tasks, timeZone }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); const transport = environmentTransportLabel(environment); return ( @@ -435,6 +441,7 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -550,6 +557,7 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -563,10 +571,12 @@ function ScheduledTaskEnvironmentSection({ function ScheduledTaskRow({ environmentId, task, + timeZone, onEdit, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; + readonly timeZone: string | undefined; readonly onEdit: () => void; }) { const canOperate = useAtomValue( @@ -611,7 +621,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule)} ·{" "} + {scheduleLabel(task.schedule, timeZone)} ·{" "} {!task.enabled ? ( "Paused" ) : isWebhook ? ( @@ -998,6 +1008,7 @@ function ScheduledTaskEditorForm({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); + const runAtZone = scheduleTimeZoneLabel(tasksQuery.data?.timeZone); const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; @@ -1501,6 +1512,9 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, timeOfDay: event.target.value })) } /> + {runAtZone === undefined ? null : ( + {runAtZone} + )} on
Date: Tue, 20 Oct 2026 04:26:31 +0000 Subject: [PATCH 5/5] test(scheduler): provide ManagedProjectFolders to the zone test layer Co-Authored-By: Claude Opus 5.5 --- .../src/scheduledTasks/ScheduledTaskService.schedule.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index c571c916246e..91d58d53b684 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -65,6 +65,10 @@ it.effect("includes the scheduler zone in empty lists and subscription updates", 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)({}), );