"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useInterval } from "@/hooks/use-interval"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
/** Splits a millisecond duration into whole days, hours, minutes, and seconds, clamped at zero. */
export function splitDuration(ms: number): {
days: number
hours: number
minutes: number
seconds: number
} {
const total = Math.max(0, Math.floor(ms / 1000))
return {
days: Math.floor(total / 86_400),
hours: Math.floor(total / 3_600) % 24,
minutes: Math.floor(total / 60) % 60,
seconds: total % 60,
}
}
type Unit = "days" | "hours" | "minutes" | "seconds"
const DEFAULT_LABELS: Record<Unit, string> = {
days: "Days",
hours: "Hours",
minutes: "Minutes",
seconds: "Seconds",
}
const UNIT_ORDER: Unit[] = ["days", "hours", "minutes", "seconds"]
// The spoken summary is built from these rather than from `labels`, which are
// display captions and may be translated or abbreviated.
const SPOKEN_UNITS: Record<Unit, string> = {
days: "day",
hours: "hour",
minutes: "minute",
seconds: "second",
}
const pad = (value: number) => String(value).padStart(2, "0")
// Stands in for every number until the clock can be read, so the markup does
// not depend on when it was rendered.
const PLACEHOLDER = "--"
// Nothing to subscribe to: the store exists only to hand the server and the
// client different snapshots, the same shape use-media-query and
// use-local-storage use to reveal a browser-only value after hydration.
const subscribeToNothing = () => () => {}
const onClient = () => true
const onServer = () => false
/** False through the server render and the hydrating one, true from the commit on — so nothing derived from the clock differs between the two. */
function useHasClock(): boolean {
return React.useSyncExternalStore(subscribeToNothing, onClient, onServer)
}
function summarise(parts: Record<Unit, number>, units: Unit[], remaining: number): string {
const spoken = units
.filter((unit) => parts[unit] > 0)
.map((unit) => `${parts[unit]} ${SPOKEN_UNITS[unit]}${parts[unit] === 1 ? "" : "s"}`)
if (spoken.length > 0) return `${spoken.join(", ")} remaining`
// Under the smallest unit shown the digits read zero, but the target has not
// passed: "Time is up" waits for the moment data-complete and onComplete do.
return remaining > 0 ? `Less than a ${SPOKEN_UNITS[units[units.length - 1]]} remaining` : "Time is up"
}
/**
* Under this much time left the seconds are what a reader is waiting on, so
* they stay under reduced motion: reduced motion stops movement, not
* information.
*/
const SHORT_WAIT = 10 * 60_000
/** A moment as milliseconds, whatever form it was handed in. */
const toTime = (moment: Date | string | number) => new Date(moment).getTime()
export type CountdownProps = React.ComponentProps<"div"> & {
/** The moment being counted down to. */
to: Date | string | number
/**
* The clock it counts against — the reader's own when left out. A fixed
* instant is where the count starts, the "now" of a sample world pinned to
* a reference date: from there the seconds that pass on the page count
* down, and the first paint is already real. A function is read on every
* tick in place of `Date.now`.
*/
now?: Date | string | number | (() => Date | number)
format?: "compact" | "units" | "clock"
/** Called once, when the target passes — including on mount for a target already in the past. */
onComplete?: () => void
labels?: Partial<Record<Unit, string>>
/** Classes for the numbers — `text-4xl` for a hero's clock, `text-base` in a row — which replace their `text-2xl`. */
valueClassName?: string
}
function Countdown({
className,
to,
now,
format = "compact",
onComplete,
labels,
valueClassName,
...props
}: CountdownProps) {
const target = React.useMemo(() => toTime(to), [to])
// A fixed now is known on the server as well as in the browser; a clock —
// the reader's own, or one passed as a function — is not.
const pinned = React.useMemo(
() => (now === undefined || typeof now === "function" ? null : toTime(now)),
[now]
)
const read = typeof now === "function" ? () => toTime(now()) : Date.now
// Two servers and a browser never agree on the millisecond, so the first
// paint reads no clock at all: it renders placeholders from `to` alone, and
// every value, label, and attribute below turns real together, one render
// after hydration. The clock is the only state from then on; what is left is
// derived from it during render, so a new `to` lands on the spot rather than
// one effect — and one extra render — later. A fixed now needs no clock to
// start from, so it paints real digits at once, identical on both sides.
const hasClock = useHasClock()
const [opened] = React.useState(() => Date.now())
const [reading, setReading] = React.useState(read)
const current = pinned === null ? reading : pinned + (hasClock ? reading - opened : 0)
const known = pinned !== null || hasClock
// Zero, not the real gap, until the clock is readable: it keeps `days > 0`
// and every other branch below off the clock as well, not just the digits.
const remaining = known ? Math.max(0, target - current) : 0
const ticking = hasClock && remaining > 0
// Reduced motion stops movement, not information. A short wait keeps its
// seconds and its tick; a longer countdown drops the seconds and moves only
// when the minute it shows changes — as the time left drops below a whole
// minute, so the next tick lands a millisecond past that boundary. Never
// sooner than a second from now, though, the seconds' own cadence: at a
// whole number of minutes the boundary is a millisecond away, and against a
// clock that stands still — a `now` function handing back one instant — that
// 1 ms step re-armed a thousand times a second. A minute that turns within
// the next second shows within a second of it; every later tick is exact.
const reduced = useReducedMotion()
const minutesOnly = reduced && remaining >= SHORT_WAIT
const step = minutesOnly ? Math.max(1000, (remaining % 60_000) + 1) : 1000
useInterval(() => setReading(read()), ticking ? step : null)
const fired = React.useRef(false)
React.useEffect(() => {
// Never on the hydrating render, where `remaining` is not measured yet.
if (!known) return
if (remaining > 0) {
fired.current = false
return
}
if (fired.current) return
fired.current = true
onComplete?.()
}, [known, remaining, onComplete])
const parts = splitDuration(remaining)
const { days, hours, minutes, seconds } = parts
const shown = minutesOnly ? UNIT_ORDER.filter((unit) => unit !== "seconds") : UNIT_ORDER
const digits = (value: number) => (known ? pad(value) : PLACEHOLDER)
const tail = minutesOnly ? "" : `:${digits(seconds)}`
const clock = `${digits(hours)}:${digits(minutes)}${tail}`
return (
<div
data-slot="countdown"
data-format={format}
data-complete={(known && remaining === 0) || undefined}
// A value that changes every second is noise, not news: the label
// carries the summary and the ticks stay silent.
role="timer"
aria-live="off"
aria-label={known ? summarise(parts, shown, remaining) : "Time remaining"}
className={cn("w-fit text-foreground", className)}
{...props}
>
{format === "units" ? (
<div data-slot="countdown-units" className="flex items-stretch divide-x rounded-md border">
{shown.map((unit) => (
<div
key={unit}
data-slot="countdown-unit"
data-unit={unit}
className="flex min-w-16 flex-col items-center gap-0.5 px-3 py-2"
>
<span
data-slot="countdown-unit-value"
className={cn("text-2xl leading-none font-semibold tracking-tight tabular-nums", valueClassName)}
>
{digits(parts[unit])}
</span>
<span className="text-xs text-muted-foreground">
{labels?.[unit] ?? DEFAULT_LABELS[unit]}
</span>
</div>
))}
</div>
) : (
<span
data-slot="countdown-value"
className={cn("text-2xl font-semibold tracking-tight tabular-nums", valueClassName)}
>
{format === "clock"
? `${digits(days * 24 + hours)}:${digits(minutes)}${tail}`
: days > 0
? `${days}d ${clock}`
: clock}
</span>
)}
</div>
)
}
export { Countdown }