From c1d63f8178e823bf3d3d9dbf8f4019ddbb41f8ba Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 7 Feb 2026 02:23:39 +0530 Subject: [PATCH] feat: improve date handling by introducing utility functions for API datetime parsing and formatting --- .../src/app/boards/[boardId]/edit/page.tsx | 16 +-- frontend/src/app/boards/[boardId]/page.tsx | 110 +++++++++--------- frontend/src/app/dashboard/page.tsx | 9 +- .../src/components/BoardApprovalsPanel.tsx | 14 ++- frontend/src/components/BoardGoalPanel.tsx | 5 +- .../src/components/organisms/TaskBoard.tsx | 5 +- frontend/src/lib/datetime.ts | 53 +++++++++ 7 files changed, 132 insertions(+), 80 deletions(-) create mode 100644 frontend/src/lib/datetime.ts diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 6714eb3..a3f9813 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -32,6 +32,7 @@ import { } from "@/components/ui/select"; import SearchableSelect from "@/components/ui/searchable-select"; import { Textarea } from "@/components/ui/textarea"; +import { localDateInputToUtcIso, toLocalDateInput } from "@/lib/datetime"; const slugify = (value: string) => value @@ -40,13 +41,6 @@ const slugify = (value: string) => .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, "") || "board"; -const toDateInput = (value?: string | null) => { - if (!value) return ""; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ""; - return date.toISOString().slice(0, 10); -}; - export default function EditBoardPage() { const { isSignedIn } = useAuth(); const router = useRouter(); @@ -167,7 +161,7 @@ export default function EditBoardPage() { ? JSON.stringify(baseBoard.success_metrics, null, 2) : ""); const resolvedTargetDate = - targetDate ?? toDateInput(baseBoard?.target_date); + targetDate ?? toLocalDateInput(baseBoard?.target_date); const displayGatewayId = resolvedGatewayId || gateways[0]?.id || ""; @@ -193,7 +187,7 @@ export default function EditBoardPage() { setSuccessMetrics( updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : "", ); - setTargetDate(toDateInput(updated.target_date)); + setTargetDate(toLocalDateInput(updated.target_date)); setIsOnboardingOpen(false); }; @@ -231,9 +225,7 @@ export default function EditBoardPage() { board_type: resolvedBoardType, objective: resolvedObjective.trim() || null, success_metrics: parsedMetrics, - target_date: resolvedTargetDate - ? new Date(resolvedTargetDate).toISOString() - : null, + target_date: localDateInputToUtcIso(resolvedTargetDate), }; updateBoardMutation.mutate({ boardId, data: payload }); diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index f3fcdc6..2f9875d 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -62,6 +62,7 @@ import type { TaskRead, } from "@/api/generated/model"; import { createExponentialBackoff } from "@/lib/backoff"; +import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; type Board = BoardRead; @@ -250,8 +251,8 @@ const Markdown = memo(function Markdown({ Markdown.displayName = "Markdown"; const formatShortTimestamp = (value: string) => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "—"; + const date = parseApiDatetime(value); + if (!date) return "—"; return date.toLocaleString(undefined, { month: "short", day: "numeric", @@ -476,8 +477,8 @@ export default function BoardDetailPage() { items.forEach((task) => { const value = task.updated_at ?? task.created_at; if (!value) return; - const time = new Date(value).getTime(); - if (!Number.isNaN(time) && time > latestTime) { + const time = apiDatetimeToMs(value); + if (time !== null && time > latestTime) { latestTime = time; } }); @@ -489,8 +490,8 @@ export default function BoardDetailPage() { items.forEach((approval) => { const value = approval.resolved_at ?? approval.created_at; if (!value) return; - const time = new Date(value).getTime(); - if (!Number.isNaN(time) && time > latestTime) { + const time = apiDatetimeToMs(value); + if (time !== null && time > latestTime) { latestTime = time; } }); @@ -502,8 +503,8 @@ export default function BoardDetailPage() { items.forEach((agent) => { const value = agent.updated_at ?? agent.last_seen_at; if (!value) return; - const time = new Date(value).getTime(); - if (!Number.isNaN(time) && time > latestTime) { + const time = apiDatetimeToMs(value); + if (time !== null && time > latestTime) { latestTime = time; } }); @@ -576,8 +577,8 @@ export default function BoardDetailPage() { const latestChatTimestamp = (items: BoardChatMessage[]) => { if (!items.length) return undefined; const latest = items.reduce((max, item) => { - const ts = new Date(item.created_at).getTime(); - return Number.isNaN(ts) ? max : Math.max(max, ts); + const ts = apiDatetimeToMs(item.created_at); + return ts === null ? max : Math.max(max, ts); }, 0); if (!latest) return undefined; return new Date(latest).toISOString(); @@ -647,14 +648,14 @@ export default function BoardDetailPage() { (item) => item.id === payload.memory?.id, ); if (exists) return prev; - const next = [...prev, payload.memory as BoardChatMessage]; - next.sort((a, b) => { - const aTime = new Date(a.created_at).getTime(); - const bTime = new Date(b.created_at).getTime(); - return aTime - bTime; - }); - return next; - }); + const next = [...prev, payload.memory as BoardChatMessage]; + next.sort((a, b) => { + const aTime = apiDatetimeToMs(a.created_at) ?? 0; + const bTime = apiDatetimeToMs(b.created_at) ?? 0; + return aTime - bTime; + }); + return next; + }); } } catch { // ignore malformed @@ -908,25 +909,24 @@ export default function BoardDetailPage() { const exists = prev.some((item) => item.id === payload.comment?.id); if (exists) { return prev; - } - const createdAt = payload.comment?.created_at; - const createdMs = createdAt ? new Date(createdAt).getTime() : NaN; - if (prev.length === 0 || Number.isNaN(createdMs)) { - return [...prev, payload.comment as TaskComment]; - } - const last = prev[prev.length - 1]; - const lastMs = last?.created_at ? new Date(last.created_at).getTime() : NaN; - if (!Number.isNaN(lastMs) && createdMs >= lastMs) { - return [...prev, payload.comment as TaskComment]; - } - const next = [...prev, payload.comment as TaskComment]; - next.sort((a, b) => { - const aTime = new Date(a.created_at).getTime(); - const bTime = new Date(b.created_at).getTime(); - return aTime - bTime; - }); - return next; - }); + } + const createdMs = apiDatetimeToMs(payload.comment?.created_at); + if (prev.length === 0 || createdMs === null) { + return [...prev, payload.comment as TaskComment]; + } + const last = prev[prev.length - 1]; + const lastMs = apiDatetimeToMs(last?.created_at); + if (lastMs !== null && createdMs >= lastMs) { + return [...prev, payload.comment as TaskComment]; + } + const next = [...prev, payload.comment as TaskComment]; + next.sort((a, b) => { + const aTime = apiDatetimeToMs(a.created_at) ?? 0; + const bTime = apiDatetimeToMs(b.created_at) ?? 0; + return aTime - bTime; + }); + return next; + }); } else if (payload.task) { setTasks((prev) => { const index = prev.findIndex((item) => item.id === payload.task?.id); @@ -1159,16 +1159,16 @@ export default function BoardDetailPage() { const created = result.data; if (created.tags?.includes("chat")) { setChatMessages((prev) => { - const exists = prev.some((item) => item.id === created.id); - if (exists) return prev; - const next = [...prev, created]; - next.sort((a, b) => { - const aTime = new Date(a.created_at).getTime(); - const bTime = new Date(b.created_at).getTime(); - return aTime - bTime; - }); - return next; - }); + const exists = prev.some((item) => item.id === created.id); + if (exists) return prev; + const next = [...prev, created]; + next.sort((a, b) => { + const aTime = apiDatetimeToMs(a.created_at) ?? 0; + const bTime = apiDatetimeToMs(b.created_at) ?? 0; + return aTime - bTime; + }); + return next; + }); } return true; } catch (err) { @@ -1209,8 +1209,8 @@ export default function BoardDetailPage() { const orderedLiveFeed = useMemo(() => { return [...liveFeed].sort((a, b) => { - const aTime = new Date(a.created_at).getTime(); - const bTime = new Date(b.created_at).getTime(); + const aTime = apiDatetimeToMs(a.created_at) ?? 0; + const bTime = apiDatetimeToMs(b.created_at) ?? 0; return bTime - aTime; }); }, [liveFeed]); @@ -1320,8 +1320,8 @@ export default function BoardDetailPage() { if (result.status !== 200) throw new Error("Unable to load comments."); const items = [...(result.data.items ?? [])]; items.sort((a, b) => { - const aTime = new Date(a.created_at).getTime(); - const bTime = new Date(b.created_at).getTime(); + const aTime = apiDatetimeToMs(a.created_at) ?? 0; + const bTime = apiDatetimeToMs(b.created_at) ?? 0; return aTime - bTime; }); setComments(items); @@ -1631,8 +1631,8 @@ export default function BoardDetailPage() { const formatTaskTimestamp = (value?: string | null) => { if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "—"; + const date = parseApiDatetime(value); + if (!date) return "—"; return date.toLocaleString(undefined, { month: "short", day: "numeric", @@ -1669,8 +1669,8 @@ export default function BoardDetailPage() { const formatApprovalTimestamp = (value?: string | null) => { if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; + const date = parseApiDatetime(value); + if (!date) return value; return date.toLocaleString(undefined, { month: "short", day: "numeric", diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 2be711f..be3ef59 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -27,6 +27,7 @@ import { type dashboardMetricsApiV1MetricsDashboardGetResponse, useDashboardMetricsApiV1MetricsDashboardGet, } from "@/api/generated/metrics/metrics"; +import { parseApiDatetime } from "@/lib/datetime"; type RangeKey = "24h" | "7d"; type BucketKey = "hour" | "day"; @@ -91,8 +92,8 @@ const updatedFormatter = new Intl.DateTimeFormat("en-US", { }); const formatPeriod = (value: string, bucket: BucketKey) => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return ""; + const date = parseApiDatetime(value); + if (!date) return ""; return bucket === "hour" ? hourFormatter.format(date) : dayFormatter.format(date); }; @@ -324,8 +325,8 @@ export default function DashboardPage() { const updatedAtLabel = useMemo(() => { if (!metrics?.generated_at) return null; - const date = new Date(metrics.generated_at); - if (Number.isNaN(date.getTime())) return null; + const date = parseApiDatetime(metrics.generated_at); + if (!date) return null; return updatedFormatter.format(date); }, [metrics]); diff --git a/frontend/src/components/BoardApprovalsPanel.tsx b/frontend/src/components/BoardApprovalsPanel.tsx index 73e1233..0ed470d 100644 --- a/frontend/src/components/BoardApprovalsPanel.tsx +++ b/frontend/src/components/BoardApprovalsPanel.tsx @@ -23,6 +23,7 @@ import { type ChartConfig, } from "@/components/charts/chart"; import { Button } from "@/components/ui/button"; +import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; type Approval = ApprovalRead & { status: string }; @@ -42,8 +43,8 @@ type BoardApprovalsPanelProps = { const formatTimestamp = (value?: string | null) => { if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; + const date = parseApiDatetime(value); + if (!date) return value; return date.toLocaleString(undefined, { month: "short", day: "numeric", @@ -241,7 +242,10 @@ export function BoardApprovalsPanel({ const pendingNext = [...approvals] .filter((item) => item.id !== approvalId) .filter((item) => item.status === "pending") - .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0] + .sort( + (a, b) => + (apiDatetimeToMs(b.created_at) ?? 0) - (apiDatetimeToMs(a.created_at) ?? 0), + )[0] ?.id; if (pendingNext) { setSelectedId(pendingNext); @@ -302,8 +306,8 @@ export function BoardApprovalsPanel({ const sortedApprovals = useMemo(() => { const sortByTime = (items: Approval[]) => [...items].sort((a, b) => { - const aTime = new Date(a.created_at).getTime(); - const bTime = new Date(b.created_at).getTime(); + const aTime = apiDatetimeToMs(a.created_at) ?? 0; + const bTime = apiDatetimeToMs(b.created_at) ?? 0; return bTime - aTime; }); const pending = sortByTime( diff --git a/frontend/src/components/BoardGoalPanel.tsx b/frontend/src/components/BoardGoalPanel.tsx index fbac9b5..e35c8c6 100644 --- a/frontend/src/components/BoardGoalPanel.tsx +++ b/frontend/src/components/BoardGoalPanel.tsx @@ -3,6 +3,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; type BoardGoal = { @@ -21,8 +22,8 @@ type BoardGoalPanelProps = { const formatTargetDate = (value?: string | null) => { if (!value) return "—"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; + const date = parseApiDatetime(value); + if (!date) return value; return date.toLocaleDateString(undefined, { month: "short", day: "numeric", diff --git a/frontend/src/components/organisms/TaskBoard.tsx b/frontend/src/components/organisms/TaskBoard.tsx index 1095db3..7370842 100644 --- a/frontend/src/components/organisms/TaskBoard.tsx +++ b/frontend/src/components/organisms/TaskBoard.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { TaskCard } from "@/components/molecules/TaskCard"; +import { parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; type TaskStatus = "inbox" | "in_progress" | "review" | "done"; @@ -74,8 +75,8 @@ const columns: Array<{ const formatDueDate = (value?: string | null) => { if (!value) return undefined; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return undefined; + const date = parseApiDatetime(value); + if (!date) return undefined; return date.toLocaleDateString(undefined, { month: "short", day: "numeric", diff --git a/frontend/src/lib/datetime.ts b/frontend/src/lib/datetime.ts new file mode 100644 index 0000000..03f8c40 --- /dev/null +++ b/frontend/src/lib/datetime.ts @@ -0,0 +1,53 @@ +const HAS_TZ_RE = /[zZ]|[+-]\d\d:\d\d$/; +const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * Backend timestamps are emitted as ISO strings (often without a timezone). + * Treat missing timezone info as UTC, then format in the browser's local timezone. + */ +export function normalizeApiDatetime(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return trimmed; + if (DATE_ONLY_RE.test(trimmed)) { + // Convert date-only to a valid ISO timestamp. + return `${trimmed}T00:00:00Z`; + } + return HAS_TZ_RE.test(trimmed) ? trimmed : `${trimmed}Z`; +} + +export function parseApiDatetime(value?: string | null): Date | null { + if (!value) return null; + const normalized = normalizeApiDatetime(value); + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) return null; + return date; +} + +export function apiDatetimeToMs(value?: string | null): number | null { + const date = parseApiDatetime(value); + return date ? date.getTime() : null; +} + +export function toLocalDateInput(value?: string | null): string { + const date = parseApiDatetime(value); + if (!date) return ""; + const year = String(date.getFullYear()); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function localDateInputToUtcIso(value?: string | null): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) return null; + const year = Number(match[1]); + const monthIndex = Number(match[2]) - 1; + const day = Number(match[3]); + const date = new Date(year, monthIndex, day); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString(); +} +