Skip to content

Commit 35638ba

Browse files
authored
Fix scheduled_at timezone display in macOS app (#9)
1 parent 8cd9948 commit 35638ba

3 files changed

Lines changed: 149 additions & 15 deletions

File tree

taskboard-electron/src/renderer/App.jsx

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import { useState, useEffect, useCallback, useRef } from "react";
2+
import {
3+
formatDateTimeLocalInput,
4+
formatTaskDateTime,
5+
formatTaskTime,
6+
parseTaskDateTime,
7+
serializeDateTimeLocalInput,
8+
} from "./dateTime.mjs";
29

310
const API = "http://127.0.0.1:9712/api";
411

@@ -611,7 +618,7 @@ function TaskCard({ task, onAction, onViewDetail }) {
611618
<Tag>{task.delay_seconds}s</Tag>
612619
)}
613620
{task.schedule_type === "scheduled_at" && task.next_run_at && (
614-
<Tag>📅 {new Date(task.next_run_at).toLocaleString()}</Tag>
621+
<Tag>📅 {formatTaskDateTime(task.next_run_at)}</Tag>
615622
)}
616623
{task.schedule_type === "cron" && (
617624
<Tag>{task.cron_expr}</Tag>
@@ -639,7 +646,7 @@ function TaskCard({ task, onAction, onViewDetail }) {
639646
{task.run_count > 0 && (
640647
<div style={{ fontSize: 10, color: theme.textDim, marginTop: 8, fontFamily: "monospace" }}>
641648
Runs: {task.run_count}{task.max_runs ? ` / ${task.max_runs}` : ""}
642-
{task.last_run_at && ` · Last: ${new Date(task.last_run_at).toLocaleTimeString()}`}
649+
{task.last_run_at && ` · Last: ${formatTaskTime(task.last_run_at)}`}
643650
</div>
644651
)}
645652

@@ -948,9 +955,9 @@ function HeartbeatCard({ heartbeat, onAction, onViewDetail }) {
948955
</div>
949956

950957
<div style={{ fontSize: 11, color: theme.textDim, marginTop: 10, fontFamily: "monospace", lineHeight: 1.6 }}>
951-
Next: {heartbeat.next_run_at ? new Date(heartbeat.next_run_at).toLocaleString() : "n/a"}
958+
Next: {heartbeat.next_run_at ? formatTaskDateTime(heartbeat.next_run_at) : "n/a"}
952959
{" · "}
953-
Triggered: {heartbeat.last_triggered_at ? new Date(heartbeat.last_triggered_at).toLocaleString() : "never"}
960+
Triggered: {heartbeat.last_triggered_at ? formatTaskDateTime(heartbeat.last_triggered_at) : "never"}
954961
</div>
955962
{heartbeat.last_error && (
956963
<div style={{ fontSize: 11, color: theme.red, marginTop: 6, lineHeight: 1.4 }}>
@@ -1037,9 +1044,9 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
10371044
{heartbeat.last_decision && <Tag>{heartbeat.last_decision}</Tag>}
10381045
</div>
10391046
<div style={{ fontSize: 12, color: theme.textMuted, lineHeight: 1.7, marginBottom: 18 }}>
1040-
<div>Next run: {heartbeat.next_run_at ? new Date(heartbeat.next_run_at).toLocaleString() : "n/a"}</div>
1041-
<div>Last tick: {heartbeat.last_tick_at ? new Date(heartbeat.last_tick_at).toLocaleString() : "never"}</div>
1042-
<div>Last trigger: {heartbeat.last_triggered_at ? new Date(heartbeat.last_triggered_at).toLocaleString() : "never"}</div>
1047+
<div>Next run: {heartbeat.next_run_at ? formatTaskDateTime(heartbeat.next_run_at) : "n/a"}</div>
1048+
<div>Last tick: {heartbeat.last_tick_at ? formatTaskDateTime(heartbeat.last_tick_at) : "never"}</div>
1049+
<div>Last trigger: {heartbeat.last_triggered_at ? formatTaskDateTime(heartbeat.last_triggered_at) : "never"}</div>
10431050
<div>Cooldown: {heartbeat.cooldown_seconds || 0}s</div>
10441051
</div>
10451052
<div style={{ marginBottom: 20 }}>
@@ -1083,7 +1090,7 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
10831090
<div onClick={() => setSelectedTickId(tick.id)} style={{ display: "flex", justifyContent: "space-between", gap: 8, marginBottom: 6 }}>
10841091
<div style={{ fontSize: 12, fontWeight: 600, color: theme.text }}>{tick.decision_type || tick.status}</div>
10851092
<div style={{ fontSize: 11, color: theme.textDim, fontFamily: "monospace" }}>
1086-
{tick.started_at ? new Date(tick.started_at).toLocaleString() : ""}
1093+
{tick.started_at ? formatTaskDateTime(tick.started_at) : ""}
10871094
</div>
10881095
</div>
10891096
{payload?.reason && (
@@ -1155,7 +1162,7 @@ function NewTaskModal({ onClose, onSubmit, initialData, mode = "create" }) {
11551162
cron_expr: initialData.cron_expr || "",
11561163
delay_seconds: initialData.delay_seconds || 60,
11571164
scheduled_at: initialData.next_run_at
1158-
? new Date(initialData.next_run_at).toISOString().slice(0, 16)
1165+
? formatDateTimeLocalInput(initialData.next_run_at)
11591166
: "",
11601167
max_runs: initialData.max_runs || "",
11611168
tags: initialData.tags || "",
@@ -1236,13 +1243,14 @@ function NewTaskModal({ onClose, onSubmit, initialData, mode = "create" }) {
12361243

12371244
// Handle scheduled_at: convert datetime-local to ISO timestamp
12381245
if (form.schedule_type === "scheduled_at") {
1239-
const localDate = new Date(form.scheduled_at);
1240-
if (!form.scheduled_at || isNaN(localDate.getTime())) {
1246+
const localDate = parseTaskDateTime(form.scheduled_at);
1247+
const serialized = serializeDateTimeLocalInput(form.scheduled_at);
1248+
if (!form.scheduled_at || !serialized || !localDate || isNaN(localDate.getTime())) {
12411249
setScheduledAtError("Please enter a valid date and time.");
12421250
return;
12431251
}
12441252
setScheduledAtError("");
1245-
data.next_run_at = localDate.toISOString();
1253+
data.next_run_at = serialized;
12461254
}
12471255

12481256
onSubmit(data);
@@ -1621,7 +1629,7 @@ function DetailPanel({ task, onClose, onRespond, onResume }) {
16211629
</h2>
16221630

16231631
<div style={{ fontSize: 11, color: theme.textDim, marginBottom: 24, fontFamily: "monospace" }}>
1624-
ID: {task.id} · Created: {new Date(task.created_at).toLocaleString()}
1632+
ID: {task.id} · Created: {formatTaskDateTime(task.created_at)}
16251633
</div>
16261634

16271635
<Section title="Prompt">
@@ -1654,7 +1662,7 @@ function DetailPanel({ task, onClose, onRespond, onResume }) {
16541662
<InfoRow label="Schedule" value={task.schedule_type} />
16551663
{task.cron_expr && <InfoRow label="Cron" value={task.cron_expr} />}
16561664
{task.delay_seconds && <InfoRow label="Delay" value={`${task.delay_seconds}s`} />}
1657-
{task.next_run_at && <InfoRow label="Next Run" value={new Date(task.next_run_at).toLocaleString()} />}
1665+
{task.next_run_at && <InfoRow label="Next Run" value={formatTaskDateTime(task.next_run_at)} />}
16581666
<InfoRow label="Runs" value={`${task.run_count}${task.max_runs ? ` / ${task.max_runs}` : ""}`} />
16591667
{task.dag_id && <InfoRow label="DAG" value={task.dag_id} />}
16601668
</Section>
@@ -1924,7 +1932,7 @@ function DetailPanel({ task, onClose, onRespond, onResume }) {
19241932
{event.event_type}
19251933
</span>
19261934
<span style={{ color: theme.textDim, fontSize: 9 }}>
1927-
{new Date(event.timestamp).toLocaleTimeString()}
1935+
{formatTaskTime(event.timestamp)}
19281936
</span>
19291937
</div>
19301938
<div style={{
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
function pad(value) {
2+
return String(value).padStart(2, "0");
3+
}
4+
5+
function hasExplicitTimezone(value) {
6+
return /(?:Z|[+-]\d{2}:\d{2})$/i.test(value);
7+
}
8+
9+
function parseLocalDateTimeParts(value) {
10+
const match = String(value).trim().match(
11+
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?$/,
12+
);
13+
if (!match) return null;
14+
const [, year, month, day, hour, minute, second = "00"] = match;
15+
return {
16+
year: Number(year),
17+
month: Number(month),
18+
day: Number(day),
19+
hour: Number(hour),
20+
minute: Number(minute),
21+
second: Number(second),
22+
};
23+
}
24+
25+
export function parseTaskDateTime(value) {
26+
if (!value) return null;
27+
const raw = String(value).trim();
28+
if (!raw) return null;
29+
30+
if (hasExplicitTimezone(raw)) {
31+
const date = new Date(raw);
32+
return Number.isNaN(date.getTime()) ? null : date;
33+
}
34+
35+
const parts = parseLocalDateTimeParts(raw);
36+
if (parts) {
37+
return new Date(
38+
parts.year,
39+
parts.month - 1,
40+
parts.day,
41+
parts.hour,
42+
parts.minute,
43+
parts.second,
44+
0,
45+
);
46+
}
47+
48+
const fallback = new Date(raw);
49+
return Number.isNaN(fallback.getTime()) ? null : fallback;
50+
}
51+
52+
export function formatTaskDateTime(value, options) {
53+
const date = parseTaskDateTime(value);
54+
return date ? date.toLocaleString(undefined, options) : "";
55+
}
56+
57+
export function formatTaskTime(value, options) {
58+
const date = parseTaskDateTime(value);
59+
return date ? date.toLocaleTimeString(undefined, options) : "";
60+
}
61+
62+
export function formatDateTimeLocalInput(value) {
63+
const date = parseTaskDateTime(value);
64+
if (!date) return "";
65+
return [
66+
date.getFullYear(),
67+
"-",
68+
pad(date.getMonth() + 1),
69+
"-",
70+
pad(date.getDate()),
71+
"T",
72+
pad(date.getHours()),
73+
":",
74+
pad(date.getMinutes()),
75+
].join("");
76+
}
77+
78+
export function serializeDateTimeLocalInput(value) {
79+
const parts = parseLocalDateTimeParts(value);
80+
if (!parts) return null;
81+
return [
82+
parts.year,
83+
"-",
84+
pad(parts.month),
85+
"-",
86+
pad(parts.day),
87+
"T",
88+
pad(parts.hour),
89+
":",
90+
pad(parts.minute),
91+
":",
92+
pad(parts.second),
93+
].join("");
94+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import {
5+
formatDateTimeLocalInput,
6+
parseTaskDateTime,
7+
serializeDateTimeLocalInput,
8+
} from "./dateTime.mjs";
9+
10+
test("parseTaskDateTime keeps naive timestamps in local wall time", () => {
11+
const parsed = parseTaskDateTime("2026-03-19T18:04:00");
12+
13+
assert.equal(parsed.getFullYear(), 2026);
14+
assert.equal(parsed.getMonth(), 2);
15+
assert.equal(parsed.getDate(), 19);
16+
assert.equal(parsed.getHours(), 18);
17+
assert.equal(parsed.getMinutes(), 4);
18+
});
19+
20+
test("formatDateTimeLocalInput converts aware timestamps into local datetime-local values", () => {
21+
assert.equal(
22+
formatDateTimeLocalInput("2026-03-19T10:04:00+00:00"),
23+
"2026-03-19T18:04",
24+
);
25+
});
26+
27+
test("serializeDateTimeLocalInput preserves local wall time without forcing UTC", () => {
28+
assert.equal(
29+
serializeDateTimeLocalInput("2026-03-19T18:04"),
30+
"2026-03-19T18:04:00",
31+
);
32+
});

0 commit comments

Comments
 (0)