diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index ef4b696c82ec8..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,7 +118,7 @@ const THREAD_MODE_LABELS: Record = { 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); @@ -125,7 +126,8 @@ function describeSchedule(task: ScheduledTask): string { } 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 { @@ -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, ); @@ -1058,7 +1061,9 @@ 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" > @@ -1070,6 +1075,7 @@ function TaskForm({ className="min-w-0 flex-1 text-right text-foreground-muted" > {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.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 4953cec766210..4fd59e610f0f3 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -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 })), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9721864fcdfd5..946c94e8664ba 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1557,6 +1557,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.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 4c3326df8c635..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"; @@ -37,10 +38,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.nextRunAt)} {task.blockedByPolicy !== undefined ? ( ` · ${task.blockedByPolicy}` ) : task.enabled && task.nextRunAt !== null ? ( @@ -179,11 +180,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 ff024e5ba2ed2..848b88130f48a 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -21,7 +21,6 @@ import type { ProjectId, ScheduledTask, ScheduledTaskId, - ScheduledTaskSchedule, ScheduledTaskThreadMode, ScheduledTaskUpsertInput, ScheduledTaskWebhookDeliverySummary, @@ -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, @@ -104,7 +104,9 @@ import { defaultScheduledTaskProjectId, matchesScheduledTaskScope, scheduleFromDraft, + scheduleLabel, scheduledTaskDefaultModel, + WEEKDAY_LABELS, taskToDraft, unbindDraftFromThread, type DraftState, @@ -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. @@ -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"; @@ -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), ), @@ -586,7 +564,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks }) => { + {populated.map(({ environment, tasks, timeZone }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); return (
@@ -610,6 +588,7 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -734,6 +713,7 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -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( @@ -795,7 +777,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule)} ·{" "} + {scheduleLabel(task.schedule, timeZone, task.nextRunAt)} ·{" "} {task.blockedByPolicy !== undefined ? ( task.blockedByPolicy ) : !task.enabled ? ( @@ -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; @@ -2063,6 +2046,9 @@ function ScheduledTaskEditorForm({ ({ ...current, timeOfDay: event.target.value })) } /> + {runAtZone === undefined ? null : ( + + {runAtZone} + + )} on
{ 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"); +}); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 26d83da69f428..1095db83469ba 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 ScheduledTaskThreadMode, type ScheduledTaskUpsertSchedule, scheduledTaskThreadMode, @@ -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, @@ -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, 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..9321ab11acbe8 --- /dev/null +++ b/packages/client-runtime/src/scheduledTaskTimeZone.ts @@ -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; + } +} diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 2a77777a9c3dc..4297ad94b73e9 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -271,6 +271,8 @@ export type ScheduledTaskListInput = typeof ScheduledTaskListInput.Type; export const ScheduledTaskListResult = Schema.Struct({ 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;