From c01a227c065433584a396b634915ee02bf8789b8 Mon Sep 17 00:00:00 2001 From: Shaun Hawk Date: Wed, 21 Oct 2026 18:11:14 -0400 Subject: [PATCH 1/8] fix(scheduler): show short server timezone on fixed-time labels Fixed-time tasks run in the server zone, but labels showed an unqualified wall-clock time. Restore the list timeZone field and append a short zone (UTC, PDT, GMT+5:30) so the automations panel stays compact after #18653 was reverted for overflowing full IANA names. Fixes #18632 Co-authored-by: Cursor --- .../SettingsScheduledTasksRouteScreen.tsx | 29 ++++++++-- .../ScheduledTaskService.schedule.test.ts | 47 ++++++++++++++++ .../scheduledTasks/ScheduledTaskService.ts | 5 +- apps/server/src/ws.ts | 1 + ...omationsPanel.permissions.browser.test.tsx | 55 ++++++++++++++++++- .../chat/ThreadAutomationsPanel.tsx | 12 ++-- .../settings/ScheduledTasksSettings.tsx | 36 +++++------- .../scheduledTasksSettings.logic.test.ts | 25 +++++++++ .../settings/scheduledTasksSettings.logic.ts | 43 +++++++++++++++ packages/contracts/src/scheduledTask.ts | 2 + 10 files changed, 222 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 36c58dc893d0d..957bcde26334a 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -102,11 +102,28 @@ const DAYS = [ { index: 0, label: "Sun" }, ] as const; -function describeSchedule(task: ScheduledTask): string { +function scheduleTimeZoneLabel(timeZone: string | undefined): string | undefined { + if (timeZone === undefined) return undefined; + if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; + for (const timeZoneName of ["short", "shortOffset"] as const) { + try { + const name = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName }) + .formatToParts(new Date()) + .find((part) => part.type === "timeZoneName")?.value; + if (name && name !== timeZone) return name; + } catch { + // Invalid IANA zone — try the next style, then fall through. + } + } + return timeZone; +} + +function describeSchedule(task: ScheduledTask, timeZone?: string): string { if (task.schedule.type === "webhook") return "On webhook"; if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs); const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day"; - return `${days} at ${formatTime(task.schedule.timeOfDay)}`; + const zone = scheduleTimeZoneLabel(timeZone); + return `${days} at ${formatTime(task.schedule.timeOfDay)}${zone === undefined ? "" : ` ${zone}`}`; } function formatTime(value: string): string { @@ -673,6 +690,7 @@ function TaskForm({ draft.task !== null && tasks.data !== null && !tasks.data.tasks.some((task) => task.id === draft.task?.id); + const runAtZone = scheduleTimeZoneLabel(tasks.data?.timeZone); const environmentUnavailable = !availableTargets.some( (target) => target.environmentId === environmentId, ); @@ -967,13 +985,16 @@ function TaskForm({ <> setTimePickerOpen((open) => !open)} className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" > Time {formatTime(draft.schedule.timeOfDay)} + {runAtZone ? ` ${runAtZone}` : ""} - {describeSchedule(task)} + {describeSchedule(task, tasks.data?.timeZone)} {!task.enabled ? " · Paused" : task.nextRunAt diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index bcb110a5da71b..91d58d53b684b 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"; @@ -58,6 +59,52 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); +it.effect("includes the scheduler zone in empty lists and subscription updates", () => + Effect.gen(function* () { + 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 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)), +); + 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 ea681a30afe03..7f694aac1483e 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -1037,7 +1037,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 763365ceeddc1..7947fe8f83c69 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1323,6 +1323,7 @@ export 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.permissions.browser.test.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx index 3a25b7196b22a..5e3f5c29f893e 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 { expect, it, vi } from "vite-plus/test"; const state = vi.hoisted(() => ({ allowed: true, toggle: vi.fn(), run: vi.fn() })); @@ -26,7 +32,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 { act, render as renderUi } from "../../../test/render"; @@ -40,12 +46,15 @@ const task = { enabled: true, schedule: { type: "interval", everyMs: 60000 }, lastRunStatus: "never", + nextRunAt: null, } as ScheduledTask; +let listResult: ScheduledTaskListResult = { tasks: [task] }; async function setup() { state.allowed = true; state.toggle.mockReset().mockResolvedValue({ _tag: "Success" }); state.run.mockReset().mockResolvedValue({ _tag: "Success" }); + listResult = { tasks: [task] }; await using stack = new AsyncDisposableStack(); const screen = stack.use(await renderUi(null)); @@ -63,7 +72,12 @@ async function setup() { container.querySelector(`[aria-label="${label}"]`)!; const environment = stack.move(); - return { renderPanel, byLabel, [Symbol.asyncDispose]: () => environment.disposeAsync() }; + return { + renderPanel, + byLabel, + container, + [Symbol.asyncDispose]: () => environment.disposeAsync(), + }; } it.each([true, false])("runs scheduled work only with task permission (%s)", async (allowed) => { await using fixture = await setup(); @@ -86,3 +100,38 @@ 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 a short server zone and preserves labels from older servers", async () => { + await using fixture = await setup(); + const { renderPanel, container } = fixture; + 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.text-2xs"), (row) => row.textContent); + + listResult = { tasks, timeZone: "America/Los_Angeles" }; + await renderPanel(); + expect(labels()[0]).toMatch(/^Daily at 09:00 (P[DS]T|GMT-)/); + expect(labels()[0]).not.toContain("America/"); + expect(labels().slice(1)).toEqual(["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/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 28fb297b35265..670ea8db30662 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -37,10 +37,10 @@ const STATUS_DOT_CLASS: Record = { failed: "bg-destructive", }; -function taskScheduleLine(task: ScheduledTask) { +function taskScheduleLine(task: ScheduledTask, timeZone: string | undefined) { return ( <> - {scheduleLabel(task.schedule)} + {scheduleLabel(task.schedule, timeZone)} {task.enabled && task.nextRunAt !== null ? ( <> {" · next "} @@ -185,11 +185,15 @@ export function ThreadAutomationsPanel(props: { {task.title} -

{taskScheduleLine(task)}

+

+ {taskScheduleLine(task, tasksQuery.data?.timeZone)} +

{task.title}

-

{taskScheduleLine(task)}

+

+ {taskScheduleLine(task, tasksQuery.data?.timeZone)} +

diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 82d2092f61fbe..16f41491036f3 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -20,7 +20,6 @@ import type { ProjectId, ScheduledTask, ScheduledTaskId, - ScheduledTaskSchedule, ScheduledTaskUpsertInput, ScheduledTaskWebhookDeliverySummary, ThreadId, @@ -84,12 +83,16 @@ import { webhookDeliveryLabel, matchesScheduledTaskScope, scheduleFromDraft, + scheduleLabel, + scheduleTimeZoneLabel, scheduledTaskDefaultModel, taskToDraft, type DraftState, type ScheduleMode, type WorkspaceMode, } from "./scheduledTasksSettings.logic"; + +export { scheduleLabel }; import { Label } from "../ui/label"; import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/menu"; import { ToggleGroup, Toggle } from "../ui/toggle-group"; @@ -286,24 +289,6 @@ function splitModelKey(value: string): ModelSelection | null { }; } -export function scheduleLabel(schedule: ScheduledTaskSchedule): string { - if (schedule.type === "webhook") return "On webhook"; - if (schedule.type === "interval") { - const minutes = schedule.everyMs / 60_000; - return Number.isInteger(minutes) - ? `Every ${minutes} min` - : `Every ${Math.round(schedule.everyMs / 1000)} sec`; - } - const weekdays = schedule.weekdays ?? []; - const days = - weekdays.length === 0 - ? "Daily" - : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) - ? "Weekdays" - : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - return `${days} at ${schedule.timeOfDay}`; -} - /** * `formatRelativeTime` only handles the past, and `nextRunAt` is a future * instant — render "in 5m" style labels for upcoming runs instead of a @@ -469,6 +454,7 @@ function ScheduledTaskEnvironmentList({ return { environment, connected: index >= 0, + timeZone: data?.timeZone, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), @@ -510,7 +496,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks }) => { + {populated.map(({ environment, tasks, timeZone }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); const transport = environmentTransportLabel(environment); return ( @@ -540,6 +526,7 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -655,6 +642,7 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -668,10 +656,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( @@ -716,7 +706,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule)} ·{" "} + {scheduleLabel(task.schedule, timeZone)} ·{" "} {!task.enabled ? ( "Paused" ) : isWebhook ? ( @@ -1132,6 +1122,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; @@ -1697,6 +1688,9 @@ function ScheduledTaskEditorForm({ setDraft((current) => ({ ...current, timeOfDay: event.target.value })) } /> + {runAtZone === undefined ? null : ( + {runAtZone} + )} on
{ expect(scheduleFromDraft({ ...draft, replayKey: "literal" })).toBeNull(); expect(scheduleFromDraft({ ...draft, coalesceKey: "{{body.bad" })).toBeNull(); }); + +describe("schedule timezone labels", () => { + it("uses short names instead of full IANA zones", () => { + expect(scheduleTimeZoneLabel(undefined)).toBeUndefined(); + expect(scheduleTimeZoneLabel("Etc/UTC")).toBe("UTC"); + expect(scheduleTimeZoneLabel("UTC")).toBe("UTC"); + const losAngeles = scheduleTimeZoneLabel("America/Los_Angeles"); + expect(losAngeles).toMatch(/^(P[DS]T|GMT-([78]|0[78](:00)?))$/); + expect(losAngeles).not.toContain("America/"); + const kolkata = scheduleTimeZoneLabel("Asia/Kolkata"); + expect(kolkata === "IST" || kolkata?.startsWith("GMT+") === true).toBe(true); + expect(kolkata).not.toContain("Asia/"); + }); + + it("appends the zone only for fixed-time schedules", () => { + expect(scheduleLabel({ type: "fixed_time", timeOfDay: "16:31" }, "Etc/UTC")).toBe( + "Daily at 16:31 UTC", + ); + expect(scheduleLabel({ type: "fixed_time", timeOfDay: "09:00" })).toBe("Daily at 09:00"); + expect(scheduleLabel({ type: "interval", everyMs: 60_000 }, "Etc/UTC")).toBe("Every 1 min"); + expect(scheduleLabel({ type: "webhook", signature: null }, "Etc/UTC")).toBe("On webhook"); + }); +}); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 43030010c5aec..476960eb16370 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -5,6 +5,7 @@ import { type ProjectId, ScheduledTaskId, type ScheduledTask, + type ScheduledTaskSchedule, type ScheduledTaskUpsertSchedule, type ModelSelection, type RuntimeMode, @@ -27,6 +28,48 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; import type { ResolvedSettingsScope } from "./settingsScope"; +const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; + +/** + * Fixed-time schedules run in the server's zone. Show a short label (UTC, PDT, + * GMT+5:30) so list rows stay compact — full IANA names overflow the + * automations panel (see #18632 / reverted #18653). + */ +export function scheduleTimeZoneLabel(timeZone: string | undefined): string | undefined { + if (timeZone === undefined) return undefined; + if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; + for (const timeZoneName of ["short", "shortOffset"] as const) { + try { + const name = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName }) + .formatToParts(new Date()) + .find((part) => part.type === "timeZoneName")?.value; + if (name && name !== timeZone) return name; + } catch { + // Invalid IANA zone — try the next style, then fall through. + } + } + return timeZone; +} + +export function scheduleLabel(schedule: ScheduledTaskSchedule, timeZone?: string): string { + if (schedule.type === "webhook") return "On webhook"; + if (schedule.type === "interval") { + const minutes = schedule.everyMs / 60_000; + return Number.isInteger(minutes) + ? `Every ${minutes} min` + : `Every ${Math.round(schedule.everyMs / 1000)} sec`; + } + const weekdays = schedule.weekdays ?? []; + const days = + weekdays.length === 0 + ? "Daily" + : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) + ? "Weekdays" + : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); + const zone = scheduleTimeZoneLabel(timeZone); + return `${days} at ${schedule.timeOfDay}${zone === undefined ? "" : ` ${zone}`}`; +} + export function matchesScheduledTaskScope( scope: ResolvedSettingsScope, environmentId: EnvironmentId, diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 67e2314a8b010..850d392afd768 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -263,6 +263,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 server's IANA zone for fixed-time schedules. Absent from older servers. */ + timeZone: Schema.optional(TrimmedNonEmptyString), }); export type ScheduledTaskListResult = typeof ScheduledTaskListResult.Type; From 9a4faa26791504b41b5cabefb36b9be952d2e540 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 11:51:22 +0000 Subject: [PATCH 2/8] fix(scheduler): keep timezone fallback labels compact --- .../SettingsScheduledTasksRouteScreen.tsx | 16 ++++++-------- ...omationsPanel.permissions.browser.test.tsx | 20 +++++++++++------- .../scheduledTasksSettings.logic.test.ts | 1 + .../settings/scheduledTasksSettings.logic.ts | 21 ++++++------------- 4 files changed, 25 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 6768c328c4c04..967fb40244aae 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -109,17 +109,13 @@ const DAYS = [ function scheduleTimeZoneLabel(timeZone: string | undefined): string | undefined { if (timeZone === undefined) return undefined; if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; - for (const timeZoneName of ["short", "shortOffset"] as const) { - try { - const name = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName }) - .formatToParts(new Date()) - .find((part) => part.type === "timeZoneName")?.value; - if (name && name !== timeZone) return name; - } catch { - // Invalid IANA zone — try the next style, then fall through. - } + try { + return new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" }) + .formatToParts(new Date()) + .find((part) => part.type === "timeZoneName")?.value; + } catch { + return undefined; } - return timeZone; } function describeSchedule(task: ScheduledTask, timeZone?: string): string { 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 272fff25bdbfb..75bb7ff6b54cf 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx @@ -7,7 +7,12 @@ import { } from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; -const state = vi.hoisted(() => ({ allowed: true, toggle: vi.fn(), run: vi.fn() })); +const state = vi.hoisted(() => ({ + allowed: true, + toggle: vi.fn(), + run: vi.fn(), + listResult: { tasks: [] } as ScheduledTaskListResult, +})); vi.mock(import("../../state/session"), async (importOriginal) => ({ ...(await importOriginal()), useEnvironmentScope: () => state.allowed, @@ -31,7 +36,7 @@ vi.mock(import("../../state/use-atom-command"), async (importOriginal) => { }); vi.mock(import("../../state/query"), async (importOriginal) => ({ ...(await importOriginal()), - useEnvironmentQuery: (() => ({ data: listResult, error: null })) as never, + useEnvironmentQuery: (() => ({ data: state.listResult, error: null })) as never, })); import { act, render as renderUi } from "../../../test/render"; @@ -47,13 +52,12 @@ const task = { lastRunStatus: "never", nextRunAt: null, } as ScheduledTask; -let listResult: ScheduledTaskListResult = { tasks: [task] }; async function setup() { state.allowed = true; state.toggle.mockReset().mockResolvedValue({ _tag: "Success" }); state.run.mockReset().mockResolvedValue({ _tag: "Success" }); - listResult = { tasks: [task] }; + state.listResult = { tasks: [task] }; await using stack = new AsyncDisposableStack(); const screen = stack.use(await renderUi(null)); @@ -116,21 +120,21 @@ it("labels fixed times with a short server zone and preserves labels from older const labels = () => Array.from(container.querySelectorAll("li p.text-2xs"), (row) => row.textContent); - listResult = { tasks, timeZone: "America/Los_Angeles" }; + state.listResult = { tasks, timeZone: "America/Los_Angeles" }; await renderPanel(); expect(labels()[0]).toMatch(/^Daily at 09:00 (P[DS]T|GMT-)/); expect(labels()[0]).not.toContain("America/"); expect(labels().slice(1)).toEqual(["Every 1 min", "On webhook"]); - listResult = { tasks, timeZone: "Etc/UTC" }; + state.listResult = { tasks, timeZone: "Etc/UTC" }; await renderPanel(); expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); - listResult = { tasks, timeZone: "UTC" }; + state.listResult = { tasks, timeZone: "UTC" }; await renderPanel(); expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); - listResult = { tasks }; + state.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 14d98c00448cd..fa03e82cc02c9 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -331,6 +331,7 @@ it("rejects dedupe templates without a request placeholder", () => { describe("schedule timezone labels", () => { it("uses short names instead of full IANA zones", () => { expect(scheduleTimeZoneLabel(undefined)).toBeUndefined(); + expect(scheduleTimeZoneLabel("Unsupported/Zone")).toBeUndefined(); expect(scheduleTimeZoneLabel("Etc/UTC")).toBe("UTC"); expect(scheduleTimeZoneLabel("UTC")).toBe("UTC"); const losAngeles = scheduleTimeZoneLabel("America/Los_Angeles"); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 4de2c82eb9355..ba67e529eae2a 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -30,25 +30,16 @@ import type { ResolvedSettingsScope } from "./settingsScope"; const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; -/** - * Fixed-time schedules run in the server's zone. Show a short label (UTC, PDT, - * GMT+5:30) so list rows stay compact — full IANA names overflow the - * automations panel (see #18632 / reverted #18653). - */ export function scheduleTimeZoneLabel(timeZone: string | undefined): string | undefined { if (timeZone === undefined) return undefined; if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; - for (const timeZoneName of ["short", "shortOffset"] as const) { - try { - const name = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName }) - .formatToParts(new Date()) - .find((part) => part.type === "timeZoneName")?.value; - if (name && name !== timeZone) return name; - } catch { - // Invalid IANA zone — try the next style, then fall through. - } + try { + return new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" }) + .formatToParts(new Date()) + .find((part) => part.type === "timeZoneName")?.value; + } catch { + return undefined; } - return timeZone; } export function scheduleLabel(schedule: ScheduledTaskSchedule, timeZone?: string): string { From 7b7ea39b598b25d590741fd48056747d5e3d5da1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 08:16:52 +0000 Subject: [PATCH 3/8] fix(scheduler): label runs with their timezone at execution --- .../SettingsScheduledTasksRouteScreen.tsx | 17 ++++++++++---- .../chat/ThreadAutomationsPanel.tsx | 2 +- .../settings/ScheduledTasksSettings.tsx | 2 +- .../scheduledTasksSettings.logic.test.ts | 4 +++- .../settings/scheduledTasksSettings.logic.ts | 23 ++++++++++++++----- 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index a1af5860bc8e0..d571eda668c0d 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -107,13 +107,20 @@ const DAYS = [ { index: 0, label: "Sun" }, ] as const; -function scheduleTimeZoneLabel(timeZone: string | undefined): string | undefined { +function scheduleTimeZoneLabel( + timeZone: string | undefined, + runAt?: string | null, +): string | undefined { if (timeZone === undefined) return undefined; if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; try { - return new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" }) - .formatToParts(new Date()) - .find((part) => part.type === "timeZoneName")?.value; + return new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: runAt ? "short" : "shortGeneric", + }) + .format(runAt ? new Date(runAt) : new Date()) + .split(", ") + .at(-1); } catch { return undefined; } @@ -127,7 +134,7 @@ function describeSchedule(task: ScheduledTask, timeZone?: string): string { } if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs); const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day"; - const zone = scheduleTimeZoneLabel(timeZone); + const zone = scheduleTimeZoneLabel(timeZone, task.nextRunAt); return `${days} at ${formatTime(task.schedule.timeOfDay)}${zone === undefined ? "" : ` ${zone}`}`; } diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index b657e769d5dd0..d86a13b8aacee 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -40,7 +40,7 @@ const STATUS_DOT_CLASS: Record = { function taskScheduleLine(task: ScheduledTask, timeZone: string | undefined) { return ( <> - {scheduleLabel(task.schedule, timeZone)} + {scheduleLabel(task.schedule, timeZone, task.nextRunAt)} {task.enabled && task.nextRunAt !== null ? ( <> {" · next "} diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 9f7bcf3d31021..3a2eed0fab947 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -744,7 +744,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule, timeZone)} ·{" "} + {scheduleLabel(task.schedule, timeZone, task.nextRunAt)} ·{" "} {!task.enabled ? ( "Paused" ) : isWebhook ? ( diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 042a97862dc23..8727b825daa64 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -390,8 +390,10 @@ describe("schedule timezone labels", () => { expect(scheduleTimeZoneLabel("Etc/UTC")).toBe("UTC"); expect(scheduleTimeZoneLabel("UTC")).toBe("UTC"); const losAngeles = scheduleTimeZoneLabel("America/Los_Angeles"); - expect(losAngeles).toMatch(/^(P[DS]T|GMT-([78]|0[78](:00)?))$/); + expect(losAngeles).toMatch(/^(PT|Los Angeles Time)$/); expect(losAngeles).not.toContain("America/"); + expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-01-15T17:00:00Z")).toBe("PST"); + expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-07-15T16:00:00Z")).toBe("PDT"); const kolkata = scheduleTimeZoneLabel("Asia/Kolkata"); expect(kolkata === "IST" || kolkata?.startsWith("GMT+") === true).toBe(true); expect(kolkata).not.toContain("Asia/"); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index dfcf14f334cc4..531774029e03c 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -30,13 +30,20 @@ import type { ResolvedSettingsScope } from "./settingsScope"; const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; -export function scheduleTimeZoneLabel(timeZone: string | undefined): string | undefined { +export function scheduleTimeZoneLabel( + timeZone: string | undefined, + runAt?: string | null, +): string | undefined { if (timeZone === undefined) return undefined; if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; try { - return new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "short" }) - .formatToParts(new Date()) - .find((part) => part.type === "timeZoneName")?.value; + return new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: runAt ? "short" : "shortGeneric", + }) + .format(runAt ? new Date(runAt) : new Date()) + .split(", ") + .at(-1); } catch { return undefined; } @@ -47,7 +54,11 @@ const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, { timeStyle: "short", }); -export function scheduleLabel(schedule: ScheduledTaskSchedule, timeZone?: string): string { +export function scheduleLabel( + schedule: ScheduledTaskSchedule, + timeZone?: string, + runAt?: string | null, +): string { if (schedule.type === "webhook") return "On webhook"; if (schedule.type === "once") return `Once, ${ONCE_FORMATTER.format(new Date(schedule.runAt))}`; if (schedule.type === "interval") { @@ -63,7 +74,7 @@ export function scheduleLabel(schedule: ScheduledTaskSchedule, timeZone?: string : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) ? "Weekdays" : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - const zone = scheduleTimeZoneLabel(timeZone); + const zone = scheduleTimeZoneLabel(timeZone, runAt); return `${days} at ${schedule.timeOfDay}${zone === undefined ? "" : ` ${zone}`}`; } From f52a6e2df154c5ded5942536ad7f95d67ec00267 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 08:19:13 +0000 Subject: [PATCH 4/8] test(scheduler): expect generic zones for paused tasks --- .../chat/ThreadAutomationsPanel.permissions.browser.test.tsx | 2 +- .../components/settings/scheduledTasksSettings.logic.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 75bb7ff6b54cf..20fe925c6e8cc 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx @@ -122,7 +122,7 @@ it("labels fixed times with a short server zone and preserves labels from older state.listResult = { tasks, timeZone: "America/Los_Angeles" }; await renderPanel(); - expect(labels()[0]).toMatch(/^Daily at 09:00 (P[DS]T|GMT-)/); + expect(labels()[0]).toMatch(/^Daily at 09:00 (PT|Los Angeles Time)$/); expect(labels()[0]).not.toContain("America/"); expect(labels().slice(1)).toEqual(["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 8727b825daa64..03533ea2ce62c 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -395,7 +395,9 @@ describe("schedule timezone labels", () => { expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-01-15T17:00:00Z")).toBe("PST"); expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-07-15T16:00:00Z")).toBe("PDT"); const kolkata = scheduleTimeZoneLabel("Asia/Kolkata"); - expect(kolkata === "IST" || kolkata?.startsWith("GMT+") === true).toBe(true); + expect( + kolkata === "IST" || kolkata === "India Time" || kolkata?.startsWith("GMT+") === true, + ).toBe(true); expect(kolkata).not.toContain("Asia/"); }); From 9954233bfe590c147ce002c20c1df68ee17fb2c8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 01:46:17 +0000 Subject: [PATCH 5/8] test(scheduler): provide managed policy in timezone case --- .../src/scheduledTasks/ScheduledTaskService.schedule.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 975729218e352..025529f515f52 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -67,6 +67,7 @@ it.effect("includes the scheduler zone in empty lists and subscription updates", const layerDependencies = Layer.mergeAll( NodeCrypto.layer, Scheduler.layer, + ManagedPolicy.layerTest(), Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects", From 31c0c67efab1e3f694345e29665f89af1445abdc Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 20:17:53 +0000 Subject: [PATCH 6/8] fix(web): describe the fixed-time input with its server zone --- .../web/src/components/settings/ScheduledTasksSettings.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index fddc79934eaf9..25d3abc5c87e0 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1895,6 +1895,9 @@ function ScheduledTaskEditorForm({ {runAtZone === undefined ? null : ( - {runAtZone} + + {runAtZone} + )} on
From ed82e314efe135b1cecad2ecffbeda99b728346e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:21:49 +0000 Subject: [PATCH 7/8] refactor(scheduler): share the zone label helper and trim tests Move scheduleTimeZoneLabel into client-runtime so web and mobile use one implementation, import scheduleLabel from the logic module instead of a re-export, and drop tests that only mirrored list() or render wiring. AI-assisted; verified with the tests and tools listed above. --- .../SettingsScheduledTasksRouteScreen.tsx | 20 +----- .../ScheduledTaskService.schedule.test.ts | 48 --------------- ...omationsPanel.permissions.browser.test.tsx | 61 ++----------------- .../chat/ThreadAutomationsPanel.tsx | 3 +- .../settings/ScheduledTasksSettings.tsx | 6 +- .../scheduledTasksSettings.logic.test.ts | 34 +++-------- .../settings/scheduledTasksSettings.logic.ts | 22 +------ packages/client-runtime/package.json | 4 ++ .../src/scheduledTaskTimeZone.test.ts | 12 ++++ .../src/scheduledTaskTimeZone.ts | 24 ++++++++ 10 files changed, 58 insertions(+), 176 deletions(-) create mode 100644 packages/client-runtime/src/scheduledTaskTimeZone.test.ts create mode 100644 packages/client-runtime/src/scheduledTaskTimeZone.ts diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 23a0bbda0c115..3f4bc4dee90e7 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -38,6 +38,7 @@ import { WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone"; import { useRef, useState, type ReactNode } from "react"; import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; @@ -117,25 +118,6 @@ const THREAD_MODE_LABELS: Record = { fresh_session: "One thread, fresh session", }; -function scheduleTimeZoneLabel( - timeZone: string | undefined, - runAt?: string | null, -): string | undefined { - if (timeZone === undefined) return undefined; - if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; - try { - return new Intl.DateTimeFormat("en-US", { - timeZone, - timeZoneName: runAt ? "short" : "shortGeneric", - }) - .format(runAt ? new Date(runAt) : new Date()) - .split(", ") - .at(-1); - } catch { - return undefined; - } -} - function describeSchedule(task: ScheduledTask, timeZone?: string): string { if (task.schedule.type === "webhook") return "On webhook"; if (task.schedule.type === "once") { diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 025529f515f52..a6adcdaba9108 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -7,7 +7,6 @@ import * as Deferred from "effect/Deferred"; 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"; @@ -62,53 +61,6 @@ it.effect("rejects a stale form save after deletion while preserving explicit-id }).pipe(Effect.provide(SqlitePersistence.layerMemory)), ); -it.effect("includes the scheduler zone in empty lists and subscription updates", () => - Effect.gen(function* () { - const layerDependencies = Layer.mergeAll( - NodeCrypto.layer, - Scheduler.layer, - ManagedPolicy.layerTest(), - 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 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)), -); - 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/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx index 20fe925c6e8cc..e82124747c187 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.permissions.browser.test.tsx @@ -1,18 +1,7 @@ -import { - EnvironmentId, - ThreadId, - ScheduledTaskId, - type ScheduledTask, - type ScheduledTaskListResult, -} from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, ScheduledTaskId, type ScheduledTask } from "@t3tools/contracts"; import { expect, it, vi } from "vite-plus/test"; -const state = vi.hoisted(() => ({ - allowed: true, - toggle: vi.fn(), - run: vi.fn(), - listResult: { tasks: [] } as ScheduledTaskListResult, -})); +const state = vi.hoisted(() => ({ allowed: true, toggle: vi.fn(), run: vi.fn() })); vi.mock(import("../../state/session"), async (importOriginal) => ({ ...(await importOriginal()), useEnvironmentScope: () => state.allowed, @@ -36,7 +25,7 @@ vi.mock(import("../../state/use-atom-command"), async (importOriginal) => { }); vi.mock(import("../../state/query"), async (importOriginal) => ({ ...(await importOriginal()), - useEnvironmentQuery: (() => ({ data: state.listResult, error: null })) as never, + useEnvironmentQuery: (() => ({ data: { tasks: [task] }, error: null })) as never, })); import { act, render as renderUi } from "../../../test/render"; @@ -50,14 +39,12 @@ const task = { enabled: true, schedule: { type: "interval", everyMs: 60000 }, lastRunStatus: "never", - nextRunAt: null, } as ScheduledTask; async function setup() { state.allowed = true; state.toggle.mockReset().mockResolvedValue({ _tag: "Success" }); state.run.mockReset().mockResolvedValue({ _tag: "Success" }); - state.listResult = { tasks: [task] }; await using stack = new AsyncDisposableStack(); const screen = stack.use(await renderUi(null)); @@ -75,12 +62,7 @@ async function setup() { container.querySelector(`[aria-label="${label}"]`)!; const environment = stack.move(); - return { - renderPanel, - byLabel, - container, - [Symbol.asyncDispose]: () => environment.disposeAsync(), - }; + return { renderPanel, byLabel, [Symbol.asyncDispose]: () => environment.disposeAsync() }; } it.each([true, false])("runs scheduled work only with task permission (%s)", async (allowed) => { await using fixture = await setup(); @@ -103,38 +85,3 @@ 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 a short server zone and preserves labels from older servers", async () => { - await using fixture = await setup(); - const { renderPanel, container } = fixture; - 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.text-2xs"), (row) => row.textContent); - - state.listResult = { tasks, timeZone: "America/Los_Angeles" }; - await renderPanel(); - expect(labels()[0]).toMatch(/^Daily at 09:00 (PT|Los Angeles Time)$/); - expect(labels()[0]).not.toContain("America/"); - expect(labels().slice(1)).toEqual(["Every 1 min", "On webhook"]); - - state.listResult = { tasks, timeZone: "Etc/UTC" }; - await renderPanel(); - expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); - - state.listResult = { tasks, timeZone: "UTC" }; - await renderPanel(); - expect(labels()).toEqual(["Daily at 09:00 UTC", "Every 1 min", "On webhook"]); - - state.listResult = { tasks }; - await renderPanel(); - expect(labels()).toEqual(["Daily at 09:00", "Every 1 min", "On webhook"]); -}); diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index fffc38113dcba..1bec327103211 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -16,7 +16,8 @@ import { import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { cn } from "../../lib/utils"; -import { ScheduledRelativeLabel, scheduleLabel } from "../settings/ScheduledTasksSettings"; +import { ScheduledRelativeLabel } from "../settings/ScheduledTasksSettings"; +import { scheduleLabel } from "../settings/scheduledTasksSettings.logic"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index e2007992c019f..848b88130f48a 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -34,6 +34,7 @@ import { WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone"; import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES, MAX_WEBHOOK_FILTERS, @@ -104,16 +105,14 @@ import { matchesScheduledTaskScope, scheduleFromDraft, scheduleLabel, - scheduleTimeZoneLabel, scheduledTaskDefaultModel, + WEEKDAY_LABELS, taskToDraft, unbindDraftFromThread, type DraftState, type ScheduleMode, type WorkspaceMode, } from "./scheduledTasksSettings.logic"; - -export { scheduleLabel }; import { Label } from "../ui/label"; import { Menu, MenuTrigger, MenuPopup, MenuItem, MenuSeparator } from "../ui/menu"; import { ToggleGroup, Toggle } from "../ui/toggle-group"; @@ -154,7 +153,6 @@ import { } from "./settingsLayout"; const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; -const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; const WEEKDAY_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; // Thread ids are trimmed, so the leading space keeps this from matching one. diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 9d1d13c6fbb44..a4918c3d2e123 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -23,7 +23,6 @@ import { matchesScheduledTaskScope, scheduleFromDraft, scheduleLabel, - scheduleTimeZoneLabel, taskToDraft, unbindDraftFromThread, } from "./scheduledTasksSettings.logic"; @@ -410,30 +409,11 @@ it("rejects dedupe templates without a request placeholder", () => { expect(scheduleFromDraft({ ...draft, coalesceKey: "{{body.bad" })).toBeNull(); }); -describe("schedule timezone labels", () => { - it("uses short names instead of full IANA zones", () => { - expect(scheduleTimeZoneLabel(undefined)).toBeUndefined(); - expect(scheduleTimeZoneLabel("Unsupported/Zone")).toBeUndefined(); - expect(scheduleTimeZoneLabel("Etc/UTC")).toBe("UTC"); - expect(scheduleTimeZoneLabel("UTC")).toBe("UTC"); - const losAngeles = scheduleTimeZoneLabel("America/Los_Angeles"); - expect(losAngeles).toMatch(/^(PT|Los Angeles Time)$/); - expect(losAngeles).not.toContain("America/"); - expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-01-15T17:00:00Z")).toBe("PST"); - expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-07-15T16:00:00Z")).toBe("PDT"); - const kolkata = scheduleTimeZoneLabel("Asia/Kolkata"); - expect( - kolkata === "IST" || kolkata === "India Time" || kolkata?.startsWith("GMT+") === true, - ).toBe(true); - expect(kolkata).not.toContain("Asia/"); - }); - - it("appends the zone only for fixed-time schedules", () => { - expect(scheduleLabel({ type: "fixed_time", timeOfDay: "16:31" }, "Etc/UTC")).toBe( - "Daily at 16:31 UTC", - ); - expect(scheduleLabel({ type: "fixed_time", timeOfDay: "09:00" })).toBe("Daily at 09:00"); - expect(scheduleLabel({ type: "interval", everyMs: 60_000 }, "Etc/UTC")).toBe("Every 1 min"); - expect(scheduleLabel({ type: "webhook", signature: null }, "Etc/UTC")).toBe("On webhook"); - }); +it("appends the server zone only to fixed-time schedule labels", () => { + expect(scheduleLabel({ type: "fixed_time", timeOfDay: "16:31" }, "Etc/UTC")).toBe( + "Daily at 16:31 UTC", + ); + expect(scheduleLabel({ type: "fixed_time", timeOfDay: "09:00" })).toBe("Daily at 09:00"); + expect(scheduleLabel({ type: "interval", everyMs: 60_000 }, "Etc/UTC")).toBe("Every 1 min"); + expect(scheduleLabel({ type: "webhook", signature: null }, "Etc/UTC")).toBe("On webhook"); }); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 1b365b3f11242..1095db83469ba 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -23,6 +23,7 @@ import { webhookFilterDraftsChanged, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone"; import { resolveProjectSettings, @@ -32,26 +33,7 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; import type { ResolvedSettingsScope } from "./settingsScope"; -const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; - -export function scheduleTimeZoneLabel( - timeZone: string | undefined, - runAt?: string | null, -): string | undefined { - if (timeZone === undefined) return undefined; - if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; - try { - return new Intl.DateTimeFormat("en-US", { - timeZone, - timeZoneName: runAt ? "short" : "shortGeneric", - }) - .format(runAt ? new Date(runAt) : new Date()) - .split(", ") - .at(-1); - } catch { - return undefined; - } -} +export const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c9e1260b956f7..920fad80e4d17 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -83,6 +83,10 @@ "types": "./src/scheduledTaskWebhook.ts", "default": "./src/scheduledTaskWebhook.ts" }, + "./scheduled-task-time-zone": { + "types": "./src/scheduledTaskTimeZone.ts", + "default": "./src/scheduledTaskTimeZone.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/scheduledTaskTimeZone.test.ts b/packages/client-runtime/src/scheduledTaskTimeZone.test.ts new file mode 100644 index 0000000000000..35ce8c4b70f7a --- /dev/null +++ b/packages/client-runtime/src/scheduledTaskTimeZone.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "vite-plus/test"; + +import { scheduleTimeZoneLabel } from "./scheduledTaskTimeZone.ts"; + +it("uses short names instead of full IANA zones", () => { + expect(scheduleTimeZoneLabel(undefined)).toBeUndefined(); + expect(scheduleTimeZoneLabel("Unsupported/Zone")).toBeUndefined(); + expect(scheduleTimeZoneLabel("Etc/UTC")).toBe("UTC"); + expect(scheduleTimeZoneLabel("America/Los_Angeles")).toMatch(/^(PT|Los Angeles Time)$/); + expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-01-15T17:00:00Z")).toBe("PST"); + expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-07-15T16:00:00Z")).toBe("PDT"); +}); diff --git a/packages/client-runtime/src/scheduledTaskTimeZone.ts b/packages/client-runtime/src/scheduledTaskTimeZone.ts new file mode 100644 index 0000000000000..5442d4bb16de1 --- /dev/null +++ b/packages/client-runtime/src/scheduledTaskTimeZone.ts @@ -0,0 +1,24 @@ +import * as DateTime from "effect/DateTime"; + +/** + * Short label for the server zone a fixed-time schedule runs in. With a run time it names + * that instant's offset (PST/PDT); without one it uses the generic name (PT). + */ +export function scheduleTimeZoneLabel( + timeZone: string | undefined, + runAt?: string | null, +): string | undefined { + if (timeZone === undefined) return undefined; + if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; + try { + return new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: runAt ? "short" : "shortGeneric", + }) + .format(runAt ? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt)) : undefined) + .split(", ") + .at(-1); + } catch { + return undefined; + } +} From f8995290d9d7f2ae3c9cc842617f366a0b665906 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 06:29:30 +0000 Subject: [PATCH 8/8] refactor(scheduler): read the zone name from formatToParts AI-assisted; verified with the tests and tools listed above. --- packages/client-runtime/src/scheduledTaskTimeZone.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/scheduledTaskTimeZone.ts b/packages/client-runtime/src/scheduledTaskTimeZone.ts index 5442d4bb16de1..9321ab11acbe8 100644 --- a/packages/client-runtime/src/scheduledTaskTimeZone.ts +++ b/packages/client-runtime/src/scheduledTaskTimeZone.ts @@ -15,9 +15,8 @@ export function scheduleTimeZoneLabel( timeZone, timeZoneName: runAt ? "short" : "shortGeneric", }) - .format(runAt ? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt)) : undefined) - .split(", ") - .at(-1); + .formatToParts(runAt ? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt)) : undefined) + .find((part) => part.type === "timeZoneName")?.value; } catch { return undefined; }