Skip to content

Commit 9a48667

Browse files
authored
Merge branch 'main' into fix/platform-ingress-netpol-base
2 parents 0e21fe5 + 654c880 commit 9a48667

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)