import { memo, useCallback, useMemo, useState } from "react"; import useInterval from "./useInterval"; export type DateTimeCountdownProps = | PlaceholderDateTimeCountdownProps | HiddenDateTimeCountdownProps | ContinueDateTimeCountdownProps; export interface BaseDateTimeCountdownProps { /** * Server-sent Timestamp that was fetched from the backend. **Unix time in seconds**. * @remarks We use a `number` instead of `Date` because `Date`s are objects and would cause unnecessary re-renders. `number`s are easier to memoize and to compare. */ targetTimestamp: number; /** * What to prefix the countdown with when it is not zero. */ prefix?: string; trimLeadingZeros?: boolean; } export interface PlaceholderDateTimeCountdownProps extends BaseDateTimeCountdownProps { onZero: "placeholder"; placeholder: string; } export interface HiddenDateTimeCountdownProps extends BaseDateTimeCountdownProps { onZero: "hide"; } export interface ContinueDateTimeCountdownProps extends BaseDateTimeCountdownProps { onZero: "continue"; } const nativeDurationFormat = // @ts-expect-error: Remove this suppression when TS ships with typeof Intl.DurationFormat === "function" // @ts-expect-error: Remove this suppression when TS ships with Intl.DurationFormat ? new Intl.DurationFormat(["de", "en"], { style: "narrow" }) : undefined; const absoluteDateFormatter = new Intl.DateTimeFormat(["de", "en"], { dateStyle: "long", }); function formatTimeRemaining( timeLeftSeconds: number, trimLeadingZeros: boolean, ): string { const days = (timeLeftSeconds / (60 * 60 * 24)) | 0; const hours = ((timeLeftSeconds % (60 * 60 * 24)) / (60 * 60)) | 0; const minutes = ((timeLeftSeconds % (60 * 60)) / 60) | 0; const seconds = (timeLeftSeconds % 60) | 0; // Docs: // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DurationFormat const toFormat: Record = { days, hours, minutes, seconds, }; if (trimLeadingZeros) { toFormat.days = days === 0 ? undefined : days; toFormat.hours = hours === 0 && toFormat.days === undefined ? undefined : hours; toFormat.minutes = minutes === 0 && toFormat.hours === undefined ? undefined : minutes; } if (!nativeDurationFormat) { if (trimLeadingZeros) { let res = ""; if (toFormat.days !== undefined) { res += `${toFormat.days}d `; } if (toFormat.hours !== undefined && toFormat.days !== undefined) { res += `${toFormat.hours}h `; } if ( toFormat.minutes !== undefined && toFormat.hours !== undefined ) { res += `${toFormat.minutes}m `; } res += `${toFormat.seconds}s`; // seconds are always shown return res; } return `${days}d ${hours}h ${minutes}m ${seconds}s`; } return nativeDurationFormat.format(toFormat); } export default memo(function DateTimeCountdown(props: DateTimeCountdownProps) { const now = (Date.now() / 1000) | 0; const targetTimestamp = props.targetTimestamp; const [secondsRemaining, setSecondsRemaining] = useState( targetTimestamp - now, ); const cb = useCallback(() => { setSecondsRemaining((targetTimestamp - Date.now() / 1000) | 0); }, [targetTimestamp]); useInterval(cb, 1000); if (secondsRemaining <= 0) { switch (props.onZero) { case "placeholder": return props.placeholder; case "hide": return undefined; case "continue": // continue to return below to show the countdown even when it reaches zero break; } } const [isoString, userReadable] = useMemo(() => { const targetDateTime = new Date(targetTimestamp * 1000); return [ targetDateTime.toISOString(), absoluteDateFormatter.format(targetDateTime), ]; }, [targetTimestamp]); const formatted = ( ); return props.prefix ? ( <> {props.prefix} {formatted} ) : ( formatted ); });