{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table-chart",
  "title": "Timeline Chart",
  "description": "Stacked timeline bar chart above the table: one bar per time bucket, one series per level, drag to select a range and zoom the table's time filter to it. Ships the bucketing helper that turns rows into chart data with the same semantics as the Drizzle handler.",
  "dependencies": ["date-fns@^4.1.0", "lucide-react@^0.469.0"],
  "registryDependencies": [
    "https://data-table.openstatus.dev/r/data-table.json",
    "chart",
    "button"
  ],
  "files": [
    {
      "path": "src/components/data-table/data-table-chart/timeline-chart.tsx",
      "content": "\"use client\";\n\nimport { useDataTable } from \"@/components/data-table/data-table-provider\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n  ChartContainer,\n  ChartTooltip,\n  ChartTooltipContent,\n  type ChartConfig,\n} from \"@/components/ui/chart\";\nimport type { BaseChartSchema } from \"@/lib/data-table/types\";\nimport { cn } from \"@/lib/utils\";\nimport { format } from \"date-fns\";\nimport { ZoomIn } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from \"recharts\";\nimport type { Box } from \"./timeline-chart-utils\";\nimport {\n  formatAxisTick,\n  formatSelectionRange,\n  getSelectionBounds,\n  getSelectionCardLeft,\n  getSelectionEdges,\n  getSelectionLabels,\n  getSelectionScrim,\n  isPointerEvent,\n  orderSelectionLabels,\n} from \"./timeline-chart-utils\";\n\n/**\n * The chart's own mouse handler type, read off `BarChart` rather than imported\n * from a deep `recharts/types/...` path: those paths moved between recharts 2\n * and 3, and the consumer gets whichever version shadcn's `chart` installs.\n */\ntype ChartMouseHandler = NonNullable<\n  React.ComponentProps<typeof BarChart>[\"onMouseDown\"]\n>;\n\n/** Shared by every control floating over the chart. */\nconst PILL_BUTTON =\n  \"flex-1 h-5 rounded-md px-1.5! py-1! font-mono text-[10px] shadow-none\";\n\n/**\n * One stacked series: a key in every chart point, what the tooltip calls it,\n * and its colour. The same shape as a table manifest's `chart.series`, so a\n * headless table can hand the endpoint's config straight through.\n */\nexport type TimelineChartSeries = {\n  /** The numeric key in each `BaseChartSchema` point. */\n  key: string;\n  /** Tooltip label. Defaults to the key. */\n  label?: React.ReactNode;\n  /**\n   * Any CSS colour. Defaults to `var(--<key>)`, which the core block defines\n   * for `success`, `warning`, `error` and `info`.\n   */\n  color?: string;\n};\n\n/**\n * Every numeric key the first point carries, in the order it carries them —\n * the fallback when the caller states no series.\n */\nfunction inferSeries(data: BaseChartSchema[]): TimelineChartSeries[] {\n  const first = data[0];\n  if (!first) return [];\n  return Object.keys(first)\n    .filter((key) => key !== \"timestamp\" && typeof first[key] === \"number\")\n    .map((key) => ({ key }));\n}\n\nfunction toChartConfig(series: TimelineChartSeries[]): ChartConfig {\n  const config: ChartConfig = {};\n  for (const entry of series) {\n    config[entry.key] = {\n      label: entry.label ?? entry.key,\n      color: entry.color ?? `var(--${entry.key})`,\n    };\n  }\n  return config;\n}\n\n/** A selected range, both ends kept apart so the separator can be styled. */\ntype SelectionRange = { start: string; end: string };\n\ninterface TimelineChartProps<TChart extends BaseChartSchema> {\n  className?: string;\n  /**\n   * The table column id to filter by - needs to be a type of `timerange` (e.g. \"date\").\n   * TBD: if using keyof TData to be closer to the data table props\n   */\n  columnId: string;\n  /**\n   * Same data as of the InfiniteQueryMeta.\n   */\n  data: TChart[];\n  /**\n   * The stacked series, bottom-up; the tooltip lists them in the same order.\n   * Omitted, every numeric key of the first point becomes a series.\n   */\n  series?: TimelineChartSeries[];\n}\n\nexport function TimelineChart<TChart extends BaseChartSchema>({\n  data,\n  className,\n  columnId,\n  series,\n}: TimelineChartProps<TChart>) {\n  const { table } = useDataTable();\n  const chartSeries = useMemo(\n    () => series ?? inferSeries(data),\n    [series, data],\n  );\n  const chartConfig = useMemo(() => toChartConfig(chartSeries), [chartSeries]);\n  // state, not a ref: the card is portaled into it, so a render has to follow\n  // the element being attached\n  const [container, setContainer] = useState<HTMLDivElement | null>(null);\n  const [chartWidth, setChartWidth] = useState(0);\n  const [refAreaLeft, setRefAreaLeft] = useState<string | null>(null);\n  const [refAreaRight, setRefAreaRight] = useState<string | null>(null);\n  const [isSelecting, setIsSelecting] = useState(false);\n\n  // REMINDER: the scrim and edges are SVG without a layout engine - we need the\n  // pixel width to keep them inside the plot near either edge\n  useEffect(() => {\n    if (!container) return;\n\n    setChartWidth(container.clientWidth);\n    const observer = new ResizeObserver(([entry]) =>\n      setChartWidth(entry.contentRect.width),\n    );\n    observer.observe(container);\n    return () => observer.disconnect();\n  }, [container]);\n\n  // REMINDER: date has to be a string for tooltip label to work - don't ask me why\n  const chart = useMemo(\n    () =>\n      data.map((item) => ({\n        ...item,\n        [columnId]: new Date(item.timestamp).toString(),\n      })),\n    [data, columnId],\n  );\n\n  const timerange = useMemo(() => {\n    if (data.length === 0) return { interval: 0, period: undefined };\n    const first = data[0].timestamp;\n    const last = data[data.length - 1].timestamp;\n    const interval = Math.abs(first - last); // in ms\n    return { interval, period: calculatePeriod(interval) };\n  }, [data]);\n\n  /**\n   * How the selection describes itself, off the same bounds we commit as the\n   * filter - so nothing on screen can disagree with what the table ends up\n   * showing.\n   */\n  const selection = useMemo(() => {\n    if (!refAreaLeft || !refAreaRight) return null;\n\n    const bounds = getSelectionBounds(data, refAreaLeft, refAreaRight);\n    if (!bounds) return null;\n\n    const { from, displayEnd } = bounds;\n\n    return {\n      range: formatSelectionRange(from, displayEnd, timerange.period),\n      // the instants the two edges sit on: `from` opens the first bucket and\n      // `displayEnd` is where the last one runs out, which is the boundary the\n      // right edge is drawn at\n      axis: {\n        start: formatAxisTick(from, timerange.period),\n        end: formatAxisTick(displayEnd, timerange.period),\n      },\n    };\n  }, [refAreaLeft, refAreaRight, data, timerange.period]);\n\n  /**\n   * The selection as `ReferenceArea` wants it: `x1` is the rect's start edge\n   * and `x2` its end, so a backwards drag has to be flipped or the highlight\n   * comes out a bucket short on either side.\n   */\n  const refArea = useMemo(\n    () =>\n      refAreaLeft && refAreaRight\n        ? orderSelectionLabels(refAreaLeft, refAreaRight)\n        : null,\n    [refAreaLeft, refAreaRight],\n  );\n\n  const handleMouseDown: ChartMouseHandler = (e) => {\n    if (e.activeLabel) {\n      // a new drag replaces whatever was still awaiting confirmation\n      // (recharts 3 types the label as string | number; ours are the date\n      // strings `chart` was built with)\n      setRefAreaLeft(String(e.activeLabel));\n      setRefAreaRight(null);\n      setIsSelecting(true);\n    }\n  };\n\n  const handleMouseMove: ChartMouseHandler = (e, event) => {\n    // only a moving pointer widens the selection - taking the a11y layer's\n    // spoofed move too (see `isPointerEvent`) would make a click select from\n    // the first bucket to the bar it landed on\n    if (!isPointerEvent(event)) return;\n    if (isSelecting && e.activeLabel) {\n      setRefAreaRight(String(e.activeLabel));\n    }\n  };\n\n  const clearSelection = () => {\n    setRefAreaLeft(null);\n    setRefAreaRight(null);\n    setIsSelecting(false);\n  };\n\n  const applySelection = () => {\n    if (!refAreaLeft || !refAreaRight) return;\n    // same bounds the readout was computed from, so the row count the user\n    // just saw is exactly what the table ends up showing\n    const bounds = getSelectionBounds(data, refAreaLeft, refAreaRight);\n    if (bounds) {\n      table\n        .getColumn(columnId)\n        ?.setFilterValue([new Date(bounds.from), new Date(bounds.filterEnd)]);\n    }\n    clearSelection();\n  };\n\n  const isPending = Boolean(!isSelecting && refAreaLeft && refAreaRight);\n\n  // the drag ends on the window, not on the chart: wandering off the plot keeps\n  // the range live and releasing anywhere parks it. it only ever parks - the\n  // filter is applied from the zoom button, so an imprecise drag can be\n  // corrected instead of refetching the table twice.\n  // both listener effects run without a dep array: they close over the current\n  // selection, and re-subscribing beats memoizing every callback to stay stable\n  useEffect(() => {\n    if (!isSelecting) return;\n    const onMouseUp = () => {\n      setIsSelecting(false);\n      // a click without a drag is the shortest range there is: the one bucket\n      // under the cursor. `x1 === x2` still spans a full band\n      if (!refAreaRight) setRefAreaRight(refAreaLeft);\n    };\n    // dragging across the page would otherwise select the table's text\n    document.body.classList.add(\"select-none\");\n    window.addEventListener(\"mouseup\", onMouseUp);\n    return () => {\n      document.body.classList.remove(\"select-none\");\n      window.removeEventListener(\"mouseup\", onMouseUp);\n    };\n  });\n\n  useEffect(() => {\n    if (!isPending) return;\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") clearSelection();\n      // the browser already activates a focused Cancel - zooming here too would\n      // do both at once\n      if (event.key === \"Enter\" && !hasInteractiveFocus()) applySelection();\n    };\n    // anywhere but the chart dismisses it. the card is portaled into the\n    // container, so its own buttons count as inside\n    const onPointerDown = (event: PointerEvent) => {\n      const target = event.target;\n      if (target instanceof Node && container?.contains(target)) return;\n      clearSelection();\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => {\n      document.removeEventListener(\"keydown\", onKeyDown);\n      document.removeEventListener(\"pointerdown\", onPointerDown);\n    };\n  });\n\n  return (\n    <div ref={setContainer} className=\"relative\">\n      <ChartContainer\n        config={chartConfig}\n        className={cn(\n          \"aspect-auto h-[60px] w-full\",\n          \"[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted/50\", // otherwise same color as 200\n          \"select-none\", // disable text selection\n          \"touch-pan-y\", // capture horizontal drags, let vertical page scroll through\n          className,\n        )}\n      >\n        <BarChart\n          accessibilityLayer\n          data={chart}\n          margin={{ top: 0, left: 0, right: 0, bottom: 0 }}\n          onMouseDown={handleMouseDown}\n          onMouseMove={handleMouseMove}\n          style={{ cursor: \"crosshair\" }}\n        >\n          <CartesianGrid vertical={false} />\n          <XAxis\n            dataKey={columnId}\n            tickLine={false}\n            minTickGap={32}\n            axisLine={false}\n            // interval=\"preserveStartEnd\"\n            // the selection labels the axis itself while it's up, so the ticks\n            // step aside rather than compete with it - but only once there is\n            // something to replace them with. the axis keeps its height either way\n            tick={!selection}\n            tickFormatter={(value) => formatAxisTick(value, timerange.period)}\n          />\n          <ChartTooltip\n            // defaultIndex={10}\n            // no hover while a selection is on the chart: mid-drag it fights the\n            // band being dragged, and once parked it covers the actions.\n            // recharts reads `active` before its own hover state, and only when\n            // it's defined - `undefined` hands control back\n            active={isSelecting || isPending ? false : undefined}\n            content={\n              <ChartTooltipContent\n                labelFormatter={(value) => {\n                  // recharts 3 types the label as a ReactNode; the axis feeds it\n                  // the date string, anything else is not a date\n                  const date = new Date(\n                    typeof value === \"string\" || typeof value === \"number\"\n                      ? value\n                      : NaN,\n                  );\n                  if (isNaN(date.getTime())) return \"N/A\";\n                  if (timerange.period === \"10m\") {\n                    return format(date, \"LLL dd, HH:mm:ss\");\n                  }\n                  return format(date, \"LLL dd, y HH:mm\");\n                }}\n              />\n            }\n          />\n          {chartSeries.map(({ key }) => (\n            <Bar\n              key={key}\n              dataKey={key}\n              stackId=\"a\"\n              fill={`var(--color-${key})`}\n            />\n          ))}\n          {refArea && (\n            <ReferenceArea\n              x1={refArea[0]}\n              x2={refArea[1]}\n              stroke=\"none\"\n              fill=\"var(--foreground)\"\n              fillOpacity={0.05}\n              label={\n                <SelectionOverlay\n                  chartWidth={chartWidth}\n                  container={container}\n                  axisLabels={selection?.axis ?? null}\n                  // nothing floats over the chart mid-drag: the shaded band and\n                  // its edges already describe the range, and a card chasing the\n                  // cursor only reads as the tooltip refusing to go away. it\n                  // comes back once the drag parks, where it carries the actions\n                  range={isPending ? (selection?.range ?? null) : null}\n                  actions={\n                    isPending\n                      ? { onZoom: applySelection, onCancel: clearSelection }\n                      : null\n                  }\n                />\n              }\n            />\n          )}\n        </BarChart>\n      </ChartContainer>\n    </div>\n  );\n}\n\n/**\n * The selection: brush-style ends, the buckets outside it faded, and the card\n * describing the range.\n *\n * Rendered through `ReferenceArea`'s `label` prop - recharts clones it with the\n * selection rect as `viewBox`, which is the exact band-snapped geometry. The\n * scrim and edges stay in the SVG; the card is portaled out of it (see\n * `SelectionCard`).\n */\nfunction SelectionOverlay({\n  viewBox,\n  chartWidth,\n  container,\n  axisLabels,\n  range,\n  actions,\n}: {\n  /** injected by recharts, not passed by the parent */\n  viewBox?: { x?: number; y?: number; width?: number; height?: number };\n  chartWidth: number;\n  container: HTMLElement | null;\n  axisLabels?: SelectionRange | null;\n  range?: SelectionRange | null;\n  actions?: { onZoom: () => void; onCancel: () => void } | null;\n}) {\n  if (!viewBox) return null;\n\n  const selection = {\n    x: viewBox.x ?? 0,\n    y: viewBox.y ?? 0,\n    width: viewBox.width ?? 0,\n    height: viewBox.height ?? 0,\n  };\n\n  return (\n    <g className=\"pointer-events-none\">\n      {/* fade the buckets outside the selection so the range reads at a glance */}\n      {getSelectionScrim(selection, chartWidth).map((rect) => (\n        <rect\n          key={rect.x}\n          {...rect}\n          fill=\"var(--background)\"\n          fillOpacity={0.6}\n        />\n      ))}\n      {getSelectionEdges(selection, chartWidth).map(({ line, grip }, index) => (\n        <g key={index} fill=\"var(--foreground)\">\n          <rect {...line} />\n          <rect {...grip} />\n        </g>\n      ))}\n      {axisLabels ? (\n        <SelectionAxisLabels\n          selection={selection}\n          chartWidth={chartWidth}\n          labels={axisLabels}\n        />\n      ) : null}\n      {range && actions && container\n        ? createPortal(\n            <SelectionCard\n              selection={selection}\n              chartWidth={chartWidth}\n              range={range}\n              actions={actions}\n            />,\n            container,\n          )\n        : null}\n    </g>\n  );\n}\n\n/**\n * The two instants the selection runs between, drawn where the axis ticks would\n * have been - the ticks are off while a selection is up, so the only dates on\n * the axis are the ones the selection is about.\n *\n * The end label is dropped when the two would touch: a narrow selection only\n * gets to say where it starts. It stays mounted and merely hidden, because\n * unmounting it would throw away the width that decision is made from - and the\n * decision would flip back on the next frame.\n */\nfunction SelectionAxisLabels({\n  selection,\n  chartWidth,\n  labels,\n}: {\n  selection: Box;\n  chartWidth: number;\n  labels: { start: string; end: string };\n}) {\n  const startRef = useRef<SVGTextElement>(null);\n  const endRef = useRef<SVGTextElement>(null);\n  const [widths, setWidths] = useState({ start: 0, end: 0 });\n\n  // before paint, so a label never shows up off-center for a frame\n  useLayoutEffect(() => {\n    setWidths({\n      start: startRef.current?.getComputedTextLength() ?? 0,\n      end: endRef.current?.getComputedTextLength() ?? 0,\n    });\n  }, [labels.start, labels.end]);\n\n  const { start, end } = getSelectionLabels(selection, chartWidth, widths);\n\n  return (\n    // `dy` is what recharts hangs its own tick text from (`capHeight`), so these\n    // sit on the same line the ticks would have\n    <g className=\"fill-muted-foreground\" textAnchor=\"middle\">\n      <text ref={startRef} x={start.x} y={start.y} dy=\"0.71em\">\n        {labels.start}\n      </text>\n      <text\n        ref={endRef}\n        x={end?.x ?? start.x}\n        y={start.y}\n        dy=\"0.71em\"\n        visibility={end ? undefined : \"hidden\"}\n      >\n        {labels.end}\n      </text>\n    </g>\n  );\n}\n\n/**\n * The range description and the actions to confirm it, shown once the drag is\n * released. One card rather than two: the range is what you're confirming.\n *\n * Portaled to the chart container instead of drawn in the SVG. An `<svg>` clips\n * to its viewport, so a card this tall lost its shadow and its bottom edge to\n * the plot boundary; as a DOM sibling it can overflow the chart the same way\n * the recharts tooltip does. It measures itself, so nothing here estimates text\n * width.\n */\nfunction SelectionCard({\n  selection,\n  chartWidth,\n  range,\n  actions,\n}: {\n  selection: Box;\n  chartWidth: number;\n  range: SelectionRange;\n  actions: { onZoom: () => void; onCancel: () => void };\n}) {\n  const ref = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n\n  // before paint, so the card never shows up off-center for a frame\n  useLayoutEffect(() => {\n    setWidth(ref.current?.offsetWidth ?? 0);\n  }, [range.start, range.end]);\n\n  return (\n    // same shell as `ChartTooltipContent` - the card replaces the tooltip\n    <div\n      ref={ref}\n      style={{ left: getSelectionCardLeft(selection, width, chartWidth) }}\n      className=\"border-border/50 bg-background pointer-events-none absolute top-0 grid w-max min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl\"\n    >\n      <div className=\"font-medium\">\n        {range.start}\n        <span className=\"text-muted-foreground mx-1 font-normal\">→</span>\n        {range.end}\n      </div>\n      <div\n        className=\"border-border/50 pointer-events-auto -mx-2.5 -mb-1.5 flex items-center gap-1 border-t px-2.5 py-1.5\"\n        // a portal bubbles through the React tree, not the DOM one - without\n        // this a click on either button reaches the chart and starts a drag\n        onMouseDown={(event) => event.stopPropagation()}\n        onMouseUp={(event) => event.stopPropagation()}\n      >\n        <Button\n          variant=\"outline\"\n          className={PILL_BUTTON}\n          onClick={actions.onCancel}\n        >\n          Cancel\n        </Button>\n        <Button className={cn(PILL_BUTTON, \"gap-1\")} onClick={actions.onZoom}>\n          <ZoomIn className=\"size-2.5!\" />\n          <span>Zoom</span>\n        </Button>\n      </div>\n    </div>\n  );\n}\n\n/** Whether the focused element handles Enter itself - the shortcut defers to it. */\nfunction hasInteractiveFocus(): boolean {\n  return Boolean(\n    document.activeElement?.closest(\n      \"button, a, input, textarea, select, [contenteditable], [role='button']\",\n    ),\n  );\n}\n\n// TODO: check what's a good abbreviation for month vs. minutes\nfunction calculatePeriod(interval: number): \"10m\" | \"1d\" | \"1w\" | \"1mo\" {\n  if (interval <= 1000 * 60 * 10) {\n    // less than 10 minutes\n    return \"10m\";\n  } else if (interval <= 1000 * 60 * 60 * 24) {\n    // less than 1 day\n    return \"1d\";\n  } else if (interval <= 1000 * 60 * 60 * 24 * 7) {\n    // less than 1 week\n    return \"1w\";\n  }\n  return \"1mo\"; // defaults to 1 month\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/data-table/data-table-chart/timeline-chart-utils.ts",
      "content": "import type { BaseChartSchema } from \"@/lib/data-table/types\";\nimport { format, isSameDay } from \"date-fns\";\n\n/**\n * Sums every numeric bucket value for the buckets falling inside the inclusive\n * `[from, to]` timestamp range.\n *\n * Stays generic over the bucket shape on purpose: `TimelineChart` is rendered\n * with different schemas across the demos (e.g. `success`/`warning`/`error` for\n * logs), so every key but `timestamp` is treated as a count.\n */\nexport function sumBucketRows<TChart extends BaseChartSchema>(\n  data: TChart[],\n  from: number,\n  to: number,\n): number {\n  return Object.values(sumBucketValues(data, from, to)).reduce(\n    (total, value) => total + value,\n    0,\n  );\n}\n\n/**\n * The same sum, kept per key, so the selection can break its total down the way\n * the tooltip does (`success`/`warning`/`error` for logs).\n *\n * Keys absent from every bucket in range are absent from the result - the\n * caller decides whether that reads as `0` or as \"not a series\".\n */\nexport function sumBucketValues<TChart extends BaseChartSchema>(\n  data: TChart[],\n  from: number,\n  to: number,\n): Record<string, number> {\n  const [start, end] = from <= to ? [from, to] : [to, from];\n\n  const totals: Record<string, number> = {};\n  for (const bucket of data) {\n    if (bucket.timestamp < start || bucket.timestamp > end) continue;\n    for (const [key, value] of Object.entries(bucket)) {\n      if (key === \"timestamp\") continue;\n      // the index signature promises `number`, runtime data doesn't\n      if (typeof value === \"number\" && Number.isFinite(value)) {\n        totals[key] = (totals[key] ?? 0) + value;\n      }\n    }\n  }\n  return totals;\n}\n\n/**\n * The time each bucket covers, derived from the gap between the first two\n * buckets. Returns `0` when there aren't enough buckets to tell.\n */\nexport function getBucketInterval<TChart extends BaseChartSchema>(\n  data: TChart[],\n): number {\n  if (data.length < 2) return 0;\n  return Math.abs(data[1].timestamp - data[0].timestamp);\n}\n\n/**\n * The timestamp range a drag between two bucket labels covers.\n *\n * A drag selects whole buckets, so the last one contributes its full interval\n * rather than zero time. That leaves two different ends:\n *\n * - `displayEnd` is exclusive — the instant the last bucket runs out, which is\n *   what a time range reads as (\"14:02 → 14:09\") and what the duration measures.\n * - `filterEnd` is inclusive, because `inDateRange` compares with `<=`. It stops\n *   one millisecond short so a row landing exactly on the next bucket's first\n *   instant isn't filtered in while `sumBucketRows` never counted it.\n *\n * Returns `null` when either label isn't a parseable date.\n */\nexport function getSelectionBounds<TChart extends BaseChartSchema>(\n  data: TChart[],\n  labelA: string,\n  labelB: string,\n): {\n  from: number;\n  toBucket: number;\n  displayEnd: number;\n  filterEnd: number;\n} | null {\n  const [from, toBucket] = [\n    new Date(labelA).getTime(),\n    new Date(labelB).getTime(),\n  ].sort((a, b) => a - b);\n  if (Number.isNaN(from) || Number.isNaN(toBucket)) return null;\n\n  const interval = getBucketInterval(data);\n  return {\n    from,\n    toBucket,\n    displayEnd: toBucket + interval,\n    // a single-bucket chart has no interval to add; don't invert the range\n    filterEnd: toBucket + Math.max(interval - 1, 0),\n  };\n}\n\n/**\n * The two bucket labels of a drag, oldest first.\n *\n * A drag can run right-to-left, and `ReferenceArea` reads `x1` as the rect's\n * start edge and `x2` as its end - handed the labels in the order they were\n * touched, a backwards drag renders a rect a bucket short on either side.\n *\n * Unparseable labels keep the order they came in: there is nothing to sort by,\n * and `getSelectionBounds` rejects them anyway.\n */\nexport function orderSelectionLabels(\n  labelA: string,\n  labelB: string,\n): [string, string] {\n  const a = new Date(labelA).getTime();\n  const b = new Date(labelB).getTime();\n  if (Number.isNaN(a) || Number.isNaN(b)) return [labelA, labelB];\n  return a <= b ? [labelA, labelB] : [labelB, labelA];\n}\n\n/**\n * Whether a recharts callback came from a real pointer.\n *\n * `accessibilityLayer` spoofs a mouse move at its keyboard cursor - the first\n * bucket, until an arrow key moves it - whenever the chart takes focus, which a\n * mousedown does. The spoof passes a bare `{ pageX, pageY }`, a real event a `type`.\n */\nexport function isPointerEvent(event: unknown): boolean {\n  return typeof (event as { type?: unknown } | null | undefined)?.type === \"string\"; // prettier-ignore\n}\n\n/** How much time the chart covers - it decides how every label is formatted. */\nexport type ChartPeriod = \"10m\" | \"1d\" | \"1w\" | \"1mo\";\n\n/**\n * An x-axis tick, as coarse as the period the chart covers.\n *\n * Shared with the labels the selection puts on the axis, so a selection edge\n * can't print its instant in a different format than the ticks around it.\n */\nexport function formatAxisTick(\n  value: number | string,\n  period?: ChartPeriod,\n): string {\n  const date = new Date(value);\n  if (isNaN(date.getTime())) return \"N/A\";\n  switch (period) {\n    case \"10m\":\n      return format(date, \"HH:mm:ss\");\n    case \"1d\":\n      return format(date, \"HH:mm\");\n    case \"1w\":\n      return format(date, \"LLL dd HH:mm\");\n    default:\n      return format(date, \"LLL dd, y\");\n  }\n}\n\n/**\n * Formats a selected range for the readout, both ends kept apart so the caller\n * can style the separator (e.g. `Jul 21 16:52` / `17:02`).\n *\n * The date always leads - a bare `16:52 → 17:02` reads as \"today\" when it may\n * well be last week - and repeats on the right only across midnight. Seconds\n * show up on the tightest period, where minutes would print both ends alike.\n */\nexport function formatSelectionRange(\n  from: number,\n  to: number,\n  period?: ChartPeriod,\n): { start: string; end: string } {\n  const timePattern = period === \"10m\" ? \"HH:mm:ss\" : \"HH:mm\";\n  return {\n    start: format(from, `LLL dd ${timePattern}`),\n    end: isSameDay(from, to)\n      ? format(to, timePattern)\n      : format(to, `LLL dd ${timePattern}`),\n  };\n}\n\n/** An SVG rect - spreadable straight onto `<rect>` or `<foreignObject>`. */\nexport type Box = { x: number; y: number; width: number; height: number };\n\n/**\n * The left edge that centers `width` on `center` without leaving the plot.\n * Every overlay is positioned against the selection but has to stay fully\n * visible, so a selection on a plot boundary pushes its overlays back inside.\n */\nexport function centerWithin(\n  center: number,\n  width: number,\n  chartWidth: number,\n) {\n  // fall back to the element's own width so a not-yet-measured chart still clamps\n  const max = Math.max((chartWidth || width) - width, 0);\n  return Math.min(Math.max(center - width / 2, 0), max);\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\n/** The breathing room left between the card and the selection it belongs to. */\nexport const SELECTION_CARD_GAP = 8;\n\n/**\n * The left edge for the card describing a selection.\n *\n * A selection wider than the card keeps its ends visible with the card centered\n * on it, so that's where it goes. A narrow one would disappear underneath, so\n * the card steps aside - to whichever side has more room, and only if the card\n * fits there whole. When neither side does, being centered and readable beats\n * being pushed half out of the plot.\n */\nexport function getSelectionCardLeft(\n  selection: { x: number; width: number },\n  cardWidth: number,\n  chartWidth: number,\n  gap = SELECTION_CARD_GAP,\n): number {\n  const center = () =>\n    centerWithin(selection.x + selection.width / 2, cardWidth, chartWidth);\n\n  if (selection.width >= cardWidth) return center();\n\n  const selectionEnd = selection.x + selection.width;\n  const room = { left: selection.x, right: chartWidth - selectionEnd };\n  const needed = cardWidth + gap;\n  // the roomier side first, so the card leans away from the nearest plot edge\n  const sides = room.right >= room.left ? [\"right\", \"left\"] : [\"left\", \"right\"];\n\n  for (const side of sides) {\n    if (side === \"right\" && room.right >= needed) return selectionEnd + gap;\n    if (side === \"left\" && room.left >= needed) return selection.x - needed;\n  }\n  return center();\n}\n\n/** Width of the vertical line marking each end of the selection. */\nexport const SELECTION_EDGE_WIDTH = 1;\n/** Width of the rounded grip sitting on the middle of each edge. */\nexport const SELECTION_GRIP_WIDTH = 2;\nconst SELECTION_GRIP_RATIO = 0.4;\nconst SELECTION_GRIP_MIN_HEIGHT = 8;\nconst SELECTION_GRIP_MAX_HEIGHT = 24;\n\n/** Brush-style ends: a full-height line per side with a grip centered on it. */\nexport function getSelectionEdges(\n  selection: Box,\n  chartWidth: number,\n): { line: Box; grip: Box & { rx: number } }[] {\n  const gripHeight = clamp(\n    selection.height * SELECTION_GRIP_RATIO,\n    SELECTION_GRIP_MIN_HEIGHT,\n    Math.min(SELECTION_GRIP_MAX_HEIGHT, selection.height),\n  );\n\n  return [selection.x, selection.x + selection.width].map((center) => ({\n    line: {\n      x: centerWithin(center, SELECTION_EDGE_WIDTH, chartWidth),\n      y: selection.y,\n      width: SELECTION_EDGE_WIDTH,\n      height: selection.height,\n    },\n    grip: {\n      x: centerWithin(center, SELECTION_GRIP_WIDTH, chartWidth),\n      y: selection.y + (selection.height - gripHeight) / 2,\n      width: SELECTION_GRIP_WIDTH,\n      height: gripHeight,\n      rx: SELECTION_GRIP_WIDTH / 2,\n    },\n  }));\n}\n\n/**\n * How far below the plot the selection's labels sit, matching where recharts\n * puts a tick: its `tickSize` (6) plus its `tickMargin` (2). They stand in for\n * the axis ticks while a selection is up, so they have to land on the same line.\n */\nexport const SELECTION_LABEL_OFFSET = 8;\n/** The smallest gap left between the two labels before the end one is dropped. */\nexport const SELECTION_LABEL_GAP = 8;\n\n/**\n * Where the labels for a selection's two edges go, each centered on its edge\n * and kept inside the plot.\n *\n * `end` is `null` when the two would touch: a narrow selection only gets to say\n * where it starts. The check runs on the clamped centers, not on the raw\n * selection width - a selection against a plot boundary has its labels pushed\n * inwards, which closes the gap the raw width says is there.\n */\nexport function getSelectionLabels(\n  selection: Box,\n  chartWidth: number,\n  widths: { start: number; end: number },\n): { start: { x: number; y: number }; end: { x: number; y: number } | null } {\n  const y = selection.y + selection.height + SELECTION_LABEL_OFFSET;\n  // `centerWithin` returns a left edge; these are drawn from their center\n  const startX = centerWithin(selection.x, widths.start, chartWidth) + widths.start / 2; // prettier-ignore\n  const endX = centerWithin(selection.x + selection.width, widths.end, chartWidth) + widths.end / 2; // prettier-ignore\n\n  const gap = endX - widths.end / 2 - (startX + widths.start / 2);\n\n  return {\n    start: { x: startX, y },\n    end: gap >= SELECTION_LABEL_GAP ? { x: endX, y } : null,\n  };\n}\n\n/**\n * Rects covering everything left and right of the selection, to fade the\n * buckets outside it. Empty sides are dropped so a selection touching a plot\n * boundary doesn't emit a zero-width rect.\n */\nexport function getSelectionScrim(selection: Box, chartWidth: number): Box[] {\n  const end = selection.x + selection.width;\n  return [\n    { x: 0, width: Math.max(selection.x, 0) },\n    { x: end, width: Math.max(chartWidth - end, 0) },\n  ]\n    .filter((rect) => rect.width > 0)\n    .map((rect) => ({ ...rect, y: selection.y, height: selection.height }));\n}\n",
      "type": "registry:component"
    },
    {
      "path": "src/lib/data-table/chart-data.ts",
      "content": "// Not `chart.ts`: the block depends on shadcn's `chart` component, and the CLI\n// rewrites any import whose last segment matches a ui item's name to the ui\n// alias — `@/lib/data-table/chart` came out as `@/components/ui/chart`.\nimport { evaluateIntervalMs } from \"./interval\";\nimport type { BaseChartSchema } from \"./types\";\n\nexport type BucketChartDataOptions<TRow> = {\n  /** The instant a row belongs to. */\n  timestamp: (row: TRow) => Date | number;\n  /** Which series a row counts towards — a log level, a status class. */\n  series: (row: TRow) => string;\n  /** Every series, so each bucket carries a zero for the ones with no rows. */\n  keys: readonly string[];\n  /**\n   * The span to bucket, as a time-range filter value: two instants, or one\n   * (read as that day). Omitted or empty, the rows' own extent is used.\n   */\n  range?: readonly (Date | null | undefined)[] | null;\n  /** Bucket width. Omitted, the ladder in `evaluateIntervalMs` picks one. */\n  intervalMs?: number;\n};\n\n/**\n * The span a chart covers: the range filter when one is set, else the oldest\n * and newest row. `null` when neither says anything.\n */\nexport function chartRange<TRow>(\n  rows: readonly TRow[],\n  timestamp: (row: TRow) => Date | number,\n  range?: BucketChartDataOptions<TRow>[\"range\"],\n): [number, number] | null {\n  const dates = (range ?? []).filter(\n    (date): date is Date => date instanceof Date,\n  );\n  // One date is that whole local day — the same bounds the timerange filter\n  // selects rows by, so the chart covers exactly the rows in the table.\n  if (dates.length === 1) {\n    return [startOfDay(dates[0]).getTime(), endOfDay(dates[0]).getTime()];\n  }\n  if (dates.length >= 2) {\n    const a = dates[0].getTime();\n    const b = dates[1].getTime();\n    return [Math.min(a, b), Math.max(a, b)];\n  }\n  if (rows.length === 0) return null;\n\n  let min = Infinity;\n  let max = -Infinity;\n  for (const row of rows) {\n    const time = toTime(timestamp(row));\n    if (time < min) min = time;\n    if (time > max) max = time;\n  }\n  return [min, max];\n}\n\n/**\n * Rows → the `meta.chartData` the timeline chart draws: one point per bucket,\n * each carrying a count per series.\n *\n * The buckets start at the range's beginning and stop at the last whole\n * interval inside it, so a row past that edge is dropped rather than counted\n * in a bucket the chart does not draw. Same semantics as the Drizzle handler's\n * SQL aggregation, which is what makes the in-memory and database examples\n * interchangeable behind one client.\n */\nexport function bucketChartData<TRow>(\n  rows: readonly TRow[],\n  options: BucketChartDataOptions<TRow>,\n): BaseChartSchema[] {\n  const span = chartRange(rows, options.timestamp, options.range);\n  if (!span) return [];\n\n  const [start, end] = span;\n  const duration = end - start;\n  const interval = options.intervalMs ?? evaluateIntervalMs(duration);\n  if (interval <= 0) return [];\n\n  // At least one bucket: a span shorter than the interval — a single row, a\n  // tight zoom — still has rows to show, and an empty array hides the chart.\n  const steps = Math.max(1, Math.floor(duration / interval));\n  const buckets: BaseChartSchema[] = Array.from({ length: steps }, (_, i) => {\n    const bucket: BaseChartSchema = { timestamp: start + i * interval };\n    for (const key of options.keys) bucket[key] = 0;\n    return bucket;\n  });\n\n  for (const row of rows) {\n    const offset = toTime(options.timestamp(row)) - start;\n    if (offset < 0 || offset > duration) continue;\n    const bucket = buckets[Math.floor(offset / interval)];\n    if (!bucket) continue;\n    const key = options.series(row);\n    if (key in bucket) bucket[key] += 1;\n  }\n\n  return buckets;\n}\n\nfunction toTime(value: Date | number): number {\n  return value instanceof Date ? value.getTime() : value;\n}\n\nfunction startOfDay(date: Date): Date {\n  const result = new Date(date);\n  result.setHours(0, 0, 0, 0);\n  return result;\n}\n\nfunction endOfDay(date: Date): Date {\n  const result = new Date(date);\n  result.setHours(23, 59, 59, 999);\n  return result;\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:block"
}
