{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "week-schedule",
  "title": "Week schedule",
  "description": "A week by the hour with events in side-by-side lanes when they overlap, 44 px minimum blocks, a now line in the signal colour, and day tabs when the columns get too narrow.",
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit",
    "@manniche/data-tile"
  ],
  "files": [
    {
      "path": "registry/manniche/week-schedule/week-schedule.tsx",
      "content": "// A week by the hour: a time rail on the left, one column per day, and the events as coloured blocks with their\n// title and time. Events that overlap sit side by side in lanes. Every block is at least 44 px tall, so a\n// 15-minute slot stays readable and easy to hit, and the lanes are worked out from that drawn height, so short\n// events never cover each other. The \"now\" line, its dot and its time on the rail are the one thing in --primary.\n// When the columns would get narrower than `minDayWidth`, the week turns into day tabs over a single-day grid.\n// Screen readers: each day is a list (\"Thu 8 Oct, today, now 14:20, 4 events\") of events read as \"Team sync,\n// 14:30 to 15:30, Work\". The schedule is one Tab stop: arrow keys walk the events (up and down within a day, left and\n// right to the nearest event on the next day), Home and End jump to the first and last, Escape clears. The active\n// event is read once through a polite live region and shows the same tooltip as a hover or a tap. With `onSelect`\n// the events are buttons in a roving tab order instead.\n// Reduced motion: the events are in place at once instead of growing in.\nimport { useId, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent } from 'react'\nimport { ChartTooltip } from '@/registry/manniche/chart-kit/chart-kit'\nimport { EASE_CSS, GRID, WELL, seriesColor } from '@/registry/manniche/chart-kit/chart-utils'\nimport { useAnnounce, useChartFrame } from '@/registry/manniche/chart-kit/use-chart'\nimport { TileLegend } from '@/registry/manniche/data-tile/data-tile'\nimport { cn } from '@/lib/utils'\n\nexport type ScheduleEvent = {\n  /** Unique and stable. */\n  id: string\n  title: string\n  /** The day, counted from `weekStart`: 0 is the first column. */\n  day: number\n  /** \"HH:MM\", 24-hour. */\n  start: string\n  /** \"HH:MM\", 24-hour, after `start`. */\n  end: string\n  /** The id of one of `categories`. */\n  category?: string\n  /** Overrides the category's colour. */\n  color?: string\n}\n\nexport type ScheduleCategory = {\n  id: string\n  /** Shown in the legend and read after each event. */\n  label: string\n  /** Default the series colours in order. */\n  color?: string\n}\n\nexport type WeekScheduleLabels = {\n  /** The heading of a day, such as \"Mon 5 Oct\". */\n  day?: (date: Date, locale: string) => string\n  today?: string\n  now?: (time: string) => string\n  /** The event count after a day's name. */\n  count?: (n: number) => string\n  /** One event, read as a sentence. */\n  event?: (e: ScheduleEvent, parts: { start: string; end: string; category: string }) => string\n  /** The length in the tooltip. */\n  duration?: (minutes: number) => string\n  /** Read after the schedule's name. */\n  hint?: string\n  /** The name of the day tabs. */\n  days?: string\n}\n\nexport type WeekScheduleProps = Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> & {\n  /** The events of the week. */\n  data: ScheduleEvent[]\n  /** The accessible name, such as \"This week\". */\n  label: string\n  /** \"YYYY-MM-DD\" of the first column. */\n  weekStart: string\n  /** How many days to show. Default 7. */\n  days?: number\n  /** The categories, in legend order. Without them events use their own `color` or the first series colour. */\n  categories?: ScheduleCategory[]\n  /** \"YYYY-MM-DDTHH:MM\". Draws the now line when it falls inside the week and marks that day as today. */\n  now?: string\n  /** The first hour on the rail. Default 8. */\n  startHour?: number\n  /** The hour the grid ends. Default 21. */\n  endHour?: number\n  /** The height of an hour in px. Default 46, or 40 inside a compact tile. */\n  hourHeight?: number\n  /** \"auto\" shows the week while each day gets `minDayWidth`, then day tabs. Default \"auto\". */\n  layout?: 'auto' | 'week' | 'day'\n  /** The narrowest a day column may get before \"auto\" switches to day tabs. Default 60. */\n  minDayWidth?: number\n  /** The day shown in day tabs (controlled). */\n  day?: number\n  /** The day shown first in day tabs. Default today, else the first day. */\n  defaultDay?: number\n  onDayChange?: (day: number) => void\n  /** Makes each event a button and is called with it. */\n  onSelect?: (event: ScheduleEvent) => void\n  /** Show the category legend under the grid. Default true. */\n  legend?: boolean\n  /** Number and date language. Default \"en-GB\". */\n  locale?: string\n  labels?: WeekScheduleLabels\n}\n\nconst DEFAULT_LABELS: Required<WeekScheduleLabels> = {\n  day: (d, locale) =>\n    new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }).format(d).replace(',', ''),\n  today: 'today',\n  now: (t) => `Now, ${t}`,\n  count: (n) => (n === 0 ? 'no events' : n === 1 ? '1 event' : `${n} events`),\n  event: (e, p) => [e.title, `${p.start} to ${p.end}`, p.category].filter(Boolean).join(', '),\n  duration: (m) => (m <= 90 ? `${m} min` : m % 60 ? `${Math.floor(m / 60)} h ${m % 60} min` : `${m / 60} h`),\n  hint: 'Use the arrow keys to move between events.',\n  days: 'Day',\n}\n\nconst RAIL = 46\nconst GAP = 6\nconst MIN_PX = 44\n/** Below this block width a title cannot be read, so the block keeps only its colour bar; the name is in the tooltip. */\nconst MIN_TEXT_PX = 56\n/** Below this block height there is room for the title only, not the time under it. */\nconst TIME_PX = 58\n\n/** A word's width in em in the semibold title face, guessed from its letters so server and client agree. */\nfunction wordEm(word: string) {\n  let em = 0\n  for (const ch of word) em += /[mwMW@]/.test(ch) ? 0.86 : /[A-Z&]/.test(ch) ? 0.68 : /[iljtfr.,'’:;!|]/.test(ch) ? 0.34 : 0.58\n  return em\n}\n\nconst toMin = (t: string) => {\n  const [h = 0, m = 0] = t.split(':').map(Number)\n  return h * 60 + m\n}\nconst dayNumber = (iso: string) => {\n  const [y, m, d] = iso.slice(0, 10).split('-').map(Number)\n  return Math.round(Date.UTC(y, m - 1, d) / 86400000)\n}\n\ntype Placed = ScheduleEvent & { s: number; e: number; lane: number; lanes: number; color: string; cat: string }\ntype Tip = { id: string; x: number; y: number; below: boolean; from: 'pointer' | 'key' }\n\n/** Puts overlapping events side by side. Overlap is judged on the drawn block, which is at least 44 px tall. */\nfunction placeDay(events: Placed[], minMinutes: number) {\n  const sorted = [...events].sort((a, b) => a.s - b.s || b.e - a.e)\n  let cluster: Placed[] = []\n  let clusterEnd = -Infinity\n  const laneEnds: number[] = []\n  const flush = () => {\n    const lanes = Math.max(1, laneEnds.length)\n    for (const p of cluster) p.lanes = lanes\n    cluster = []\n    laneEnds.length = 0\n  }\n  for (const p of sorted) {\n    const visualEnd = Math.max(p.e, p.s + minMinutes)\n    if (p.s >= clusterEnd) flush()\n    let lane = laneEnds.findIndex((end) => end <= p.s)\n    if (lane === -1) lane = laneEnds.length\n    laneEnds[lane] = visualEnd\n    p.lane = lane\n    cluster.push(p)\n    clusterEnd = Math.max(clusterEnd === -Infinity ? 0 : clusterEnd, visualEnd)\n  }\n  flush()\n  return sorted\n}\n\nexport function WeekSchedule({\n  data,\n  label,\n  weekStart,\n  days = 7,\n  categories,\n  now,\n  startHour = 8,\n  endHour = 21,\n  hourHeight,\n  layout = 'auto',\n  minDayWidth = 60,\n  day: dayProp,\n  defaultDay,\n  onDayChange,\n  onSelect,\n  legend = true,\n  locale = 'en-GB',\n  labels: labelsProp,\n  className,\n  style,\n  ...rest\n}: WeekScheduleProps) {\n  const labels = { ...DEFAULT_LABELS, ...labelsProp }\n  const { ref, width, drawn, reduced } = useChartFrame<HTMLDivElement>()\n  const { ref: bodyRef, height: bodyHeight } = useChartFrame<HTMLDivElement>()\n  const { say, region } = useAnnounce()\n  const uid = useId()\n  const hours = Math.max(1, endHour - startHour)\n  const span = hours * 60\n  const rh = bodyHeight ? bodyHeight / hours : (hourHeight ?? 46)\n  const minMinutes = ((MIN_PX + 4) / rh) * 60\n\n  const startN = dayNumber(weekStart)\n  const dates = Array.from({ length: days }, (_, i) => new Date((startN + i) * 86400000))\n  const nowDay = now ? dayNumber(now) - startN : -1\n  const nowMin = now && now.length > 10 ? toMin(now.slice(11, 16)) : -1\n  const today = nowDay >= 0 && nowDay < days ? nowDay : -1\n  const nowInGrid = today >= 0 && nowMin >= startHour * 60 && nowMin <= endHour * 60\n\n  const timeFmt = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })\n  const clock = (min: number) => timeFmt.format(new Date(min * 60000))\n  const weekdayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' })\n  const dateFmt = new Intl.DateTimeFormat(locale, { day: 'numeric', timeZone: 'UTC' })\n\n  const catIndex = new Map((categories ?? []).map((c, i) => [c.id, { ...c, color: c.color ?? seriesColor(i) }]))\n  const byDay: Placed[][] = Array.from({ length: days }, () => [])\n  for (const ev of data) {\n    if (ev.day < 0 || ev.day >= days) continue\n    // An event wholly outside the shown hours has no place on the grid; one that overlaps an edge is clamped.\n    if (toMin(ev.end) <= startHour * 60 || toMin(ev.start) >= endHour * 60) continue\n    const c = ev.category ? catIndex.get(ev.category) : undefined\n    byDay[ev.day].push({\n      ...ev,\n      s: toMin(ev.start),\n      e: Math.max(toMin(ev.end), toMin(ev.start) + 1),\n      lane: 0,\n      lanes: 1,\n      color: ev.color ?? c?.color ?? seriesColor(0),\n      cat: c?.label ?? '',\n    })\n  }\n  const placed = byDay.map((list) => placeDay(list, minMinutes))\n  // Reading order inside a day: by start, then lane.\n  const ordered = placed.map((list) => [...list].sort((a, b) => a.s - b.s || a.lane - b.lane))\n  const all = ordered.flat()\n\n  // Layout and day tabs ------------------------------------------------------------------------------------------\n  const colWidth = width ? (width - RAIL - GAP * days) / days : Infinity\n  const mode = layout === 'auto' ? (colWidth < minDayWidth ? 'day' : 'week') : layout\n  const [innerDay, setInnerDay] = useState(defaultDay ?? (today >= 0 ? today : 0))\n  const shownDay = Math.min(days - 1, Math.max(0, dayProp ?? innerDay))\n  const setDay = (d: number) => {\n    if (dayProp === undefined) setInnerDay(d)\n    onDayChange?.(d)\n  }\n  const columns = mode === 'week' ? Array.from({ length: days }, (_, i) => i) : [shownDay]\n\n  // Active event, tooltip ----------------------------------------------------------------------------------------\n  const [active, setActive] = useState<string | null>(null)\n  const [tip, setTip] = useState<Tip | null>(null)\n  const find = (id: string | null) => all.find((p) => p.id === id)\n\n  const sentence = (p: Placed) => labels.event(p, { start: clock(p.s), end: clock(p.e), category: p.cat })\n  const dayName = (d: number) => labels.day(dates[d], locale)\n\n  const tipAt = (el: Element, id: string, from: Tip['from']): Tip | null => {\n    const root = el.closest('[data-ws-root]')\n    if (!root) return null\n    const r = root.getBoundingClientRect()\n    const b = el.getBoundingClientRect()\n    const below = b.top - r.top < 96\n    return { id, x: b.left - r.left + b.width / 2, y: below ? b.bottom - r.top : b.top - r.top, below, from }\n  }\n\n  const activate = (root: Element, id: string, speak: boolean) => {\n    const p = find(id)\n    if (!p) return\n    if (mode === 'day' && p.day !== shownDay) setDay(p.day)\n    setActive(id)\n    if (speak) say(sentence(p))\n    // The block may only exist after the day switch, so measure on the next frame.\n    requestAnimationFrame(() => {\n      const el = root.querySelector(`[data-ev=\"${CSS.escape(id)}\"]`)\n      if (!el) return\n      setTip(tipAt(el, id, 'key'))\n      if (onSelect) (el as HTMLElement).focus()\n    })\n  }\n\n  // The event the keyboard starts from: the next one still to come, else the first.\n  const startEvent = () => {\n    const pool = mode === 'day' ? ordered[shownDay] : all\n    if (today >= 0) {\n      const upcoming = pool.find((p) => p.day > today || (p.day === today && p.e > nowMin))\n      if (upcoming) return upcoming\n    }\n    return pool[0]\n  }\n  const rovingId = (active && find(active) && (mode === 'week' || find(active)?.day === shownDay) ? active : startEvent()?.id) ?? null\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const root = e.currentTarget\n    if (e.key === 'Escape') {\n      if (!tip && !active) return\n      setTip(null)\n      if (!onSelect) setActive(null)\n      e.preventDefault()\n      return\n    }\n    if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return\n    e.preventDefault()\n    const cur = find(onSelect ? rovingId : active)\n    let to: Placed | undefined\n    if (!cur) to = startEvent()\n    else if (e.key === 'Home' || e.key === 'End') {\n      const pool = mode === 'day' ? ordered[cur.day] : all\n      to = e.key === 'Home' ? pool[0] : pool[pool.length - 1]\n    } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {\n      const list = ordered[cur.day]\n      const i = list.indexOf(cur)\n      to = list[Math.max(0, Math.min(list.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]\n    } else {\n      const dir = e.key === 'ArrowRight' ? 1 : -1\n      for (let d = cur.day + dir; d >= 0 && d < days; d += dir) {\n        if (!ordered[d].length) continue\n        to = ordered[d].reduce((best, p) => (Math.abs(p.s - cur.s) < Math.abs(best.s - cur.s) ? p : best))\n        break\n      }\n      to ??= cur\n    }\n    if (to) activate(root, to.id, !onSelect)\n  }\n\n  const onPointerOver = (e: PointerEvent<HTMLElement>, id: string) => {\n    if (e.pointerType === 'touch') return\n    setTip(tipAt(e.currentTarget, id, 'pointer'))\n  }\n  const onPointerLeave = (e: PointerEvent<HTMLElement>) => {\n    if (e.pointerType === 'touch') return\n    setTip((t) => (t?.from === 'pointer' ? null : t))\n  }\n  // Touch: a tap shows the tooltip, and sliding the finger across the blocks moves it.\n  const onTouch = (e: PointerEvent<HTMLDivElement>) => {\n    if (e.pointerType !== 'touch') return\n    const el = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-ev]')\n    if (el) setTip(tipAt(el, el.getAttribute('data-ev') ?? '', 'pointer'))\n    else if (e.type === 'pointerdown') setTip(null)\n  }\n\n  const tipEvent = find(tip?.id ?? null)\n  const nowPct = ((nowMin - startHour * 60) / span) * 100\n  const legendItems = [\n    ...(categories ?? []).map((c, i) => ({ label: c.label, color: c.color ?? seriesColor(i), shape: 'square' as const })),\n    ...(nowInGrid ? [{ label: labels.now(clock(nowMin)), color: 'var(--primary)', shape: 'dot' as const }] : []),\n  ]\n  const gridCols = { gridTemplateColumns: `${RAIL}px repeat(${columns.length}, minmax(0, 1fr))`, columnGap: GAP } as CSSProperties\n  // The width of one day column in px, to decide per block whether its title fits.\n  const dayPx = width ? (width - RAIL - GAP * columns.length) / columns.length : Infinity\n  let stagger = 0\n\n  return (\n    <div\n      ref={ref}\n      data-ws-root=\"\"\n      className={cn(\n        'relative grid w-full min-w-0 gap-2.5',\n        hourHeight === undefined && '[--rh:46px] group-data-[density=compact]/tile:[--rh:40px]',\n        className,\n      )}\n      style={{ ...(hourHeight !== undefined ? ({ '--rh': `${hourHeight}px` } as CSSProperties) : null), ...style }}\n      {...rest}\n    >\n      {mode === 'week' ? (\n        <div aria-hidden className=\"grid\" style={gridCols}>\n          <span />\n          {columns.map((d) => (\n            <span key={d} className=\"grid min-w-0 justify-items-center gap-0.5 text-center\">\n              <span className=\"text-[12.5px] leading-[1.3] font-medium text-muted-foreground\">{weekdayFmt.format(dates[d])}</span>\n              <DateMark text={dateFmt.format(dates[d])} on={d === today} />\n            </span>\n          ))}\n        </div>\n      ) : (\n        <DayTabs\n          days={days}\n          shown={shownDay}\n          today={today}\n          label={`${label}, ${labels.days}`}\n          tabId={(d) => `${uid}-tab${d}`}\n          panelId={`${uid}-panel`}\n          weekday={(d) => weekdayFmt.format(dates[d])}\n          date={(d) => dateFmt.format(dates[d])}\n          name={(d) => `${dayName(d)}${d === today ? `, ${labels.today}` : ''}`}\n          onPick={(d) => {\n            setDay(d)\n            setTip(null)\n          }}\n        />\n      )}\n\n      <div\n        role={mode === 'day' ? 'tabpanel' : 'group'}\n        id={mode === 'day' ? `${uid}-panel` : undefined}\n        aria-labelledby={mode === 'day' ? `${uid}-tab${shownDay}` : undefined}\n        aria-label={mode === 'day' ? undefined : label}\n        aria-describedby={onSelect ? undefined : `${uid}-hint`}\n        tabIndex={onSelect || !all.length ? undefined : 0}\n        onKeyDown={onKeyDown}\n        onPointerDown={onTouch}\n        onPointerMove={onTouch}\n        onBlur={(e) => {\n          if (e.currentTarget.contains(e.relatedTarget)) return\n          setTip((t) => (t?.from === 'key' ? null : t))\n          setActive(null)\n        }}\n        className=\"relative grid touch-pan-y rounded-[14px] outline-offset-4\"\n        style={gridCols}\n      >\n        <span id={`${uid}-hint`} className=\"sr-only\">\n          {labels.hint}\n        </span>\n        {/* The rail: hour labels on the lines, and the time of now in the signal colour. */}\n        <div ref={bodyRef} aria-hidden className=\"relative\" style={{ height: `calc(var(--rh) * ${hours})` }}>\n          {Array.from({ length: hours - 1 }, (_, i) => {\n            const h = startHour + i + 1\n            // The now pill wins over an hour label it would touch.\n            if (nowInGrid && columns.includes(today) && (Math.abs(nowMin - h * 60) / 60) * rh < 20) return null\n            return (\n              <span\n                key={h}\n                className=\"absolute right-2 -translate-y-1/2 font-mono text-[11px] leading-none text-muted-foreground tabular-nums\"\n                style={{ top: `${((i + 1) / hours) * 100}%` }}\n              >\n                {clock(h * 60)}\n              </span>\n            )\n          })}\n          {nowInGrid && columns.includes(today) && (\n            <span\n              className=\"absolute right-0.5 z-10 -translate-y-1/2 rounded-full bg-primary px-1.5 py-[3px] font-mono text-[10.5px] leading-none font-medium text-primary-foreground tabular-nums\"\n              style={{ top: `${nowPct}%` }}\n            >\n              {clock(nowMin)}\n            </span>\n          )}\n        </div>\n\n        {columns.map((d) => {\n          const isToday = d === today\n          const col = isToday ? 'color-mix(in oklab, var(--foreground) 8%, var(--card))' : WELL\n          const name = [dayName(d), isToday && labels.today, isToday && nowInGrid && labels.now(clock(nowMin)), labels.count(ordered[d].length)]\n            .filter(Boolean)\n            .join(', ')\n          return (\n            <div key={d} className=\"relative min-w-0 rounded-[14px]\" style={{ background: col, '--col': col } as CSSProperties}>\n              <svg aria-hidden className=\"pointer-events-none absolute inset-0 size-full overflow-visible\">\n                {Array.from({ length: hours - 1 }, (_, i) => {\n                  const y = `${((i + 1) / hours) * 100}%`\n                  return <line key={i} x1=\"0\" x2=\"100%\" y1={y} y2={y} stroke={GRID} strokeWidth={1} strokeDasharray=\"2 4\" />\n                })}\n              </svg>\n              <ul aria-label={name} className=\"absolute inset-0\">\n                {ordered[d].map((p) => {\n                  const top = Math.max(0, (p.s - startHour * 60) / span) * 100\n                  const bottom = Math.min(1, (p.e - startHour * 60) / span) * 100\n                  const k = stagger++\n                  const isActive = (tip?.id === p.id && tip.from === 'key') || active === p.id\n                  const dur = p.e - p.s\n                  const blockW = dayPx / p.lanes - 6\n                  const blockH = Math.max(MIN_PX, (dur / 60) * rh - 4)\n                  // Too narrow for a readable word: only the colour bar shows; the name is read aloud and in the tooltip.\n                  const short = blockH < TIME_PX\n                  const small = blockW < 92\n                  // The longest word must fit on a line whole, so a title is never cut to \"Sta…\" or broken as \"Desig-n\".\n                  const room = blockW - (small ? 21 : 23)\n                  const longest = Math.max(0, ...p.title.split(/\\s+/).map((w) => wordEm(w))) * (small ? 11.5 : 12.5)\n                  // The guess is kept a tenth wide, so a near miss drops the text rather than break a word.\n                  const bare = blockW < MIN_TEXT_PX || longest * 1.1 > room\n                  const inner = (\n                    <>\n                      <span className=\"sr-only\">{sentence(p)}</span>\n                      <i\n                        aria-hidden\n                        className={cn('absolute left-1.5 w-[3px] rounded-full', short ? 'top-1.5 bottom-1.5' : 'top-2 bottom-2')}\n                        style={{ background: p.color }}\n                      />\n                      {!bare && (\n                        <span\n                          aria-hidden\n                          className={cn(\n                            'line-clamp-2 block text-[12.5px] leading-[1.2] font-semibold [overflow-wrap:break-word]',\n                            small && 'text-[11.5px] leading-[1.15]',\n                          )}\n                        >\n                          {p.title}\n                        </span>\n                      )}\n                      {!bare && !short && (\n                        <span\n                          aria-hidden\n                          className=\"mt-0.5 block truncate font-mono text-[10.5px] leading-[1.3] tabular-nums text-[color-mix(in_oklab,var(--foreground)_70%,transparent)]\"\n                        >\n                          <span className=\"hidden @[92px]:inline\">\n                            {clock(p.s)}–{clock(p.e)}\n                          </span>\n                          <span className=\"hidden @[52px]:inline @[92px]:hidden\">{clock(p.s)}</span>\n                        </span>\n                      )}\n                    </>\n                  )\n                  const box = cn(\n                    '@container absolute block overflow-hidden rounded-[11px] text-left text-foreground',\n                    bare ? 'p-0' : cn(short ? 'flex flex-col justify-center py-1' : 'py-[7px]', small ? 'pr-1.5 pl-[15px]' : 'pr-2 pl-[15px]'),\n                    'shadow-[0_0_0_2px_var(--col)] [transform-origin:top]',\n                    isActive && 'outline-2 outline-offset-0 outline-ring',\n                  )\n                  const boxStyle = {\n                    top: `min(calc(${top}% + 2px), calc(100% - ${MIN_PX + 2}px))`,\n                    height: `max(${MIN_PX}px, calc(${bottom - top}% - 4px))`,\n                    left: `calc(${(p.lane / p.lanes) * 100}% + 3px)`,\n                    width: `calc(${100 / p.lanes}% - 6px)`,\n                    background: `color-mix(in oklab, ${p.color} 24%, var(--card))`,\n                    opacity: drawn ? 1 : 0,\n                    transform: drawn ? 'none' : 'scaleY(0.6)',\n                    transition: reduced ? 'none' : `opacity 300ms ${EASE_CSS}, transform 300ms ${EASE_CSS}`,\n                    transitionDelay: drawn && !reduced ? `${Math.min(k * 15, 300)}ms` : undefined,\n                  } as CSSProperties\n                  const handlers = {\n                    'data-ev': p.id,\n                    'data-minutes': dur,\n                    onPointerEnter: (e: PointerEvent<HTMLElement>) => onPointerOver(e, p.id),\n                    onPointerLeave,\n                  }\n                  return (\n                    <li key={p.id}>\n                      {onSelect ? (\n                        <button\n                          type=\"button\"\n                          {...handlers}\n                          tabIndex={p.id === rovingId ? 0 : -1}\n                          onFocus={(e) => {\n                            setActive(p.id)\n                            setTip(tipAt(e.currentTarget, p.id, 'key'))\n                          }}\n                          onClick={() => onSelect(p)}\n                          className={cn(box, 'cursor-pointer focus-visible:outline-offset-0')}\n                          style={boxStyle}\n                        >\n                          {inner}\n                        </button>\n                      ) : (\n                        <div {...handlers} className={box} style={boxStyle}>\n                          {inner}\n                        </div>\n                      )}\n                    </li>\n                  )\n                })}\n              </ul>\n              {isToday && nowInGrid && (\n                <span aria-hidden className=\"pointer-events-none absolute inset-x-0 z-10 h-0.5 -translate-y-1/2 bg-primary\" style={{ top: `${nowPct}%` }}>\n                  <i className=\"absolute top-1/2 -left-[5px] size-2.5 -translate-y-1/2 rounded-full bg-primary shadow-[0_0_0_2px_var(--card)]\" />\n                </span>\n              )}\n            </div>\n          )\n        })}\n      </div>\n\n      {tipEvent && tip && (\n        <ChartTooltip\n          open\n          x={tip.x}\n          y={tip.y}\n          bounds={width}\n          below={tip.below}\n          title={[dayName(tipEvent.day), tipEvent.cat].filter(Boolean).join(' · ')}\n          value={tipEvent.title}\n          rows={[{ label: `${clock(tipEvent.s)}–${clock(tipEvent.e)}`, value: labels.duration(tipEvent.e - tipEvent.s), color: tipEvent.color }]}\n        />\n      )}\n      {legend && legendItems.length > 0 && <TileLegend items={legendItems} className=\"mt-1.5\" />}\n      {region}\n    </div>\n  )\n}\n\nfunction DateMark({ text, on, small = false }: { text: string; on: boolean; small?: boolean }) {\n  return (\n    <span\n      className={cn(\n        'grid place-items-center rounded-full leading-none font-extrabold tabular-nums',\n        small ? 'size-8 text-[17px]' : 'size-[38px] text-[22px] group-data-[density=compact]/tile:size-8 group-data-[density=compact]/tile:text-[18px]',\n        on ? 'bg-foreground text-card' : 'text-foreground',\n      )}\n      style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '88%' }}\n    >\n      {text}\n    </span>\n  )\n}\n\nfunction DayTabs({\n  days,\n  shown,\n  today,\n  label,\n  tabId,\n  panelId,\n  weekday,\n  date,\n  name,\n  onPick,\n}: {\n  days: number\n  shown: number\n  today: number\n  label: string\n  tabId: (d: number) => string\n  panelId: string\n  weekday: (d: number) => string\n  date: (d: number) => string\n  name: (d: number) => string\n  onPick: (d: number) => void\n}) {\n  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, d: number) => {\n    const to =\n      e.key === 'ArrowRight' ? (d + 1) % days : e.key === 'ArrowLeft' ? (d - 1 + days) % days : e.key === 'Home' ? 0 : e.key === 'End' ? days - 1 : null\n    if (to === null) return\n    e.preventDefault()\n    onPick(to)\n    ;(e.currentTarget.parentElement?.children[to] as HTMLElement | undefined)?.focus()\n  }\n  return (\n    <div role=\"tablist\" aria-label={label} className=\"-mx-1 flex overflow-x-auto px-1 py-1 [scrollbar-width:none]\">\n      {Array.from({ length: days }, (_, d) => (\n        <button\n          key={d}\n          type=\"button\"\n          role=\"tab\"\n          id={tabId(d)}\n          aria-selected={d === shown}\n          aria-controls={panelId}\n          aria-label={name(d)}\n          tabIndex={d === shown ? 0 : -1}\n          onClick={() => onPick(d)}\n          onKeyDown={(e) => onKeyDown(e, d)}\n          className=\"relative grid min-h-11 min-w-11 flex-1 cursor-pointer justify-items-center gap-0.5 rounded-[14px] py-1 focus-visible:outline-offset-0\"\n        >\n          <span aria-hidden className=\"text-[12px] leading-[1.3] font-medium text-muted-foreground\">\n            {weekday(d)}\n          </span>\n          <DateMark text={date(d)} on={d === shown} small />\n          <i aria-hidden className={cn('size-1 rounded-full', d === today ? 'bg-primary' : 'bg-transparent')} />\n        </button>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}