# City Time

> A local-time page for any city: a live clock whose digits roll, the sun on today's arc, your working hours against theirs with the best time to meet, and the year's clock changes, all re-computed as you switch cities.

A shadcn/ui block from the [@devl](https://devl.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/devl/city-time).

- Page, with a live preview: https://shadcn.exchange/r/devl/city-time
- Screenshot: https://shadcn.exchange/shot/devl/city-time/light.png
- Registry item: https://devl.dev/r/city-time.json
- Categories: block, Apps
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/devl/city-time.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://coss.com/ui/r/combobox.json`, `https://coss.com/ui/r/segmented-control.json`

## Files

### `registry/devl/city-time.tsx`

```tsx
"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<string, Intl.DateTimeFormat>();

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<string, number> = {};
  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<number | null>(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<string | null>(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 (
    <span className="relative inline-block h-[1em] overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)]">
      <span className="invisible block h-[1em]">0</span>
      <motion.span className="absolute inset-x-0 top-0 flex flex-col" style={{ y }}>
        {strip.map((digit, slot) => (
          <span className="block h-[1em] text-center" key={slot}>
            {digit}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

function Slot({ children }: { children: ReactNode }) {
  return (
    <span className="relative inline-block h-[1em]">
      <span className="invisible">0</span>
      <span className="absolute inset-0 flex items-center justify-center">{children}</span>
    </span>
  );
}

function Colon() {
  return (
    <span className="mx-[0.05em] flex h-[1em] flex-col items-center justify-center gap-[0.19em] pt-[0.02em] text-foreground/30">
      <span className="size-[0.1em] rounded-full bg-current" />
      <span className="size-[0.1em] rounded-full bg-current" />
    </span>
  );
}

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 (
    <div className="flex items-end font-heading font-semibold text-[clamp(3.75rem,24cqi,8.25rem)] tabular-nums leading-none tracking-[-0.03em]">
      <span aria-hidden className="flex">
        {p ? (
          <>
            {hours.length > 1 && <Digit forward={forward} reduce={reduce} value={Number(hours[0])} />}
            <Digit forward={forward} reduce={reduce} value={Number(hours[hours.length - 1])} />
            <Colon />
            <Digit forward={forward} reduce={reduce} value={Number(minutes[0])} />
            <Digit forward={forward} reduce={reduce} value={Number(minutes[1])} />
          </>
        ) : (
          <span className="flex text-muted-foreground/30">
            <Slot>–</Slot>
            <Slot>–</Slot>
            <Colon />
            <Slot>–</Slot>
            <Slot>–</Slot>
          </span>
        )}
      </span>
      <span
        aria-hidden
        className="ms-[0.14em] mb-[0.225em] flex flex-col gap-[0.25em] font-medium font-sans text-[0.2em] text-muted-foreground leading-none tracking-normal"
      >
        <span className={cn(!twelve && "invisible")}>{p && p.hour >= 12 ? "PM" : "AM"}</span>
        <span className="tabular-nums">{p ? `:${String(p.second).padStart(2, "0")}` : ":––"}</span>
      </span>
    </div>
  );
}

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<HTMLDivElement>) => {
    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 (
    <SegmentedControl aria-label="Clock format" onKeyDown={onKeyDown} role="radiogroup" size="sm">
      {options.map((option, i) => {
        const checked = option === value;
        return (
          <SegmentedControlItem
            aria-checked={checked}
            className="min-w-11 px-2.5 font-mono text-xs"
            data-checked={checked ? "" : undefined}
            key={option}
            onClick={() => onChange(option)}
            ref={(el: HTMLButtonElement | null) => {
              refs.current[i] = el;
            }}
            role="radio"
            tabIndex={checked ? 0 : -1}
          >
            {option}
          </SegmentedControlItem>
        );
      })}
    </SegmentedControl>
  );
}

function Swap({ id, children, className }: { id: string; children: ReactNode; className?: string }) {
  return (
    <AnimatePresence initial={false} mode="popLayout">
      <motion.span
        animate={{ opacity: 1 }}
        className={cn("inline-block", className)}
        exit={{ opacity: 0 }}
        initial={{ opacity: 0 }}
        key={id}
        transition={{ duration: 0.16, ease }}
      >
        {children}
      </motion.span>
    </AnimatePresence>
  );
}

function CityPicker({
  cities,
  city,
  now,
  twelve,
  onChange,
}: {
  cities: City[];
  city: City;
  now: number | null;
  twelve: boolean;
  onChange: (id: string) => void;
}) {
  return (
    <Combobox<City>
      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}
    >
      <ComboboxInput aria-label="Choose a city" placeholder="Search a city" startAddon={<SearchIcon />} />
      <ComboboxPopup className="w-(--anchor-width) min-w-64">
        <ComboboxEmpty>No city by that name.</ComboboxEmpty>
        <ComboboxList>
          {(item: City) => (
            <ComboboxItem className="py-1.5" key={item.id} value={item}>
              <span className="flex items-center justify-between gap-3">
                <span className="min-w-0">
                  <span className="block truncate font-medium">{item.name}</span>
                  <span className="block truncate text-muted-foreground text-xs">{item.country}</span>
                </span>
                <span className="shrink-0 text-end text-muted-foreground text-xs tabular-nums">
                  <span className="block text-foreground">{now === null ? "––:––" : clockAt(item.timeZone, now, twelve)}</span>
                  <span className="block">{now === null ? "UTC" : formatOffset(offsetAt(item.timeZone, now))}</span>
                </span>
              </span>
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxPopup>
    </Combobox>
  );
}

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 (
    <div className="@container flex min-w-0 flex-col gap-5 p-5 sm:p-6">
      <div className="flex items-center justify-between gap-3">
        <p className="flex min-w-0 items-center gap-2 text-muted-foreground text-xs">
          {names?.short && (
            <span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 font-medium font-mono text-foreground">{names.short}</span>
          )}
          <span className="truncate">
            {names ? `${names.long} · ${formatOffset(offset ?? 0)}` : <span className="text-muted-foreground/50">Finding the time zone…</span>}
          </span>
        </p>
        <HourCycleSwitch onChange={onCycle} value={cycle} />
      </div>

      <div>
        <time className="sr-only" dateTime={minute === null ? undefined : new Date(minute).toISOString()}>
          {spoken}
        </time>
        <BigTime forward={forward} now={now} reduce={reduce} timeZone={city.timeZone} twelve={twelve} />
        <p className="mt-3 flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1 text-base text-foreground">
          {date ?? <span className="text-muted-foreground/50">Today</span>}
          {dayGap !== 0 && (
            <span className="rounded-full bg-info/10 px-2 py-0.5 font-medium text-info-foreground text-xs">
              {relative.format(dayGap, "day").replace(/^./, (c) => c.toUpperCase())} for you
            </span>
          )}
        </p>
      </div>

      <div className="mt-auto flex flex-wrap items-baseline gap-x-2 gap-y-1 border-t pt-4 text-sm">
        <span className="font-medium">
          {!ready ? "Comparing with your time…" : gap === 0 ? "Same time as you" : `${formatSpan(gap)} ${gap > 0 ? "ahead of" : "behind"} you`}
        </span>
        {ready && (
          <span className="text-muted-foreground">
            You’re in {placeOf(visitorZone)}, <span className="tabular-nums">{clockAt(visitorZone, minute, twelve)}</span>
          </span>
        )}
      </div>
    </div>
  );
}

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<number>; 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 (
    <motion.g animate={{ opacity: visible ? 1 : 0 }} initial={false} transition={{ duration: 0.3 }}>
      <motion.path
        className="stroke-amber-500 dark:stroke-amber-300"
        d={trail}
        fill="none"
        strokeLinecap="round"
        strokeWidth={2}
        style={{ opacity: sun }}
      />
      <motion.g style={{ x, y }}>
        <motion.circle className="fill-amber-400 stroke-card dark:fill-amber-300" r={9} strokeWidth={3} style={{ opacity: sun }} />
        <motion.path
          className="fill-indigo-400 stroke-card dark:fill-indigo-300"
          d="M 2.5 -8.6 A 9 9 0 1 0 8.9 1.6 A 7 7 0 0 1 2.5 -8.6 Z"
          strokeLinejoin="round"
          strokeWidth={2.5}
          style={{ opacity: moon }}
        />
      </motion.g>
    </motion.g>
  );
}

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 (
    <div className="flex min-w-0 flex-col gap-3 p-5 sm:p-6">
      <div className="flex items-baseline justify-between gap-3 text-xs">
        <span className="font-medium text-foreground text-sm">Daylight</span>
        <Swap className="text-muted-foreground tabular-nums" id={status ? `${city.id}-${status}` : "pending"}>
          {status || " "}
        </Swap>
      </div>
      <svg aria-hidden className="mx-auto block h-auto w-full max-w-[360px] overflow-visible" viewBox="0 0 320 136">
        <line className="stroke-border" strokeWidth={1} x1={8} x2={312} y1={arc.horizon} y2={arc.horizon} />
        <path
          className="stroke-foreground/15"
          d={`M ${arc.cx - arc.rx} ${arc.horizon} A ${arc.rx} ${arc.top} 0 0 1 ${arc.cx + arc.rx} ${arc.horizon}`}
          fill="none"
          strokeWidth={2}
        />
        <path
          className="stroke-foreground/20"
          d={`M ${arc.cx + arc.rx} ${arc.horizon} A ${arc.rx} ${arc.bottom} 0 0 1 ${arc.cx - arc.rx} ${arc.horizon}`}
          fill="none"
          strokeDasharray="0.5 5"
          strokeLinecap="round"
          strokeWidth={2}
        />
        <circle className="fill-card stroke-foreground/30" cx={arc.cx - arc.rx} cy={arc.horizon} r={3.5} strokeWidth={1.5} />
        <circle className="fill-card stroke-foreground/30" cx={arc.cx + arc.rx} cy={arc.horizon} r={3.5} strokeWidth={1.5} />
        <SunMark phase={phase} visible={light !== null} />
      </svg>
      <dl className="grid grid-cols-3 gap-2 text-xs">
        <div>
          <dt className="text-muted-foreground">Sunrise</dt>
          <dd className="mt-0.5 font-medium text-foreground text-sm tabular-nums">
            {sun?.kind === "normal" ? at(sun.rise) : sun ? "None" : "––:––"}
          </dd>
        </div>
        <div className="text-center">
          <dt className="text-muted-foreground">Day length</dt>
          <dd className="mt-0.5 font-medium text-foreground text-sm tabular-nums">
            {length !== null ? formatSpan(length) : sun?.kind === "day" ? "24 h" : sun ? "0 min" : "–"}
          </dd>
        </div>
        <div className="text-end">
          <dt className="text-muted-foreground">Sunset</dt>
          <dd className="mt-0.5 font-medium text-foreground text-sm tabular-nums">
            {sun?.kind === "normal" ? at(sun.set) : sun ? "None" : "––:––"}
          </dd>
        </div>
      </dl>
      <p className="min-h-4 text-center text-muted-foreground text-xs tabular-nums">
        {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`}
      </p>
    </div>
  );
}

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 (
    <div className="grid gap-x-4 gap-y-1.5 @2xl:grid-cols-[9.5rem_minmax(0,1fr)] @2xl:items-start">
      <div className="flex min-w-0 items-baseline justify-between gap-3 @2xl:flex-col @2xl:items-start @2xl:gap-0.5 @2xl:pt-0.5">
        <span className="flex min-w-0 items-center gap-2 font-medium text-sm">
          <span
            aria-hidden
            className={cn(
              "size-2 shrink-0 rounded-full",
              tone === "you" ? "bg-sky-500 dark:bg-sky-400" : "bg-violet-500 dark:bg-violet-400",
            )}
          />
          <span className="truncate">{name}</span>
        </span>
        <span className="shrink-0 truncate text-muted-foreground text-xs tabular-nums @2xl:ps-4">
          {detail} · {local}
        </span>
      </div>
      <div className="min-w-0">
        <div className="relative h-7 overflow-hidden rounded-lg bg-muted/70 dark:bg-muted/50">
          {ticks.slice(1).map((tick) => (
            <span aria-hidden className="absolute inset-y-0 w-px bg-border/70" key={tick} style={{ left: `${(tick / 24) * 100}%` }} />
          ))}
          <motion.div
            animate={{ x: `${(-shift / 72) * 100}%` }}
            className="absolute inset-y-0 -left-full w-[300%]"
            initial={false}
            transition={glide}
          >
            {[0, 24, 48].map((day) => (
              <span
                className={cn("absolute inset-y-1 rounded-md ring-1 ring-inset transition-opacity", band, !ready && "opacity-40")}
                key={day}
                style={{ left: `${((day + hours.start) / 72) * 100}%`, width: `${(span / 72) * 100}%` }}
              />
            ))}
          </motion.div>
          <motion.span
            animate={{
              left: `${((overlap?.start ?? 12) / 24) * 100}%`,
              width: `${(((overlap?.end ?? 12) - (overlap?.start ?? 12)) / 24) * 100}%`,
              opacity: overlap ? 1 : 0,
            }}
            className="absolute inset-y-1 rounded-md bg-emerald-500 dark:bg-emerald-400"
            initial={false}
            transition={glide}
          />
          {nowAt !== null && (
            <span
              aria-hidden
              className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-foreground"
              style={{ left: `${(nowAt / 24) * 100}%` }}
            />
          )}
        </div>
        <div aria-hidden className="mt-1 grid grid-cols-8 text-[10px] text-muted-foreground tabular-nums">
          {ticks.map((tick) => (
            <span className="truncate ps-1" key={tick}>
              {ready ? formatTick(tick + shift, twelve) : formatTick(tick, twelve)}
            </span>
          ))}
        </div>
      </div>
    </div>
  );
}

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 (
    <section aria-label={`Best time to meet with ${city.name}`} className="rounded-2xl border bg-card shadow-xs/5">
      <div className="grid gap-6 p-5 sm:p-6 @3xl:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] @3xl:gap-10">
        <div className="flex flex-col gap-3">
          <h3 className="font-medium text-muted-foreground text-sm">Best time to meet</h3>
          <div>
            <Swap
              className="font-heading font-semibold text-3xl tabular-nums tracking-tight"
              id={`${city.id}-${headline}`}
            >
              {headline}
            </Swap>
            <p className="mt-1 min-h-10 text-pretty text-muted-foreground text-sm">
              {!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.`}
            </p>
          </div>
          <p className="mt-auto flex flex-wrap items-center gap-2 text-xs">
            <span
              className={cn(
                "inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium",
                !ready
                  ? "bg-muted text-muted-foreground"
                  : overlap
                    ? "bg-emerald-500/12 text-emerald-700 dark:bg-emerald-400/12 dark:text-emerald-300"
                    : "bg-warning/12 text-warning-foreground",
              )}
            >
              <span aria-hidden className={cn("size-1.5 rounded-full", overlap ? "bg-emerald-500 dark:bg-emerald-400" : "bg-current")} />
              {!ready ? "Working hours" : overlap ? `${formatSpan(shared)} shared` : "No shared hours"}
            </span>
            <span className="text-muted-foreground">
              {formatClock(hours.start, twelve)}–{formatClock(hours.end, twelve)} each side
            </span>
          </p>
        </div>

        <div className="@container flex min-w-0 flex-col gap-4">
          <DayTrack
            detail={yourNames?.short ?? (ready ? formatOffset(yourOffset) : "Your zone")}
            hours={hours}
            local={ready ? clockAt(visitorZone, minute, twelve) : "––:––"}
            name={ready ? `You · ${placeOf(visitorZone)}` : "You"}
            nowAt={nowAt}
            overlap={overlap}
            ready={ready}
            shift={0}
            tone="you"
            twelve={twelve}
          />
          <DayTrack
            detail={cityNames?.short ?? (ready ? formatOffset(cityOffset) : "–")}
            hours={hours}
            local={ready ? clockAt(city.timeZone, minute, twelve) : "––:––"}
            name={city.name}
            nowAt={nowAt}
            overlap={overlap}
            ready={ready}
            shift={shift}
            tone="city"
            twelve={twelve}
          />
          <p className="flex items-center gap-2 text-muted-foreground text-xs @2xl:ps-[calc(9.5rem+1rem)]">
            <span aria-hidden className="h-3 w-0.5 rounded-full bg-foreground" />
            Now, on your clock
          </p>
        </div>
      </div>
    </section>
  );
}

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 (
    <section aria-label="Clock changes" className="@container flex min-w-0 flex-col gap-4 rounded-2xl border bg-card p-5 shadow-xs/5 sm:p-6">
      <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
        <h3 className="font-medium text-sm">{year && !changes ? "No clock changes" : "Clock changes"}</h3>
        <div aria-hidden className="flex items-center gap-3 text-muted-foreground text-xs">
          <span className="inline-flex items-center gap-1.5">
            <span className="size-2 rounded-full bg-amber-400 dark:bg-amber-300" />
            Daylight saving
          </span>
          <span className="inline-flex items-center gap-1.5">
            <span className="size-2 rounded-full bg-foreground/15" />
            Standard
          </span>
        </div>
      </div>
      <Swap className="text-muted-foreground text-sm" id={year ? `${city.id}-${summary}` : "pending"}>
        {summary}
      </Swap>

      <div aria-hidden>
        <div className="relative py-1.5">
          <div className="relative h-2.5 overflow-hidden rounded-full bg-foreground/10">
            <AnimatePresence initial={false}>
              <motion.div
                animate={{ opacity: 1 }}
                className="absolute inset-0"
                exit={{ opacity: 0 }}
                initial={{ opacity: 0 }}
                key={city.id}
                transition={{ duration: 0.2, ease }}
              >
                {year?.seasons
                  .filter((season) => season.offset > year.standard)
                  .map((season) => (
                    <span
                      className="absolute inset-y-0 bg-amber-400 dark:bg-amber-300"
                      key={season.start}
                      style={{ left: `${position(season.start)}%`, width: `${position(season.end) - position(season.start)}%` }}
                    />
                  ))}
              </motion.div>
            </AnimatePresence>
          </div>
          {rows.map((row) => {
            const at = row.past ? recent?.at : year?.next?.at;
            if (at === undefined) return null;
            return (
              <span
                className="absolute inset-y-0 w-0.5 -translate-x-1/2 rounded-full bg-foreground/45"
                key={row.key}
                style={{ left: `${position(at)}%` }}
              />
            );
          })}
          {minute !== null && year && (
            <span
              className="absolute top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground ring-2 ring-card"
              style={{ left: `${position(minute)}%` }}
            />
          )}
        </div>
        <div className="mt-1 grid grid-cols-12 text-[10px] text-muted-foreground tabular-nums">
          {(months.length ? months : Array.from({ length: 12 }, () => null)).map((month, i) => (
            <span className="truncate text-center" key={month ?? i}>
              {month === null ? " " : monthName(month)}
            </span>
          ))}
        </div>
      </div>

      <ul className="grid gap-2 text-sm">
        {rows.length ? (
          rows.map((row) => (
            <li className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5" key={row.key}>
              <span className={cn("inline-flex items-center gap-2", row.past ? "text-muted-foreground" : "font-medium")}>
                <row.icon aria-hidden className="size-3.5 shrink-0 translate-y-px opacity-70" />
                {row.label}
              </span>
              <span className="text-muted-foreground text-xs tabular-nums">
                {row.when} · {row.date}
              </span>
            </li>
          ))
        ) : (
          <li className="text-muted-foreground text-xs">
            {year ? `${city.name} doesn’t observe daylight saving time.` : " "}
          </li>
        )}
      </ul>

      <dl className="mt-auto grid grid-cols-2 gap-3 border-t border-dashed pt-4 text-xs @md:grid-cols-3">
        {[
          { 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) => (
          <div className={cn("min-w-0", item.mono && "col-span-2 @md:col-span-1")} key={item.label}>
            <dt className="text-muted-foreground">{item.label}</dt>
            <dd className={cn("mt-0.5 truncate text-foreground text-sm tabular-nums", item.mono && "font-mono text-xs leading-5")}>
              <Swap id={`${city.id}-${item.label}-${item.value}`}>{item.value}</Swap>
            </dd>
          </div>
        ))}
      </dl>
    </section>
  );
}

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 (
    <section aria-label={`About ${city.name}`} className="@container rounded-2xl border bg-card px-5 pt-4 pb-2 shadow-xs/5 sm:px-6">
      <h3 className="font-medium text-sm">About {city.name}</h3>
      <dl className="mt-2 grid grid-cols-2 gap-x-5">
        {facts.map((fact) => (
          <div className="flex min-w-0 items-start gap-2.5 border-t border-dashed py-2.5 first:border-t-0 [&:nth-child(2)]:border-t-0" key={fact.label}>
            <fact.icon aria-hidden className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
            <div className="min-w-0">
              <dt className="text-muted-foreground text-xs">{fact.label}</dt>
              <dd className={cn("mt-0.5 text-sm", "numeric" in fact && "tabular-nums")}>
                <Swap id={`${city.id}-${fact.label}`}>{fact.value}</Swap>
              </dd>
            </div>
          </div>
        ))}
      </dl>
    </section>
  );
}

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<HourCycle>(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 (
    <MotionConfig reducedMotion="user">
      <section
        aria-labelledby={headingId}
        className={cn("@container mx-auto w-full max-w-[1100px] px-4 py-8 sm:px-8 sm:py-10", className)}
      >
        <div className="flex flex-col gap-4 @3xl:flex-row @3xl:items-end @3xl:justify-between">
          <div className="min-w-0">
            <h2 className="text-balance font-heading font-semibold text-2xl tracking-tight @3xl:text-3xl" id={headingId}>
              Local time in <Swap id={city.id}>{city.name}</Swap>
            </h2>
            <p className="mt-1 text-muted-foreground text-sm">
              <Swap id={city.id}>
                {city.region === city.name ? city.country : `${city.region}, ${city.country}`}
              </Swap>
            </p>
          </div>
          <div className="w-full @3xl:w-72">
            <CityPicker cities={cities} city={city} now={now} onChange={setCityId} twelve={twelve} />
          </div>
        </div>

        <div className="mt-6 grid gap-4">
          <div className="grid overflow-hidden rounded-2xl border bg-card shadow-xs/5 @3xl:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
            <ClockPanel
              city={city}
              cycle={cycle}
              forward={forward}
              locale={locale}
              now={now}
              onCycle={setCycle}
              reduce={reduce}
              twelve={twelve}
              visitorZone={visitorZone}
            />
            <div className="border-t @3xl:border-t-0 @3xl:border-l">
              <DayArc city={city} now={now} reduce={reduce} twelve={twelve} />
            </div>
          </div>

          <MeetPlanner city={city} hours={workingHours} now={now} twelve={twelve} visitorZone={visitorZone} />

          <div className="grid gap-4 @3xl:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
            <ClockChanges city={city} locale={locale} now={now} />
            <Facts city={city} />
          </div>
        </div>
      </section>
    </MotionConfig>
  );
}
```
