Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c01a227
fix(scheduler): show short server timezone on fixed-time labels
Oct 21, 2026
5342313
Merge remote-tracking branch 'github/main' into takeover-23111
maria-rcks Oct 26, 2026
9a4faa2
fix(scheduler): keep timezone fallback labels compact
maria-rcks Oct 26, 2026
5a9c92b
chore: merge main
maria-rcks Oct 29, 2026
73a54e5
Merge remote-tracking branch 'github/main' into takeover-23111
maria-rcks Nov 1, 2026
7b7ea39
fix(scheduler): label runs with their timezone at execution
maria-rcks Nov 1, 2026
f52a6e2
test(scheduler): expect generic zones for paused tasks
maria-rcks Nov 1, 2026
415fa8a
chore: merge main into scheduler timezone fix
maria-rcks Nov 2, 2026
9954233
test(scheduler): provide managed policy in timezone case
maria-rcks Nov 2, 2026
a9027f9
Merge remote-tracking branch 'github/main' into fix/scheduler-timezon…
maria-rcks Nov 5, 2026
bcf6f56
Merge remote-tracking branch 'github/main' into fix/scheduler-timezon…
maria-rcks Nov 5, 2026
31c0c67
fix(web): describe the fixed-time input with its server zone
maria-rcks Nov 5, 2026
d0ee521
Merge remote-tracking branch 'origin/main' into fix/scheduler-timezon…
juliusmarminge Nov 11, 2026
ed82e31
refactor(scheduler): share the zone label helper and trim tests
juliusmarminge Nov 11, 2026
b3418a0
Merge remote-tracking branch 'origin/main' into fix/scheduler-timezon…
juliusmarminge Nov 11, 2026
9d92c50
Merge remote-tracking branch 'origin/main' into fix/scheduler-timezon…
shawk08033 Nov 11, 2026
4a4cef5
Merge remote-tracking branch 'origin/main' into fix/scheduler-timezon…
juliusmarminge Nov 11, 2026
f899529
refactor(scheduler): read the zone name from formatToParts
juliusmarminge Nov 11, 2026
9f5f596
Merge branch 'fix/scheduler-timezone-short-label' of https://github.c…
juliusmarminge Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -117,15 +118,16 @@ const THREAD_MODE_LABELS: Record<ScheduledTaskThreadMode, string> = {
fresh_session: "One thread, fresh session",
};

function describeSchedule(task: ScheduledTask): string {
function describeSchedule(task: ScheduledTask, timeZone?: string): string {
if (task.schedule.type === "webhook") return "On webhook";
if (task.schedule.type === "once") {
const runAt = new Date(task.schedule.runAt);
return `Once, ${formatRunAtDate(runAt)} at ${formatRunAtTime(runAt)}`;
}
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, task.nextRunAt);
return `${days} at ${formatTime(task.schedule.timeOfDay)}${zone === undefined ? "" : ` ${zone}`}`;
}

function formatTime(value: string): string {
Expand Down Expand Up @@ -684,6 +686,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,
);
Expand Down Expand Up @@ -1058,7 +1061,9 @@ function TaskForm({
<>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Time, ${formatTime(draft.schedule.timeOfDay)}`}
accessibilityLabel={`Time, ${formatTime(draft.schedule.timeOfDay)}${
runAtZone ? ` ${runAtZone}` : ""
}`}
onPress={() => 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"
>
Expand All @@ -1070,6 +1075,7 @@ function TaskForm({
className="min-w-0 flex-1 text-right text-foreground-muted"
>
{formatTime(draft.schedule.timeOfDay)}
{runAtZone ? ` ${runAtZone}` : ""}
</Text>
<SymbolView
name="chevron.right"
Expand Down Expand Up @@ -1495,7 +1501,7 @@ function EnvironmentTasks({
{task.title}
</Text>
<Text className="text-sm text-foreground-muted" numberOfLines={2}>
{describeSchedule(task)}
{describeSchedule(task, tasks.data?.timeZone)}
{!task.enabled
? " · Paused"
: task.nextRunAt
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/scheduledTasks/ScheduledTaskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1237,7 +1237,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 })),
);

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,7 @@ export const layerWsRpc = (
currentSession.scopes.includes(AuthOrchestrationOperateScope)
? result
: {
...result,
tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task),
};
const authorizeEffect = <A, E, R>(
Expand Down
15 changes: 10 additions & 5 deletions apps/web/src/components/chat/ThreadAutomationsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -37,10 +38,10 @@ const STATUS_DOT_CLASS: Record<ScheduledTask["lastRunStatus"], string> = {
failed: "bg-destructive",
};

function taskScheduleLine(task: ScheduledTask) {
function taskScheduleLine(task: ScheduledTask, timeZone: string | undefined) {
return (
<>
{scheduleLabel(task.schedule)}
{scheduleLabel(task.schedule, timeZone, task.nextRunAt)}
{task.blockedByPolicy !== undefined ? (
` · ${task.blockedByPolicy}`
) : task.enabled && task.nextRunAt !== null ? (
Expand Down Expand Up @@ -179,11 +180,15 @@ export function ThreadAutomationsPanel(props: {
<span className="block truncate text-sm font-medium text-foreground/80">
{task.title}
</span>
<p className="truncate text-2xs text-muted-foreground">{taskScheduleLine(task)}</p>
<p className="truncate text-2xs text-muted-foreground">
{taskScheduleLine(task, tasksQuery.data?.timeZone)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</p>
</TooltipTrigger>
<TooltipPopup side="left">
<p className="font-medium">{task.title}</p>
<p className="text-muted-foreground">{taskScheduleLine(task)}</p>
<p className="text-muted-foreground">
{taskScheduleLine(task, tasksQuery.data?.timeZone)}
</p>
</TooltipPopup>
</Tooltip>
<Tooltip>
Expand Down
47 changes: 19 additions & 28 deletions apps/web/src/components/settings/ScheduledTasksSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import type {
ProjectId,
ScheduledTask,
ScheduledTaskId,
ScheduledTaskSchedule,
ScheduledTaskThreadMode,
ScheduledTaskUpsertInput,
ScheduledTaskWebhookDeliverySummary,
Expand All @@ -35,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,
Expand Down Expand Up @@ -104,7 +104,9 @@ import {
defaultScheduledTaskProjectId,
matchesScheduledTaskScope,
scheduleFromDraft,
scheduleLabel,
scheduledTaskDefaultModel,
WEEKDAY_LABELS,
taskToDraft,
unbindDraftFromThread,
type DraftState,
Expand Down Expand Up @@ -151,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.
Expand Down Expand Up @@ -322,30 +323,6 @@ function splitModelKey(value: string): ModelSelection | null {
};
}

const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});

export function scheduleLabel(schedule: ScheduledTaskSchedule): 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") {
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 handles only the past; upcoming runs need future labels. */
function relativeLabel(value: string | null, nowMs: number): string {
if (!value) return "Not scheduled";
Expand Down Expand Up @@ -552,6 +529,7 @@ function ScheduledTaskEnvironmentList({
return {
environment,
connected: index >= 0,
timeZone: data?.timeZone,
tasks: data?.tasks.filter((task) =>
matchesScheduledTaskScope(scope, environment.environmentId, task.projectId),
),
Expand Down Expand Up @@ -586,7 +564,7 @@ function ScheduledTaskEnvironmentList({

return (
<SettingsGroup>
{populated.map(({ environment, tasks }) => {
{populated.map(({ environment, tasks, timeZone }) => {
const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments);
return (
<div key={environment.environmentId} className="divide-y divide-border/50">
Expand All @@ -610,6 +588,7 @@ function ScheduledTaskEnvironmentList({
key={task.id}
environmentId={environment.environmentId}
task={task}
timeZone={timeZone}
onEdit={() => onEdit(environment.environmentId, task)}
/>
))}
Expand Down Expand Up @@ -734,6 +713,7 @@ function ScheduledTaskEnvironmentSection({
key={task.id}
environmentId={environment.environmentId}
task={task}
timeZone={tasksQuery.data?.timeZone}
onEdit={() => onEdit(environment.environmentId, task)}
/>
))
Expand All @@ -747,10 +727,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(
Expand Down Expand Up @@ -795,7 +777,7 @@ function ScheduledTaskRow({
status={
<div className="flex flex-wrap items-center gap-2">
<span>
{scheduleLabel(task.schedule)} ·{" "}
{scheduleLabel(task.schedule, timeZone, task.nextRunAt)} ·{" "}
{task.blockedByPolicy !== undefined ? (
task.blockedByPolicy
) : !task.enabled ? (
Expand Down Expand Up @@ -1416,6 +1398,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;
Expand Down Expand Up @@ -2063,13 +2046,21 @@ function ScheduledTaskEditorForm({
<Input
type="time"
id="scheduled-task-time"
aria-describedby={
runAtZone === undefined ? undefined : "scheduled-task-time-zone"
}
nativeInput
className="w-32"
value={draft.timeOfDay}
onChange={(event) =>
setDraft((current) => ({ ...current, timeOfDay: event.target.value }))
}
/>
{runAtZone === undefined ? null : (
<span id="scheduled-task-time-zone" className="text-xs text-muted-foreground">
{runAtZone}
</span>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<span className="text-xs text-muted-foreground">on</span>
</div>
<ToggleGroup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
defaultScheduledTaskProjectId,
matchesScheduledTaskScope,
scheduleFromDraft,
scheduleLabel,
taskToDraft,
unbindDraftFromThread,
} from "./scheduledTasksSettings.logic";
Expand Down Expand Up @@ -407,3 +408,12 @@ it("rejects dedupe templates without a request placeholder", () => {
expect(scheduleFromDraft({ ...draft, replayKey: "literal" })).toBeNull();
expect(scheduleFromDraft({ ...draft, coalesceKey: "{{body.bad" })).toBeNull();
});

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");
});
33 changes: 33 additions & 0 deletions apps/web/src/components/settings/scheduledTasksSettings.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type ProjectId,
ScheduledTaskId,
type ScheduledTask,
type ScheduledTaskSchedule,
type ScheduledTaskThreadMode,
type ScheduledTaskUpsertSchedule,
scheduledTaskThreadMode,
Expand All @@ -22,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,
Expand All @@ -31,6 +33,37 @@ import type { ProviderInstanceEntry } from "../../providerInstances";

import type { ResolvedSettingsScope } from "./settingsScope";

export const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;

const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});

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") {
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, runAt);
return `${days} at ${schedule.timeOfDay}${zone === undefined ? "" : ` ${zone}`}`;
}

export function matchesScheduledTaskScope(
scope: ResolvedSettingsScope,
environmentId: EnvironmentId,
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions packages/client-runtime/src/scheduledTaskTimeZone.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
23 changes: 23 additions & 0 deletions packages/client-runtime/src/scheduledTaskTimeZone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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",
})
.formatToParts(runAt ? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt)) : undefined)
.find((part) => part.type === "timeZoneName")?.value;
} catch {
return undefined;
}
}
Loading
Loading