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",
+ );
+});