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 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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
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