Time Widgets
Two SSR-safe time components: LocalTime renders an ISO instant in the viewer's own timezone (mount-gated, no hydration mismatch), and CountdownClock ticks down to an absolute instant (correct regardless of clock skew). Built on @zq/kit's useMounted/useNow.
"use client";
import * as React from "react";
import { useMounted, useNow } from "@zq/kit/hooks";
/**
* Kickoff time/date rendered in the USER's local timezone. Server components can't know the
* viewer's TZ, so we gate on mount: the first (SSR-matching) render shows a stable placeholder,
* then we fill the browser-local value. Fixes the "everything shows the server's timezone" bug.
*/
export function LocalTime({
iso,
kind = "time",
fallback = "·",
}: {
iso: string | null | undefined;
kind?: "time" | "date" | "daytime" | "weekday";
fallback?: string;
}) {
const mounted = useMounted();
if (!iso) return <>{""}</>;
if (!mounted) return <span suppressHydrationWarning>{fallback}</span>;
const d = new Date(iso);
const opts: Intl.DateTimeFormatOptions =
kind === "date"
? { month: "short", day: "numeric" }
: kind === "daytime"
? { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }
: kind === "weekday"
? { weekday: "long", month: "long", day: "numeric" }
: { hour: "numeric", minute: "2-digit" };
return <span suppressHydrationWarning>{d.toLocaleString([], opts)}</span>;
}
function parts(ms: number): { d: number; h: number; m: number; s: number } {
const s = Math.max(0, Math.floor(ms / 1000));
return { d: Math.floor(s / 86400), h: Math.floor((s % 86400) / 3600), m: Math.floor((s % 3600) / 60), s: s % 60 };
}
/**
* Countdown to an absolute instant (ISO). Ticks every second client-side. The target is an
* absolute UTC instant so it's correct regardless of the viewer's clock skew/TZ. Renders nothing
* once the instant has passed (the caller swaps to a live/result state).
*/
export function CountdownClock({ iso, className }: { iso: string; className?: string }) {
const mounted = useMounted();
const target = React.useMemo(() => (iso ? new Date(iso).getTime() : 0), [iso]);
const now = useNow(mounted);
if (!mounted || !target) return <span className={className} suppressHydrationWarning />;
const remaining = target - now;
if (remaining <= 0) return <span className={className} suppressHydrationWarning />;
const { d, h, m, s } = parts(remaining);
const text = d > 0 ? `${d}d ${h}h ${m}m` : h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
return (
<span className={className} suppressHydrationWarning>
{text}
</span>
);
}