feat: improve date handling by introducing utility functions for API datetime parsing and formatting

This commit is contained in:
Abhimanyu Saharan
2026-02-07 02:23:39 +05:30
parent 0ab5a75641
commit c1d63f8178
7 changed files with 132 additions and 80 deletions

View File

@@ -32,6 +32,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import SearchableSelect from "@/components/ui/searchable-select"; import SearchableSelect from "@/components/ui/searchable-select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { localDateInputToUtcIso, toLocalDateInput } from "@/lib/datetime";
const slugify = (value: string) => const slugify = (value: string) =>
value value
@@ -40,13 +41,6 @@ const slugify = (value: string) =>
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "board"; .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() { export default function EditBoardPage() {
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const router = useRouter(); const router = useRouter();
@@ -167,7 +161,7 @@ export default function EditBoardPage() {
? JSON.stringify(baseBoard.success_metrics, null, 2) ? JSON.stringify(baseBoard.success_metrics, null, 2)
: ""); : "");
const resolvedTargetDate = const resolvedTargetDate =
targetDate ?? toDateInput(baseBoard?.target_date); targetDate ?? toLocalDateInput(baseBoard?.target_date);
const displayGatewayId = resolvedGatewayId || gateways[0]?.id || ""; const displayGatewayId = resolvedGatewayId || gateways[0]?.id || "";
@@ -193,7 +187,7 @@ export default function EditBoardPage() {
setSuccessMetrics( setSuccessMetrics(
updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : "", updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : "",
); );
setTargetDate(toDateInput(updated.target_date)); setTargetDate(toLocalDateInput(updated.target_date));
setIsOnboardingOpen(false); setIsOnboardingOpen(false);
}; };
@@ -231,9 +225,7 @@ export default function EditBoardPage() {
board_type: resolvedBoardType, board_type: resolvedBoardType,
objective: resolvedObjective.trim() || null, objective: resolvedObjective.trim() || null,
success_metrics: parsedMetrics, success_metrics: parsedMetrics,
target_date: resolvedTargetDate target_date: localDateInputToUtcIso(resolvedTargetDate),
? new Date(resolvedTargetDate).toISOString()
: null,
}; };
updateBoardMutation.mutate({ boardId, data: payload }); updateBoardMutation.mutate({ boardId, data: payload });

View File

@@ -62,6 +62,7 @@ import type {
TaskRead, TaskRead,
} from "@/api/generated/model"; } from "@/api/generated/model";
import { createExponentialBackoff } from "@/lib/backoff"; import { createExponentialBackoff } from "@/lib/backoff";
import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Board = BoardRead; type Board = BoardRead;
@@ -250,8 +251,8 @@ const Markdown = memo(function Markdown({
Markdown.displayName = "Markdown"; Markdown.displayName = "Markdown";
const formatShortTimestamp = (value: string) => { const formatShortTimestamp = (value: string) => {
const date = new Date(value); const date = parseApiDatetime(value);
if (Number.isNaN(date.getTime())) return "—"; if (!date) return "—";
return date.toLocaleString(undefined, { return date.toLocaleString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -476,8 +477,8 @@ export default function BoardDetailPage() {
items.forEach((task) => { items.forEach((task) => {
const value = task.updated_at ?? task.created_at; const value = task.updated_at ?? task.created_at;
if (!value) return; if (!value) return;
const time = new Date(value).getTime(); const time = apiDatetimeToMs(value);
if (!Number.isNaN(time) && time > latestTime) { if (time !== null && time > latestTime) {
latestTime = time; latestTime = time;
} }
}); });
@@ -489,8 +490,8 @@ export default function BoardDetailPage() {
items.forEach((approval) => { items.forEach((approval) => {
const value = approval.resolved_at ?? approval.created_at; const value = approval.resolved_at ?? approval.created_at;
if (!value) return; if (!value) return;
const time = new Date(value).getTime(); const time = apiDatetimeToMs(value);
if (!Number.isNaN(time) && time > latestTime) { if (time !== null && time > latestTime) {
latestTime = time; latestTime = time;
} }
}); });
@@ -502,8 +503,8 @@ export default function BoardDetailPage() {
items.forEach((agent) => { items.forEach((agent) => {
const value = agent.updated_at ?? agent.last_seen_at; const value = agent.updated_at ?? agent.last_seen_at;
if (!value) return; if (!value) return;
const time = new Date(value).getTime(); const time = apiDatetimeToMs(value);
if (!Number.isNaN(time) && time > latestTime) { if (time !== null && time > latestTime) {
latestTime = time; latestTime = time;
} }
}); });
@@ -576,8 +577,8 @@ export default function BoardDetailPage() {
const latestChatTimestamp = (items: BoardChatMessage[]) => { const latestChatTimestamp = (items: BoardChatMessage[]) => {
if (!items.length) return undefined; if (!items.length) return undefined;
const latest = items.reduce((max, item) => { const latest = items.reduce((max, item) => {
const ts = new Date(item.created_at).getTime(); const ts = apiDatetimeToMs(item.created_at);
return Number.isNaN(ts) ? max : Math.max(max, ts); return ts === null ? max : Math.max(max, ts);
}, 0); }, 0);
if (!latest) return undefined; if (!latest) return undefined;
return new Date(latest).toISOString(); return new Date(latest).toISOString();
@@ -649,8 +650,8 @@ export default function BoardDetailPage() {
if (exists) return prev; if (exists) return prev;
const next = [...prev, payload.memory as BoardChatMessage]; const next = [...prev, payload.memory as BoardChatMessage];
next.sort((a, b) => { next.sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = new Date(b.created_at).getTime(); const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return aTime - bTime; return aTime - bTime;
}); });
return next; return next;
@@ -909,20 +910,19 @@ export default function BoardDetailPage() {
if (exists) { if (exists) {
return prev; return prev;
} }
const createdAt = payload.comment?.created_at; const createdMs = apiDatetimeToMs(payload.comment?.created_at);
const createdMs = createdAt ? new Date(createdAt).getTime() : NaN; if (prev.length === 0 || createdMs === null) {
if (prev.length === 0 || Number.isNaN(createdMs)) {
return [...prev, payload.comment as TaskComment]; return [...prev, payload.comment as TaskComment];
} }
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
const lastMs = last?.created_at ? new Date(last.created_at).getTime() : NaN; const lastMs = apiDatetimeToMs(last?.created_at);
if (!Number.isNaN(lastMs) && createdMs >= lastMs) { if (lastMs !== null && createdMs >= lastMs) {
return [...prev, payload.comment as TaskComment]; return [...prev, payload.comment as TaskComment];
} }
const next = [...prev, payload.comment as TaskComment]; const next = [...prev, payload.comment as TaskComment];
next.sort((a, b) => { next.sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = new Date(b.created_at).getTime(); const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return aTime - bTime; return aTime - bTime;
}); });
return next; return next;
@@ -1163,8 +1163,8 @@ export default function BoardDetailPage() {
if (exists) return prev; if (exists) return prev;
const next = [...prev, created]; const next = [...prev, created];
next.sort((a, b) => { next.sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = new Date(b.created_at).getTime(); const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return aTime - bTime; return aTime - bTime;
}); });
return next; return next;
@@ -1209,8 +1209,8 @@ export default function BoardDetailPage() {
const orderedLiveFeed = useMemo(() => { const orderedLiveFeed = useMemo(() => {
return [...liveFeed].sort((a, b) => { return [...liveFeed].sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = new Date(b.created_at).getTime(); const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return bTime - aTime; return bTime - aTime;
}); });
}, [liveFeed]); }, [liveFeed]);
@@ -1320,8 +1320,8 @@ export default function BoardDetailPage() {
if (result.status !== 200) throw new Error("Unable to load comments."); if (result.status !== 200) throw new Error("Unable to load comments.");
const items = [...(result.data.items ?? [])]; const items = [...(result.data.items ?? [])];
items.sort((a, b) => { items.sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = new Date(b.created_at).getTime(); const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return aTime - bTime; return aTime - bTime;
}); });
setComments(items); setComments(items);
@@ -1631,8 +1631,8 @@ export default function BoardDetailPage() {
const formatTaskTimestamp = (value?: string | null) => { const formatTaskTimestamp = (value?: string | null) => {
if (!value) return "—"; if (!value) return "—";
const date = new Date(value); const date = parseApiDatetime(value);
if (Number.isNaN(date.getTime())) return "—"; if (!date) return "—";
return date.toLocaleString(undefined, { return date.toLocaleString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -1669,8 +1669,8 @@ export default function BoardDetailPage() {
const formatApprovalTimestamp = (value?: string | null) => { const formatApprovalTimestamp = (value?: string | null) => {
if (!value) return "—"; if (!value) return "—";
const date = new Date(value); const date = parseApiDatetime(value);
if (Number.isNaN(date.getTime())) return value; if (!date) return value;
return date.toLocaleString(undefined, { return date.toLocaleString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",

View File

@@ -27,6 +27,7 @@ import {
type dashboardMetricsApiV1MetricsDashboardGetResponse, type dashboardMetricsApiV1MetricsDashboardGetResponse,
useDashboardMetricsApiV1MetricsDashboardGet, useDashboardMetricsApiV1MetricsDashboardGet,
} from "@/api/generated/metrics/metrics"; } from "@/api/generated/metrics/metrics";
import { parseApiDatetime } from "@/lib/datetime";
type RangeKey = "24h" | "7d"; type RangeKey = "24h" | "7d";
type BucketKey = "hour" | "day"; type BucketKey = "hour" | "day";
@@ -91,8 +92,8 @@ const updatedFormatter = new Intl.DateTimeFormat("en-US", {
}); });
const formatPeriod = (value: string, bucket: BucketKey) => { const formatPeriod = (value: string, bucket: BucketKey) => {
const date = new Date(value); const date = parseApiDatetime(value);
if (Number.isNaN(date.getTime())) return ""; if (!date) return "";
return bucket === "hour" ? hourFormatter.format(date) : dayFormatter.format(date); return bucket === "hour" ? hourFormatter.format(date) : dayFormatter.format(date);
}; };
@@ -324,8 +325,8 @@ export default function DashboardPage() {
const updatedAtLabel = useMemo(() => { const updatedAtLabel = useMemo(() => {
if (!metrics?.generated_at) return null; if (!metrics?.generated_at) return null;
const date = new Date(metrics.generated_at); const date = parseApiDatetime(metrics.generated_at);
if (Number.isNaN(date.getTime())) return null; if (!date) return null;
return updatedFormatter.format(date); return updatedFormatter.format(date);
}, [metrics]); }, [metrics]);

View File

@@ -23,6 +23,7 @@ import {
type ChartConfig, type ChartConfig,
} from "@/components/charts/chart"; } from "@/components/charts/chart";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { apiDatetimeToMs, parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type Approval = ApprovalRead & { status: string }; type Approval = ApprovalRead & { status: string };
@@ -42,8 +43,8 @@ type BoardApprovalsPanelProps = {
const formatTimestamp = (value?: string | null) => { const formatTimestamp = (value?: string | null) => {
if (!value) return "—"; if (!value) return "—";
const date = new Date(value); const date = parseApiDatetime(value);
if (Number.isNaN(date.getTime())) return value; if (!date) return value;
return date.toLocaleString(undefined, { return date.toLocaleString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -241,7 +242,10 @@ export function BoardApprovalsPanel({
const pendingNext = [...approvals] const pendingNext = [...approvals]
.filter((item) => item.id !== approvalId) .filter((item) => item.id !== approvalId)
.filter((item) => item.status === "pending") .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; ?.id;
if (pendingNext) { if (pendingNext) {
setSelectedId(pendingNext); setSelectedId(pendingNext);
@@ -302,8 +306,8 @@ export function BoardApprovalsPanel({
const sortedApprovals = useMemo(() => { const sortedApprovals = useMemo(() => {
const sortByTime = (items: Approval[]) => const sortByTime = (items: Approval[]) =>
[...items].sort((a, b) => { [...items].sort((a, b) => {
const aTime = new Date(a.created_at).getTime(); const aTime = apiDatetimeToMs(a.created_at) ?? 0;
const bTime = new Date(b.created_at).getTime(); const bTime = apiDatetimeToMs(b.created_at) ?? 0;
return bTime - aTime; return bTime - aTime;
}); });
const pending = sortByTime( const pending = sortByTime(

View File

@@ -3,6 +3,7 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type BoardGoal = { type BoardGoal = {
@@ -21,8 +22,8 @@ type BoardGoalPanelProps = {
const formatTargetDate = (value?: string | null) => { const formatTargetDate = (value?: string | null) => {
if (!value) return "—"; if (!value) return "—";
const date = new Date(value); const date = parseApiDatetime(value);
if (Number.isNaN(date.getTime())) return value; if (!date) return value;
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",

View File

@@ -3,6 +3,7 @@
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import { TaskCard } from "@/components/molecules/TaskCard"; import { TaskCard } from "@/components/molecules/TaskCard";
import { parseApiDatetime } from "@/lib/datetime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type TaskStatus = "inbox" | "in_progress" | "review" | "done"; type TaskStatus = "inbox" | "in_progress" | "review" | "done";
@@ -74,8 +75,8 @@ const columns: Array<{
const formatDueDate = (value?: string | null) => { const formatDueDate = (value?: string | null) => {
if (!value) return undefined; if (!value) return undefined;
const date = new Date(value); const date = parseApiDatetime(value);
if (Number.isNaN(date.getTime())) return undefined; if (!date) return undefined;
return date.toLocaleDateString(undefined, { return date.toLocaleDateString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",

View File

@@ -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();
}