From a45ee977143e3d7377b688aa71ceab7342b8eaf9 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Thu, 19 Mar 2026 17:21:44 +0800 Subject: [PATCH] Fix scheduled_at timezone display in macOS app --- taskboard-electron/src/renderer/App.jsx | 38 +++++--- taskboard-electron/src/renderer/dateTime.mjs | 94 +++++++++++++++++++ .../src/renderer/dateTime.test.mjs | 32 +++++++ 3 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 taskboard-electron/src/renderer/dateTime.mjs create mode 100644 taskboard-electron/src/renderer/dateTime.test.mjs diff --git a/taskboard-electron/src/renderer/App.jsx b/taskboard-electron/src/renderer/App.jsx index b0cc8f2..4216250 100644 --- a/taskboard-electron/src/renderer/App.jsx +++ b/taskboard-electron/src/renderer/App.jsx @@ -1,4 +1,11 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { + formatDateTimeLocalInput, + formatTaskDateTime, + formatTaskTime, + parseTaskDateTime, + serializeDateTimeLocalInput, +} from "./dateTime.mjs"; const API = "http://127.0.0.1:9712/api"; @@ -611,7 +618,7 @@ function TaskCard({ task, onAction, onViewDetail }) { ⏳ {task.delay_seconds}s )} {task.schedule_type === "scheduled_at" && task.next_run_at && ( - 📅 {new Date(task.next_run_at).toLocaleString()} + 📅 {formatTaskDateTime(task.next_run_at)} )} {task.schedule_type === "cron" && ( ⏲ {task.cron_expr} @@ -639,7 +646,7 @@ function TaskCard({ task, onAction, onViewDetail }) { {task.run_count > 0 && (
Runs: {task.run_count}{task.max_runs ? ` / ${task.max_runs}` : ""} - {task.last_run_at && ` · Last: ${new Date(task.last_run_at).toLocaleTimeString()}`} + {task.last_run_at && ` · Last: ${formatTaskTime(task.last_run_at)}`}
)} @@ -948,9 +955,9 @@ function HeartbeatCard({ heartbeat, onAction, onViewDetail }) {
- Next: {heartbeat.next_run_at ? new Date(heartbeat.next_run_at).toLocaleString() : "n/a"} + Next: {heartbeat.next_run_at ? formatTaskDateTime(heartbeat.next_run_at) : "n/a"} {" · "} - Triggered: {heartbeat.last_triggered_at ? new Date(heartbeat.last_triggered_at).toLocaleString() : "never"} + Triggered: {heartbeat.last_triggered_at ? formatTaskDateTime(heartbeat.last_triggered_at) : "never"}
{heartbeat.last_error && (
@@ -1037,9 +1044,9 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) { {heartbeat.last_decision && {heartbeat.last_decision}}
-
Next run: {heartbeat.next_run_at ? new Date(heartbeat.next_run_at).toLocaleString() : "n/a"}
-
Last tick: {heartbeat.last_tick_at ? new Date(heartbeat.last_tick_at).toLocaleString() : "never"}
-
Last trigger: {heartbeat.last_triggered_at ? new Date(heartbeat.last_triggered_at).toLocaleString() : "never"}
+
Next run: {heartbeat.next_run_at ? formatTaskDateTime(heartbeat.next_run_at) : "n/a"}
+
Last tick: {heartbeat.last_tick_at ? formatTaskDateTime(heartbeat.last_tick_at) : "never"}
+
Last trigger: {heartbeat.last_triggered_at ? formatTaskDateTime(heartbeat.last_triggered_at) : "never"}
Cooldown: {heartbeat.cooldown_seconds || 0}s
@@ -1083,7 +1090,7 @@ function HeartbeatDetailPanel({ heartbeat, ticks, onClose }) {
setSelectedTickId(tick.id)} style={{ display: "flex", justifyContent: "space-between", gap: 8, marginBottom: 6 }}>
{tick.decision_type || tick.status}
- {tick.started_at ? new Date(tick.started_at).toLocaleString() : ""} + {tick.started_at ? formatTaskDateTime(tick.started_at) : ""}
{payload?.reason && ( @@ -1155,7 +1162,7 @@ function NewTaskModal({ onClose, onSubmit, initialData, mode = "create" }) { cron_expr: initialData.cron_expr || "", delay_seconds: initialData.delay_seconds || 60, scheduled_at: initialData.next_run_at - ? new Date(initialData.next_run_at).toISOString().slice(0, 16) + ? formatDateTimeLocalInput(initialData.next_run_at) : "", max_runs: initialData.max_runs || "", tags: initialData.tags || "", @@ -1236,13 +1243,14 @@ function NewTaskModal({ onClose, onSubmit, initialData, mode = "create" }) { // Handle scheduled_at: convert datetime-local to ISO timestamp if (form.schedule_type === "scheduled_at") { - const localDate = new Date(form.scheduled_at); - if (!form.scheduled_at || isNaN(localDate.getTime())) { + const localDate = parseTaskDateTime(form.scheduled_at); + const serialized = serializeDateTimeLocalInput(form.scheduled_at); + if (!form.scheduled_at || !serialized || !localDate || isNaN(localDate.getTime())) { setScheduledAtError("Please enter a valid date and time."); return; } setScheduledAtError(""); - data.next_run_at = localDate.toISOString(); + data.next_run_at = serialized; } onSubmit(data); @@ -1621,7 +1629,7 @@ function DetailPanel({ task, onClose, onRespond, onResume }) {
- ID: {task.id} · Created: {new Date(task.created_at).toLocaleString()} + ID: {task.id} · Created: {formatTaskDateTime(task.created_at)}
@@ -1654,7 +1662,7 @@ function DetailPanel({ task, onClose, onRespond, onResume }) { {task.cron_expr && } {task.delay_seconds && } - {task.next_run_at && } + {task.next_run_at && } {task.dag_id && }
@@ -1924,7 +1932,7 @@ function DetailPanel({ task, onClose, onRespond, onResume }) { {event.event_type} - {new Date(event.timestamp).toLocaleTimeString()} + {formatTaskTime(event.timestamp)}
{ + const parsed = parseTaskDateTime("2026-03-19T18:04:00"); + + assert.equal(parsed.getFullYear(), 2026); + assert.equal(parsed.getMonth(), 2); + assert.equal(parsed.getDate(), 19); + assert.equal(parsed.getHours(), 18); + assert.equal(parsed.getMinutes(), 4); +}); + +test("formatDateTimeLocalInput converts aware timestamps into local datetime-local values", () => { + assert.equal( + formatDateTimeLocalInput("2026-03-19T10:04:00+00:00"), + "2026-03-19T18:04", + ); +}); + +test("serializeDateTimeLocalInput preserves local wall time without forcing UTC", () => { + assert.equal( + serializeDateTimeLocalInput("2026-03-19T18:04"), + "2026-03-19T18:04:00", + ); +});