Skip to content

Commit f7efeef

Browse files
ambient-code[bot]Ambient Code Botclaudejeremyeder
authored
fix: display UTC and local timezone in Scheduled Sessions UI (#1195)
<!-- acp:session_id=session-9a78de7c-c632-4105-b640-24a91e138443 source=#1194 last_action=2026-04-03T19:46:37Z retry_count=0 --> ## Summary - Add `formatScheduleTime()` and `formatScheduleDateTime()` utilities to `format-timestamp.ts` that display both UTC time and the user's local timezone with abbreviation (e.g. `5:00 PM UTC (1:00 PM EDT)`) - Update **Scheduled Sessions tab** (list view): schedule column header now says "Schedule (UTC)", last run shows UTC + local time with relative timestamp - Update **Scheduled Session detail page**: header shows "(UTC)" suffix on cron description, details card adds "Next Run" field with dual-timezone display, last run shows UTC + local - Update **Scheduled Session form**: "Next 3 runs" preview now shows dual-timezone format instead of browser-only locale - Add 13 unit tests for all new and existing formatting functions Closes #1194 ## Test plan - [x] `npx vitest run src/lib/__tests__/format-timestamp.test.ts` — 13 tests pass - [x] `npx tsc --noEmit` — no type errors - [ ] Manual: verify schedule list shows "Schedule (UTC)" column header - [ ] Manual: verify last run times show `HH:MM AM/PM UTC (HH:MM AM/PM TZ)` format - [ ] Manual: verify detail page "Next Run" field shows dual timezone - [ ] Manual: verify form "Next 3 runs" preview shows dual timezone 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Ambient Code Bot <bot@ambient-code.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jeremy Eder <jeder@redhat.com>
1 parent cc9ad31 commit f7efeef

6 files changed

Lines changed: 202 additions & 9 deletions

File tree

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

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"use client";
22

3+
import { useMemo } from "react";
34
import { formatDistanceToNow } from "date-fns";
45
import { Info } from "lucide-react";
56

67
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
78
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
8-
import { getCronDescription } from "@/lib/cron";
9+
import { getCronDescription, getNextRuns } from "@/lib/cron";
10+
import { formatScheduleDateTime, formatScheduleTime } from "@/lib/format-timestamp";
911
import { INACTIVITY_TIMEOUT_TOOLTIP } from "@/lib/constants";
1012
import { useRunnerTypes } from "@/services/queries/use-runner-types";
1113
import { useModels } from "@/services/queries/use-models";
@@ -32,6 +34,11 @@ export function ScheduledSessionDetailsCard({
3234
const runnerLabel = selectedRunner?.displayName ?? runnerTypeId;
3335
const modelDisplay = modelLabel ?? modelId;
3436

37+
const nextRun = useMemo(() => {
38+
const runs = getNextRuns(scheduledSession.schedule, 1);
39+
return runs.length > 0 ? runs[0] : null;
40+
}, [scheduledSession.schedule]);
41+
3542
return (
3643
<Card>
3744
<CardHeader>
@@ -44,14 +51,29 @@ export function ScheduledSessionDetailsCard({
4451
<dd className="font-mono">{scheduledSession.name}</dd>
4552
</div>
4653
<div>
47-
<dt className="text-muted-foreground">Schedule</dt>
54+
<dt className="text-muted-foreground">Schedule (UTC)</dt>
4855
<dd>
4956
<span className="font-mono">{scheduledSession.schedule}</span>
5057
<span className="text-muted-foreground ml-2">
5158
({getCronDescription(scheduledSession.schedule)})
5259
</span>
5360
</dd>
5461
</div>
62+
<div>
63+
<dt className="text-muted-foreground">Next Run</dt>
64+
<dd>
65+
{nextRun ? (
66+
<div>
67+
<span>{formatScheduleDateTime(nextRun)}</span>
68+
<span className="text-muted-foreground ml-2">
69+
({formatDistanceToNow(nextRun, { addSuffix: true })})
70+
</span>
71+
</div>
72+
) : (
73+
<span className="text-muted-foreground">&mdash;</span>
74+
)}
75+
</dd>
76+
</div>
5577
<div>
5678
<dt className="text-muted-foreground">Created</dt>
5779
<dd>
@@ -64,9 +86,16 @@ export function ScheduledSessionDetailsCard({
6486
<dt className="text-muted-foreground">Last Run</dt>
6587
<dd>
6688
{scheduledSession.lastScheduleTime
67-
? formatDistanceToNow(new Date(scheduledSession.lastScheduleTime), {
68-
addSuffix: true,
69-
})
89+
? (
90+
<div>
91+
<span>{formatScheduleTime(new Date(scheduledSession.lastScheduleTime))}</span>
92+
<span className="text-muted-foreground ml-2">
93+
({formatDistanceToNow(new Date(scheduledSession.lastScheduleTime), {
94+
addSuffix: true,
95+
})})
96+
</span>
97+
</div>
98+
)
7099
: "Never"}
71100
</dd>
72101
</div>

components/frontend/src/app/projects/[name]/scheduled-sessions/[scheduledSessionName]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export default function ScheduledSessionDetailPage() {
140140
<div className="flex items-center gap-2 mt-1">
141141
<Calendar className="h-4 w-4 text-muted-foreground" />
142142
<span className="text-sm text-muted-foreground">
143-
{getCronDescription(scheduledSession.schedule)}
143+
{getCronDescription(scheduledSession.schedule)} (UTC)
144144
</span>
145145
{scheduledSession.suspend ? (
146146
<Badge variant="secondary">Suspended</Badge>

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
77
import * as z from "zod";
88
import { ArrowLeft, Loader2, AlertCircle, X, Info } from "lucide-react";
99
import { getCronDescription, getNextRuns } from "@/lib/cron";
10+
import { formatScheduleDateTime } from "@/lib/format-timestamp";
1011

1112
import { Button } from "@/components/ui/button";
1213
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -398,7 +399,7 @@ export function ScheduledSessionForm({ projectName, mode, initialData }: Schedul
398399
<div className="text-xs text-muted-foreground space-y-0.5">
399400
<p className="font-medium">Next 3 runs:</p>
400401
{nextRuns.map((date, i) => (
401-
<p key={i}>{date.toLocaleString()}</p>
402+
<p key={i}>{formatScheduleDateTime(date)}</p>
402403
))}
403404
</div>
404405
)}

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { formatDistanceToNow } from "date-fns";
44
import { Plus, RefreshCw, MoreVertical, Play, Pause, Pencil, PlayCircle, Trash2, Calendar, Loader2, AlertCircle } from "lucide-react";
55
import { getCronDescription } from "@/lib/cron";
6+
import { formatScheduleTime } from "@/lib/format-timestamp";
67

78
import { Button } from "@/components/ui/button";
89
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -123,7 +124,7 @@ export function SchedulesSection({ projectName }: SchedulesSectionProps) {
123124
<TableHeader>
124125
<TableRow>
125126
<TableHead className="min-w-[180px]">Name</TableHead>
126-
<TableHead>Schedule</TableHead>
127+
<TableHead>Schedule (UTC)</TableHead>
127128
<TableHead>Status</TableHead>
128129
<TableHead className="hidden md:table-cell">Last Run</TableHead>
129130
<TableHead className="w-[50px]">Actions</TableHead>
@@ -165,7 +166,14 @@ export function SchedulesSection({ projectName }: SchedulesSectionProps) {
165166
</TableCell>
166167
<TableCell className="hidden md:table-cell">
167168
{ss.lastScheduleTime
168-
? formatDistanceToNow(new Date(ss.lastScheduleTime), { addSuffix: true })
169+
? (
170+
<div>
171+
<div className="text-sm">{formatScheduleTime(new Date(ss.lastScheduleTime))}</div>
172+
<div className="text-xs text-muted-foreground">
173+
{formatDistanceToNow(new Date(ss.lastScheduleTime), { addSuffix: true })}
174+
</div>
175+
</div>
176+
)
169177
: <span className="text-muted-foreground">Never</span>}
170178
</TableCell>
171179
<TableCell>
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
formatTimestamp,
4+
formatTimeUTC,
5+
formatTimeLocal,
6+
formatScheduleTime,
7+
formatScheduleDateTime,
8+
} from "../format-timestamp";
9+
10+
describe("formatTimestamp", () => {
11+
it("returns empty string for undefined", () => {
12+
expect(formatTimestamp(undefined)).toBe("");
13+
});
14+
15+
it("returns empty string for empty string", () => {
16+
expect(formatTimestamp("")).toBe("");
17+
});
18+
19+
it("returns empty string for invalid date", () => {
20+
expect(formatTimestamp("not-a-date")).toBe("");
21+
});
22+
23+
it("returns formatted date for valid ISO timestamp", () => {
24+
const result = formatTimestamp("2025-02-27T17:34:00Z");
25+
expect(result).toBeTruthy();
26+
expect(result.length).toBeGreaterThan(0);
27+
});
28+
});
29+
30+
describe("formatTimeUTC", () => {
31+
it("includes UTC suffix", () => {
32+
const date = new Date("2025-06-15T17:00:00Z");
33+
const result = formatTimeUTC(date);
34+
expect(result).toContain("UTC");
35+
});
36+
37+
it("formats time in UTC timezone", () => {
38+
const date = new Date("2025-06-15T17:00:00Z");
39+
const result = formatTimeUTC(date);
40+
// Should contain 5:00 PM or 17:00 depending on locale
41+
expect(result).toMatch(/5:00\s*PM\s*UTC|17:00\s*UTC/);
42+
});
43+
});
44+
45+
describe("formatTimeLocal", () => {
46+
it("returns a non-empty string", () => {
47+
const date = new Date("2025-06-15T17:00:00Z");
48+
const result = formatTimeLocal(date);
49+
expect(result).toBeTruthy();
50+
expect(result.length).toBeGreaterThan(0);
51+
});
52+
53+
it("includes a timezone abbreviation", () => {
54+
const date = new Date("2025-06-15T17:00:00Z");
55+
const result = formatTimeLocal(date);
56+
// Should include some timezone indicator (UTC, EDT, PST, etc.)
57+
expect(result).toMatch(/[A-Z]{2,}/);
58+
});
59+
});
60+
61+
describe("formatScheduleTime", () => {
62+
it("includes UTC time", () => {
63+
const date = new Date("2025-06-15T17:00:00Z");
64+
const result = formatScheduleTime(date);
65+
expect(result).toContain("UTC");
66+
});
67+
68+
it("returns a non-empty string for valid date", () => {
69+
const date = new Date("2025-06-15T17:00:00Z");
70+
const result = formatScheduleTime(date);
71+
expect(result).toBeTruthy();
72+
});
73+
});
74+
75+
describe("formatScheduleDateTime", () => {
76+
it("includes UTC time", () => {
77+
const date = new Date("2025-06-15T17:00:00Z");
78+
const result = formatScheduleDateTime(date);
79+
expect(result).toContain("UTC");
80+
});
81+
82+
it("includes date portion", () => {
83+
const date = new Date("2025-06-15T17:00:00Z");
84+
const result = formatScheduleDateTime(date);
85+
// Should contain a month abbreviation and day
86+
expect(result).toMatch(/\w+\s+\d+/);
87+
});
88+
89+
it("returns a non-empty string for valid date", () => {
90+
const date = new Date("2025-06-15T17:00:00Z");
91+
const result = formatScheduleDateTime(date);
92+
expect(result).toBeTruthy();
93+
});
94+
});

components/frontend/src/lib/format-timestamp.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,64 @@ export function formatTimestamp(timestamp: string | undefined): string {
1818
return "";
1919
}
2020
}
21+
22+
/**
23+
* Formats a Date as a UTC time string with "UTC" suffix (e.g. "5:00 PM UTC").
24+
*/
25+
export function formatTimeUTC(date: Date): string {
26+
return date.toLocaleString(undefined, {
27+
hour: "numeric",
28+
minute: "2-digit",
29+
timeZone: "UTC",
30+
}) + " UTC";
31+
}
32+
33+
/**
34+
* Formats a Date as a local time string with timezone abbreviation (e.g. "1:00 PM EDT").
35+
*/
36+
export function formatTimeLocal(date: Date): string {
37+
return date.toLocaleString(undefined, {
38+
hour: "numeric",
39+
minute: "2-digit",
40+
timeZoneName: "short",
41+
});
42+
}
43+
44+
/**
45+
* Formats a Date showing both UTC and local time (e.g. "5:00 PM UTC (1:00 PM EDT)").
46+
* If the user's timezone is UTC, only the UTC time is shown.
47+
*/
48+
export function formatScheduleTime(date: Date): string {
49+
const utc = formatTimeUTC(date);
50+
const local = formatTimeLocal(date);
51+
52+
// If local already shows UTC, don't duplicate
53+
if (local.endsWith("UTC")) {
54+
return utc;
55+
}
56+
57+
return `${utc} (${local})`;
58+
}
59+
60+
/**
61+
* Formats a Date showing date + both UTC and local time for schedule displays.
62+
* (e.g. "Feb 27, 5:00 PM UTC (1:00 PM EDT)")
63+
*/
64+
export function formatScheduleDateTime(date: Date): string {
65+
const datePart = date.toLocaleString(undefined, {
66+
month: "short",
67+
day: "numeric",
68+
});
69+
const utcTime = date.toLocaleString(undefined, {
70+
hour: "numeric",
71+
minute: "2-digit",
72+
timeZone: "UTC",
73+
});
74+
const localTime = formatTimeLocal(date);
75+
76+
if (localTime.endsWith("UTC")) {
77+
return `${datePart}, ${utcTime} UTC`;
78+
}
79+
80+
return `${datePart}, ${utcTime} UTC (${localTime})`;
81+
}

0 commit comments

Comments
 (0)