{
  "name": "timezone-planner",
  "type": "registry:component",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Timezone Planner\n * @version: 1.0.0\n * @date: 2026-10-21\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  animate,\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst DAY = 1440;\nconst STEP = 15;\nconst MIN_DURATION = 30;\nconst MAX_DURATION = 480;\n// Local hours that count as asleep, whatever the working day is.\nconst NIGHT_FROM = 22;\nconst NIGHT_TO = 7;\nconst WEEKDAYS = [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"];\nconst DIGITS = [\"0\", \"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\"];\nconst DIGIT = /\\d/;\nconst OFFSET = /GMT([+-])(\\d{1,2})(?::?(\\d{2}))?/;\nconst NON_WORD = /[^a-zA-Z0-9]/g;\nconst GLIDE = { type: \"spring\", duration: 0.5, bounce: 0.15 } as const;\nconst TAU = Math.PI * 2;\n\n// Dial geometry, in viewBox units.\nconst SIZE = 340;\nconst C = SIZE / 2;\nconst R_OUT = 134;\nconst R_IN = 60;\nconst RING_GAP = 4;\nconst WEDGE_IN = R_IN - 7;\nconst WEDGE_OUT = R_OUT + 6;\nconst LABEL_R = 160;\nconst HOURS = Array.from({ length: 24 }, (_, hour) => hour);\n/** How far past a wedge edge (in viewBox units) still grabs that edge. */\nconst EDGE_REACH = 13;\n/** Flick projection: how far the wedge coasts on release, in seconds of its speed. */\nconst COAST = 0.14;\n\nexport interface PlannerPerson {\n  id: string;\n  name: string;\n  city: string;\n  /** IANA zone, such as \"America/New_York\". */\n  timeZone: string;\n  /** Start of their working day, in local hours. */\n  workStart?: number;\n  /** End of their working day, in local hours. */\n  workEnd?: number;\n}\n\nexport interface PlannerSlot {\n  start: Date;\n  end: Date;\n  /** Ids of the people for whom the whole slot is in working hours. */\n  available: string[];\n}\n\nexport interface TimezonePlannerProps {\n  /** The first person is \"you\": the dial runs on their clock. */\n  people?: PlannerPerson[];\n  /** The day to plan. Defaults to today. */\n  date?: Date;\n  /** Start of the meeting in minutes after midnight, on the first person's clock. */\n  defaultStart?: number;\n  /** Length of the meeting in minutes. */\n  defaultDuration?: number;\n  /** Called when the slot settles: after a drag, a key press or Best time. */\n  onChange?: (slot: PlannerSlot) => void;\n  className?: string;\n}\n\nconst DEFAULT_PEOPLE: PlannerPerson[] = [\n  { id: \"you\", name: \"You\", city: \"Paris\", timeZone: \"Europe/Paris\" },\n  {\n    id: \"lucas\",\n    name: \"Lucas Moreau\",\n    city: \"New York\",\n    timeZone: \"America/New_York\",\n  },\n  {\n    id: \"maya\",\n    name: \"Maya Chen\",\n    city: \"San Francisco\",\n    timeZone: \"America/Los_Angeles\",\n  },\n  {\n    id: \"priya\",\n    name: \"Priya Nair\",\n    city: \"Bengaluru\",\n    timeZone: \"Asia/Kolkata\",\n  },\n  { id: \"aiko\", name: \"Aiko Tanaka\", city: \"Tokyo\", timeZone: \"Asia/Tokyo\" },\n];\n\ntype Status = \"working\" | \"early\" | \"late\" | \"asleep\";\n\nconst STATUS_LABEL: Record<Status, string> = {\n  working: \"Working\",\n  early: \"Early\",\n  late: \"Late\",\n  asleep: \"Asleep\",\n};\n\nconst STATUS_TEXT: Record<Status, string> = {\n  working: \"text-emerald-600 dark:text-emerald-400\",\n  early: \"text-zinc-500 dark:text-zinc-400\",\n  late: \"text-zinc-500 dark:text-zinc-400\",\n  asleep: \"text-zinc-400 dark:text-zinc-500\",\n};\n\ninterface Day {\n  /** Each person's offset from the first person, in minutes. */\n  shifts: number[];\n  /** Short labels such as \"−9h\" or \"+3h30\". */\n  shiftLabels: string[];\n  weekday: number;\n  label: string;\n  /** Minutes after midnight on your clock, when the day is today. */\n  now: number | null;\n  /** The instant of midnight on your clock, for building Dates. */\n  midnight: number;\n}\n\ninterface Segment {\n  from: number;\n  to: number;\n  kind: \"work\" | \"night\" | \"off\";\n}\n\ntype Grab =\n  | { mode: \"move\"; offset: number }\n  | { mode: \"start\"; end: number }\n  | { mode: \"end\" };\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\nconst snap = (v: number) => Math.round(v / STEP) * STEP;\nconst mod = (v: number, m: number) => ((v % m) + m) % m;\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n/** The shortest way round the dial from `from` to `to`, in minutes. */\nconst turn = (from: number, to: number) =>\n  mod(to - from + DAY / 2, DAY) - DAY / 2;\nconst round = (n: number) => Math.round(n * 100) / 100;\n\nfunction clock(minutes: number) {\n  const m = mod(Math.round(minutes), DAY);\n  return `${pad(Math.floor(m / 60))}:${pad(m % 60)}`;\n}\n\nfunction offsetOf(timeZone: string, at: Date) {\n  const name =\n    new Intl.DateTimeFormat(\"en-US\", { timeZone, timeZoneName: \"longOffset\" })\n      .formatToParts(at)\n      .find((part) => part.type === \"timeZoneName\")?.value ?? \"GMT\";\n  const match = OFFSET.exec(name);\n  if (!match) {\n    return 0;\n  }\n  const sign = match[1] === \"-\" ? -1 : 1;\n  return sign * (Number(match[2]) * 60 + Number(match[3] ?? 0));\n}\n\nfunction shiftLabel(shift: number) {\n  if (shift === 0) {\n    return \"same time\";\n  }\n  const sign = shift > 0 ? \"+\" : \"−\";\n  const abs = Math.abs(shift);\n  const minutes = abs % 60;\n  return `${sign}${Math.floor(abs / 60)}h${minutes ? pad(minutes) : \"\"}`;\n}\n\n/** Wall-clock fields of an instant in a zone. */\nfunction fieldsIn(timeZone: string, at: Date) {\n  const parts = new Intl.DateTimeFormat(\"en-US\", {\n    timeZone,\n    year: \"numeric\",\n    month: \"numeric\",\n    day: \"numeric\",\n    hour: \"numeric\",\n    minute: \"numeric\",\n    weekday: \"short\",\n    hourCycle: \"h23\",\n  }).formatToParts(at);\n  const get = (type: string) =>\n    parts.find((part) => part.type === type)?.value ?? \"0\";\n  return {\n    year: Number(get(\"year\")),\n    month: Number(get(\"month\")),\n    day: Number(get(\"day\")),\n    minutes: Number(get(\"hour\")) * 60 + Number(get(\"minute\")),\n    weekday: WEEKDAYS.indexOf(get(\"weekday\")),\n  };\n}\n\nfunction buildDay(people: PlannerPerson[], date: Date, today: Date): Day {\n  const ref = people[0]?.timeZone ?? \"UTC\";\n  const f = fieldsIn(ref, date);\n  // Noon on the planned day, so the offsets are that day's (DST included).\n  const noon = new Date(Date.UTC(f.year, f.month - 1, f.day, 12));\n  const refOffset = offsetOf(ref, noon);\n  const shifts = people.map(\n    (person) => offsetOf(person.timeZone, noon) - refOffset\n  );\n  const t = fieldsIn(ref, today);\n  const sameDay = t.year === f.year && t.month === f.month && t.day === f.day;\n  return {\n    shifts,\n    shiftLabels: shifts.map(shiftLabel),\n    weekday: f.weekday,\n    label: new Intl.DateTimeFormat(\"en-US\", {\n      timeZone: ref,\n      weekday: \"short\",\n      month: \"short\",\n      day: \"numeric\",\n    }).format(noon),\n    now: sameDay ? t.minutes : null,\n    midnight: Date.UTC(f.year, f.month - 1, f.day) - refOffset * 60_000,\n  };\n}\n\nfunction kindAt(local: number, start: number, end: number): Segment[\"kind\"] {\n  const hour = mod(local, DAY) / 60;\n  if (hour >= start && hour < end) {\n    return \"work\";\n  }\n  if (hour >= NIGHT_FROM || hour < NIGHT_TO) {\n    return \"night\";\n  }\n  return \"off\";\n}\n\n/** One person's ring, cut wherever their day changes kind. */\nfunction segmentsFor(person: PlannerPerson, shift: number): Segment[] {\n  const start = person.workStart ?? 9;\n  const end = person.workEnd ?? 18;\n  const segments: Segment[] = [];\n  for (let m = 0; m < DAY; m += STEP) {\n    const kind = kindAt(m + shift, start, end);\n    const last = segments.at(-1);\n    if (last && last.kind === kind) {\n      last.to = m + STEP;\n    } else {\n      segments.push({ from: m, to: m + STEP, kind });\n    }\n  }\n  return segments;\n}\n\nfunction statusFor(\n  person: PlannerPerson,\n  shift: number,\n  start: number,\n  duration: number\n): Status {\n  const workStart = (person.workStart ?? 9) * 60;\n  const workEnd = (person.workEnd ?? 18) * 60;\n  const local = start + shift;\n  const day = Math.floor(local / DAY) * DAY;\n  if (local >= day + workStart && local + duration <= day + workEnd) {\n    return \"working\";\n  }\n  const kind = kindAt(local, workStart / 60, workEnd / 60);\n  if (kind === \"night\") {\n    return \"asleep\";\n  }\n  return mod(local, DAY) < workStart ? \"early\" : \"late\";\n}\n\n/** Minutes of the slot that fall outside someone's working day. */\nfunction outsideMinutes(\n  person: PlannerPerson,\n  shift: number,\n  start: number,\n  duration: number\n) {\n  let outside = 0;\n  const workStart = person.workStart ?? 9;\n  const workEnd = person.workEnd ?? 18;\n  for (let m = start; m < start + duration; m += STEP) {\n    if (kindAt(m + shift, workStart, workEnd) !== \"work\") {\n      outside += STEP;\n    }\n  }\n  return outside;\n}\n\n/** The slot that keeps the most people inside their working day. */\nfunction bestStart(\n  people: PlannerPerson[],\n  shifts: number[],\n  duration: number\n) {\n  let best = 0;\n  let bestScore = Number.NEGATIVE_INFINITY;\n  for (let s = 0; s < DAY; s += STEP) {\n    let score = 0;\n    people.forEach((person, i) => {\n      const shift = shifts[i] ?? 0;\n      if (statusFor(person, shift, s, duration) === \"working\") {\n        score += 10_000;\n      }\n      score -= outsideMinutes(person, shift, s, duration);\n    });\n    if (score > bestScore) {\n      bestScore = score;\n      best = s;\n    }\n  }\n  return best;\n}\n\n/** A point on the dial: minutes run clockwise from midnight at the top. */\nfunction polar(r: number, minutes: number) {\n  const a = (minutes / DAY) * TAU;\n  return [round(C + r * Math.sin(a)), round(C - r * Math.cos(a))] as const;\n}\n\n/** An annular slice between two radii and two times. */\nfunction slice(r0: number, r1: number, from: number, to: number) {\n  const span = to - from;\n  if (span >= DAY) {\n    return `M ${C} ${C - r1} A ${r1} ${r1} 0 1 1 ${C} ${C + r1} A ${r1} ${r1} 0 1 1 ${C} ${C - r1} M ${C} ${C - r0} A ${r0} ${r0} 0 1 0 ${C} ${C + r0} A ${r0} ${r0} 0 1 0 ${C} ${C - r0} Z`;\n  }\n  const large = span > DAY / 2 ? 1 : 0;\n  const [ax, ay] = polar(r1, from);\n  const [bx, by] = polar(r1, to);\n  const [cx, cy] = polar(r0, to);\n  const [dx, dy] = polar(r0, from);\n  return `M ${ax} ${ay} A ${r1} ${r1} 0 ${large} 1 ${bx} ${by} L ${cx} ${cy} A ${r0} ${r0} 0 ${large} 0 ${dx} ${dy} Z`;\n}\n\nfunction ringBounds(index: number, count: number) {\n  const width = (R_OUT - R_IN - RING_GAP * (count - 1)) / count;\n  const outer = R_OUT - index * (width + RING_GAP);\n  return { outer, inner: outer - width };\n}\n\nconst KIND_BASE: Record<Segment[\"kind\"], string> = {\n  work: \"fill-emerald-500/20 dark:fill-emerald-400/20\",\n  off: \"fill-zinc-100 dark:fill-white/[0.05]\",\n  night: \"fill-zinc-200 dark:fill-white/[0.1]\",\n};\n\nconst KIND_LIT: Record<Segment[\"kind\"], string> = {\n  work: \"fill-emerald-500 dark:fill-emerald-400\",\n  off: \"fill-zinc-300 dark:fill-zinc-600\",\n  night: \"fill-zinc-400 dark:fill-zinc-500\",\n};\n\n/** Each digit rolls on its own wheel; separators sit still. */\nfunction Rolling({ value }: { value: string }) {\n  const chars = value.split(\"\");\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"inline-flex h-[1.25em] align-top tabular-nums leading-[1.25em]\"\n    >\n      {chars.map((char, i) => {\n        const key = chars.length - i;\n        if (!DIGIT.test(char)) {\n          return (\n            <span className=\"whitespace-pre\" key={`s${key}`}>\n              {char}\n            </span>\n          );\n        }\n        return (\n          <span\n            className=\"relative inline-block h-[1.25em] overflow-hidden leading-[1.25em]\"\n            key={`d${key}`}\n          >\n            <span className=\"invisible\">0</span>\n            <span\n              className=\"absolute inset-x-0 top-0 flex flex-col transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n              style={{ transform: `translateY(-${Number(char) * 10}%)` }}\n            >\n              {DIGITS.map((d) => (\n                <span className=\"block h-[1.25em] leading-[1.25em]\" key={d}>\n                  {d}\n                </span>\n              ))}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction Ring({\n  segments,\n  index,\n  count,\n  shift,\n  lit,\n}: {\n  segments: Segment[];\n  index: number;\n  count: number;\n  shift: number;\n  lit: boolean;\n}) {\n  const { outer, inner } = ringBounds(index, count);\n  // Where their clock passes midnight, so the day change shows on the ring.\n  const midnight = mod(-shift, DAY);\n  const [x0, y0] = polar(inner, midnight);\n  const [x1, y1] = polar(outer, midnight);\n  return (\n    <>\n      {segments.map((segment) => (\n        <path\n          className={lit ? KIND_LIT[segment.kind] : KIND_BASE[segment.kind]}\n          d={slice(inner, outer, segment.from, segment.to)}\n          key={segment.from}\n        />\n      ))}\n      {!lit && shift !== 0 && (\n        <line\n          className=\"stroke-zinc-500 dark:stroke-zinc-400\"\n          strokeWidth={1.25}\n          x1={x0}\n          x2={x1}\n          y1={y0}\n          y2={y1}\n        />\n      )}\n    </>\n  );\n}\n\nfunction Knob({ x, y }: { x: MotionValue<number>; y: MotionValue<number> }) {\n  return (\n    <motion.circle\n      className=\"fill-white stroke-zinc-900 dark:fill-zinc-900 dark:stroke-white\"\n      cx={x}\n      cy={y}\n      r={4.5}\n      strokeWidth={1.5}\n    />\n  );\n}\n\nexport default function TimezonePlanner({\n  people = DEFAULT_PEOPLE,\n  date,\n  defaultStart = 16 * 60,\n  defaultDuration = 60,\n  onChange,\n  className,\n}: TimezonePlannerProps) {\n  const reduceMotion = useReducedMotion();\n  const clipId = `tz${useId().replace(NON_WORD, \"\")}`;\n  const [day, setDay] = useState<Day | null>(null);\n  const [slot, setSlot] = useState({\n    start: defaultStart,\n    duration: defaultDuration,\n  });\n  const [hover, setHover] = useState<number | null>(null);\n  const [focus, setFocus] = useState<number | null>(null);\n  const [dragging, setDragging] = useState(false);\n  const [keyed, setKeyed] = useState(false);\n\n  // The start runs on unbounded: a wedge dragged past midnight keeps going.\n  const start = useMotionValue(defaultStart);\n  const duration = useMotionValue(defaultDuration);\n  const svgRef = useRef<SVGSVGElement>(null);\n  const grabRef = useRef<Grab | null>(null);\n  const lastRef = useRef(0);\n  const glideRef = useRef<ReturnType<typeof animate> | null>(null);\n  const onChangeRef = useRef(onChange);\n  onChangeRef.current = onChange;\n\n  // Offsets depend on the day (DST) and \"now\" on the clock, so both wait for\n  // the browser rather than differ between server and client.\n  const dateKey = date?.getTime();\n  const peopleRef = useRef(people);\n  peopleRef.current = people;\n  // Keyed on the zones, so a new array with the same people doesn't rebuild.\n  const zonesKey = people.map((person) => person.timeZone).join(\"|\");\n  // biome-ignore lint/correctness/useExhaustiveDependencies: rebuilds when the zones change, not the array\n  useEffect(() => {\n    const now = new Date();\n    setDay(\n      buildDay(\n        peopleRef.current,\n        dateKey === undefined ? now : new Date(dateKey),\n        now\n      )\n    );\n  }, [zonesKey, dateKey]);\n\n  const segments = useMemo(\n    () =>\n      day ? people.map((person, i) => segmentsFor(person, day.shifts[i])) : [],\n    [day, people]\n  );\n\n  const wedge = useTransform([start, duration], ([s, d]: number[]) =>\n    slice(WEDGE_IN, WEDGE_OUT, s, s + d)\n  );\n  const startX = useTransform(start, (s) => polar(WEDGE_OUT, s)[0]);\n  const startY = useTransform(start, (s) => polar(WEDGE_OUT, s)[1]);\n  const endX = useTransform(\n    [start, duration],\n    ([s, d]: number[]) => polar(WEDGE_OUT, s + d)[0]\n  );\n  const endY = useTransform(\n    [start, duration],\n    ([s, d]: number[]) => polar(WEDGE_OUT, s + d)[1]\n  );\n\n  // Runs every frame of a drag or glide: only re-render when the readout moves.\n  const sync = useCallback(() => {\n    const nextStart = mod(snap(start.get()), DAY);\n    const nextDuration = duration.get();\n    setSlot((prev) =>\n      prev.start === nextStart && prev.duration === nextDuration\n        ? prev\n        : { start: nextStart, duration: nextDuration }\n    );\n  }, [start, duration]);\n\n  useEffect(() => () => glideRef.current?.stop(), []);\n  useMotionValueEvent(start, \"change\", sync);\n  useMotionValueEvent(duration, \"change\", sync);\n\n  const commit = useCallback(\n    (s: number, d: number) => {\n      if (!day) {\n        return;\n      }\n      const from = mod(s, DAY);\n      onChangeRef.current?.({\n        start: new Date(day.midnight + from * 60_000),\n        end: new Date(day.midnight + (from + d) * 60_000),\n        available: people\n          .filter(\n            (person, i) =>\n              statusFor(person, day.shifts[i], from, d) === \"working\"\n          )\n          .map((person) => person.id),\n      });\n    },\n    [day, people]\n  );\n\n  /** Springs the wedge to a time, the short way round, carrying any fling. */\n  const glideTo = useCallback(\n    (target: number, velocity = 0) => {\n      glideRef.current?.stop();\n      const from = start.get();\n      const to = from + turn(from, snap(target));\n      glideRef.current = animate(\n        start,\n        to,\n        reduceMotion ? { duration: 0 } : { ...GLIDE, velocity }\n      );\n      commit(to, duration.get());\n    },\n    [start, duration, reduceMotion, commit]\n  );\n\n  /** Pointer position as minutes on the dial and distance from its middle. */\n  const pointAt = (clientX: number, clientY: number) => {\n    const rect = svgRef.current?.getBoundingClientRect();\n    if (!rect) {\n      return { minutes: 0, r: 0 };\n    }\n    const scale = SIZE / rect.width;\n    const dx = (clientX - rect.left) * scale - C;\n    const dy = (clientY - rect.top) * scale - C;\n    const angle = Math.atan2(dx, -dy);\n    return { minutes: mod((angle / TAU) * DAY, DAY), r: Math.hypot(dx, dy) };\n  };\n\n  const grabFor = (minutes: number, r: number): Grab | null => {\n    const s = start.get();\n    const d = duration.get();\n    // The edges reach a little outside the wedge and only a little way in,\n    // so a short meeting still has a middle to grab.\n    const reach = (EDGE_REACH / Math.max(r, R_IN)) * (DAY / TAU);\n    const inside = Math.min(reach / 2, d / 4);\n    const fromStart = turn(s, minutes);\n    const fromEnd = turn(s + d, minutes);\n    if (fromStart >= -reach && fromStart <= inside) {\n      return { mode: \"start\", end: s + d };\n    }\n    if (fromEnd >= -inside && fromEnd <= reach) {\n      return { mode: \"end\" };\n    }\n    const into = mod(minutes - s, DAY);\n    return into < d ? { mode: \"move\", offset: into } : null;\n  };\n\n  const ringAt = (r: number) => {\n    for (let i = 0; i < people.length; i++) {\n      const { outer, inner } = ringBounds(i, people.length);\n      if (r <= outer + RING_GAP / 2 && r >= inner - RING_GAP / 2) {\n        return i;\n      }\n    }\n    return null;\n  };\n\n  const onPointerDown = (event: PointerEvent<SVGSVGElement>) => {\n    if (!day || grabRef.current || event.button !== 0) {\n      return;\n    }\n    const { minutes, r } = pointAt(event.clientX, event.clientY);\n    // The middle holds the readout, and past the labels is outside the dial.\n    if (r < WEDGE_IN - 4 || r > LABEL_R + 8) {\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    lastRef.current = minutes;\n    const grab = grabFor(minutes, r);\n    if (grab) {\n      glideRef.current?.stop();\n      grabRef.current = grab;\n    } else {\n      // A press anywhere else brings the meeting there, then keeps\n      // following the pointer if it drags on.\n      const d = duration.get();\n      glideTo(minutes - d / 2);\n      grabRef.current = { mode: \"move\", offset: d / 2 };\n    }\n    setDragging(true);\n    setHover(null);\n  };\n\n  const dragTo = (minutes: number, grab: Grab) => {\n    // Unwrap the pointer so crossing midnight keeps the same direction.\n    const pointer = lastRef.current + turn(lastRef.current, minutes);\n    lastRef.current = pointer;\n    if (grab.mode === \"move\") {\n      start.set(pointer - grab.offset);\n      return;\n    }\n    if (grab.mode === \"end\") {\n      const span = mod(pointer - start.get(), DAY);\n      // Dragged back past the start, the meeting stays at its shortest.\n      const d = span > (MAX_DURATION + DAY) / 2 ? MIN_DURATION : span;\n      duration.set(clamp(snap(d), MIN_DURATION, MAX_DURATION));\n      return;\n    }\n    const span = mod(grab.end - pointer, DAY);\n    const d = clamp(\n      snap(span > (MAX_DURATION + DAY) / 2 ? MIN_DURATION : span),\n      MIN_DURATION,\n      MAX_DURATION\n    );\n    start.set(grab.end - d);\n    duration.set(d);\n  };\n\n  const onPointerMove = (event: PointerEvent<SVGSVGElement>) => {\n    const { minutes, r } = pointAt(event.clientX, event.clientY);\n    const grab = grabRef.current;\n    if (grab) {\n      glideRef.current?.stop();\n      dragTo(minutes, grab);\n      return;\n    }\n    if (event.pointerType !== \"mouse\") {\n      return;\n    }\n    const onDial = r >= WEDGE_IN - 4 && r <= WEDGE_OUT + 4;\n    setHover(onDial ? minutes : null);\n    setFocus(onDial ? ringAt(r) : null);\n  };\n\n  const onPointerUp = () => {\n    const grab = grabRef.current;\n    if (!grab) {\n      return;\n    }\n    grabRef.current = null;\n    setDragging(false);\n    if (grab.mode !== \"move\") {\n      commit(start.get(), duration.get());\n      return;\n    }\n    // A fling coasts on a little, then settles on the quarter hour.\n    const velocity = start.getVelocity();\n    const coast = clamp(velocity * COAST, -120, 120);\n    glideTo(start.get() + coast, velocity);\n  };\n\n  const resize = (by: number) => {\n    const d = clamp(duration.get() + by, MIN_DURATION, MAX_DURATION);\n    duration.set(d);\n    commit(snap(start.get()), d);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const s = snap(start.get());\n    const d = duration.get();\n    const step = event.shiftKey ? 60 : STEP;\n    const moves: Record<string, number> = {\n      ArrowLeft: s - step,\n      ArrowDown: s - step,\n      ArrowRight: s + step,\n      ArrowUp: s + step,\n      PageDown: s - 60,\n      PageUp: s + 60,\n    };\n    if (event.key === \"-\" || event.key === \"+\" || event.key === \"=\") {\n      event.preventDefault();\n      resize(event.key === \"-\" ? -STEP : STEP);\n      return;\n    }\n    if (!(event.key in moves)) {\n      return;\n    }\n    event.preventDefault();\n    // Keys move instantly: they repeat too often to animate.\n    glideRef.current?.stop();\n    start.set(moves[event.key]);\n    commit(moves[event.key], d);\n  };\n\n  const statuses = day\n    ? people.map((person, i) =>\n        statusFor(person, day.shifts[i], slot.start, slot.duration)\n      )\n    : [];\n  const available = statuses.filter((s) => s === \"working\").length;\n  const everyone = day !== null && available === people.length;\n  const best = useMemo(\n    () => (day ? bestStart(people, day.shifts, slot.duration) : null),\n    [day, people, slot.duration]\n  );\n  const atBest = best === slot.start;\n  const end = slot.start + slot.duration;\n  const hours = slot.duration / 60;\n  const lengthLabel =\n    slot.duration % 60\n      ? `${hours >= 1 ? `${Math.floor(hours)} h ` : \"\"}${slot.duration % 60} min`\n      : `${hours} h`;\n  const you = people[0];\n\n  const localRange = (i: number) => {\n    const shift = day?.shifts[i] ?? 0;\n    const local = slot.start + shift;\n    const dayShift = Math.floor(local / DAY);\n    const prefix =\n      dayShift !== 0 && day\n        ? `${WEEKDAYS[mod(day.weekday + dayShift, 7)]} `\n        : \"\";\n    return `${prefix}${clock(local)}–${clock(local + slot.duration)}`;\n  };\n\n  return (\n    <div\n      className={cn(\n        \"@container w-full max-w-2xl select-none rounded-2xl bg-white @md:p-5 p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)] ring-1 ring-black/5 dark:bg-zinc-900 dark:ring-white/10\",\n        className\n      )}\n    >\n      <header className=\"flex items-center gap-3\">\n        <div className=\"min-w-0\">\n          <h3 className=\"font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n            Find a time\n          </h3>\n          <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">\n            {day ? day.label : \" \"} · {people.length} people\n          </p>\n        </div>\n        <button\n          className=\"ml-auto h-8 rounded-lg px-3 font-medium text-sm text-zinc-700 ring-1 ring-black/10 transition-colors duration-150 ease-out hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-emerald-500/60 disabled:cursor-default disabled:text-zinc-400 disabled:hover:bg-transparent dark:text-zinc-200 dark:ring-white/15 dark:disabled:text-zinc-500 dark:hover:bg-white/5 dark:hover:text-white dark:disabled:hover:bg-transparent\"\n          disabled={best === null || atBest}\n          onClick={() => best !== null && glideTo(best)}\n          type=\"button\"\n        >\n          {atBest ? \"Best time\" : \"Find best time\"}\n        </button>\n      </header>\n\n      <div className=\"mt-4 grid @lg:grid-cols-[minmax(0,19rem)_minmax(0,1fr)] grid-cols-1 items-center @lg:gap-6 gap-4\">\n        <div className=\"relative mx-auto aspect-square w-full max-w-[19rem]\">\n          <svg\n            aria-hidden=\"true\"\n            className={cn(\n              \"size-full touch-none overflow-visible\",\n              dragging ? \"cursor-grabbing\" : \"cursor-grab\"\n            )}\n            onPointerCancel={onPointerUp}\n            onPointerDown={onPointerDown}\n            onPointerLeave={() => {\n              setHover(null);\n              setFocus(null);\n            }}\n            onPointerMove={onPointerMove}\n            onPointerUp={onPointerUp}\n            ref={svgRef}\n            viewBox={`0 0 ${SIZE} ${SIZE}`}\n          >\n            <defs>\n              <clipPath id={clipId}>\n                <motion.path d={wedge} />\n              </clipPath>\n            </defs>\n\n            <HourMarks />\n            {day && (\n              <Rings\n                clipId={clipId}\n                day={day}\n                focus={focus}\n                people={people}\n                segments={segments}\n              />\n            )}\n\n            {day?.now !== null && day?.now !== undefined && (\n              <NowHand minutes={day.now} />\n            )}\n            {hover !== null && !dragging && <HoverHand minutes={hover} />}\n\n            <g className={cn(!day && \"opacity-0\")}>\n              <motion.path\n                className={cn(\n                  \"fill-zinc-900/[0.03] transition-[stroke] duration-200 dark:fill-white/[0.04]\",\n                  everyone\n                    ? \"stroke-emerald-600 dark:stroke-emerald-400\"\n                    : \"stroke-zinc-900 dark:stroke-white\"\n                )}\n                d={wedge}\n                strokeLinejoin=\"round\"\n                strokeWidth={keyed ? 2.75 : 1.5}\n              />\n              <Knob x={startX} y={startY} />\n              <Knob x={endX} y={endY} />\n            </g>\n          </svg>\n\n          {/* The readout, in the hole of the dial, is also the keyboard handle */}\n          <div\n            aria-label=\"Meeting time\"\n            aria-valuemax={DAY - STEP}\n            aria-valuemin={0}\n            aria-valuenow={slot.start}\n            aria-valuetext={`${clock(slot.start)} to ${clock(end)} ${you?.city ?? \"\"} time, ${available} of ${people.length} in working hours`}\n            className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center outline-hidden\"\n            onBlur={() => setKeyed(false)}\n            onFocus={(event) =>\n              setKeyed(event.currentTarget.matches(\":focus-visible\"))\n            }\n            onKeyDown={onKeyDown}\n            role=\"slider\"\n            tabIndex={0}\n          >\n            <span className=\"font-semibold text-[22px] text-zinc-900 leading-none tracking-tight dark:text-zinc-50\">\n              <Rolling value={clock(slot.start)} />\n            </span>\n            <span className=\"mt-1 text-[11px] text-zinc-500 leading-none dark:text-zinc-400\">\n              to <Rolling value={clock(end)} />\n            </span>\n            <span aria-hidden=\"true\" className=\"mt-2 flex gap-1\">\n              {people.map((person, i) => (\n                <span\n                  className={cn(\n                    \"size-1.5 rounded-full transition-colors duration-200\",\n                    statuses[i] === \"working\"\n                      ? \"bg-emerald-500 dark:bg-emerald-400\"\n                      : \"bg-zinc-200 dark:bg-zinc-700\"\n                  )}\n                  key={person.id}\n                />\n              ))}\n            </span>\n          </div>\n        </div>\n\n        <ul className=\"flex flex-col\">\n          {people.map((person, i) => (\n            <PersonRow\n              count={people.length}\n              dimmed={focus !== null && focus !== i}\n              index={i}\n              key={person.id}\n              onHover={setFocus}\n              person={person}\n              range={day ? localRange(i) : \"\"}\n              shiftLabel={day?.shiftLabels[i] ?? \"\"}\n              status={statuses[i]}\n            />\n          ))}\n        </ul>\n      </div>\n\n      <footer className=\"mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 border-black/5 border-t pt-3 text-xs dark:border-white/10\">\n        <span className=\"text-zinc-500 dark:text-zinc-400\">\n          {you?.city ?? \"Your\"} time\n        </span>\n        <div className=\"flex items-center rounded-lg ring-1 ring-black/10 dark:ring-white/15\">\n          <StepButton\n            disabled={slot.duration <= MIN_DURATION}\n            label=\"Shorter\"\n            onClick={() => resize(-STEP)}\n          >\n            −\n          </StepButton>\n          <span className=\"min-w-16 text-center text-zinc-700 tabular-nums dark:text-zinc-200\">\n            {lengthLabel}\n          </span>\n          <StepButton\n            disabled={slot.duration >= MAX_DURATION}\n            label=\"Longer\"\n            onClick={() => resize(STEP)}\n          >\n            +\n          </StepButton>\n        </div>\n        <span\n          aria-live=\"polite\"\n          className={cn(\n            \"ml-auto transition-colors duration-150\",\n            everyone\n              ? \"text-emerald-600 dark:text-emerald-400\"\n              : \"text-zinc-500 dark:text-zinc-400\"\n          )}\n        >\n          <span className=\"font-medium text-zinc-900 tabular-nums dark:text-zinc-100\">\n            {day ? available : \"–\"}\n          </span>{\" \"}\n          of {people.length} in working hours\n        </span>\n      </footer>\n    </div>\n  );\n}\n\n/** Hour ticks round the rim, with a label every three hours in place of its tick. */\nfunction HourMarks() {\n  return (\n    <g>\n      {HOURS.map((hour) => {\n        if (hour % 3 === 0) {\n          const [x, y] = polar(LABEL_R - 6, hour * 60);\n          return (\n            <text\n              className=\"fill-zinc-400 font-medium text-[9.5px] tabular-nums dark:fill-zinc-500\"\n              dominantBaseline=\"central\"\n              key={hour}\n              textAnchor=\"middle\"\n              x={x}\n              y={y}\n            >\n              {pad(hour)}\n            </text>\n          );\n        }\n        const [x0, y0] = polar(R_OUT + 11, hour * 60);\n        const [x1, y1] = polar(R_OUT + 15, hour * 60);\n        return (\n          <line\n            className=\"stroke-zinc-300 dark:stroke-zinc-700\"\n            key={hour}\n            strokeWidth={1}\n            x1={x0}\n            x2={x1}\n            y1={y0}\n            y2={y1}\n          />\n        );\n      })}\n    </g>\n  );\n}\n\nfunction Rings({\n  day,\n  segments,\n  people,\n  focus,\n  clipId,\n}: {\n  day: Day;\n  segments: Segment[][];\n  people: PlannerPerson[];\n  focus: number | null;\n  clipId: string;\n}) {\n  return (\n    <g\n      className={cn(\n        \"transition-opacity duration-300 ease-out\",\n        \"opacity-100 starting:opacity-0\"\n      )}\n    >\n      {segments.map((ring, i) => (\n        <g\n          className={cn(\n            \"transition-opacity duration-150 ease-out\",\n            focus !== null && focus !== i && \"opacity-35\"\n          )}\n          key={people[i]?.id ?? i}\n        >\n          <Ring\n            count={people.length}\n            index={i}\n            lit={false}\n            segments={ring}\n            shift={day.shifts[i]}\n          />\n          <g clipPath={`url(#${clipId})`}>\n            <Ring\n              count={people.length}\n              index={i}\n              lit\n              segments={ring}\n              shift={day.shifts[i]}\n            />\n          </g>\n        </g>\n      ))}\n    </g>\n  );\n}\n\nfunction NowHand({ minutes }: { minutes: number }) {\n  const [x0, y0] = polar(WEDGE_IN - 2, minutes);\n  const [x1, y1] = polar(R_OUT + 14, minutes);\n  return (\n    <g className=\"pointer-events-none\">\n      <line\n        className=\"stroke-orange-500\"\n        strokeLinecap=\"round\"\n        strokeWidth={1.5}\n        x1={x0}\n        x2={x1}\n        y1={y0}\n        y2={y1}\n      />\n      <circle className=\"fill-orange-500\" cx={x1} cy={y1} r={2.5} />\n    </g>\n  );\n}\n\nfunction HoverHand({ minutes }: { minutes: number }) {\n  const [x0, y0] = polar(WEDGE_IN, minutes);\n  const [x1, y1] = polar(WEDGE_OUT, minutes);\n  return (\n    <line\n      className=\"pointer-events-none stroke-zinc-900/30 dark:stroke-white/35\"\n      strokeWidth={1}\n      x1={x0}\n      x2={x1}\n      y1={y0}\n      y2={y1}\n    />\n  );\n}\n\n/** Which ring is theirs: the same circles as the dial, theirs drawn in. */\nfunction RingGlyph({ index, count }: { index: number; count: number }) {\n  return (\n    <svg aria-hidden=\"true\" className=\"size-4 shrink-0\" viewBox=\"0 0 16 16\">\n      {Array.from({ length: count }, (_, i) => {\n        const r = 7 - (i * 4.5) / Math.max(1, count - 1);\n        return (\n          <circle\n            className={\n              i === index\n                ? \"stroke-zinc-900 dark:stroke-zinc-100\"\n                : \"stroke-zinc-200 dark:stroke-zinc-700\"\n            }\n            cx={8}\n            cy={8}\n            fill=\"none\"\n            key={r}\n            r={r}\n            strokeWidth={i === index ? 1.5 : 1}\n          />\n        );\n      })}\n    </svg>\n  );\n}\n\nfunction PersonRow({\n  person,\n  index,\n  count,\n  status,\n  range,\n  shiftLabel: shift,\n  dimmed,\n  onHover,\n}: {\n  person: PlannerPerson;\n  index: number;\n  count: number;\n  status?: Status;\n  range: string;\n  shiftLabel: string;\n  dimmed: boolean;\n  onHover: (index: number | null) => void;\n}) {\n  return (\n    <li\n      className={cn(\n        \"flex items-center gap-3 border-black/5 border-b py-2 transition-opacity duration-150 ease-out last:border-b-0 dark:border-white/10\",\n        dimmed && \"opacity-45\"\n      )}\n      onPointerEnter={(event) =>\n        event.pointerType === \"mouse\" && onHover(index)\n      }\n      onPointerLeave={() => onHover(null)}\n      title={person.name}\n    >\n      <RingGlyph count={count} index={index} />\n      <div className=\"flex min-w-0 flex-1 flex-col\">\n        <span className=\"flex min-w-0 items-baseline gap-1.5\">\n          <span className=\"truncate font-medium text-[13px] text-zinc-900 leading-5 dark:text-zinc-100\">\n            {person.city}\n          </span>\n          <span className=\"shrink-0 text-[11px] text-zinc-400 tabular-nums dark:text-zinc-500\">\n            {index === 0 ? \"You\" : shift}\n          </span>\n        </span>\n        <span className=\"truncate text-[11px] text-zinc-500 leading-4 dark:text-zinc-400\">\n          {person.name === \"You\" ? \"Your clock\" : person.name}\n        </span>\n      </div>\n      <div className=\"flex shrink-0 flex-col items-end\">\n        <span className=\"text-[12px] text-zinc-700 leading-5 dark:text-zinc-200\">\n          {range ? <Rolling value={range} /> : \" \"}\n        </span>\n        <span\n          className={cn(\n            \"text-[11px] leading-4 transition-colors duration-150\",\n            status ? STATUS_TEXT[status] : \"text-zinc-400\"\n          )}\n        >\n          {status ? STATUS_LABEL[status] : \" \"}\n        </span>\n      </div>\n      <span className=\"sr-only\">\n        {person.name}, {range} {status ? STATUS_LABEL[status] : \"\"}\n      </span>\n    </li>\n  );\n}\n\nfunction StepButton({\n  children,\n  label,\n  disabled,\n  onClick,\n}: {\n  children: string;\n  label: string;\n  disabled: boolean;\n  onClick: () => void;\n}) {\n  return (\n    <button\n      aria-label={label}\n      className=\"flex size-7 items-center justify-center rounded-lg text-sm text-zinc-500 transition-colors duration-150 ease-out hover:text-zinc-900 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-emerald-500/60 disabled:cursor-default disabled:text-zinc-300 dark:text-zinc-400 dark:disabled:text-zinc-600 dark:hover:text-white\"\n      disabled={disabled}\n      onClick={onClick}\n      type=\"button\"\n    >\n      {children}\n    </button>\n  );\n}\n",
      "path": "/components/kokonutui/timezone-planner.tsx",
      "target": "components/kokonutui/timezone-planner.tsx"
    }
  ]
}