# Files changed · PR \#23911 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23911/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(scheduler\): run fixed-time tasks in a chosen time zone](https://git.cafe/pingdotgg/t3code/pull/23911.md)

Version: 6

Head commit: 986158533aebad5757667af1ac4efa5ee8b7e9fa

Base commit: 51273020db3245f4ec29514a419d29b5dcb012dc

Comparison base: 2c8bdf6177159d8fcd12a897d7c9311f6eb93237

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx

Status: modified; +30; −2

```diff
@@ -51,6 +51,7 @@
 import { SegmentedControl } from "../../components/ui/SegmentedControl";
 import { ThemedSwitch } from "../../components/ui/ThemedSwitch";
 import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
+import { localTimeZone, timeZoneForSave } from "@t3tools/client-runtime/scheduled-task-schedule";
 import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
 import { usePreparedConnection } from "../../state/session";
 import { buildModelOptions } from "../../lib/modelOptions";
@@ -106,7 +107,8 @@
   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 = task.schedule.timeZone ? ` (${task.schedule.timeZone})` : "";
+  return `${days} at ${formatTime(task.schedule.timeOfDay)}${zone}`;
 }
 
 function formatTime(value: string): string {
@@ -148,6 +150,8 @@
   readonly disabled?: boolean;
   readonly placeholder?: string;
   readonly borderTop?: boolean;
+  /** For identifiers such as time zone names, which autocorrect would mangle. */
+  readonly literal?: boolean;
 }) {
   return (
     <View
@@ -163,6 +167,8 @@
         onChangeText={props.onChange}
         textAlignVertical="center"
         keyboardType={props.keyboardType}
+        autoCapitalize={props.literal ? "none" : undefined}
+        autoCorrect={props.literal ? false : undefined}
         placeholder={props.placeholder}
         placeholderTextColorClassName="accent-foreground-muted"
         className="min-h-8 font-sans text-base text-foreground"
@@ -711,6 +717,16 @@
       );
       return;
     }
+    if (
+      draft.schedule.mode === "fixed_time" &&
+      timeZoneForSave(draft.schedule.timeZone, draft.schedule.savedTimeZone) === undefined
+    ) {
+      Dialog.alert(
+        "Unknown time zone",
+        "Enter a time zone such as America/Chicago, or leave it blank.",
+      );
+      return;
+    }
     const parsedFilters = parseWebhookFilterDrafts(draft.schedule.filters);
     if (draft.schedule.mode === "webhook" && "error" in parsedFilters) {
       Dialog.alert("Invalid filter", parsedFilters.error);
@@ -1034,6 +1050,17 @@
                   setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } });
                 }}
               />
+              <FormField
+                label="Time zone"
+                value={draft.schedule.timeZone}
+                placeholder="Environment's time zone"
+                disabled={saving}
+                borderTop
+                literal
+                onChange={(timeZone) =>
+                  setDraft({ ...draft, schedule: { ...draft.schedule, timeZone } })
+                }
+              />
             </>
           ) : draft.schedule.mode === "webhook" ? (
             <>
@@ -1139,7 +1166,8 @@
         </SettingsSection>
         {draft.schedule.mode === "fixed_time" ? (
           <Text className="px-2 text-sm text-foreground-muted">
-            Time uses the environment's time zone, which may differ from your phone's.
+            Enter an IANA time zone such as {localTimeZone()}, this phone's. Left blank, the time
+            uses the environment's time zone, which may differ from your phone's.
           </Text>
         ) : null}
       </View>
```

## apps/mobile/src/features/settings/scheduledTaskDraft.test.ts

Status: modified; +27; −0

```diff
@@ -87,6 +87,33 @@
     expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, weekdays: [] })).toBeNull();
   });
 
+  it("keeps a fixed time's zone, clears it when blank, and rejects an unknown one", () => {
+    const draft = scheduleDraftForTask({
+      schedule: { type: "fixed_time", timeOfDay: "09:00", timeZone: "America/Chicago" },
+    });
+    expect(scheduleFromDraft(draft)).toEqual({
+      type: "fixed_time",
+      timeOfDay: "09:00",
+      timeZone: "America/Chicago",
+    });
+    expect(scheduleFromDraft({ ...draft, timeZone: "america/chicago" })).toMatchObject({
+      timeZone: "America/Chicago",
+    });
+    expect(scheduleFromDraft({ ...draft, timeZone: "" })).toMatchObject({ timeZone: null });
+    expect(scheduleFromDraft({ ...draft, timeZone: "Chicago" })).toBeNull();
+  });
+
+  it("keeps a stored zone this client's time zone data lacks when other fields change", () => {
+    const draft = scheduleDraftForTask({
+      schedule: { type: "fixed_time", timeOfDay: "09:00", timeZone: "Mars/Olympus_Mons" },
+    });
+    expect(scheduleFromDraft({ ...draft, timeOfDay: "10:00" })).toEqual({
+      type: "fixed_time",
+      timeOfDay: "10:00",
+    });
+    expect(scheduleFromDraft({ ...draft, timeZone: "Mars/Tharsis" })).toBeNull();
+  });
+
   it.each([
     ["1.5", 90_000],
     [String(60_001 / 60_000), 60_001],
```

## apps/mobile/src/features/settings/scheduledTaskDraft.ts

Status: modified; +13; −0

```diff
@@ -17,6 +17,7 @@
   webhookFilterDraftsChanged,
   type WebhookFilterDraft,
 } from "@t3tools/client-runtime/scheduled-task-webhook";
+import { timeZoneForSave } from "@t3tools/client-runtime/scheduled-task-schedule";
 import {
   resolveProjectSettings,
   type LegacyProjectSettingsFields,
@@ -54,6 +55,10 @@
   readonly mode: "fixed_time" | "interval" | "webhook";
   readonly timeOfDay: string;
   readonly weekdays: ReadonlyArray<number>;
+  /** Blank runs at the environment's local time. */
+  readonly timeZone: string;
+  /** The zone the form opened with. */
+  readonly savedTimeZone: string;
   readonly intervalMinutes: string;
   readonly signature: ScheduledTaskWebhookSignature | null;
   readonly maxDeliveryAgeMinutes: string;
@@ -69,6 +74,8 @@
   mode: "fixed_time",
   timeOfDay: "09:00",
   weekdays: [1, 2, 3, 4, 5],
+  timeZone: "",
+  savedTimeZone: "",
   intervalMinutes: "15",
   signature: null,
   maxDeliveryAgeMinutes: "",
@@ -88,6 +95,8 @@
         weekdays: task.schedule.weekdays?.length
           ? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b)
           : [0, 1, 2, 3, 4, 5, 6],
+        timeZone: task.schedule.timeZone ?? "",
+        savedTimeZone: task.schedule.timeZone ?? "",
       };
     case "interval":
       return {
@@ -168,7 +177,9 @@
     return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null;
   }
   const weekdays = [...new Set(draft.weekdays)].sort((a, b) => a - b);
+  const timeZone = timeZoneForSave(draft.timeZone, draft.savedTimeZone);
   if (
+    timeZone === undefined ||
     !/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay) ||
     weekdays.length === 0 ||
     weekdays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)
@@ -179,6 +190,7 @@
     type: "fixed_time",
     timeOfDay: draft.timeOfDay,
     ...(weekdays.length === 7 ? {} : { weekdays }),
+    ...timeZone,
   };
 }
 
@@ -212,6 +224,7 @@
     draft.schedule.mode,
     draft.schedule.timeOfDay,
     [...draft.schedule.weekdays].sort((a, b) => a - b),
+    draft.schedule.timeZone,
     draft.schedule.intervalMinutes,
     draft.schedule.maxDeliveryAgeMinutes,
     draft.schedule.replayKey,
```

## apps/server/src/mcp/toolkits/orchestrator/tools.ts

Status: modified; +2; −2

```diff
@@ -118,7 +118,7 @@
 
 const ScheduleTaskTool = Tool.make("schedule_task", {
   description:
-    "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
+    "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. A fixed time uses the environment's local clock unless you add timeZone, an IANA name such as 'America/Chicago'; add it when the user names a zone or a city, so daylight-saving changes follow their clock. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.",
   parameters: OrchestratorMcpScheduleTaskInput,
   success: OrchestratorMcpScheduleTaskResult,
   failure: OrchestratorMcpFailure,
@@ -145,7 +145,7 @@
 
 const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", {
   description:
-    "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Webhook schedule dedupe accepts replayKey, coalesceKey, and coalesceWindowSeconds (10–3600, default 120) to suppress replays for 7 days and retain the latest payload in each fixed window. Only the provided fields change; omit a field to leave it as-is. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.",
+    "Update an existing scheduled task by scheduledTaskId (from list_scheduled_tasks). Webhook schedule dedupe accepts replayKey, coalesceKey, and coalesceWindowSeconds (10–3600, default 120) to suppress replays for 7 days and retain the latest payload in each fixed window. Only the provided fields change; omit a field to leave it as-is. A schedule you pass replaces the stored one, but a fixed_time timeZone you leave out is kept (null removes it), as are webhook dedupe and filters. So resend a fixed_time's weekdays, or it runs every day. On a signed webhook, resend signature with its header, encoding and prefix, or signature checks stop; the stored secret is kept. Resend maxDeliveryAgeMinutes too, or the age limit is removed. Use enabled=false to pause a task without deleting it. Set bindToCurrentThread to move the task between posting into this thread and launching a fresh thread per run. A webhook schedule without filters keeps the stored filters; pass filters to replace them, or filters:[] to clear them.",
   parameters: OrchestratorMcpUpdateScheduledTaskInput,
   success: OrchestratorMcpScheduleTaskResult,
   failure: OrchestratorMcpFailure,
```

## apps/server/src/scheduledTasks/Schedule.test.ts

Status: modified; +66; −0

```diff
@@ -1,4 +1,5 @@
 import { describe, expect, it } from "@effect/vitest";
+import type { ScheduledTaskSchedule } from "@t3tools/contracts";
 import * as DateTime from "effect/DateTime";
 
 import { isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";
@@ -26,6 +27,65 @@
     expect(parts?.minute).toBe(0);
   });
 
+  describe("fixed time in a time zone", () => {
+    const next = (
+      schedule: Partial<Extract<ScheduledTaskSchedule, { type: "fixed_time" }>>,
+      from: string,
+    ) => {
+      const run = nextScheduledRunAt(
+        { type: "fixed_time", timeOfDay: "09:00", timeZone: "America/Chicago", ...schedule },
+        DateTime.makeUnsafe(from),
+      );
+      return run === null ? null : DateTime.formatIso(DateTime.toUtc(run));
+    };
+
+    it("runs at the wall-clock time of its zone, not the server's", () => {
+      expect(next({}, "2026-07-03T10:00:00.000Z")).toBe("2026-07-03T14:00:00.000Z");
+      expect(next({ timeZone: "Asia/Tokyo" }, "2026-07-03T10:00:00.000Z")).toBe(
+        "2026-07-04T00:00:00.000Z",
+      );
+    });
+
+    it("keeps the wall-clock time across the spring-forward change", () => {
+      // 09:00 CST is 15:00Z; after clocks move forward on March 8, 09:00 CDT is 14:00Z.
+      expect(next({}, "2026-03-07T16:00:00.000Z")).toBe("2026-03-08T14:00:00.000Z");
+      // 02:30 does not exist on March 8, so that run moves to 03:30 CDT.
+      expect(next({ timeOfDay: "02:30" }, "2026-03-08T07:00:00.000Z")).toBe(
+        "2026-03-08T08:30:00.000Z",
+      );
+      expect(next({ timeOfDay: "02:30" }, "2026-03-08T08:31:00.000Z")).toBe(
+        "2026-03-09T07:30:00.000Z",
+      );
+      // Lord Howe Island moves forward 30 minutes on October 4, so 02:15 runs at 02:45.
+      expect(
+        next({ timeOfDay: "02:15", timeZone: "Australia/Lord_Howe" }, "2026-10-03T14:00:00.000Z"),
+      ).toBe("2026-10-03T15:45:00.000Z");
+    });
+
+    it("keeps the wall-clock time across the fall-back change and runs a repeated time once", () => {
+      // 09:00 CDT is 14:00Z; after clocks move back on November 1, 09:00 CST is 15:00Z.
+      expect(next({}, "2026-10-31T15:00:00.000Z")).toBe("2026-11-01T15:00:00.000Z");
+      // 01:30 happens twice on November 1: first in CDT (06:30Z), then in CST (07:30Z).
+      expect(next({ timeOfDay: "01:30" }, "2026-11-01T05:00:00.000Z")).toBe(
+        "2026-11-01T06:30:00.000Z",
+      );
+      expect(next({ timeOfDay: "01:30" }, "2026-11-01T06:31:00.000Z")).toBe(
+        "2026-11-02T07:30:00.000Z",
+      );
+    });
+
+    it("applies weekdays in the zone's calendar", () => {
+      // Friday 20:00 in Chicago is already Saturday in UTC.
+      expect(next({ timeOfDay: "20:00", weekdays: [5] }, "2026-07-03T12:00:00.000Z")).toBe(
+        "2026-07-04T01:00:00.000Z",
+      );
+    });
+
+    it("has no next run for a zone this runtime does not know", () => {
+      expect(next({ timeZone: "Mars/Olympus_Mons" }, "2026-07-03T10:00:00.000Z")).toBeNull();
+    });
+  });
+
   it("compares schedules structurally", () => {
     expect(
       isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }),
@@ -75,5 +135,11 @@
         { type: "fixed_time", timeOfDay: "09:00" },
       ),
     ).toBe(false);
+    expect(
+      isSameSchedule(
+        { type: "fixed_time", timeOfDay: "09:00", timeZone: "America/Chicago" },
+        { type: "fixed_time", timeOfDay: "09:00" },
+      ),
+    ).toBe(false);
   });
 });
```

## apps/server/src/scheduledTasks/Schedule.ts

Status: modified; +44; −3

```diff
@@ -1,5 +1,10 @@
+import {
+  MIN_SCHEDULED_TASK_INTERVAL_MS,
-import { MIN_SCHEDULED_TASK_INTERVAL_MS, type ScheduledTaskSchedule } from "@t3tools/contracts";
+  type ScheduledTaskSchedule,
+  type ScheduledTaskUpsertSchedule,
+} from "@t3tools/contracts";
 import * as DateTime from "effect/DateTime";
+import * as Option from "effect/Option";
 
 const MINUTE_MS = 60_000;
 
@@ -9,6 +14,12 @@
   return { hour: Number(match[1]), minute: Number(match[2]) };
 }
 
+/** Without a zone, `from` keeps the environment's local zone it was read in. */
+function inTimeZone(from: DateTime.DateTime, timeZone: string | undefined) {
+  if (timeZone === undefined) return from;
+  return Option.getOrNull(DateTime.setZoneNamed(from, timeZone));
+}
+
 export function nextScheduledRunAt(
   schedule: ScheduledTaskSchedule,
   from: DateTime.DateTime,
@@ -23,10 +34,14 @@
 
   const time = parseTimeOfDay(schedule.timeOfDay);
   if (time === null) return null;
+  const zoned = inTimeZone(from, schedule.timeZone);
+  if (zoned === null) return null;
   const weekdays =
     schedule.weekdays && schedule.weekdays.length > 0 ? new Set(schedule.weekdays) : null;
+  // A wall-clock time skipped by a daylight-saving jump runs that much later;
+  // a repeated one runs at its first occurrence only.
   for (let offset = 0; offset <= 7; offset += 1) {
-    const candidate = DateTime.setParts(DateTime.add(from, { days: offset }), {
+    const candidate = DateTime.setParts(DateTime.add(zoned, { days: offset }), {
       hour: time.hour,
       minute: time.minute,
       second: 0,
@@ -39,6 +54,31 @@
   return null;
 }
 
+/**
+ * An omitted optional field keeps its stored value, so a client that predates
+ * the field can re-save a task without clearing it. Null clears it.
+ */
+function kept<A>(value: A | null | undefined, stored: A | undefined): A | undefined {
+  return value === undefined ? stored : (value ?? undefined);
+}
+
+export function timerScheduleToStore(
+  input: Exclude<ScheduledTaskUpsertSchedule, { readonly type: "webhook" }>,
+  stored: ScheduledTaskSchedule | undefined,
+): ScheduledTaskSchedule {
+  if (input.type === "interval") return input;
+  const timeZone = kept(
+    input.timeZone,
+    stored?.type === "fixed_time" ? stored.timeZone : undefined,
+  );
+  return {
+    type: "fixed_time",
+    timeOfDay: input.timeOfDay,
+    ...(input.weekdays === undefined ? {} : { weekdays: input.weekdays }),
+    ...(timeZone === undefined ? {} : { timeZone }),
+  };
+}
+
 function weekdayKey(weekdays: ReadonlyArray<number> | undefined): string {
   const unique = [...new Set(weekdays ?? [])].toSorted((x, y) => x - y);
   if (unique.length === 0 || unique.length === 7) return "daily";
@@ -61,7 +101,8 @@
     bTime !== null &&
     aTime.hour === bTime.hour &&
     aTime.minute === bTime.minute &&
+    weekdayKey(a.weekdays) === weekdayKey(b.weekdays) &&
-    weekdayKey(a.weekdays) === weekdayKey(b.weekdays)
+    a.timeZone === b.timeZone
   );
 }
 
```

## apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts

Status: modified; +66; −0

```diff
@@ -167,3 +167,69 @@
     }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies))));
   }).pipe(Effect.provide(SqlitePersistence.layerMemory)),
 );
+
+const serviceLayer = ScheduledTaskService.layer.pipe(
+  Layer.provide(
+    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)({}),
+    ),
+  ),
+);
+
+const taskInput = (schedule: unknown, extra: Record<string, unknown> = {}) =>
+  decodeUpsertInput({
+    id: "scheduled-task:schedule-controls",
+    title: "Review",
+    prompt: "Review the open pull requests.",
+    enabled: true,
+    schedule,
+    projectId: "project-schedule-controls",
+    workspaceStrategy: { type: "root" },
+    modelSelection: { instanceId: "codex", model: "gpt-5.4" },
+    runtimeMode: "full-access",
+    interactionMode: "default",
+    ...extra,
+  });
+
+/** Pins the clock before the service starts, then runs `body` against it. */
+const withService = <A, E>(
+  at: string,
+  body: Effect.Effect<A, E, ScheduledTaskService.ScheduledTaskService>,
+) =>
+  TestClock.setTime(Date.parse(at)).pipe(
+    Effect.andThen(body.pipe(Effect.provide(serviceLayer))),
+    Effect.provide(SqlitePersistence.layerMemory),
+  );
+
+it.effect("keeps a stored time zone when a save omits it and removes it on null", () =>
+  withService(
+    "2026-07-03T10:00:00.000Z",
+    Effect.gen(function* () {
+      const service = yield* ScheduledTaskService.ScheduledTaskService;
+      const zoned = { type: "fixed_time", timeOfDay: "09:00", timeZone: "America/Chicago" };
+      const created = yield* service.upsert(yield* taskInput(zoned));
+      expect(created.task.schedule).toEqual(zoned);
+      expect(created.task.nextRunAt).toBe("2026-07-03T14:00:00.000Z");
+
+      // A client that predates time zones sends the schedule without one.
+      const kept = yield* service.upsert(
+        yield* taskInput({ type: "fixed_time", timeOfDay: "09:00" }, { title: "Renamed" }),
+      );
+      expect(kept.task.schedule).toEqual(zoned);
+      expect(kept.task.nextRunAt).toBe("2026-07-03T14:00:00.000Z");
+
+      const cleared = yield* service.upsert(
+        yield* taskInput({ type: "fixed_time", timeOfDay: "09:00", timeZone: null }),
+      );
+      expect(cleared.task.schedule).toEqual({ type: "fixed_time", timeOfDay: "09:00" });
+    }),
+  ),
+);
```

## apps/server/src/scheduledTasks/ScheduledTaskService.ts

Status: modified; +7; −2

```diff
@@ -48,7 +48,12 @@
 import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
 import * as SecretRequests from "../secrets/SecretRequests.ts";
 import * as Scheduler from "../scheduling/Scheduler.ts";
+import {
+  isMissedFixedTimeRun,
+  isSameSchedule,
+  nextScheduledRunAt,
+  timerScheduleToStore,
-import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";
+} from "./Schedule.ts";
 import {
   redactHeaders,
   redactQuery,
@@ -1110,7 +1115,7 @@
                     : undefined),
               ),
             }
-          : input.schedule;
+          : timerScheduleToStore(input.schedule, existingTask?.schedule);
       const webhook =
         input.schedule.type === "webhook"
           ? yield* Effect.gen(function* () {
```

## apps/web/src/components/settings/ScheduledTasksSettings.tsx

Status: modified; +50; −3

```diff
@@ -56,6 +56,7 @@
 import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState";
 import { requestConfirmDialog } from "../../confirmDialog";
 import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
+import { timeZoneSuggestions } from "@t3tools/client-runtime/scheduled-task-schedule";
 import { Link } from "@tanstack/react-router";
 import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
 import { useNowMinute } from "../../hooks/useNowMinute";
@@ -80,7 +81,7 @@
 import { settingsScopeEnvironmentLabel, settingsScopeEnvironmentLabels } from "./settingsScopeAxis";
 import {
   WEBHOOK_SIGNATURE_DEFAULTS,
-  webhookDraftErrors,
+  scheduleDraftErrors,
   webhookDeliveryLabel,
   matchesScheduledTaskScope,
   scheduleFromDraft,
@@ -139,6 +140,8 @@
   intervalMinutes: "15",
   timeOfDay: "09:00",
   weekdays: new Set([1, 2, 3, 4, 5]),
+  timeZone: "",
+  savedTimeZone: "",
   projectId: "",
   threadId: "",
   workspaceMode: "worktree",
@@ -276,7 +279,44 @@
     </div>
   );
 }
+
+const TIME_ZONE_LIST_ID = "scheduled-task-time-zones";
 
+function TimeZoneOptions() {
+  return (
+    <datalist id={TIME_ZONE_LIST_ID}>
+      {timeZoneSuggestions().map((zone) => (
+        <option key={zone} value={zone} />
+      ))}
+    </datalist>
+  );
+}
+
+/** Blank means the environment's local time, which may differ from this browser's. */
+function TimeZoneField(props: {
+  readonly id: string;
+  readonly label: string;
+  readonly value: string;
+  readonly onChange: (value: string) => void;
+}) {
+  return (
+    <div className="flex w-full items-center gap-2">
+      <Label htmlFor={props.id}>{props.label}</Label>
+      <Input
+        id={props.id}
+        nativeInput
+        list={TIME_ZONE_LIST_ID}
+        className="min-w-0 flex-1"
+        placeholder="Environment's time zone"
+        autoComplete="off"
+        spellCheck={false}
+        value={props.value}
+        onChange={(event) => props.onChange(event.target.value)}
+      />
+    </div>
+  );
+}
+
 function splitModelKey(value: string): ModelSelection | null {
   const index = value.indexOf(":");
   if (index <= 0 || index === value.length - 1) return null;
@@ -301,7 +341,7 @@
       : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5)
         ? "Weekdays"
         : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", ");
-  return `${days} at ${schedule.timeOfDay}`;
+  return `${days} at ${schedule.timeOfDay}${schedule.timeZone ? ` (${schedule.timeZone})` : ""}`;
 }
 
 /**
@@ -1199,7 +1239,7 @@
     }
     const schedule = scheduleFromDraft(draft);
     if (schedule === null) {
-      for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description);
+      for (const error of scheduleDraftErrors(draft)) reportFailure(error.title, error.description);
       return;
     }
     if (
@@ -1719,6 +1759,12 @@
                     </Toggle>
                   ))}
                 </ToggleGroup>
+                <TimeZoneField
+                  id="scheduled-task-time-zone"
+                  label="Time zone"
+                  value={draft.timeZone}
+                  onChange={(timeZone) => setDraft((current) => ({ ...current, timeZone }))}
+                />
               </div>
             ) : (
               <div className="flex items-center gap-2">
@@ -1754,6 +1800,7 @@
               onCheckedChange={(enabled) => setDraft((current) => ({ ...current, enabled }))}
             />
           </div>
+          <TimeZoneOptions />
         </fieldset>
       </DialogPanel>
 
```

## apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts

Status: modified; +29; −0

```diff
@@ -151,6 +151,35 @@
   });
 });
 
+describe("timed schedule settings", () => {
+  it("round-trips a fixed time's zone, clears it when blank, and rejects an unknown one", () => {
+    const draft = taskToDraft({
+      ...legacyTask,
+      schedule: { type: "fixed_time", timeOfDay: "09:00", timeZone: "America/Chicago" },
+    });
+    expect(draft.timeZone).toBe("America/Chicago");
+    expect(scheduleFromDraft(draft)).toEqual({
+      type: "fixed_time",
+      timeOfDay: "09:00",
+      timeZone: "America/Chicago",
+    });
+    expect(scheduleFromDraft({ ...draft, timeZone: " " })).toMatchObject({ timeZone: null });
+    expect(scheduleFromDraft({ ...draft, timeZone: "Chicago" })).toBeNull();
+  });
+
+  it("keeps a stored zone this client's time zone data lacks when other fields change", () => {
+    const draft = taskToDraft({
+      ...legacyTask,
+      schedule: { type: "fixed_time", timeOfDay: "09:00", timeZone: "Mars/Olympus_Mons" },
+    });
+    expect(scheduleFromDraft({ ...draft, timeOfDay: "10:00" })).toEqual({
+      type: "fixed_time",
+      timeOfDay: "10:00",
+    });
+    expect(scheduleFromDraft({ ...draft, timeZone: "Mars/Tharsis" })).toBeNull();
+  });
+});
+
 describe("webhook scheduled tasks", () => {
   const signature = { header: "x-signature", encoding: "base64", prefix: "" } as const;
   const webhookTask: ScheduledTask = {
```

## apps/web/src/components/settings/scheduledTasksSettings.logic.ts

Status: modified; +25; −3

```diff
@@ -18,6 +18,7 @@
   webhookFilterDraftsChanged,
   type WebhookFilterDraft,
 } from "@t3tools/client-runtime/scheduled-task-webhook";
+import { timeZoneForSave } from "@t3tools/client-runtime/scheduled-task-schedule";
 
 import {
   resolveProjectSettings,
@@ -64,6 +65,10 @@
   readonly intervalMinutes: string;
   readonly timeOfDay: string;
   readonly weekdays: ReadonlySet<number>;
+  /** Blank runs at the environment's local time. */
+  readonly timeZone: string;
+  /** The zone the form opened with. */
+  readonly savedTimeZone: string;
   readonly projectId: string;
   readonly threadId: string;
   readonly workspaceMode: WorkspaceMode;
@@ -99,8 +104,22 @@
   signaturePrefix: "sha256=",
 } as const;
 
-export function webhookDraftErrors(draft: DraftState) {
-  if (draft.scheduleMode !== "webhook") return [];
+export function scheduleDraftErrors(draft: DraftState) {
+  if (draft.scheduleMode === "webhook") return webhookDraftErrors(draft);
+  if (
+    draft.scheduleMode === "fixed" &&
+    timeZoneForSave(draft.timeZone, draft.savedTimeZone) === undefined
+  )
+    return [
+      {
+        title: "Unknown time zone",
+        description: "Pick a time zone such as America/Chicago, or leave it blank.",
+      },
+    ];
+  return [];
+}
+
+function webhookDraftErrors(draft: DraftState) {
   if (parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes) === undefined)
     return [
       {
@@ -147,8 +166,8 @@
 }
 
 export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedule | null {
+  if (scheduleDraftErrors(draft).length > 0) return null;
   if (draft.scheduleMode === "webhook") {
-    if (webhookDraftErrors(draft).length > 0) return null;
     const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes);
     const replayKey = draft.replayKey.trim();
     const coalesceKey = draft.coalesceKey.trim();
@@ -194,6 +213,7 @@
     type: "fixed_time",
     timeOfDay: draft.timeOfDay || "09:00",
     ...(selectedEveryDay ? {} : { weekdays: [...draft.weekdays].toSorted() }),
+    ...timeZoneForSave(draft.timeZone, draft.savedTimeZone),
   };
 }
 
@@ -215,6 +235,8 @@
       schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15",
     timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00",
     weekdays,
+    timeZone: schedule.type === "fixed_time" ? (schedule.timeZone ?? "") : "",
+    savedTimeZone: schedule.type === "fixed_time" ? (schedule.timeZone ?? "") : "",
     projectId: task.projectId,
     threadId: task.threadId ?? "",
     workspaceMode: task.workspaceStrategy.type,
```

## docs/user/project-settings.md

Status: modified; +10; −1

```diff
@@ -66,7 +66,8 @@
 ones across your connected environments. Use the settings filter to narrow the
 list by environment or project. Each task runs on the environment you choose,
 using its project, model, and workspace settings. Fixed-time schedules use that
+environment's time zone, which may differ from your phone's, unless the task
+sets its own.
-environment's time zone, which may differ from your phone's.
 
 Deleting a thread also deletes tasks that post into that thread. Tasks that start
 a new thread on each run survive deletion of their previous threads.
@@ -76,6 +77,14 @@
 immediately.
 Leaving an edited form asks before discarding unsaved changes.
 
+## Scheduled task timing
+
+A task that runs **At a time** uses the environment's clock unless you set a
+**Time zone**, such as `America/Chicago`. It then keeps that local time when
+daylight saving starts or ends. A time skipped when clocks move forward runs
+later by the size of the change, and a time that repeats when they move back
+runs once.
+
 ## Webhook automations
 
 In **Settings → Scheduled tasks**, choose **On webhook**
```

## packages/client-runtime/package.json

Status: modified; +4; −0

```diff
@@ -59,6 +59,10 @@
       "types": "./src/scheduledTaskWebhook.ts",
       "default": "./src/scheduledTaskWebhook.ts"
     },
+    "./scheduled-task-schedule": {
+      "types": "./src/scheduledTaskSchedule.ts",
+      "default": "./src/scheduledTaskSchedule.ts"
+    },
     "./connection": {
       "types": "./src/connection/index.ts",
       "default": "./src/connection/index.ts"
```

## packages/client-runtime/src/scheduledTaskSchedule.ts

Status: added; +37; −0

```diff
@@ -1,0 +1,37 @@
+import { isValidTimeZone } from "@t3tools/contracts";
+
+/** Blank means the environment's local time; undefined means the text is not a zone this client knows. */
+function parseTimeZoneDraft(value: string): string | null | undefined {
+  const zone = value.trim();
+  if (zone === "") return null;
+  // Intl matches names case-insensitively; store the canonical spelling.
+  return isValidTimeZone(zone)
+    ? Intl.DateTimeFormat("en-US", { timeZone: zone }).resolvedOptions().timeZone
+    : undefined;
+}
+
+/**
+ * The `timeZone` part of a fixed-time save, or undefined when the entry blocks saving.
+ * A zone left as it was loaded is omitted when this client's time zone data lacks it;
+ * the server keeps the stored zone, so other edits to the task still save.
+ */
+export function timeZoneForSave(
+  value: string,
+  saved: string,
+): { readonly timeZone?: string | null } | undefined {
+  const timeZone = parseTimeZoneDraft(value);
+  if (timeZone !== undefined) return { timeZone };
+  return value === saved ? {} : undefined;
+}
+
+export function localTimeZone(): string {
+  return Intl.DateTimeFormat().resolvedOptions().timeZone;
+}
+
+/** Zones this client knows, starting with its own. */
+export function timeZoneSuggestions(): ReadonlyArray<string> {
+  const local = localTimeZone();
+  const known =
+    typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : [];
+  return [local, ...known.filter((zone) => zone !== local)];
+}
```

## packages/contracts/src/orchestratorMcp.ts

Status: modified; +1; −1

```diff
@@ -67,7 +67,7 @@
   OrchestratorMcpScheduleFromJsonString,
 ]).annotate({
   description:
-    "Trigger object: {type:'interval', everyMs}, {type:'fixed_time', timeOfDay, weekdays?}, or {type:'webhook'} to run on each request to a generated URL. Never stringify it unless the provider requires the compatibility form.",
+    "Trigger object: {type:'interval', everyMs}, {type:'fixed_time', timeOfDay, weekdays?, timeZone?}, or {type:'webhook'} to run on each request to a generated URL. Never stringify it unless the provider requires the compatibility form.",
 });
 
 const OrchestratorMcpTargetOptionsFromRecord = Schema.Record(
```

## packages/contracts/src/scheduledTask.test.ts

Status: modified; +17; −0

```diff
@@ -22,6 +22,23 @@
       everyMs: MIN_SCHEDULED_TASK_INTERVAL_MS - 1,
     });
   });
+
+  it("checks a written time zone against Intl but reads any stored one", () => {
+    const fixed = { type: "fixed_time", timeOfDay: "09:00" };
+    expect(decodeUpsertSchedule({ ...fixed, timeZone: "America/Chicago" })).toEqual({
+      ...fixed,
+      timeZone: "America/Chicago",
+    });
+    expect(decodeUpsertSchedule({ ...fixed, timeZone: null })).toEqual({
+      ...fixed,
+      timeZone: null,
+    });
+    expect(() => decodeUpsertSchedule({ ...fixed, timeZone: "Mars/Olympus_Mons" })).toThrow();
+    expect(decodeSchedule({ ...fixed, timeZone: "Mars/Olympus_Mons" })).toEqual({
+      ...fixed,
+      timeZone: "Mars/Olympus_Mons",
+    });
+  });
 });
 
 it.each(["literal", " ", "\t\n", "{{body.bad", "{{unknown.field}}"])(
```

## packages/contracts/src/scheduledTask.ts

Status: modified; +39; −3

```diff
@@ -1,3 +1,5 @@
+import * as DateTime from "effect/DateTime";
+import * as Option from "effect/Option";
 import * as Schema from "effect/Schema";
 
 import {
@@ -23,6 +25,23 @@
   Schema.isPattern(/^([01]?\d|2[0-3]):([0-5]\d)$/),
 ).annotate({ description: "Local wall-clock time in 24-hour HH:MM form, such as 09:30." });
 
+/** Whether this runtime's Intl data knows the time zone. */
+export function isValidTimeZone(value: string): boolean {
+  return Option.isSome(DateTime.zoneMakeNamed(value));
+}
+
+/**
+ * Stored zones are read without the Intl check, so a client whose time zone
+ * data lacks one still lists the task.
+ */
+const ScheduledTaskTimeZone = TrimmedNonEmptyString.annotate({
+  description: "IANA time zone name, such as America/Chicago.",
+});
+
+const WritableTimeZone = ScheduledTaskTimeZone.check(
+  Schema.makeFilter((value) => isValidTimeZone(value) || `Unknown time zone: ${value}`),
+);
+
 export const MIN_SCHEDULED_TASK_INTERVAL_MS = 60_000;
 
 const ScheduledTaskIntervalMs = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
@@ -38,7 +57,7 @@
   description: "Run repeatedly after a fixed number of milliseconds.",
 });
 
-const ScheduledTaskFixedTimeSchedule = Schema.Struct({
+const ScheduledTaskFixedTimeFields = {
   type: Schema.Literal("fixed_time").annotate({
     description: "Select a fixed local wall-clock time.",
   }),
@@ -52,8 +71,25 @@
       description: "Optional weekdays; omit to run every day.",
     }),
   ),
+};
+
+const ScheduledTaskFixedTimeSchedule = Schema.Struct({
+  ...ScheduledTaskFixedTimeFields,
+  timeZone: Schema.optional(ScheduledTaskTimeZone),
 }).annotate({
+  description:
+    "Run at a fixed wall-clock time on selected weekdays, in timeZone or the environment's local time.",
+});
+
+const ScheduledTaskUpsertFixedTimeSchedule = Schema.Struct({
+  ...ScheduledTaskFixedTimeFields,
+  timeZone: Schema.optional(Schema.NullOr(WritableTimeZone)).annotate({
+    description:
+      "IANA time zone for timeOfDay, such as America/Chicago. Without one, the time is the environment's local time. Omit to keep the stored zone; null removes it.",
-  description: "Run at a fixed local wall-clock time on selected weekdays.",
+  }),
+}).annotate({
+  description:
+    "Run at a fixed wall-clock time on selected weekdays, in timeZone or the environment's local time.",
 });
 
 const ScheduledTaskWebhookSignatureFields = {
@@ -214,7 +250,7 @@
   }).annotate({
     description: "Run repeatedly after a fixed number of milliseconds.",
   }),
-  ScheduledTaskFixedTimeSchedule,
+  ScheduledTaskUpsertFixedTimeSchedule,
   ScheduledTaskUpsertWebhookSchedule,
 ]).annotate({
   description: "Writable trigger. Pass an object with type 'interval', 'fixed_time' or 'webhook'.",
```

## packages/provider-core/src/server/orchestrationInstructions.ts

Status: modified; +1; −1

```diff
@@ -13,7 +13,7 @@
 - A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. It lists a short set of models per provider; pass \`search\` to find others. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model and no model options are requested or inherited. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. For a child task that needs a separate checkout, use \`delegate_task\` with \`isolation:"worktree"\` across any provider or model. It starts from the parent local commit and runs project setup; uncommitted edits are not copied. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round.
 - \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work.
 - For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.`,
-  `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule, or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing.
+  `- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule (add \`"timeZone":"America/Chicago"\` when the user's clock differs from the environment's), or \`{"type":"webhook"}\` to run on each request to the returned \`webhookUrl\` (the run sees the request only through \`{{body.path}}\`-style placeholders in the prompt). By default runs return to the current thread, which suits orchestrating: each trigger arrives here and you delegate or dedupe; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling a timer, report the returned cadence and next run time; for a webhook, report its \`webhookUrl\`, or say T3 Connect remote access is needed if it is missing.
 - When you need a secret from the user (a token, API key, or webhook signing secret), call \`request_secret\` so they enter it privately, then pass the returned \`secretRef\` to the tool that needs it, e.g. \`signature.secretRef\` on a webhook task for a sender that signs requests such as GitHub. A \`secretRef\` works once. Never ask for a secret in chat, never invent one, and never repeat one.
 - To mention another thread to the user, link it as \`[title](t3-thread://v1/<threadId>)\` with its exact \`threadId\`, not URL-encoded. T3 Code opens the thread in the app and shows its current title.`,
   `### Choose the workspace before starting a new thread
```
