Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,11 +51,11 @@ export function ScheduledSessionDetailsCard({
<dd className="font-mono">{scheduledSession.name}</dd>
</div>
<div>
<dt className="text-muted-foreground">Schedule (UTC)</dt>
<dt className="text-muted-foreground">Schedule</dt>
<dd>
<span className="font-mono">{scheduledSession.schedule}</span>
<span className="text-muted-foreground ml-2">
({getCronDescription(scheduledSession.schedule)})
({getCronDescriptionWithLocal(scheduledSession.schedule)})
</span>
</dd>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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";
Expand Down Expand Up @@ -210,7 +210,7 @@
}
form.setValue("model", modelsData.defaultModel, { shouldDirty: false });
}
}, [modelsData?.defaultModel, form, isEdit, initialData]);

Check warning on line 213 in components/frontend/src/app/projects/[name]/scheduled-sessions/_components/scheduled-session-form.tsx

View workflow job for this annotation

GitHub Actions / Frontend Lint and Type Check

React Hook useEffect has a missing dependency: 'modelsData.models'. Either include it or remove the dependency array

// Resolve workflow state once OOTB workflows finish loading. The Skeleton
// guard on the Select (workflowsLoading || !workflowResolved) ensures Radix
Expand All @@ -233,7 +233,7 @@

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);
Expand Down Expand Up @@ -393,6 +393,7 @@
</SelectContent>
</Select>
<FormMessage />
<p className="text-xs text-muted-foreground">Times are in UTC</p>
</FormItem>
)}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -35,8 +36,13 @@
const resumeMutation = useResumeScheduledSession();
const triggerMutation = useTriggerScheduledSession();

const items = scheduledSessions ?? [];

Check warning on line 39 in components/frontend/src/components/workspace-sections/scheduled-sessions-tab.tsx

View workflow job for this annotation

GitHub Actions / Frontend Lint and Type Check

The 'items' logical expression could make the dependencies of useMemo Hook (at line 43) change on every render. To fix this, wrap the initialization of 'items' in its own useMemo() Hook

const cronDescriptions = useMemo(
() => new Map(items.map((ss) => [ss.name, getCronDescriptionWithLocal(ss.schedule)])),
[items]
);

const handleTrigger = (name: string) => {
triggerMutation.mutate(
{ projectName, name },
Expand Down Expand Up @@ -124,7 +130,7 @@
<TableHeader>
<TableRow>
<TableHead className="min-w-[180px]">Name</TableHead>
<TableHead>Schedule (UTC)</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Status</TableHead>
<TableHead className="hidden md:table-cell">Last Run</TableHead>
<TableHead className="w-[50px]">Actions</TableHead>
Expand Down Expand Up @@ -154,7 +160,7 @@
</Link>
</TableCell>
<TableCell>
<div className="text-sm">{getCronDescription(ss.schedule)}</div>
<div className="text-sm">{cronDescriptions.get(ss.name)}</div>
<div className="text-xs text-muted-foreground font-mono">{ss.schedule}</div>
</TableCell>
<TableCell>
Expand Down
62 changes: 61 additions & 1 deletion components/frontend/src/lib/__tests__/cron.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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/);
});
});
36 changes: 36 additions & 0 deletions components/frontend/src/lib/cron.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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})`;
}
118 changes: 118 additions & 0 deletions specs/frontend/schedule-timezone-display.spec.md
Original file line number Diff line number Diff line change
@@ -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 `<description> UTC (<local time> <timezone>)`
- 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"
Loading