1
0
Fork 0

feat(ui): add ClientDate component for timezone-aware date rendering

This commit is contained in:
Jason Fraley 2026-08-31 12:27:17 -04:00
parent 1fb117e028
commit e3e8b69a54
4 changed files with 101 additions and 49 deletions

View file

@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import type { Faction, Media, Mission } from "@/payload-types"; import type { Faction, Media, Mission } from "@/payload-types";
import { RichText } from "@payloadcms/richtext-lexical/react"; import { RichText } from "@payloadcms/richtext-lexical/react";
import { ClientDate } from "@/components/frontend/ClientDate";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
CalendarDaysIcon, CalendarDaysIcon,
@ -104,21 +105,6 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
const missions = missionsRes.docs as Mission[]; const missions = missionsRes.docs as Mission[];
const factions = factionsRes.docs as Faction[]; const factions = factionsRes.docs as Faction[];
const startDate = campaign.startDate
? new Date(campaign.startDate).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
const endDate = campaign.endDate
? new Date(campaign.endDate).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
// Mission status breakdown // Mission status breakdown
const statusCounts: Record<string, number> = {}; const statusCounts: Record<string, number> = {};
for (const m of missions) { for (const m of missions) {
@ -169,12 +155,28 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
{campaign.name} {campaign.name}
</h1> </h1>
<p className="mt-3 max-w-3xl text-lg leading-relaxed text-white/80">{campaign.summary}</p> <p className="mt-3 max-w-3xl text-lg leading-relaxed text-white/80">{campaign.summary}</p>
{(startDate || endDate) && ( {(campaign.startDate || campaign.endDate) && (
<div className="mt-6 flex items-center gap-2 text-sm text-white/70"> <div className="mt-6 flex items-center gap-2 text-sm text-white/70">
<CalendarDaysIcon className="size-4 text-white/60" /> <CalendarDaysIcon className="size-4 text-white/60" />
{startDate && <span>{startDate}</span>} {campaign.startDate && (
{startDate && endDate && <span>—</span>} <span>
{endDate ? <span>{endDate}</span> : startDate ? <span>Present</span> : null} <ClientDate
date={campaign.startDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</span>
)}
{campaign.startDate && campaign.endDate && <span>—</span>}
{campaign.endDate ? (
<span>
<ClientDate
date={campaign.endDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</span>
) : campaign.startDate ? (
<span>Present</span>
) : null}
</div> </div>
)} )}
</div> </div>
@ -257,8 +259,23 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
<div className="flex justify-between text-sm pt-1 border-t border-border/50"> <div className="flex justify-between text-sm pt-1 border-t border-border/50">
<span className="text-muted-foreground">Duration</span> <span className="text-muted-foreground">Duration</span>
<span> <span>
{startDate} {campaign.startDate && (
{endDate ? ` — ${endDate}` : " — Present"} <ClientDate
date={campaign.startDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
)}
{campaign.endDate ? (
<>
{" — "}
<ClientDate
date={campaign.endDate}
options={{ year: "numeric", month: "short", day: "numeric" }}
/>
</>
) : (
" — Present"
)}
</span> </span>
</div> </div>
<div className="flex justify-between text-sm pt-1 border-t border-border/50"> <div className="flex justify-between text-sm pt-1 border-t border-border/50">

View file

@ -15,6 +15,7 @@ import {
ServerIcon, ServerIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ClientDate } from "@/components/frontend/ClientDate";
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection"; import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives"; import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
import { hasPermission } from "@/utils/access-control/hasPermission"; import { hasPermission } from "@/utils/access-control/hasPermission";
@ -91,17 +92,6 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
: null; : null;
const coverImageUrl = coverImage?.url ?? null; const coverImageUrl = coverImage?.url ?? null;
const startDate = new Date(mission.classification.startDateTime);
const dateStr = startDate.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const timeStr = startDate.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
const durationHours = Math.floor(mission.classification.estimatedDuration / 60); const durationHours = Math.floor(mission.classification.estimatedDuration / 60);
const durationMins = mission.classification.estimatedDuration % 60; const durationMins = mission.classification.estimatedDuration % 60;
const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`; const durationStr = durationMins > 0 ? `${durationHours}h ${durationMins}m` : `${durationHours}h`;
@ -241,7 +231,12 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/50"> <span className="text-[10px] font-semibold uppercase tracking-wider text-white/50">
Date Date
</span> </span>
<span className="text-sm text-white">{dateStr}</span> <span className="text-sm text-white">
<ClientDate
date={mission.classification.startDateTime}
options={{ weekday: "long", year: "numeric", month: "long", day: "numeric" }}
/>
</span>
</span> </span>
</div> </div>
<div className="flex items-center gap-2 px-3 py-2"> <div className="flex items-center gap-2 px-3 py-2">
@ -251,7 +246,11 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
Time Time
</span> </span>
<span className="text-sm text-white"> <span className="text-sm text-white">
{timeStr} · {durationStr} <ClientDate
date={mission.classification.startDateTime}
options={{ hour: "2-digit", minute: "2-digit" }}
/>{" "}
· {durationStr}
</span> </span>
</span> </span>
</div> </div>

View file

@ -12,6 +12,7 @@ import {
TruckIcon, TruckIcon,
} from "lucide-react"; } from "lucide-react";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item"; import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { ClientDate } from "@/components/frontend/ClientDate";
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions"; import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar"; import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
@ -308,11 +309,11 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Dispatched</span> <span className="text-muted-foreground">Dispatched</span>
<span> <span>
{dispatchedAt.toLocaleDateString()}{" "} <ClientDate date={shipment.dispatchedAt} options={{ dateStyle: "medium" }} />{" "}
{dispatchedAt.toLocaleTimeString([], { <ClientDate
hour: "2-digit", date={shipment.dispatchedAt}
minute: "2-digit", options={{ hour: "2-digit", minute: "2-digit" }}
})} />
</span> </span>
</div> </div>
)} )}
@ -322,11 +323,14 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
{shipment.status === "completed" ? "Arrived" : "Est. Arrival"} {shipment.status === "completed" ? "Arrived" : "Est. Arrival"}
</span> </span>
<span> <span>
{estimatedArrival.toLocaleDateString()}{" "} <ClientDate
{estimatedArrival.toLocaleTimeString([], { date={shipment.estimatedArrival}
hour: "2-digit", options={{ dateStyle: "medium" }}
minute: "2-digit", />{" "}
})} <ClientDate
date={shipment.estimatedArrival}
options={{ hour: "2-digit", minute: "2-digit" }}
/>
</span> </span>
</div> </div>
)} )}
@ -334,11 +338,11 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Completed</span> <span className="text-muted-foreground">Completed</span>
<span> <span>
{completedAt.toLocaleDateString()}{" "} <ClientDate date={shipment.completedAt} options={{ dateStyle: "medium" }} />{" "}
{completedAt.toLocaleTimeString([], { <ClientDate
hour: "2-digit", date={shipment.completedAt}
minute: "2-digit", options={{ hour: "2-digit", minute: "2-digit" }}
})} />
</span> </span>
</div> </div>
)} )}

View file

@ -0,0 +1,32 @@
"use client";
/**
* Renders a date/time in the browser's local timezone.
*
* Server components cannot know the visitor's timezone — `Intl` methods there
* format using the server process's timezone (typically UTC in Docker/Node),
* producing a misleading multi-hour offset. This client component guarantees
* the value is formatted in the browser's local timezone instead, matching
* client-rendered lists (e.g. `MissionCard`).
*
* Usage:
* <ClientDate date={iso} options={{ dateStyle: "medium", timeStyle: "short" }} />
* <ClientDate date={iso} options={{ weekday: "long", month: "long", day: "numeric", year: "numeric" }} />
*
* Falls back to an em dash when `date` is missing/invalid.
*/
export function ClientDate({
date,
options,
locale,
}: {
date: string | null | undefined;
options?: Intl.DateTimeFormatOptions;
locale?: string;
}) {
if (!date) return <>&mdash;</>;
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime())) return <>&mdash;</>;
const formatted = new Intl.DateTimeFormat(locale ?? undefined, options).format(parsed);
return <>{formatted}</>;
}