Skip to content

Commit 654c880

Browse files
machadovilacaclaudemergify[bot]jsell-rh
authored
feat(frontend): show local timezone alongside UTC in schedule displays (#1564)
## Summary Follows up on #1496. Shows the user's local timezone alongside UTC in all schedule-related displays, so users don't have to mentally convert UTC times. - Add `getCronDescriptionWithLocal()` that inserts "UTC" after the time in cron descriptions and appends the local equivalent in parentheses - Update schedule column, details card, and form preview to use the new function - Add "Times are in UTC" helper text to the schedule form - Sub-daily schedules (every hour, every 15 min) skip the annotation since local offset adds no value **Before:** `At 05:30 AM, Monday through Friday` **After:** `At 05:30 AM UTC, Monday through Friday (6:30 AM GMT+1)` Edit <img width="1291" height="602" alt="s1" src="https://github.com/user-attachments/assets/d8a6135d-c0f0-4dfb-b26f-2853cfe4cc8c" /> List <img width="1320" height="257" alt="s2" src="https://github.com/user-attachments/assets/3e460d18-0ccf-4d56-9070-cefb89738243" /> Show <img width="1386" height="317" alt="s3" src="https://github.com/user-attachments/assets/5b240bac-ca57-4599-b3b8-4e8af2509514" /> ## Why UTC input (not local-first input) We considered letting users input cron schedules in their local timezone and converting to UTC behind the scenes, but rejected it: - **DST drift**: `getTimezoneOffset()` returns the offset at input time, not execution time. A user in London (UTC+0 winter, UTC+1 summer) who sets "9 AM local" in January gets `0 9 * * *` UTC — which silently becomes 10 AM local in summer - **Fractional offsets**: UTC+5:30 (India, 1.4B people), UTC+5:45 (Nepal), UTC+12:45 (Chatham Islands) require shifting both hour and minute fields, with minute overflow creating double-wrap edge cases - **Day-of-week wrapping**: Timezone shifts crossing midnight require adjusting day-of-week fields — ranges like `1-5` must become `0-4` or `2-6`, with modular wrapping on each element - **Round-trip conversion**: Editing an existing schedule requires reverse-converting UTC→local, which may produce a different cron than originally entered if DST changed since creation UTC input avoids all of these. Showing the local equivalent next to it gives users the "when does this fire for me?" answer without any conversion complexity or correctness risks. ## Test plan - [x] Unit tests for `getCronDescriptionWithLocal()` — daily, weekly, monthly schedules include UTC + local; sub-daily schedules return plain description; invalid cron falls back gracefully - [x] All frontend unit tests pass - [x] Production build passes with 0 errors, 0 warnings - [x] Visual verification against Kind cluster: schedule list, detail card, and form preview all show dual timezone <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Schedule descriptions now include a UTC label and, when appropriate, a parenthetical local-time preview; sub-daily schedules omit timezone annotations. Table and detail labels simplified to “Schedule”. Schedule preview areas note “Times are in UTC”. * **Tests** * Added tests validating the timezone-aware schedule description behavior and edge cases (sub-daily, invalid cron, 12h/24h formats). * **Documentation** * Added a frontend spec defining timezone display rules for cron schedules. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: jsell-rh <jsell@redhat.com>
1 parent 596bb2e commit 654c880

6 files changed

Lines changed: 232 additions & 11 deletions

File tree

components/frontend/src/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/_components/scheduled-session-details-card.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Info } from "lucide-react";
66

77
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
88
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
9-
import { getCronDescription, getNextRuns } from "@/lib/cron";
9+
import { getCronDescriptionWithLocal, getNextRuns } from "@/lib/cron";
1010
import { formatScheduleDateTime, formatScheduleTime } from "@/lib/format-timestamp";
1111
import { INACTIVITY_TIMEOUT_TOOLTIP } from "@/lib/constants";
1212
import { useRunnerTypes } from "@/services/queries/use-runner-types";
@@ -51,11 +51,11 @@ export function ScheduledSessionDetailsCard({
5151
<dd className="font-mono">{scheduledSession.name}</dd>
5252
</div>
5353
<div>
54-
<dt className="text-muted-foreground">Schedule (UTC)</dt>
54+
<dt className="text-muted-foreground">Schedule</dt>
5555
<dd>
5656
<span className="font-mono">{scheduledSession.schedule}</span>
5757
<span className="text-muted-foreground ml-2">
58-
({getCronDescription(scheduledSession.schedule)})
58+
({getCronDescriptionWithLocal(scheduledSession.schedule)})
5959
</span>
6060
</dd>
6161
</div>

components/frontend/src/app/projects/[name]/scheduled-sessions/_components/scheduled-session-form.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useForm } from "react-hook-form";
66
import { zodResolver } from "@hookform/resolvers/zod";
77
import * as z from "zod";
88
import { ArrowLeft, Loader2, AlertCircle, X, Info } from "lucide-react";
9-
import { getCronDescription, getNextRuns } from "@/lib/cron";
9+
import { getCronDescriptionWithLocal, getNextRuns } from "@/lib/cron";
1010
import { formatScheduleDateTime } from "@/lib/format-timestamp";
1111

1212
import { Button } from "@/components/ui/button";
@@ -210,7 +210,7 @@ export function ScheduledSessionForm({ projectName, mode, initialData }: Schedul
210210
}
211211
form.setValue("model", modelsData.defaultModel, { shouldDirty: false });
212212
}
213-
}, [modelsData?.defaultModel, form, isEdit, initialData]);
213+
}, [modelsData, form, isEdit, initialData]);
214214

215215
// Resolve workflow state once OOTB workflows finish loading. The Skeleton
216216
// guard on the Select (workflowsLoading || !workflowResolved) ensures Radix
@@ -233,7 +233,7 @@ export function ScheduledSessionForm({ projectName, mode, initialData }: Schedul
233233

234234
const effectiveCron = schedulePreset === "custom" ? (customCron ?? "") : schedulePreset;
235235
const nextRuns = useMemo(() => getNextRuns(effectiveCron, 3), [effectiveCron]);
236-
const cronDescription = useMemo(() => effectiveCron ? getCronDescription(effectiveCron) : "", [effectiveCron]);
236+
const cronDescription = useMemo(() => effectiveCron ? getCronDescriptionWithLocal(effectiveCron) : "", [effectiveCron]);
237237

238238
const handleRunnerTypeChange = (value: string, onChange: (v: string) => void) => {
239239
onChange(value);
@@ -393,6 +393,7 @@ export function ScheduledSessionForm({ projectName, mode, initialData }: Schedul
393393
</SelectContent>
394394
</Select>
395395
<FormMessage />
396+
<p className="text-xs text-muted-foreground">Times are in UTC</p>
396397
</FormItem>
397398
)}
398399
/>

components/frontend/src/components/workspace-sections/scheduled-sessions-tab.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"use client";
22

3+
import { useMemo } from "react";
34
import { formatDistanceToNow } from "date-fns";
45
import { Plus, RefreshCw, MoreVertical, Play, Pause, Pencil, PlayCircle, Trash2, Calendar, Loader2, AlertCircle } from "lucide-react";
5-
import { getCronDescription } from "@/lib/cron";
6+
import { getCronDescriptionWithLocal } from "@/lib/cron";
67
import { formatScheduleTime } from "@/lib/format-timestamp";
78

89
import { Button } from "@/components/ui/button";
@@ -35,7 +36,12 @@ export function SchedulesSection({ projectName }: SchedulesSectionProps) {
3536
const resumeMutation = useResumeScheduledSession();
3637
const triggerMutation = useTriggerScheduledSession();
3738

38-
const items = scheduledSessions ?? [];
39+
const items = useMemo(() => scheduledSessions ?? [], [scheduledSessions]);
40+
41+
const cronDescriptions = useMemo(
42+
() => new Map(items.map((ss) => [ss.name, getCronDescriptionWithLocal(ss.schedule)])),
43+
[items]
44+
);
3945

4046
const handleTrigger = (name: string) => {
4147
triggerMutation.mutate(
@@ -124,7 +130,7 @@ export function SchedulesSection({ projectName }: SchedulesSectionProps) {
124130
<TableHeader>
125131
<TableRow>
126132
<TableHead className="min-w-[180px]">Name</TableHead>
127-
<TableHead>Schedule (UTC)</TableHead>
133+
<TableHead>Schedule</TableHead>
128134
<TableHead>Status</TableHead>
129135
<TableHead className="hidden md:table-cell">Last Run</TableHead>
130136
<TableHead className="w-[50px]">Actions</TableHead>
@@ -154,7 +160,7 @@ export function SchedulesSection({ projectName }: SchedulesSectionProps) {
154160
</Link>
155161
</TableCell>
156162
<TableCell>
157-
<div className="text-sm">{getCronDescription(ss.schedule)}</div>
163+
<div className="text-sm">{cronDescriptions.get(ss.name)}</div>
158164
<div className="text-xs text-muted-foreground font-mono">{ss.schedule}</div>
159165
</TableCell>
160166
<TableCell>

components/frontend/src/lib/__tests__/cron.test.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { getCronDescription, getNextRuns } from '../cron';
2+
import { getCronDescription, getCronDescriptionWithLocal, getNextRuns } from '../cron';
33

44
describe('getCronDescription', () => {
55
it('returns human-readable description for standard cron expressions', () => {
@@ -32,3 +32,63 @@ describe('getNextRuns', () => {
3232
expect(getNextRuns('invalid', 3)).toEqual([]);
3333
});
3434
});
35+
36+
describe('getCronDescriptionWithLocal', () => {
37+
it('includes UTC label for daily schedule', () => {
38+
const result = getCronDescriptionWithLocal('0 9 * * *');
39+
expect(result).toContain('UTC');
40+
});
41+
42+
it('appends parenthesized local time when browser is not in UTC', () => {
43+
const result = getCronDescriptionWithLocal('0 9 * * *');
44+
const isUtcEnv = new Date().getTimezoneOffset() === 0;
45+
if (isUtcEnv) {
46+
expect(result).not.toContain('(');
47+
} else {
48+
expect(result).toMatch(/UTC\s*\([^)]+\)$/);
49+
}
50+
});
51+
52+
it('returns plain description without timezone for sub-daily schedules', () => {
53+
const everyMinute = getCronDescriptionWithLocal('* * * * *');
54+
expect(everyMinute).not.toContain('UTC');
55+
expect(everyMinute).toBe(getCronDescription('* * * * *'));
56+
57+
const every15Min = getCronDescriptionWithLocal('*/15 * * * *');
58+
expect(every15Min).not.toContain('UTC');
59+
expect(every15Min).toBe(getCronDescription('*/15 * * * *'));
60+
61+
const everyHour = getCronDescriptionWithLocal('0 * * * *');
62+
expect(everyHour).not.toContain('UTC');
63+
expect(everyHour).toBe(getCronDescription('0 * * * *'));
64+
65+
const every2Hours = getCronDescriptionWithLocal('0 */2 * * *');
66+
expect(every2Hours).not.toContain('UTC');
67+
expect(every2Hours).toBe(getCronDescription('0 */2 * * *'));
68+
});
69+
70+
it('falls back to plain description for invalid cron', () => {
71+
const result = getCronDescriptionWithLocal('not-valid');
72+
expect(result).toBe('not-valid');
73+
});
74+
75+
it('includes UTC label for weekday schedule', () => {
76+
const result = getCronDescriptionWithLocal('30 14 * * 1-5');
77+
expect(result).toContain('UTC');
78+
});
79+
80+
it('includes UTC label for weekly schedule', () => {
81+
const result = getCronDescriptionWithLocal('0 9 * * 1');
82+
expect(result).toContain('UTC');
83+
});
84+
85+
it('includes UTC label for monthly schedule', () => {
86+
const result = getCronDescriptionWithLocal('0 12 1 * *');
87+
expect(result).toContain('UTC');
88+
});
89+
90+
it('inserts UTC after time portion for both 12h and 24h formats', () => {
91+
const result = getCronDescriptionWithLocal('30 14 * * *');
92+
expect(result).toMatch(/14:30\s*(PM\s*)?UTC|02:30\s*PM\s*UTC/);
93+
});
94+
});

components/frontend/src/lib/cron.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import cronstrue from "cronstrue";
22
import { CronExpressionParser } from "cron-parser";
3+
import { formatTimeLocal } from "./format-timestamp";
34

45
/**
56
* Returns a human-readable description of a cron expression.
@@ -29,3 +30,38 @@ export function getNextRuns(cronExpr: string, count: number): Date[] {
2930
return [];
3031
}
3132
}
33+
34+
/**
35+
* Returns a cron description with UTC and local timezone labels.
36+
* Sub-daily schedules return the plain description without timezone annotation.
37+
*/
38+
export function getCronDescriptionWithLocal(cronExpr: string): string {
39+
const description = getCronDescription(cronExpr);
40+
41+
const runs = getNextRuns(cronExpr, 2);
42+
if (runs.length < 2) {
43+
return description;
44+
}
45+
46+
const subDaily = runs[1].getTime() - runs[0].getTime() < 24 * 60 * 60 * 1000;
47+
48+
if (subDaily) {
49+
return description;
50+
}
51+
52+
const timePattern = /(\d{1,2}:\d{2}(\s*[AP]M)?)/;
53+
54+
if (!timePattern.test(description)) {
55+
return description;
56+
}
57+
58+
const withUtc = description.replace(timePattern, "$1 UTC");
59+
60+
const local = formatTimeLocal(runs[0]);
61+
62+
if (local.endsWith("UTC")) {
63+
return withUtc;
64+
}
65+
66+
return `${withUtc} (${local})`;
67+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Schedule Timezone Display Specification
2+
3+
## Purpose
4+
5+
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.
6+
7+
## Requirements
8+
9+
### Requirement: UTC Label on Daily-or-Longer Schedules
10+
11+
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.
12+
13+
#### Scenario: Daily schedule at 09:00
14+
15+
- GIVEN a cron expression `0 9 * * *`
16+
- WHEN the frontend renders the schedule description
17+
- THEN the description SHALL contain "09:00 UTC" or "9:00 AM UTC"
18+
19+
#### Scenario: Weekday schedule at 14:30
20+
21+
- GIVEN a cron expression `30 14 * * 1-5`
22+
- WHEN the frontend renders the schedule description
23+
- THEN the description SHALL contain "14:30 UTC" or "02:30 PM UTC"
24+
25+
#### Scenario: Monthly schedule
26+
27+
- GIVEN a cron expression `0 12 1 * *`
28+
- WHEN the frontend renders the schedule description
29+
- THEN the description SHALL contain "UTC" after the time portion
30+
31+
### Requirement: Local Timezone Parenthetical
32+
33+
When the user's browser timezone differs from UTC, the system SHALL append the equivalent local time in parentheses after the UTC-labeled description.
34+
35+
#### Scenario: User in UTC+1 viewing a daily schedule
36+
37+
- GIVEN a cron expression `0 9 * * *`
38+
- AND the user's browser is in a UTC+1 timezone
39+
- WHEN the frontend renders the schedule description
40+
- THEN the output SHALL match the pattern `<description> UTC (<local time> <timezone>)`
41+
- AND the local time SHALL reflect the browser's offset (e.g., "10:00 AM GMT+1")
42+
43+
#### Scenario: User in UTC viewing a daily schedule
44+
45+
- GIVEN a cron expression `0 9 * * *`
46+
- AND the user's browser is in UTC
47+
- WHEN the frontend renders the schedule description
48+
- THEN the output SHALL contain "UTC"
49+
- AND the output SHALL NOT contain a parenthesized local time (since local equals UTC)
50+
51+
### Requirement: Sub-Daily Schedules Omit Timezone Annotation
52+
53+
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.
54+
55+
#### Scenario: Every 5 minutes
56+
57+
- GIVEN a cron expression `*/5 * * * *`
58+
- WHEN the frontend renders the schedule description
59+
- THEN the output SHALL be the plain human-readable description (e.g., "Every 5 minutes")
60+
- AND the output SHALL NOT contain "UTC"
61+
62+
#### Scenario: Every 2 hours
63+
64+
- GIVEN a cron expression `0 */2 * * *`
65+
- WHEN the frontend renders the schedule description
66+
- THEN the output SHALL be the plain human-readable description
67+
- AND the output SHALL NOT contain "UTC"
68+
69+
#### Scenario: Hourly
70+
71+
- GIVEN a cron expression `0 * * * *`
72+
- WHEN the frontend renders the schedule description
73+
- THEN the output SHALL be the plain human-readable description
74+
- AND the output SHALL NOT contain "UTC"
75+
76+
### Requirement: Sub-Daily Detection
77+
78+
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.
79+
80+
#### Scenario: Every 12 hours classified as sub-daily
81+
82+
- GIVEN a cron expression `0 */12 * * *`
83+
- WHEN the system evaluates whether the schedule is sub-daily
84+
- THEN it SHALL classify the schedule as sub-daily regardless of the current time of day
85+
86+
### Requirement: Graceful Fallback
87+
88+
The system SHALL gracefully handle invalid or unparseable cron expressions by returning the raw expression string without modification.
89+
90+
#### Scenario: Invalid cron expression
91+
92+
- GIVEN an invalid cron expression `not-a-cron`
93+
- WHEN the frontend renders the schedule description
94+
- THEN the output SHALL be the raw string `not-a-cron`
95+
96+
#### Scenario: Cron expression with no recognizable time portion
97+
98+
- GIVEN a valid cron expression whose human-readable description does not contain a recognizable time pattern
99+
- WHEN the frontend renders the schedule description
100+
- THEN the output SHALL be the plain description without UTC annotation
101+
102+
### Requirement: Both 12-Hour and 24-Hour Format Support
103+
104+
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.
105+
106+
#### Scenario: 24-hour locale
107+
108+
- GIVEN a cron expression `30 14 * * *`
109+
- AND the user's locale produces 24-hour time format "14:30"
110+
- WHEN the frontend renders the schedule description
111+
- THEN the output SHALL contain "14:30 UTC"
112+
113+
#### Scenario: 12-hour locale
114+
115+
- GIVEN a cron expression `30 14 * * *`
116+
- AND the user's locale produces 12-hour time format "02:30 PM"
117+
- WHEN the frontend renders the schedule description
118+
- THEN the output SHALL contain "02:30 PM UTC"

0 commit comments

Comments
 (0)