feat(ui): add ClientDate component for timezone-aware date rendering
This commit is contained in:
parent
1fb117e028
commit
e3e8b69a54
4 changed files with 101 additions and 49 deletions
|
|
@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
|||
import Link from "next/link";
|
||||
import type { Faction, Media, Mission } from "@/payload-types";
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
import { ClientDate } from "@/components/frontend/ClientDate";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarDaysIcon,
|
||||
|
|
@ -104,21 +105,6 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
|
|||
const missions = missionsRes.docs as Mission[];
|
||||
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
|
||||
const statusCounts: Record<string, number> = {};
|
||||
for (const m of missions) {
|
||||
|
|
@ -169,12 +155,28 @@ export default async function CampaignDetailPage({ params }: CampaignDetailPageP
|
|||
{campaign.name}
|
||||
</h1>
|
||||
<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">
|
||||
<CalendarDaysIcon className="size-4 text-white/60" />
|
||||
{startDate && <span>{startDate}</span>}
|
||||
{startDate && endDate && <span>—</span>}
|
||||
{endDate ? <span>{endDate}</span> : startDate ? <span>Present</span> : null}
|
||||
{campaign.startDate && (
|
||||
<span>
|
||||
<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>
|
||||
|
|
@ -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">
|
||||
<span className="text-muted-foreground">Duration</span>
|
||||
<span>
|
||||
{startDate}
|
||||
{endDate ? ` — ${endDate}` : " — Present"}
|
||||
{campaign.startDate && (
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm pt-1 border-t border-border/50">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
ServerIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ClientDate } from "@/components/frontend/ClientDate";
|
||||
import { MissionBriefingSection } from "@/components/frontend/intelligence/MissionBriefingSection";
|
||||
import { MissionObjectives } from "@/components/frontend/intelligence/MissionObjectives";
|
||||
import { hasPermission } from "@/utils/access-control/hasPermission";
|
||||
|
|
@ -91,17 +92,6 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
: 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 durationMins = mission.classification.estimatedDuration % 60;
|
||||
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">
|
||||
Date
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2">
|
||||
|
|
@ -251,7 +246,11 @@ export default async function MissionDetailPage({ params }: MissionDetailPagePro
|
|||
Time
|
||||
</span>
|
||||
<span className="text-sm text-white">
|
||||
{timeStr} · {durationStr}
|
||||
<ClientDate
|
||||
date={mission.classification.startDateTime}
|
||||
options={{ hour: "2-digit", minute: "2-digit" }}
|
||||
/>{" "}
|
||||
· {durationStr}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
TruckIcon,
|
||||
} from "lucide-react";
|
||||
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
|
||||
import { ClientDate } from "@/components/frontend/ClientDate";
|
||||
import { ShipmentActions } from "@/components/frontend/logistics/ShipmentActions";
|
||||
import { ShipmentProgressBar } from "@/components/frontend/logistics/ShipmentProgressBar";
|
||||
|
||||
|
|
@ -308,11 +309,11 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
|
|||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Dispatched</span>
|
||||
<span>
|
||||
{dispatchedAt.toLocaleDateString()}{" "}
|
||||
{dispatchedAt.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
<ClientDate date={shipment.dispatchedAt} options={{ dateStyle: "medium" }} />{" "}
|
||||
<ClientDate
|
||||
date={shipment.dispatchedAt}
|
||||
options={{ hour: "2-digit", minute: "2-digit" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -322,11 +323,14 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
|
|||
{shipment.status === "completed" ? "Arrived" : "Est. Arrival"}
|
||||
</span>
|
||||
<span>
|
||||
{estimatedArrival.toLocaleDateString()}{" "}
|
||||
{estimatedArrival.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
<ClientDate
|
||||
date={shipment.estimatedArrival}
|
||||
options={{ dateStyle: "medium" }}
|
||||
/>{" "}
|
||||
<ClientDate
|
||||
date={shipment.estimatedArrival}
|
||||
options={{ hour: "2-digit", minute: "2-digit" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -334,11 +338,11 @@ export default async function ShipmentDetailPage({ params }: ShipmentDetailPageP
|
|||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Completed</span>
|
||||
<span>
|
||||
{completedAt.toLocaleDateString()}{" "}
|
||||
{completedAt.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
<ClientDate date={shipment.completedAt} options={{ dateStyle: "medium" }} />{" "}
|
||||
<ClientDate
|
||||
date={shipment.completedAt}
|
||||
options={{ hour: "2-digit", minute: "2-digit" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
32
src/components/frontend/ClientDate.tsx
Normal file
32
src/components/frontend/ClientDate.tsx
Normal 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 <>—</>;
|
||||
const parsed = new Date(date);
|
||||
if (Number.isNaN(parsed.getTime())) return <>—</>;
|
||||
const formatted = new Intl.DateTimeFormat(locale ?? undefined, options).format(parsed);
|
||||
return <>{formatted}</>;
|
||||
}
|
||||
Loading…
Reference in a new issue