diff --git a/components/frontend/src/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/_components/scheduled-session-details-card.tsx b/components/frontend/src/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/_components/scheduled-session-details-card.tsx
index 762f451b7..015e9babb 100644
--- a/components/frontend/src/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/_components/scheduled-session-details-card.tsx
+++ b/components/frontend/src/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/_components/scheduled-session-details-card.tsx
@@ -6,7 +6,7 @@ import { Info } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
-import { getCronDescription, getNextRuns } from "@/lib/cron";
+import { getCronDescriptionWithLocal, getNextRuns } from "@/lib/cron";
import { formatScheduleDateTime, formatScheduleTime } from "@/lib/format-timestamp";
import { INACTIVITY_TIMEOUT_TOOLTIP } from "@/lib/constants";
import { useRunnerTypes } from "@/services/queries/use-runner-types";
@@ -51,11 +51,11 @@ export function ScheduledSessionDetailsCard({
{scheduledSession.name}
-
Schedule (UTC)
+ Schedule
{scheduledSession.schedule}
- ({getCronDescription(scheduledSession.schedule)})
+ ({getCronDescriptionWithLocal(scheduledSession.schedule)})
diff --git a/components/frontend/src/app/projects/[name]/scheduled-sessions/_components/scheduled-session-form.tsx b/components/frontend/src/app/projects/[name]/scheduled-sessions/_components/scheduled-session-form.tsx
index 10c27b3eb..fe6908019 100644
--- a/components/frontend/src/app/projects/[name]/scheduled-sessions/_components/scheduled-session-form.tsx
+++ b/components/frontend/src/app/projects/[name]/scheduled-sessions/_components/scheduled-session-form.tsx
@@ -6,7 +6,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { ArrowLeft, Loader2, AlertCircle, X, Info } from "lucide-react";
-import { getCronDescription, getNextRuns } from "@/lib/cron";
+import { getCronDescriptionWithLocal, getNextRuns } from "@/lib/cron";
import { formatScheduleDateTime } from "@/lib/format-timestamp";
import { Button } from "@/components/ui/button";
@@ -233,7 +233,7 @@ export function ScheduledSessionForm({ projectName, mode, initialData }: Schedul
const effectiveCron = schedulePreset === "custom" ? (customCron ?? "") : schedulePreset;
const nextRuns = useMemo(() => getNextRuns(effectiveCron, 3), [effectiveCron]);
- const cronDescription = useMemo(() => effectiveCron ? getCronDescription(effectiveCron) : "", [effectiveCron]);
+ const cronDescription = useMemo(() => effectiveCron ? getCronDescriptionWithLocal(effectiveCron) : "", [effectiveCron]);
const handleRunnerTypeChange = (value: string, onChange: (v: string) => void) => {
onChange(value);
@@ -393,6 +393,7 @@ export function ScheduledSessionForm({ projectName, mode, initialData }: Schedul
+ Times are in UTC
)}
/>
diff --git a/components/frontend/src/components/workspace-sections/scheduled-sessions-tab.tsx b/components/frontend/src/components/workspace-sections/scheduled-sessions-tab.tsx
index 08277deea..2e6e37021 100644
--- a/components/frontend/src/components/workspace-sections/scheduled-sessions-tab.tsx
+++ b/components/frontend/src/components/workspace-sections/scheduled-sessions-tab.tsx
@@ -1,8 +1,9 @@
"use client";
+import { useMemo } from "react";
import { formatDistanceToNow } from "date-fns";
import { Plus, RefreshCw, MoreVertical, Play, Pause, Pencil, PlayCircle, Trash2, Calendar, Loader2, AlertCircle } from "lucide-react";
-import { getCronDescription } from "@/lib/cron";
+import { getCronDescriptionWithLocal } from "@/lib/cron";
import { formatScheduleTime } from "@/lib/format-timestamp";
import { Button } from "@/components/ui/button";
@@ -37,6 +38,11 @@ export function SchedulesSection({ projectName }: SchedulesSectionProps) {
const items = scheduledSessions ?? [];
+ const cronDescriptions = useMemo(
+ () => new Map(items.map((ss) => [ss.name, getCronDescriptionWithLocal(ss.schedule)])),
+ [items]
+ );
+
const handleTrigger = (name: string) => {
triggerMutation.mutate(
{ projectName, name },
@@ -124,7 +130,7 @@ export function SchedulesSection({ projectName }: SchedulesSectionProps) {
Name
- Schedule (UTC)
+ Schedule
Status
Last Run
Actions
@@ -154,7 +160,7 @@ export function SchedulesSection({ projectName }: SchedulesSectionProps) {
- {getCronDescription(ss.schedule)}
+ {cronDescriptions.get(ss.name)}
{ss.schedule}
diff --git a/components/frontend/src/lib/__tests__/cron.test.ts b/components/frontend/src/lib/__tests__/cron.test.ts
index c2a6b9b20..7c6ddadf3 100644
--- a/components/frontend/src/lib/__tests__/cron.test.ts
+++ b/components/frontend/src/lib/__tests__/cron.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { getCronDescription, getNextRuns } from '../cron';
+import { getCronDescription, getCronDescriptionWithLocal, getNextRuns } from '../cron';
describe('getCronDescription', () => {
it('returns human-readable description for standard cron expressions', () => {
@@ -32,3 +32,63 @@ describe('getNextRuns', () => {
expect(getNextRuns('invalid', 3)).toEqual([]);
});
});
+
+describe('getCronDescriptionWithLocal', () => {
+ it('includes UTC label for daily schedule', () => {
+ const result = getCronDescriptionWithLocal('0 9 * * *');
+ expect(result).toContain('UTC');
+ });
+
+ it('appends parenthesized local time when browser is not in UTC', () => {
+ const result = getCronDescriptionWithLocal('0 9 * * *');
+ const isUtcEnv = new Date().getTimezoneOffset() === 0;
+ if (isUtcEnv) {
+ expect(result).not.toContain('(');
+ } else {
+ expect(result).toMatch(/UTC\s*\([^)]+\)$/);
+ }
+ });
+
+ it('returns plain description without timezone for sub-daily schedules', () => {
+ const everyMinute = getCronDescriptionWithLocal('* * * * *');
+ expect(everyMinute).not.toContain('UTC');
+ expect(everyMinute).toBe(getCronDescription('* * * * *'));
+
+ const every15Min = getCronDescriptionWithLocal('*/15 * * * *');
+ expect(every15Min).not.toContain('UTC');
+ expect(every15Min).toBe(getCronDescription('*/15 * * * *'));
+
+ const everyHour = getCronDescriptionWithLocal('0 * * * *');
+ expect(everyHour).not.toContain('UTC');
+ expect(everyHour).toBe(getCronDescription('0 * * * *'));
+
+ const every2Hours = getCronDescriptionWithLocal('0 */2 * * *');
+ expect(every2Hours).not.toContain('UTC');
+ expect(every2Hours).toBe(getCronDescription('0 */2 * * *'));
+ });
+
+ it('falls back to plain description for invalid cron', () => {
+ const result = getCronDescriptionWithLocal('not-valid');
+ expect(result).toBe('not-valid');
+ });
+
+ it('includes UTC label for weekday schedule', () => {
+ const result = getCronDescriptionWithLocal('30 14 * * 1-5');
+ expect(result).toContain('UTC');
+ });
+
+ it('includes UTC label for weekly schedule', () => {
+ const result = getCronDescriptionWithLocal('0 9 * * 1');
+ expect(result).toContain('UTC');
+ });
+
+ it('includes UTC label for monthly schedule', () => {
+ const result = getCronDescriptionWithLocal('0 12 1 * *');
+ expect(result).toContain('UTC');
+ });
+
+ it('inserts UTC after time portion for both 12h and 24h formats', () => {
+ const result = getCronDescriptionWithLocal('30 14 * * *');
+ expect(result).toMatch(/14:30\s*(PM\s*)?UTC|02:30\s*PM\s*UTC/);
+ });
+});
diff --git a/components/frontend/src/lib/cron.ts b/components/frontend/src/lib/cron.ts
index 19a1ae3f1..b61dc2341 100644
--- a/components/frontend/src/lib/cron.ts
+++ b/components/frontend/src/lib/cron.ts
@@ -1,5 +1,6 @@
import cronstrue from "cronstrue";
import { CronExpressionParser } from "cron-parser";
+import { formatTimeLocal } from "./format-timestamp";
/**
* Returns a human-readable description of a cron expression.
@@ -29,3 +30,38 @@ export function getNextRuns(cronExpr: string, count: number): Date[] {
return [];
}
}
+
+/**
+ * Returns a cron description with UTC and local timezone labels.
+ * Sub-daily schedules return the plain description without timezone annotation.
+ */
+export function getCronDescriptionWithLocal(cronExpr: string): string {
+ const description = getCronDescription(cronExpr);
+
+ const runs = getNextRuns(cronExpr, 2);
+ if (runs.length < 2) {
+ return description;
+ }
+
+ const subDaily = runs[1].getTime() - runs[0].getTime() < 24 * 60 * 60 * 1000;
+
+ if (subDaily) {
+ return description;
+ }
+
+ const timePattern = /(\d{1,2}:\d{2}(\s*[AP]M)?)/;
+
+ if (!timePattern.test(description)) {
+ return description;
+ }
+
+ const withUtc = description.replace(timePattern, "$1 UTC");
+
+ const local = formatTimeLocal(runs[0]);
+
+ if (local.endsWith("UTC")) {
+ return withUtc;
+ }
+
+ return `${withUtc} (${local})`;
+}
diff --git a/specs/frontend/schedule-timezone-display.spec.md b/specs/frontend/schedule-timezone-display.spec.md
new file mode 100644
index 000000000..25ae4a1fa
--- /dev/null
+++ b/specs/frontend/schedule-timezone-display.spec.md
@@ -0,0 +1,118 @@
+# Schedule Timezone Display Specification
+
+## Purpose
+
+Defines how the frontend displays cron-based schedule times to users. All schedules are stored and configured in UTC. The frontend annotates displayed times with the UTC label and, when the user's browser is not in UTC, appends the local timezone equivalent so users can understand when a schedule will fire in their own timezone without performing mental offset calculations.
+
+## Requirements
+
+### Requirement: UTC Label on Daily-or-Longer Schedules
+
+When displaying a human-readable cron description for a schedule that fires once per day or less frequently (daily, weekly, monthly), the system SHALL insert "UTC" immediately after the time portion in the description.
+
+#### Scenario: Daily schedule at 09:00
+
+- GIVEN a cron expression `0 9 * * *`
+- WHEN the frontend renders the schedule description
+- THEN the description SHALL contain "09:00 UTC" or "9:00 AM UTC"
+
+#### Scenario: Weekday schedule at 14:30
+
+- GIVEN a cron expression `30 14 * * 1-5`
+- WHEN the frontend renders the schedule description
+- THEN the description SHALL contain "14:30 UTC" or "02:30 PM UTC"
+
+#### Scenario: Monthly schedule
+
+- GIVEN a cron expression `0 12 1 * *`
+- WHEN the frontend renders the schedule description
+- THEN the description SHALL contain "UTC" after the time portion
+
+### Requirement: Local Timezone Parenthetical
+
+When the user's browser timezone differs from UTC, the system SHALL append the equivalent local time in parentheses after the UTC-labeled description.
+
+#### Scenario: User in UTC+1 viewing a daily schedule
+
+- GIVEN a cron expression `0 9 * * *`
+- AND the user's browser is in a UTC+1 timezone
+- WHEN the frontend renders the schedule description
+- THEN the output SHALL match the pattern ` UTC ( )`
+- AND the local time SHALL reflect the browser's offset (e.g., "10:00 AM GMT+1")
+
+#### Scenario: User in UTC viewing a daily schedule
+
+- GIVEN a cron expression `0 9 * * *`
+- AND the user's browser is in UTC
+- WHEN the frontend renders the schedule description
+- THEN the output SHALL contain "UTC"
+- AND the output SHALL NOT contain a parenthesized local time (since local equals UTC)
+
+### Requirement: Sub-Daily Schedules Omit Timezone Annotation
+
+Schedules that fire more frequently than once per day (every minute, every N minutes, hourly, every N hours) SHALL display the plain cron description without any UTC label or local timezone parenthetical.
+
+#### Scenario: Every 5 minutes
+
+- GIVEN a cron expression `*/5 * * * *`
+- WHEN the frontend renders the schedule description
+- THEN the output SHALL be the plain human-readable description (e.g., "Every 5 minutes")
+- AND the output SHALL NOT contain "UTC"
+
+#### Scenario: Every 2 hours
+
+- GIVEN a cron expression `0 */2 * * *`
+- WHEN the frontend renders the schedule description
+- THEN the output SHALL be the plain human-readable description
+- AND the output SHALL NOT contain "UTC"
+
+#### Scenario: Hourly
+
+- GIVEN a cron expression `0 * * * *`
+- WHEN the frontend renders the schedule description
+- THEN the output SHALL be the plain human-readable description
+- AND the output SHALL NOT contain "UTC"
+
+### Requirement: Sub-Daily Detection
+
+The system SHALL determine whether a schedule is sub-daily by comparing the interval between consecutive future run times. If the interval between the first two next runs is less than 24 hours, the schedule is sub-daily.
+
+#### Scenario: Every 12 hours classified as sub-daily
+
+- GIVEN a cron expression `0 */12 * * *`
+- WHEN the system evaluates whether the schedule is sub-daily
+- THEN it SHALL classify the schedule as sub-daily regardless of the current time of day
+
+### Requirement: Graceful Fallback
+
+The system SHALL gracefully handle invalid or unparseable cron expressions by returning the raw expression string without modification.
+
+#### Scenario: Invalid cron expression
+
+- GIVEN an invalid cron expression `not-a-cron`
+- WHEN the frontend renders the schedule description
+- THEN the output SHALL be the raw string `not-a-cron`
+
+#### Scenario: Cron expression with no recognizable time portion
+
+- GIVEN a valid cron expression whose human-readable description does not contain a recognizable time pattern
+- WHEN the frontend renders the schedule description
+- THEN the output SHALL be the plain description without UTC annotation
+
+### Requirement: Both 12-Hour and 24-Hour Format Support
+
+The system SHALL correctly identify and annotate time portions in both 12-hour format (e.g., "02:30 PM") and 24-hour format (e.g., "14:30"), depending on the user's locale settings.
+
+#### Scenario: 24-hour locale
+
+- GIVEN a cron expression `30 14 * * *`
+- AND the user's locale produces 24-hour time format "14:30"
+- WHEN the frontend renders the schedule description
+- THEN the output SHALL contain "14:30 UTC"
+
+#### Scenario: 12-hour locale
+
+- GIVEN a cron expression `30 14 * * *`
+- AND the user's locale produces 12-hour time format "02:30 PM"
+- WHEN the frontend renders the schedule description
+- THEN the output SHALL contain "02:30 PM UTC"