From f262bb930781a2ae35818cd533df04bfd2444a32 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Mon, 15 Sep 2025 01:15:52 -0500 Subject: [PATCH] fix(platform): add timezone awareness to scheduler (#10921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Changes 🏗️ This PR restores and improves timezone awareness in the scheduler service to correctly handle daylight savings time (DST) transitions. The changes ensure that scheduled agents run at the correct local time even when crossing DST boundaries. #### Backend Changes: - **Scheduler Service (`scheduler.py`):** - Added `user_timezone` parameter to `add_graph_execution_schedule()` method - CronTrigger now uses the user's timezone instead of hardcoded UTC - Added timezone field to `GraphExecutionJobInfo` for visibility - Falls back to UTC with a warning if no timezone is provided - Extracts and includes timezone information from job triggers - **API Router (`v1.py`):** - Added optional `timezone` field to `ScheduleCreationRequest` - Fetches user's saved timezone from profile if not provided in request - Passes timezone to scheduler client when creating schedules - Converts `next_run_time` back to user timezone for display #### Frontend Changes: - **Schedule Creation Modal:** - Now sends user's timezone with schedule creation requests - Uses browser's local timezone if user hasn't set one in their profile - **Schedule Display Components:** - Updated to show timezone information in schedule details - Improved formatting of schedule information in monitoring views - Fixed schedule table display to properly show timezone-aware times - **Cron Expression Utils:** - Removed UTC conversion logic from `formatTime()` function - Cron expressions are now stored in the schedule's timezone - Simplified humanization logic since no conversion is needed - **API Types & OpenAPI:** - Added `timezone` field to schedule-related types - Updated OpenAPI schema to include timezone parameter ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [ ] I have tested my changes according to the test plan: ### Test Plan 🧪 #### 1. Schedule Creation Tests - [ ] Create a new schedule and verify the timezone is correctly saved - [ ] Create a schedule without specifying timezone - should use user's profile timezone - [ ] Create a schedule when user has no profile timezone - should default to UTC with warning #### 2. Daylight Savings Time Tests - [ ] Create a schedule for a daily task at 2:00 PM in a DST timezone (e.g., America/New_York) - [ ] Verify the schedule runs at 2:00 PM local time before DST transition - [ ] Verify the schedule still runs at 2:00 PM local time after DST transition - [ ] Check that the next_run_time adjusts correctly across DST boundaries #### 3. Display and UI Tests - [ ] Verify timezone is displayed in schedule details view - [ ] Verify schedule times are shown in user's local timezone in monitoring page - [ ] Verify cron expression humanization shows correct local times - [ ] Check that schedule table shows timezone information #### 4. API Tests - [ ] Test schedule creation API with timezone parameter - [ ] Test schedule creation API without timezone parameter - [ ] Verify GET schedules endpoint returns timezone information - [ ] Verify next_run_time is converted to user timezone in responses #### 5. Edge Cases - [ ] Test with various timezones (UTC, EST, PST, Europe/London, Asia/Tokyo) - [ ] Test with invalid timezone strings - should handle gracefully - [ ] Test scheduling at DST transition times (2:00 AM during spring forward) - [ ] Verify existing schedules without timezone info default to UTC #### 6. Regression Tests - [ ] Verify existing schedules continue to work - [ ] Verify schedule deletion still works - [ ] Verify schedule listing endpoints work correctly - [ ] Check that scheduled graph executions trigger as expected --------- Co-authored-by: Claude --- .../backend/backend/executor/scheduler.py | 25 ++++++++- .../backend/backend/server/routers/v1.py | 55 +++++-------------- .../RunAgentModal/useAgentRunModal.ts | 11 ++++ .../ScheduleDetails/ScheduleDetails.tsx | 12 ++-- .../agent-schedule-details-view.tsx | 2 +- .../src/app/(platform)/monitoring/page.tsx | 39 +++++++------ .../frontend/src/app/api/openapi.json | 13 ++++- .../src/components/monitor/scheduleTable.tsx | 19 ++++--- .../src/lib/autogpt-server-api/types.ts | 1 + .../frontend/src/lib/cron-expression-utils.ts | 47 +++------------- 10 files changed, 108 insertions(+), 116 deletions(-) diff --git a/autogpt_platform/backend/backend/executor/scheduler.py b/autogpt_platform/backend/backend/executor/scheduler.py index e476b3ceed..77ed652886 100644 --- a/autogpt_platform/backend/backend/executor/scheduler.py +++ b/autogpt_platform/backend/backend/executor/scheduler.py @@ -191,15 +191,22 @@ class GraphExecutionJobInfo(GraphExecutionJobArgs): id: str name: str next_run_time: str + timezone: str = Field(default="UTC", description="Timezone used for scheduling") @staticmethod def from_db( job_args: GraphExecutionJobArgs, job_obj: JobObj ) -> "GraphExecutionJobInfo": + # Extract timezone from the trigger if it's a CronTrigger + timezone_str = "UTC" + if hasattr(job_obj.trigger, "timezone"): + timezone_str = str(job_obj.trigger.timezone) + return GraphExecutionJobInfo( id=job_obj.id, name=job_obj.name, next_run_time=job_obj.next_run_time.isoformat(), + timezone=timezone_str, **job_args.model_dump(), ) @@ -395,6 +402,7 @@ class Scheduler(AppService): input_data: BlockInput, input_credentials: dict[str, CredentialsMetaInput], name: Optional[str] = None, + user_timezone: str | None = None, ) -> GraphExecutionJobInfo: # Validate the graph before scheduling to prevent runtime failures # We don't need the return value, just want the validation to run @@ -408,7 +416,18 @@ class Scheduler(AppService): ) ) - logger.info(f"Scheduling job for user {user_id} in UTC (cron: {cron})") + # Use provided timezone or default to UTC + # Note: Timezone should be passed from the client to avoid database lookups + if not user_timezone: + user_timezone = "UTC" + logger.warning( + f"No timezone provided for user {user_id}, using UTC for scheduling. " + f"Client should pass user's timezone for correct scheduling." + ) + + logger.info( + f"Scheduling job for user {user_id} with timezone {user_timezone} (cron: {cron})" + ) job_args = GraphExecutionJobArgs( user_id=user_id, @@ -422,12 +441,12 @@ class Scheduler(AppService): execute_graph, kwargs=job_args.model_dump(), name=name, - trigger=CronTrigger.from_crontab(cron, timezone="UTC"), + trigger=CronTrigger.from_crontab(cron, timezone=user_timezone), jobstore=Jobstores.EXECUTION.value, replace_existing=True, ) logger.info( - f"Added job {job.id} with cron schedule '{cron}' in UTC, input data: {input_data}" + f"Added job {job.id} with cron schedule '{cron}' in timezone {user_timezone}, input data: {input_data}" ) return GraphExecutionJobInfo.from_db(job_args, job) diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index c5c7d818e3..084b0f3cb2 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -90,7 +90,6 @@ from backend.util.cloud_storage import get_cloud_storage_handler from backend.util.exceptions import GraphValidationError, NotFoundError from backend.util.settings import Settings from backend.util.timezone_utils import ( - convert_cron_to_utc, convert_utc_time_to_user_timezone, get_user_timezone_or_utc, ) @@ -945,6 +944,10 @@ class ScheduleCreationRequest(pydantic.BaseModel): cron: str inputs: dict[str, Any] credentials: dict[str, CredentialsMetaInput] = pydantic.Field(default_factory=dict) + timezone: Optional[str] = pydantic.Field( + default=None, + description="User's timezone for scheduling (e.g., 'America/New_York'). If not provided, will use user's saved timezone or UTC.", + ) @v1_router.post( @@ -969,26 +972,22 @@ async def create_graph_execution_schedule( detail=f"Graph #{graph_id} v{schedule_params.graph_version} not found.", ) - user = await get_user_by_id(user_id) - user_timezone = get_user_timezone_or_utc(user.timezone if user else None) - - # Convert cron expression from user timezone to UTC - try: - utc_cron = convert_cron_to_utc(schedule_params.cron, user_timezone) - except ValueError as e: - raise HTTPException( - status_code=400, - detail=f"Invalid cron expression for timezone {user_timezone}: {e}", - ) + # Use timezone from request if provided, otherwise fetch from user profile + if schedule_params.timezone: + user_timezone = schedule_params.timezone + else: + user = await get_user_by_id(user_id) + user_timezone = get_user_timezone_or_utc(user.timezone if user else None) result = await get_scheduler_client().add_execution_schedule( user_id=user_id, graph_id=graph_id, graph_version=graph.version, name=schedule_params.name, - cron=utc_cron, # Send UTC cron to scheduler + cron=schedule_params.cron, input_data=schedule_params.inputs, input_credentials=schedule_params.credentials, + user_timezone=user_timezone, ) # Convert the next_run_time back to user timezone for display @@ -1010,24 +1009,11 @@ async def list_graph_execution_schedules( user_id: Annotated[str, Security(get_user_id)], graph_id: str = Path(), ) -> list[scheduler.GraphExecutionJobInfo]: - schedules = await get_scheduler_client().get_execution_schedules( + return await get_scheduler_client().get_execution_schedules( user_id=user_id, graph_id=graph_id, ) - # Get user timezone for conversion - user = await get_user_by_id(user_id) - user_timezone = get_user_timezone_or_utc(user.timezone if user else None) - - # Convert next_run_time to user timezone for display - for schedule in schedules: - if schedule.next_run_time: - schedule.next_run_time = convert_utc_time_to_user_timezone( - schedule.next_run_time, user_timezone - ) - - return schedules - @v1_router.get( path="/schedules", @@ -1038,20 +1024,7 @@ async def list_graph_execution_schedules( async def list_all_graphs_execution_schedules( user_id: Annotated[str, Security(get_user_id)], ) -> list[scheduler.GraphExecutionJobInfo]: - schedules = await get_scheduler_client().get_execution_schedules(user_id=user_id) - - # Get user timezone for conversion - user = await get_user_by_id(user_id) - user_timezone = get_user_timezone_or_utc(user.timezone if user else None) - - # Convert UTC next_run_time to user timezone for display - for schedule in schedules: - if schedule.next_run_time: - schedule.next_run_time = convert_utc_time_to_user_timezone( - schedule.next_run_time, user_timezone - ) - - return schedules + return await get_scheduler_client().get_execution_schedules(user_id=user_id) @v1_router.delete( diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/useAgentRunModal.ts b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/useAgentRunModal.ts index 4fc6e1c926..daaa920814 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/useAgentRunModal.ts +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/useAgentRunModal.ts @@ -15,6 +15,7 @@ import { usePostV2SetupTrigger } from "@/app/api/__generated__/endpoints/presets import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta"; import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo"; import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset"; +import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth"; export type RunVariant = | "manual" @@ -48,6 +49,13 @@ export function useAgentRunModal( agent.recommended_schedule_cron || "0 9 * * 1", ); + // Get user timezone for scheduling + const { data: userTimezone } = useGetV1GetUserTimezone({ + query: { + select: (res) => (res.status === 200 ? res.data.timezone : undefined), + }, + }); + // Determine the default run type based on agent capabilities const defaultRunType: RunVariant = agent.has_external_trigger ? "automatic-trigger" @@ -307,6 +315,8 @@ export function useAgentRunModal( inputs: inputValues, graph_version: agent.graph_version, credentials: inputCredentials, + timezone: + userTimezone && userTimezone !== "not-set" ? userTimezone : undefined, }, }); }, [ @@ -319,6 +329,7 @@ export function useAgentRunModal( notifyMissingRequirements, createScheduleMutation, toast, + userTimezone, ]); function handleShowSchedule() { diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/ScheduleDetails/ScheduleDetails.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/ScheduleDetails/ScheduleDetails.tsx index 9cd20aa6c2..7040b2f958 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/ScheduleDetails/ScheduleDetails.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/AgentRunsView/components/ScheduleDetails/ScheduleDetails.tsx @@ -98,7 +98,7 @@ export function ScheduleDetails({ run={undefined} scheduleRecurrence={ schedule - ? `${humanizeCronExpression(schedule.cron || "", userTzRes)} · ${getTimezoneDisplayName(userTzRes || "UTC")}` + ? `${humanizeCronExpression(schedule.cron || "")} · ${getTimezoneDisplayName(schedule.timezone || userTzRes || "UTC")}` : undefined } /> @@ -161,10 +161,12 @@ export function ScheduleDetails({ Recurrence

- {humanizeCronExpression(schedule.cron, userTzRes)} + {humanizeCronExpression(schedule.cron)} {" • "} - {getTimezoneDisplayName(userTzRes || "UTC")} + {getTimezoneDisplayName( + schedule.timezone || userTzRes || "UTC", + )}

@@ -187,7 +189,9 @@ export function ScheduleDetails({ )}{" "} •{" "} - {getTimezoneDisplayName(userTzRes || "UTC")} + {getTimezoneDisplayName( + schedule.timezone || userTzRes || "UTC", + )}

diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx index fbf057f42f..dddc870823 100644 --- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-schedule-details-view.tsx @@ -59,7 +59,7 @@ export function AgentScheduleDetailsView({ }, { label: "Schedule", - value: humanizeCronExpression(schedule.cron, userTimezone), + value: humanizeCronExpression(schedule.cron), }, { label: "Next run", diff --git a/autogpt_platform/frontend/src/app/(platform)/monitoring/page.tsx b/autogpt_platform/frontend/src/app/(platform)/monitoring/page.tsx index 434103bf34..f02d146c5a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/monitoring/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/monitoring/page.tsx @@ -1,12 +1,12 @@ "use client"; import React, { useCallback, useEffect, useState } from "react"; +import { GraphExecutionMeta, LibraryAgent } from "@/lib/autogpt-server-api"; +import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo"; import { - GraphExecutionMeta, - Schedule, - LibraryAgent, - ScheduleID, -} from "@/lib/autogpt-server-api"; + useGetV1ListExecutionSchedulesForAUser, + useDeleteV1DeleteExecutionSchedule, +} from "@/app/api/__generated__/endpoints/schedules/schedules"; import { Card } from "@/components/ui/card"; import { @@ -22,26 +22,29 @@ import { useBackendAPI } from "@/lib/autogpt-server-api/context"; const Monitor = () => { const [flows, setFlows] = useState([]); const [executions, setExecutions] = useState([]); - const [schedules, setSchedules] = useState([]); const [selectedFlow, setSelectedFlow] = useState(null); const [selectedRun, setSelectedRun] = useState( null, ); - const [sortColumn, setSortColumn] = useState("id"); + const [sortColumn, setSortColumn] = + useState("id"); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const api = useBackendAPI(); - const fetchSchedules = useCallback(async () => { - setSchedules(await api.listAllGraphsExecutionSchedules()); - }, [api]); + // Use generated API hooks for schedules + const { data: schedulesResponse, refetch: refetchSchedules } = + useGetV1ListExecutionSchedulesForAUser(); + const deleteScheduleMutation = useDeleteV1DeleteExecutionSchedule(); + + const schedules = + schedulesResponse?.status === 200 ? schedulesResponse.data : []; const removeSchedule = useCallback( - async (scheduleId: ScheduleID) => { - const removedSchedule = - await api.deleteGraphExecutionSchedule(scheduleId); - setSchedules(schedules.filter((s) => s.id !== removedSchedule.id)); + async (scheduleId: string) => { + await deleteScheduleMutation.mutateAsync({ scheduleId }); + refetchSchedules(); }, - [schedules, api], + [deleteScheduleMutation, refetchSchedules], ); const fetchAgents = useCallback(() => { @@ -57,10 +60,6 @@ const Monitor = () => { fetchAgents(); }, [fetchAgents]); - useEffect(() => { - fetchSchedules(); - }, [fetchSchedules]); - useEffect(() => { const intervalId = setInterval(() => fetchAgents(), 5000); return () => clearInterval(intervalId); @@ -70,7 +69,7 @@ const Monitor = () => { const column2 = "md:col-span-3 lg:col-span-2 xl:col-span-3"; const column3 = "col-span-full xl:col-span-4 xxl:col-span-5"; - const handleSort = (column: keyof Schedule) => { + const handleSort = (column: keyof GraphExecutionJobInfo) => { if (sortColumn === column) { setSortDirection(sortDirection === "asc" ? "desc" : "asc"); } else { diff --git a/autogpt_platform/frontend/src/app/api/openapi.json b/autogpt_platform/frontend/src/app/api/openapi.json index f860d76e79..b4d3ed9597 100644 --- a/autogpt_platform/frontend/src/app/api/openapi.json +++ b/autogpt_platform/frontend/src/app/api/openapi.json @@ -5369,7 +5369,13 @@ }, "id": { "type": "string", "title": "Id" }, "name": { "type": "string", "title": "Name" }, - "next_run_time": { "type": "string", "title": "Next Run Time" } + "next_run_time": { "type": "string", "title": "Next Run Time" }, + "timezone": { + "type": "string", + "title": "Timezone", + "description": "Timezone used for scheduling", + "default": "UTC" + } }, "type": "object", "required": [ @@ -7051,6 +7057,11 @@ }, "type": "object", "title": "Credentials" + }, + "timezone": { + "anyOf": [{ "type": "string" }, { "type": "null" }], + "title": "Timezone", + "description": "User's timezone for scheduling (e.g., 'America/New_York'). If not provided, will use user's saved timezone or UTC." } }, "type": "object", diff --git a/autogpt_platform/frontend/src/components/monitor/scheduleTable.tsx b/autogpt_platform/frontend/src/components/monitor/scheduleTable.tsx index de50dfe3e4..153417a665 100644 --- a/autogpt_platform/frontend/src/components/monitor/scheduleTable.tsx +++ b/autogpt_platform/frontend/src/components/monitor/scheduleTable.tsx @@ -1,4 +1,5 @@ -import { LibraryAgent, Schedule, ScheduleID } from "@/lib/autogpt-server-api"; +import { LibraryAgent } from "@/lib/autogpt-server-api"; +import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { @@ -39,12 +40,12 @@ import { Input } from "../ui/input"; import { Label } from "../ui/label"; interface SchedulesTableProps { - schedules: Schedule[]; + schedules: GraphExecutionJobInfo[]; agents: LibraryAgent[]; - onRemoveSchedule: (scheduleId: ScheduleID, enabled: boolean) => void; - sortColumn: keyof Schedule; + onRemoveSchedule: (scheduleId: string, enabled: boolean) => void; + sortColumn: keyof GraphExecutionJobInfo; sortDirection: "asc" | "desc"; - onSort: (column: keyof Schedule) => void; + onSort: (column: keyof GraphExecutionJobInfo) => void; } export const SchedulesTable = ({ @@ -84,7 +85,7 @@ export const SchedulesTable = ({ return String(bValue).localeCompare(String(aValue)); }); - const handleToggleSchedule = (scheduleId: ScheduleID, enabled: boolean) => { + const handleToggleSchedule = (scheduleId: string, enabled: boolean) => { onRemoveSchedule(scheduleId, enabled); if (!enabled) { toast({ @@ -257,12 +258,14 @@ export const SchedulesTable = ({ - {humanizeCronExpression(schedule.cron, userTimezone)} + {humanizeCronExpression(schedule.cron)} - {userTimezone && getTimezoneAbbreviation(userTimezone)} + {schedule.timezone + ? getTimezoneAbbreviation(schedule.timezone) + : userTimezone && getTimezoneAbbreviation(userTimezone)} diff --git a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts index 1b06121d2f..eadd84d818 100644 --- a/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts +++ b/autogpt_platform/frontend/src/lib/autogpt-server-api/types.ts @@ -797,6 +797,7 @@ export type Schedule = { input_data: Record; input_credentials: Record; next_run_time: Date; + timezone: string; }; export type ScheduleID = Brand; diff --git a/autogpt_platform/frontend/src/lib/cron-expression-utils.ts b/autogpt_platform/frontend/src/lib/cron-expression-utils.ts index afe290bb7e..c75dbd5e68 100644 --- a/autogpt_platform/frontend/src/lib/cron-expression-utils.ts +++ b/autogpt_platform/frontend/src/lib/cron-expression-utils.ts @@ -79,10 +79,7 @@ export function makeCronExpression(params: CronExpressionParams): string { return ""; } -export function humanizeCronExpression( - cronExpression: string, - userTimezone?: string, -): string { +export function humanizeCronExpression(cronExpression: string): string { const parts = cronExpression.trim().split(/\s+/); if (parts.length !== 5) { throw new Error("Invalid cron expression format."); @@ -138,7 +135,7 @@ export function humanizeCronExpression( !minute.includes("/") && !hour.includes("/") ) { - return `Every day at ${formatTime(hour, minute, userTimezone)}`; + return `Every day at ${formatTime(hour, minute)}`; } // Handle weekly (e.g., 30 14 * * 1,3,5) @@ -150,7 +147,7 @@ export function humanizeCronExpression( !hour.includes("/") ) { const days = getDayNames(dayOfWeek); - return `Every ${days} at ${formatTime(hour, minute, userTimezone)}`; + return `Every ${days} at ${formatTime(hour, minute)}`; } // Handle monthly (e.g., 30 14 1,15 * *) @@ -163,7 +160,7 @@ export function humanizeCronExpression( ) { const days = dayOfMonth.split(",").map(Number); const dayList = days.join(", "); - return `On day ${dayList} of every month at ${formatTime(hour, minute, userTimezone)}`; + return `On day ${dayList} of every month at ${formatTime(hour, minute)}`; } // Handle yearly (e.g., 30 14 1 1,6,12 *) @@ -175,7 +172,7 @@ export function humanizeCronExpression( !hour.includes("/") ) { const months = getMonthNames(month); - return `Every year on the 1st day of ${months} at ${formatTime(hour, minute, userTimezone)}`; + return `Every year on the 1st day of ${months} at ${formatTime(hour, minute)}`; } // Handle custom minute intervals with other fields as * (e.g., every N minutes) @@ -211,41 +208,15 @@ export function humanizeCronExpression( !hour.includes("/") ) { const interval = dayOfMonth.substring(2); - return `Every ${interval} days at ${formatTime(hour, minute, userTimezone)}`; + return `Every ${interval} days at ${formatTime(hour, minute)}`; } return `Cron Expression: ${cronExpression}`; } -function formatTime( - hour: string, - minute: string, - userTimezone?: string, -): string { - // Convert from UTC cron time to user timezone for display consistency with next_run_time - if (userTimezone && userTimezone !== "UTC" && userTimezone !== "not-set") { - try { - // Create a date in UTC with the cron hour/minute (cron expressions are stored in UTC) - const utcDate = new Date(); - utcDate.setUTCHours(parseInt(hour), parseInt(minute), 0, 0); - - // Format in user's timezone to match next_run_time display - const formatter = new Intl.DateTimeFormat("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: userTimezone, - }); - - return formatter.format(utcDate); - } catch { - // Fallback to original formatting if timezone conversion fails - const formattedHour = padZero(hour); - const formattedMinute = padZero(minute); - return `${formattedHour}:${formattedMinute}`; - } - } - +function formatTime(hour: string, minute: string): string { + // Cron expressions are now stored in the schedule's timezone (not UTC) + // So we just format the time as-is without conversion const formattedHour = padZero(hour); const formattedMinute = padZero(minute); return `${formattedHour}:${formattedMinute}`;