Skip to content

Commit 7b635bc

Browse files
willsmanleyclaude
andcommitted
feat(scheduler): run fixed-time tasks in a chosen time zone
A fixed-time task always ran on the environment's local clock, so a user in another zone had to convert times by hand and redo it at every daylight-saving change. fixed_time schedules take an optional IANA timeZone, checked with Intl when written. Without one, runs stay on the server's local time. The next run is computed in that zone, so a time skipped when clocks spring forward runs that much later and a repeated one runs once. A save that omits timeZone keeps the stored zone, so older clients do not clear it; null removes it. Web and mobile settings can set and show the zone, and the agent tools describe it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 2c8bdf6 commit 7b635bc

18 files changed

Lines changed: 415 additions & 21 deletions

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScroll
5151
import { SegmentedControl } from "../../components/ui/SegmentedControl";
5252
import { ThemedSwitch } from "../../components/ui/ThemedSwitch";
5353
import { webhookAddress } from "@t3tools/client-runtime/webhook-address";
54+
import { localTimeZone, parseTimeZoneDraft } from "@t3tools/client-runtime/scheduled-task-schedule";
5455
import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic";
5556
import { usePreparedConnection } from "../../state/session";
5657
import { buildModelOptions } from "../../lib/modelOptions";
@@ -106,7 +107,8 @@ function describeSchedule(task: ScheduledTask): string {
106107
if (task.schedule.type === "webhook") return "On webhook";
107108
if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs);
108109
const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day";
109-
return `${days} at ${formatTime(task.schedule.timeOfDay)}`;
110+
const zone = task.schedule.timeZone ? ` (${task.schedule.timeZone})` : "";
111+
return `${days} at ${formatTime(task.schedule.timeOfDay)}${zone}`;
110112
}
111113

112114
function formatTime(value: string): string {
@@ -148,6 +150,8 @@ function FormField(props: {
148150
readonly disabled?: boolean;
149151
readonly placeholder?: string;
150152
readonly borderTop?: boolean;
153+
/** For identifiers such as time zone names, which autocorrect would mangle. */
154+
readonly literal?: boolean;
151155
}) {
152156
return (
153157
<View
@@ -163,6 +167,8 @@ function FormField(props: {
163167
onChangeText={props.onChange}
164168
textAlignVertical="center"
165169
keyboardType={props.keyboardType}
170+
autoCapitalize={props.literal ? "none" : undefined}
171+
autoCorrect={props.literal ? false : undefined}
166172
placeholder={props.placeholder}
167173
placeholderTextColorClassName="accent-foreground-muted"
168174
className="min-h-8 font-sans text-base text-foreground"
@@ -711,6 +717,16 @@ function TaskForm({
711717
);
712718
return;
713719
}
720+
if (
721+
draft.schedule.mode === "fixed_time" &&
722+
parseTimeZoneDraft(draft.schedule.timeZone) === undefined
723+
) {
724+
Dialog.alert(
725+
"Unknown time zone",
726+
"Enter a time zone such as America/Chicago, or leave it blank.",
727+
);
728+
return;
729+
}
714730
const parsedFilters = parseWebhookFilterDrafts(draft.schedule.filters);
715731
if (draft.schedule.mode === "webhook" && "error" in parsedFilters) {
716732
Dialog.alert("Invalid filter", parsedFilters.error);
@@ -1034,6 +1050,17 @@ function TaskForm({
10341050
setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } });
10351051
}}
10361052
/>
1053+
<FormField
1054+
label="Time zone"
1055+
value={draft.schedule.timeZone}
1056+
placeholder="Environment's time zone"
1057+
disabled={saving}
1058+
borderTop
1059+
literal
1060+
onChange={(timeZone) =>
1061+
setDraft({ ...draft, schedule: { ...draft.schedule, timeZone } })
1062+
}
1063+
/>
10371064
</>
10381065
) : draft.schedule.mode === "webhook" ? (
10391066
<>
@@ -1139,7 +1166,8 @@ function TaskForm({
11391166
</SettingsSection>
11401167
{draft.schedule.mode === "fixed_time" ? (
11411168
<Text className="px-2 text-sm text-foreground-muted">
1142-
Time uses the environment's time zone, which may differ from your phone's.
1169+
Enter an IANA time zone such as {localTimeZone()}, this phone's. Left blank, the time
1170+
uses the environment's time zone, which may differ from your phone's.
11431171
</Text>
11441172
) : null}
11451173
</View>

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ describe("scheduleFromDraft", () => {
8787
expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, weekdays: [] })).toBeNull();
8888
});
8989

90+
it("keeps a fixed time's zone, clears it when blank, and rejects an unknown one", () => {
91+
const draft = scheduleDraftForTask({
92+
schedule: { type: "fixed_time", timeOfDay: "09:00", timeZone: "America/Chicago" },
93+
});
94+
expect(scheduleFromDraft(draft)).toEqual({
95+
type: "fixed_time",
96+
timeOfDay: "09:00",
97+
timeZone: "America/Chicago",
98+
});
99+
expect(scheduleFromDraft({ ...draft, timeZone: "america/chicago" })).toMatchObject({
100+
timeZone: "America/Chicago",
101+
});
102+
expect(scheduleFromDraft({ ...draft, timeZone: "" })).toMatchObject({ timeZone: null });
103+
expect(scheduleFromDraft({ ...draft, timeZone: "Chicago" })).toBeNull();
104+
});
105+
90106
it.each([
91107
["1.5", 90_000],
92108
[String(60_001 / 60_000), 60_001],

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
webhookFilterDraftsChanged,
1818
type WebhookFilterDraft,
1919
} from "@t3tools/client-runtime/scheduled-task-webhook";
20+
import { parseTimeZoneDraft } from "@t3tools/client-runtime/scheduled-task-schedule";
2021
import {
2122
resolveProjectSettings,
2223
type LegacyProjectSettingsFields,
@@ -54,6 +55,8 @@ export type ScheduleDraft = {
5455
readonly mode: "fixed_time" | "interval" | "webhook";
5556
readonly timeOfDay: string;
5657
readonly weekdays: ReadonlyArray<number>;
58+
/** Blank runs at the environment's local time. */
59+
readonly timeZone: string;
5760
readonly intervalMinutes: string;
5861
readonly signature: ScheduledTaskWebhookSignature | null;
5962
readonly maxDeliveryAgeMinutes: string;
@@ -69,6 +72,7 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = {
6972
mode: "fixed_time",
7073
timeOfDay: "09:00",
7174
weekdays: [1, 2, 3, 4, 5],
75+
timeZone: "",
7276
intervalMinutes: "15",
7377
signature: null,
7478
maxDeliveryAgeMinutes: "",
@@ -88,6 +92,7 @@ export function scheduleDraftForTask(task: Pick<ScheduledTask, "schedule">): Sch
8892
weekdays: task.schedule.weekdays?.length
8993
? [...new Set(task.schedule.weekdays)].sort((a, b) => a - b)
9094
: [0, 1, 2, 3, 4, 5, 6],
95+
timeZone: task.schedule.timeZone ?? "",
9196
};
9297
case "interval":
9398
return {
@@ -168,7 +173,9 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
168173
return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null;
169174
}
170175
const weekdays = [...new Set(draft.weekdays)].sort((a, b) => a - b);
176+
const timeZone = parseTimeZoneDraft(draft.timeZone);
171177
if (
178+
timeZone === undefined ||
172179
!/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay) ||
173180
weekdays.length === 0 ||
174181
weekdays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)
@@ -179,6 +186,7 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche
179186
type: "fixed_time",
180187
timeOfDay: draft.timeOfDay,
181188
...(weekdays.length === 7 ? {} : { weekdays }),
189+
timeZone,
182190
};
183191
}
184192

@@ -212,6 +220,7 @@ function draftSignature(draft: ScheduledTaskDraft): string {
212220
draft.schedule.mode,
213221
draft.schedule.timeOfDay,
214222
[...draft.schedule.weekdays].sort((a, b) => a - b),
223+
draft.schedule.timeZone,
215224
draft.schedule.intervalMinutes,
216225
draft.schedule.maxDeliveryAgeMinutes,
217226
draft.schedule.replayKey,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", {
118118

119119
const ScheduleTaskTool = Tool.make("schedule_task", {
120120
description:
121-
"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.",
121+
"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.",
122122
parameters: OrchestratorMcpScheduleTaskInput,
123123
success: OrchestratorMcpScheduleTaskResult,
124124
failure: OrchestratorMcpFailure,
@@ -145,7 +145,7 @@ const ListScheduledTasksTool = Tool.make("list_scheduled_tasks", {
145145

146146
const UpdateScheduledTaskTool = Tool.make("update_scheduled_task", {
147147
description:
148-
"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.",
148+
"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. The same holds inside schedule: an optional field such as timeZone that you leave out keeps its stored value, and null removes it. 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.",
149149
parameters: OrchestratorMcpUpdateScheduledTaskInput,
150150
success: OrchestratorMcpScheduleTaskResult,
151151
failure: OrchestratorMcpFailure,

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it } from "@effect/vitest";
2+
import type { ScheduledTaskSchedule } from "@t3tools/contracts";
23
import * as DateTime from "effect/DateTime";
34

45
import { isSameSchedule, nextScheduledRunAt } from "./Schedule.ts";
@@ -26,6 +27,61 @@ describe("scheduled task schedule calculation", () => {
2627
expect(parts?.minute).toBe(0);
2728
});
2829

30+
describe("fixed time in a time zone", () => {
31+
const next = (
32+
schedule: Partial<Extract<ScheduledTaskSchedule, { type: "fixed_time" }>>,
33+
from: string,
34+
) => {
35+
const run = nextScheduledRunAt(
36+
{ type: "fixed_time", timeOfDay: "09:00", timeZone: "America/Chicago", ...schedule },
37+
DateTime.makeUnsafe(from),
38+
);
39+
return run === null ? null : DateTime.formatIso(DateTime.toUtc(run));
40+
};
41+
42+
it("runs at the wall-clock time of its zone, not the server's", () => {
43+
expect(next({}, "2026-07-03T10:00:00.000Z")).toBe("2026-07-03T14:00:00.000Z");
44+
expect(next({ timeZone: "Asia/Tokyo" }, "2026-07-03T10:00:00.000Z")).toBe(
45+
"2026-07-04T00:00:00.000Z",
46+
);
47+
});
48+
49+
it("keeps the wall-clock time across the spring-forward change", () => {
50+
// 09:00 CST is 15:00Z; after clocks move forward on March 8, 09:00 CDT is 14:00Z.
51+
expect(next({}, "2026-03-07T16:00:00.000Z")).toBe("2026-03-08T14:00:00.000Z");
52+
// 02:30 does not exist on March 8, so that run moves to 03:30 CDT.
53+
expect(next({ timeOfDay: "02:30" }, "2026-03-08T07:00:00.000Z")).toBe(
54+
"2026-03-08T08:30:00.000Z",
55+
);
56+
expect(next({ timeOfDay: "02:30" }, "2026-03-08T08:31:00.000Z")).toBe(
57+
"2026-03-09T07:30:00.000Z",
58+
);
59+
});
60+
61+
it("keeps the wall-clock time across the fall-back change and runs a repeated time once", () => {
62+
// 09:00 CDT is 14:00Z; after clocks move back on November 1, 09:00 CST is 15:00Z.
63+
expect(next({}, "2026-10-31T15:00:00.000Z")).toBe("2026-11-01T15:00:00.000Z");
64+
// 01:30 happens twice on November 1: first in CDT (06:30Z), then in CST (07:30Z).
65+
expect(next({ timeOfDay: "01:30" }, "2026-11-01T05:00:00.000Z")).toBe(
66+
"2026-11-01T06:30:00.000Z",
67+
);
68+
expect(next({ timeOfDay: "01:30" }, "2026-11-01T06:31:00.000Z")).toBe(
69+
"2026-11-02T07:30:00.000Z",
70+
);
71+
});
72+
73+
it("applies weekdays in the zone's calendar", () => {
74+
// Friday 20:00 in Chicago is already Saturday in UTC.
75+
expect(next({ timeOfDay: "20:00", weekdays: [5] }, "2026-07-03T12:00:00.000Z")).toBe(
76+
"2026-07-04T01:00:00.000Z",
77+
);
78+
});
79+
80+
it("has no next run for a zone this runtime does not know", () => {
81+
expect(next({ timeZone: "Mars/Olympus_Mons" }, "2026-07-03T10:00:00.000Z")).toBeNull();
82+
});
83+
});
84+
2985
it("compares schedules structurally", () => {
3086
expect(
3187
isSameSchedule({ type: "interval", everyMs: 60_000 }, { type: "interval", everyMs: 60_000 }),
@@ -75,5 +131,11 @@ describe("scheduled task schedule calculation", () => {
75131
{ type: "fixed_time", timeOfDay: "09:00" },
76132
),
77133
).toBe(false);
134+
expect(
135+
isSameSchedule(
136+
{ type: "fixed_time", timeOfDay: "09:00", timeZone: "America/Chicago" },
137+
{ type: "fixed_time", timeOfDay: "09:00" },
138+
),
139+
).toBe(false);
78140
});
79141
});

0 commit comments

Comments
 (0)