"use client"; import { BanknoteIcon, FlagIcon, LanguagesIcon, MapIcon, MountainIcon, NavigationIcon, PhoneIcon, RotateCcwIcon, RotateCwIcon, SearchIcon, UsersIcon, } from "lucide-react"; import { AnimatePresence, animate, MotionConfig, type MotionValue, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type KeyboardEvent, type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"; import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxPopup, } from "@/components/ui/combobox"; import { SegmentedControl, SegmentedControlItem } from "@/components/ui/segmented-control"; import { cn } from "@/lib/utils"; export interface City { id: string; name: string; region: string; country: string; timeZone: string; latitude: number; longitude: number; elevation: number; population: number; currency: string; languages: string[]; dialCode: string; } export type HourCycle = "12h" | "24h"; export interface CityTimeProps { cities?: City[]; defaultCity?: string; workingHours?: { start: number; end: number }; defaultHourCycle?: HourCycle; locale?: string; className?: string; } export const defaultCities: City[] = [ { id: "flensburg", name: "Flensburg", region: "Schleswig-Holstein", country: "Germany", timeZone: "Europe/Berlin", latitude: 54.7937, longitude: 9.4469, elevation: 12, population: 92_550, currency: "Euro (EUR)", languages: ["German", "Danish", "Low German"], dialCode: "+49 461", }, { id: "new-york", name: "New York", region: "New York", country: "United States", timeZone: "America/New_York", latitude: 40.7128, longitude: -74.006, elevation: 10, population: 8_258_035, currency: "US dollar (USD)", languages: ["English", "Spanish"], dialCode: "+1 212", }, { id: "los-angeles", name: "Los Angeles", region: "California", country: "United States", timeZone: "America/Los_Angeles", latitude: 34.0522, longitude: -118.2437, elevation: 93, population: 3_820_914, currency: "US dollar (USD)", languages: ["English", "Spanish"], dialCode: "+1 213", }, { id: "sao-paulo", name: "São Paulo", region: "São Paulo", country: "Brazil", timeZone: "America/Sao_Paulo", latitude: -23.5505, longitude: -46.6333, elevation: 760, population: 11_451_999, currency: "Brazilian real (BRL)", languages: ["Portuguese"], dialCode: "+55 11", }, { id: "reykjavik", name: "Reykjavík", region: "Capital Region", country: "Iceland", timeZone: "Atlantic/Reykjavik", latitude: 64.1466, longitude: -21.9426, elevation: 15, population: 139_875, currency: "Icelandic króna (ISK)", languages: ["Icelandic"], dialCode: "+354", }, { id: "longyearbyen", name: "Longyearbyen", region: "Svalbard", country: "Norway", timeZone: "Arctic/Longyearbyen", latitude: 78.2232, longitude: 15.6267, elevation: 20, population: 2_583, currency: "Norwegian krone (NOK)", languages: ["Norwegian"], dialCode: "+47 79", }, { id: "mumbai", name: "Mumbai", region: "Maharashtra", country: "India", timeZone: "Asia/Kolkata", latitude: 19.076, longitude: 72.8777, elevation: 14, population: 12_442_373, currency: "Indian rupee (INR)", languages: ["Marathi", "Hindi", "English"], dialCode: "+91 22", }, { id: "kathmandu", name: "Kathmandu", region: "Bagmati", country: "Nepal", timeZone: "Asia/Kathmandu", latitude: 27.7172, longitude: 85.324, elevation: 1_400, population: 845_767, currency: "Nepalese rupee (NPR)", languages: ["Nepali", "Newar"], dialCode: "+977 1", }, { id: "tokyo", name: "Tokyo", region: "Kantō", country: "Japan", timeZone: "Asia/Tokyo", latitude: 35.6762, longitude: 139.6503, elevation: 40, population: 14_094_034, currency: "Japanese yen (JPY)", languages: ["Japanese"], dialCode: "+81 3", }, { id: "sydney", name: "Sydney", region: "New South Wales", country: "Australia", timeZone: "Australia/Sydney", latitude: -33.8688, longitude: 151.2093, elevation: 39, population: 5_450_496, currency: "Australian dollar (AUD)", languages: ["English"], dialCode: "+61 2", }, { id: "auckland", name: "Auckland", region: "Auckland", country: "New Zealand", timeZone: "Pacific/Auckland", latitude: -36.8509, longitude: 174.7645, elevation: 26, population: 1_798_300, currency: "New Zealand dollar (NZD)", languages: ["English", "Māori"], dialCode: "+64 9", }, ]; const SECOND = 1_000; const MINUTE = 60_000; const HOUR = 3_600_000; const DAY = 86_400_000; const RAD = Math.PI / 180; const ease = [0.23, 1, 0.32, 1] as const; const glide = { type: "spring", duration: 0.5, bounce: 0 } as const; const strip = Array.from({ length: 11 }, (_, slot) => slot % 10); const ticks = [0, 3, 6, 9, 12, 15, 18, 21]; const shortZoneLocales = ["en-US", "en-GB", "en-AU", "en-IN", "en-NZ", "en-CA"]; type ZoneParts = { year: number; month: number; day: number; hour: number; minute: number; second: number }; type Shift = { at: number; from: number; to: number }; type Season = { start: number; end: number; offset: number }; type ClockYear = { start: number; end: number; seasons: Season[]; standard: number; previous: Shift | null; next: Shift | null; }; type Sun = { kind: "normal"; rise: number; set: number; noon: number } | { kind: "day"; noon: number } | { kind: "night"; noon: number }; type Daylight = { sun: Sun; yesterday: Sun; phase: number; next: { kind: "sunrise" | "sunset"; at: number } | null }; const formatters = new Map(); function formatter(locale: string, options: Intl.DateTimeFormatOptions) { const key = `${locale}|${JSON.stringify(options)}`; let cached = formatters.get(key); if (!cached) { cached = new Intl.DateTimeFormat(locale, options); formatters.set(key, cached); } return cached; } function zoneParts(timeZone: string, at: number): ZoneParts { const parts: Record = {}; const wall = formatter("en-US", { timeZone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric", }); for (const part of wall.formatToParts(at)) if (part.type !== "literal") parts[part.type] = Number(part.value); return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour % 24, minute: parts.minute, second: parts.second }; } function offsetAt(timeZone: string, at: number) { const p = zoneParts(timeZone, at); const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); return Math.round((wall - (at - (at % SECOND))) / MINUTE); } function dayOf(timeZone: string, at: number) { const p = zoneParts(timeZone, at); return Date.UTC(p.year, p.month - 1, p.day) / DAY; } function hoursOf(timeZone: string, at: number) { const p = zoneParts(timeZone, at); return p.hour + p.minute / 60; } function findShift(timeZone: string, from: number, direction: 1 | -1, days: number): Shift | null { let a = from; let offsetA = offsetAt(timeZone, a); for (let walked = 0; walked < days; walked += 7) { const b = a + direction * 7 * DAY; const offsetB = offsetAt(timeZone, b); if (offsetB !== offsetA) { let lo = Math.floor(Math.min(a, b) / MINUTE); let hi = Math.ceil(Math.max(a, b) / MINUTE); const before = offsetAt(timeZone, lo * MINUTE); while (hi - lo > 1) { const mid = Math.floor((lo + hi) / 2); if (offsetAt(timeZone, mid * MINUTE) === before) lo = mid; else hi = mid; } return { at: hi * MINUTE, from: before, to: offsetAt(timeZone, hi * MINUTE) }; } a = b; offsetA = offsetB; } return null; } function monthStart(at: number, plus = 0) { const date = new Date(at); return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + plus, 1); } function clockYear(timeZone: string, now: number): ClockYear { const previous = findShift(timeZone, now, -1, 3_650); const next = findShift(timeZone, now, 1, 400); let start = monthStart(previous && now - previous.at < 300 * DAY ? previous.at : now - 150 * DAY); let end = monthStart(start, 12); if (next && next.at >= end) { end = monthStart(next.at, 1); start = monthStart(end, -12); } const seasons: Season[] = []; let cursor = start; let offset = offsetAt(timeZone, start); while (cursor < end) { const shift = findShift(timeZone, cursor, 1, Math.ceil((end - cursor) / DAY) + 7); const stop = shift && shift.at < end ? shift.at : end; seasons.push({ start: cursor, end: stop, offset }); if (!shift || shift.at >= end) break; cursor = shift.at; offset = shift.to; } const standard = Math.min(...seasons.map((season) => season.offset)); return { start, end, seasons, standard, previous, next }; } function sunOn(day: number, latitude: number, longitude: number): Sun { const meanNoon = day + 2_440_588 - 2_451_545 - longitude / 360; const anomaly = (357.5291 + 0.98560028 * meanNoon) % 360; const m = anomaly * RAD; const centre = 1.9148 * Math.sin(m) + 0.02 * Math.sin(2 * m) + 0.0003 * Math.sin(3 * m); const ecliptic = ((anomaly + centre + 180 + 102.9372) % 360) * RAD; const transit = 2_451_545 + meanNoon + 0.0053 * Math.sin(m) - 0.0069 * Math.sin(2 * ecliptic); const sinDeclination = Math.sin(ecliptic) * Math.sin(23.4397 * RAD); const cosDeclination = Math.cos(Math.asin(sinDeclination)); const lat = latitude * RAD; const cosHour = (Math.sin(-0.833 * RAD) - Math.sin(lat) * sinDeclination) / (Math.cos(lat) * cosDeclination); const toTime = (julian: number) => (julian - 2_440_587.5) * DAY; const noon = toTime(transit); if (cosHour < -1) return { kind: "day", noon }; if (cosHour > 1) return { kind: "night", noon }; const half = Math.acos(cosHour) / (2 * Math.PI); return { kind: "normal", rise: toTime(transit - half), set: toTime(transit + half), noon }; } function daylightAt(city: City, now: number): Daylight { const today = dayOf(city.timeZone, now); const sun = sunOn(today, city.latitude, city.longitude); const yesterday = sunOn(today - 1, city.latitude, city.longitude); const midnight = today * DAY - offsetAt(city.timeZone, now) * MINUTE; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); if (sun.kind === "day") return { sun, yesterday, phase: clamp(0.5 + (now - sun.noon) / DAY, 0.03, 0.97), next: null }; if (sun.kind === "night") { const fromMidnight = (now - midnight) / DAY; return { sun, yesterday, phase: 1 + clamp((fromMidnight + 0.5) % 1, 0.03, 0.97), next: null }; } if (now < sun.rise) { const start = yesterday.kind === "normal" ? yesterday.set : midnight; return { sun, yesterday, phase: 1 + clamp((now - start) / (sun.rise - start), 0, 1), next: { kind: "sunrise", at: sun.rise } }; } if (now <= sun.set) { return { sun, yesterday, phase: (now - sun.rise) / (sun.set - sun.rise), next: { kind: "sunset", at: sun.set } }; } const tomorrow = sunOn(today + 1, city.latitude, city.longitude); const end = tomorrow.kind === "normal" ? tomorrow.rise : midnight + DAY; return { sun, yesterday, phase: 1 + clamp((now - sun.set) / (end - sun.set), 0, 1), next: tomorrow.kind === "normal" ? { kind: "sunrise", at: tomorrow.rise } : null, }; } function zoneNames(timeZone: string, at: number) { const nameOf = (locale: string, style: "short" | "long") => formatter(locale, { timeZone, timeZoneName: style }) .formatToParts(at) .find((part) => part.type === "timeZoneName")?.value ?? ""; const short = shortZoneLocales .map((locale) => nameOf(locale, "short").replace(/^(GMT|UTC)([+\-−]0)?$/, "GMT")) .find((name) => !/^(GMT|UTC)[+\-−]/.test(name)); return { short: short ?? null, long: nameOf("en-GB", "long") || timeZone }; } function formatOffset(minutes: number) { const abs = Math.abs(minutes); const rest = abs % 60; return `UTC${minutes < 0 ? "−" : "+"}${Math.floor(abs / 60)}${rest ? `:${String(rest).padStart(2, "0")}` : ""}`; } function formatSpan(minutes: number) { const abs = Math.round(Math.abs(minutes)); const hours = Math.floor(abs / 60); const rest = abs % 60; if (!hours) return `${rest} min`; return rest ? `${hours} h ${rest} min` : `${hours} h`; } function formatClock(hours: number, twelve: boolean) { const total = Math.round((((hours % 24) + 24) % 24) * 60) % 1_440; const hour = Math.floor(total / 60); const minute = String(total % 60).padStart(2, "0"); if (!twelve) return `${String(hour).padStart(2, "0")}:${minute}`; return `${hour % 12 || 12}:${minute} ${hour < 12 ? "AM" : "PM"}`; } function formatTick(hours: number, twelve: boolean) { const total = Math.round((((hours % 24) + 24) % 24) * 60) % 1_440; const hour = Math.floor(total / 60); const minute = total % 60; const tail = minute ? `:${String(minute).padStart(2, "0")}` : ""; if (!twelve) return `${String(hour).padStart(2, "0")}${tail}`; return `${hour % 12 || 12}${tail}${hour < 12 ? "a" : "p"}`; } function clockAt(timeZone: string, at: number, twelve: boolean) { return formatClock(hoursOf(timeZone, at), twelve); } function placeOf(timeZone: string) { return (timeZone.split("/").pop() ?? timeZone).replaceAll("_", " "); } function searchable(text: string) { return text .normalize("NFD") .replace(/\p{Diacritic}/gu, "") .toLowerCase(); } function useReducedMotionSafe() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && Boolean(prefers); } function useNow() { const [now, setNow] = useState(null); useEffect(() => { let timer = 0; const tick = () => { const at = Date.now(); setNow(at); timer = window.setTimeout(tick, SECOND - (at % SECOND) + 4); }; tick(); return () => window.clearTimeout(timer); }, []); return now; } function useVisitorZone() { const [zone, setZone] = useState(null); useEffect(() => setZone(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"), []); return zone; } function Digit({ value, forward, reduce }: { value: number; forward: boolean; reduce: boolean }) { const turn = useMotionValue(value); const goal = useRef(value); const forwardRef = useRef(forward); forwardRef.current = forward; const y = useTransform(turn, (t) => `${-(((t % 10) + 10) % 10)}em`); useEffect(() => { const at = ((goal.current % 10) + 10) % 10; let step = (value - at + 10) % 10; if (!forwardRef.current && step > 5) step -= 10; goal.current += step; if (reduce) { turn.jump(goal.current); return; } if (turn.get() === goal.current) return; const controls = animate(turn, goal.current, { type: "spring", stiffness: 320, damping: 2 * Math.sqrt(320) }); return () => controls.stop(); }, [value, reduce, turn]); return ( 0 {strip.map((digit, slot) => ( {digit} ))} ); } function Slot({ children }: { children: ReactNode }) { return ( 0 {children} ); } function Colon() { return ( ); } function BigTime({ timeZone, now, twelve, forward, reduce, }: { timeZone: string; now: number | null; twelve: boolean; forward: boolean; reduce: boolean; }) { const p = now === null ? null : zoneParts(timeZone, now); const hour = p ? (twelve ? p.hour % 12 || 12 : p.hour) : 0; const hours = twelve ? String(hour) : String(hour).padStart(2, "0"); const minutes = p ? String(p.minute).padStart(2, "0") : "00"; return (
{p ? ( <> {hours.length > 1 && } ) : ( – – – – )} {p && p.hour >= 12 ? "PM" : "AM"} {p ? `:${String(p.second).padStart(2, "0")}` : ":––"}
); } function HourCycleSwitch({ value, onChange }: { value: HourCycle; onChange: (value: HourCycle) => void }) { const options: HourCycle[] = ["12h", "24h"]; const refs = useRef<(HTMLButtonElement | null)[]>([]); const index = options.indexOf(value); const onKeyDown = (event: KeyboardEvent) => { if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index; onChange(options[next]); refs.current[next]?.focus(); }; return ( {options.map((option, i) => { const checked = option === value; return ( onChange(option)} ref={(el: HTMLButtonElement | null) => { refs.current[i] = el; }} role="radio" tabIndex={checked ? 0 : -1} > {option} ); })} ); } function Swap({ id, children, className }: { id: string; children: ReactNode; className?: string }) { return ( {children} ); } function CityPicker({ cities, city, now, twelve, onChange, }: { cities: City[]; city: City; now: number | null; twelve: boolean; onChange: (id: string) => void; }) { return ( autoHighlight filter={(item, query) => { const needle = searchable(query.trim()); if (!needle || needle === searchable(city.name)) return true; return searchable(`${item.name} ${item.region} ${item.country} ${item.timeZone}`).includes(needle); }} isItemEqualToValue={(a, b) => a.id === b.id} itemToStringLabel={(item) => item.name} items={cities} onValueChange={(next) => next && onChange(next.id)} value={city} > } triggerProps={{ "aria-label": "Show cities" }} /> No city by that name. {(item: City) => ( {item.name} {item.country} {now === null ? "––:––" : clockAt(item.timeZone, now, twelve)} {now === null ? "UTC" : formatOffset(offsetAt(item.timeZone, now))} )} ); } function ClockPanel({ city, now, visitorZone, twelve, cycle, onCycle, forward, reduce, locale, }: { city: City; now: number | null; visitorZone: string | null; twelve: boolean; cycle: HourCycle; onCycle: (value: HourCycle) => void; forward: boolean; reduce: boolean; locale: string; }) { const minute = now === null ? null : Math.floor(now / MINUTE) * MINUTE; const offset = minute === null ? null : offsetAt(city.timeZone, minute); const names = useMemo(() => (offset === null ? null : zoneNames(city.timeZone, Date.now())), [city.timeZone, offset]); const date = minute === null ? null : formatter(locale, { timeZone: city.timeZone, weekday: "long", day: "numeric", month: "long", year: "numeric" }).format(minute); const ready = minute !== null && visitorZone !== null && offset !== null; const gap = ready ? offset - offsetAt(visitorZone, minute) : 0; const dayGap = ready ? dayOf(city.timeZone, minute) - dayOf(visitorZone, minute) : 0; const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); const spoken = minute === null ? "" : `${clockAt(city.timeZone, minute, twelve)}, ${date}, ${names?.long ?? ""}`; return (

{names?.short && ( {names.short} )} {names ? `${names.long} · ${formatOffset(offset ?? 0)}` : Finding the time zone…}

{date ?? Today} {dayGap !== 0 && ( {relative.format(dayGap, "day").replace(/^./, (c) => c.toUpperCase())} for you )}

{!ready ? "Comparing with your time…" : gap === 0 ? "Same time as you" : `${formatSpan(gap)} ${gap > 0 ? "ahead of" : "behind"} you`} {ready && ( You’re in {placeOf(visitorZone)}, {clockAt(visitorZone, minute, twelve)} )}
); } const arc = { cx: 160, horizon: 104, rx: 136, top: 84, bottom: 26 }; function pointAt(phase: number) { if (phase <= 1) { const angle = Math.PI * (1 - phase); return { x: arc.cx + arc.rx * Math.cos(angle), y: arc.horizon - arc.top * Math.sin(angle) }; } const angle = Math.PI * Math.min(1, phase - 1); return { x: arc.cx + arc.rx * Math.cos(angle), y: arc.horizon + arc.bottom * Math.sin(angle) }; } function SunMark({ phase, visible }: { phase: MotionValue; visible: boolean }) { const x = useTransform(phase, (p) => pointAt(p).x); const y = useTransform(phase, (p) => pointAt(p).y); const sun = useTransform(phase, (p) => (p <= 1 ? 1 : 0)); const moon = useTransform(phase, (p) => (p <= 1 ? 0 : 1)); const trail = useTransform(phase, (p) => { const point = pointAt(Math.min(1, Math.max(0, p))); return `M ${arc.cx - arc.rx} ${arc.horizon} A ${arc.rx} ${arc.top} 0 0 1 ${point.x.toFixed(2)} ${point.y.toFixed(2)}`; }); return ( ); } function DayArc({ city, now, twelve, reduce }: { city: City; now: number | null; twelve: boolean; reduce: boolean }) { const minute = now === null ? null : Math.floor(now / MINUTE) * MINUTE; const light = useMemo(() => (minute === null ? null : daylightAt(city, minute)), [city, minute]); const phase = useMotionValue(light?.phase ?? 0.5); const placed = useRef(false); useEffect(() => { if (!light) return; if (!placed.current || reduce) { phase.jump(light.phase); placed.current = true; return; } const controls = animate(phase, light.phase, glide); return () => controls.stop(); }, [light, reduce, phase]); const sun = light?.sun; const at = (time: number) => clockAt(city.timeZone, time, twelve); const length = sun?.kind === "normal" ? (sun.set - sun.rise) / MINUTE : null; const before = light?.yesterday.kind === "normal" ? (light.yesterday.set - light.yesterday.rise) / MINUTE : null; const change = length !== null && before !== null ? Math.round(length - before) : null; const status = !light ? "" : light.next ? `${light.next.kind === "sunrise" ? "Sunrise" : "Sunset"} in ${formatSpan(Math.max(1, (light.next.at - (minute ?? 0)) / MINUTE))}` : sun?.kind === "day" ? "Midnight sun today" : sun?.kind === "normal" ? "No sunrise tomorrow" : "Polar night today"; return (
Daylight {status || " "}
Sunrise
{sun?.kind === "normal" ? at(sun.rise) : sun ? "None" : "––:––"}
Day length
{length !== null ? formatSpan(length) : sun?.kind === "day" ? "24 h" : sun ? "0 min" : "–"}
Sunset
{sun?.kind === "normal" ? at(sun.set) : sun ? "None" : "––:––"}

{change === null || change === 0 ? sun && sun.kind !== "normal" ? sun.kind === "day" ? "The sun stays above the horizon all day." : "The sun stays below the horizon all day." : " " : `${formatSpan(change)} ${change > 0 ? "longer" : "shorter"} than yesterday`}

); } function overlapOf(hours: { start: number; end: number }, shift: number) { for (const day of [-48, -24, 0, 24, 48]) { const start = Math.max(hours.start, hours.start - shift + day); const end = Math.min(hours.end, hours.end - shift + day); if (end > start) return { start, end }; } return null; } function DayTrack({ name, detail, local, shift, hours, overlap, nowAt, twelve, tone, ready, }: { name: ReactNode; detail: ReactNode; local: string; shift: number; hours: { start: number; end: number }; overlap: { start: number; end: number } | null; nowAt: number | null; twelve: boolean; tone: "you" | "city"; ready: boolean; }) { const span = hours.end - hours.start; const band = tone === "you" ? "bg-sky-500/15 ring-sky-500/35 dark:bg-sky-400/15 dark:ring-sky-400/35" : "bg-violet-500/15 ring-violet-500/35 dark:bg-violet-400/15 dark:ring-violet-400/35"; return (
{name} {detail} · {local}
{ticks.slice(1).map((tick) => ( ))} {[0, 24, 48].map((day) => ( ))} {nowAt !== null && ( )}
{ticks.map((tick) => ( {ready ? formatTick(tick + shift, twelve) : formatTick(tick, twelve)} ))}
); } function MeetPlanner({ city, now, visitorZone, twelve, hours, }: { city: City; now: number | null; visitorZone: string | null; twelve: boolean; hours: { start: number; end: number }; }) { const minute = now === null ? null : Math.floor(now / MINUTE) * MINUTE; const ready = minute !== null && visitorZone !== null; const yourOffset = ready ? offsetAt(visitorZone, minute) : 0; const cityOffset = ready ? offsetAt(city.timeZone, minute) : 0; const shift = (cityOffset - yourOffset) / 60; const overlap = ready ? overlapOf(hours, shift) : null; const nowAt = ready ? hoursOf(visitorZone, minute) : null; const yourNames = ready ? zoneNames(visitorZone, minute) : null; const cityNames = ready ? zoneNames(city.timeZone, minute) : null; const range = (start: number, end: number) => `${formatClock(start, twelve)}–${formatClock(end, twelve)}`.replaceAll(" ", "\u00a0").replace("–", "\u2060–\u2060"); const shared = overlap ? (overlap.end - overlap.start) * 60 : 0; const headline = !ready ? "––:––" : overlap ? range(overlap.start, overlap.end) : "No overlap"; return (

Best time to meet

{headline}

{!ready ? " " : overlap ? shift === 0 ? "Your working hours line up exactly." : `your time, which is ${range(overlap.start + shift, overlap.end + shift)} in ${city.name}` : `Their working day runs ${range(hours.start - shift, hours.end - shift)} your time.`}

{!ready ? "Working hours" : overlap ? `${formatSpan(shared)} shared` : "No shared hours"} {formatClock(hours.start, twelve)}–{formatClock(hours.end, twelve)} each side

Now, on your clock

); } function ClockChanges({ city, now, locale }: { city: City; now: number | null; locale: string }) { const hourKey = now === null ? null : Math.floor(now / HOUR); const year = useMemo( () => (hourKey === null ? null : clockYear(city.timeZone, hourKey * HOUR + 1)), [city.timeZone, hourKey], ); const minute = now === null ? null : Math.floor(now / MINUTE) * MINUTE; const relative = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); const longDate = (at: number) => formatter(locale, { timeZone: city.timeZone, weekday: "short", day: "numeric", month: "short", year: "numeric" }).format(at); const monthYear = (at: number) => formatter(locale, { timeZone: city.timeZone, month: "short", year: "numeric" }).format(at); const daysFrom = (at: number) => (minute === null ? 0 : dayOf(city.timeZone, at) - dayOf(city.timeZone, minute)); const position = (at: number) => (year ? ((at - year.start) / (year.end - year.start)) * 100 : 0); const offset = minute === null ? 0 : offsetAt(city.timeZone, minute); const changes = year !== null && (year.seasons.length > 1 || year.next !== null); const recent = year?.previous && minute !== null && minute - year.previous.at < 370 * DAY ? year.previous : null; const months = year ? Array.from({ length: 12 }, (_, i) => monthStart(year.start, i)) : []; const monthName = (at: number) => formatter(locale, { timeZone: "UTC", month: "short" }).format(at); const zones = useMemo(() => { if (!year) return null; const standard = year.seasons.find((season) => season.offset === year.standard); const summer = year.seasons.find((season) => season.offset > year.standard); const nameAt = (season: Season | undefined) => season ? { offset: season.offset, short: zoneNames(city.timeZone, (season.start + season.end) / 2).short } : null; return { standard: nameAt(standard), summer: nameAt(summer) }; }, [year, city.timeZone]); const rows = [ recent && { key: "previous", icon: recent.to > recent.from ? RotateCwIcon : RotateCcwIcon, label: `Clocks went ${recent.to > recent.from ? "forward" : "back"} ${formatSpan(recent.to - recent.from)}`, when: relative.format(daysFrom(recent.at), "day"), date: longDate(recent.at), past: true, }, year?.next && { key: "next", icon: year.next.to > year.next.from ? RotateCwIcon : RotateCcwIcon, label: `Clocks go ${year.next.to > year.next.from ? "forward" : "back"} ${formatSpan(year.next.to - year.next.from)}`, when: relative.format(daysFrom(year.next.at), "day"), date: longDate(year.next.at), past: false, }, ].filter((row) => Boolean(row)) as { key: string; icon: typeof RotateCwIcon; label: string; when: string; date: string; past: boolean; }[]; const summary = !year ? " " : changes ? `On ${offset > year.standard ? "daylight saving time" : "standard time"}, ${formatOffset(offset)}.` : `${formatOffset(offset)} all year${year.previous ? `, unchanged since ${monthYear(year.previous.at)}` : ""}.`; return (

{year && !changes ? "No clock changes" : "Clock changes"}

Daylight saving Standard
{summary}
{year?.seasons .filter((season) => season.offset > year.standard) .map((season) => ( ))}
{rows.map((row) => { const at = row.past ? recent?.at : year?.next?.at; if (at === undefined) return null; return ( ); })} {minute !== null && year && ( )}
{(months.length ? months : Array.from({ length: 12 }, () => null)).map((month, i) => ( {month === null ? " " : monthName(month)} ))}
    {rows.length ? ( rows.map((row) => (
  • {row.label} {row.when} · {row.date}
  • )) ) : (
  • {year ? `${city.name} doesn’t observe daylight saving time.` : " "}
  • )}
{[ { label: "Time zone", value: city.timeZone.replaceAll("_", " "), mono: true }, { label: "Standard", value: zones?.standard ? `${formatOffset(zones.standard.offset)}${zones.standard.short ? ` · ${zones.standard.short}` : ""}` : "–", }, { label: "Daylight saving", value: zones?.summer ? `${formatOffset(zones.summer.offset)}${zones.summer.short ? ` · ${zones.summer.short}` : ""}` : zones ? "Not observed" : "–", }, ].map((item) => (
{item.label}
{item.value}
))}
); } function Facts({ city }: { city: City }) { const coordinates = `${Math.abs(city.latitude).toFixed(2)}° ${city.latitude >= 0 ? "N" : "S"}, ${Math.abs(city.longitude).toFixed(2)}° ${city.longitude >= 0 ? "E" : "W"}`; const facts = [ { label: "Country", value: city.country, icon: FlagIcon }, { label: "Region", value: city.region, icon: MapIcon }, { label: "Coordinates", value: coordinates, icon: NavigationIcon, numeric: true }, { label: "Elevation", value: `${city.elevation.toLocaleString("en-US")} m`, icon: MountainIcon, numeric: true }, { label: "Population", value: city.population.toLocaleString("en-US"), icon: UsersIcon, numeric: true }, { label: "Currency", value: city.currency, icon: BanknoteIcon }, { label: "Languages", value: city.languages.join(", "), icon: LanguagesIcon }, { label: "Dialling code", value: city.dialCode, icon: PhoneIcon, numeric: true }, ]; return (

About {city.name}

{facts.map((fact) => (
{fact.label}
{fact.value}
))}
); } export function CityTime({ cities = defaultCities, defaultCity, workingHours = { start: 9, end: 17 }, defaultHourCycle = "24h", locale = "en-GB", className, }: CityTimeProps) { const headingId = useId(); const reduce = useReducedMotionSafe(); const now = useNow(); const visitorZone = useVisitorZone(); const [cityId, setCityId] = useState(defaultCity ?? cities[0]?.id); const [cycle, setCycle] = useState(defaultHourCycle); const city = cities.find((item) => item.id === cityId) ?? cities[0]; const twelve = cycle === "12h"; const shownCity = useRef(city?.id); const forward = shownCity.current === city?.id; useEffect(() => { shownCity.current = city?.id; }); if (!city) return null; return (

Local time in {city.name}

{city.region === city.name ? city.country : `${city.region}, ${city.country}`}

); }