{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "weekends",
  "type": "registry:component",
  "title": "Weekends",
  "description": "Diagonal-stripe or solid weekend shading behind series on date x axes (line, area, composed, bar)",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@bklit/chart-context",
    "@bklit/background"
  ],
  "files": [
    {
      "path": "src/charts/weekends.tsx",
      "content": "\"use client\";\n\nimport { m } from \"motion/react\";\nimport { useId, useMemo } from \"react\";\nimport { chartCssVars, useChartStable } from \"./chart-context\";\nimport {\n  computeWeekendIndexRuns,\n  computeWeekendRanges,\n  type DateRangeAlign,\n  DEFAULT_WEEKEND_DAYS,\n} from \"./date-ranges\";\nimport { type PatternPresetId, renderPatternPreset } from \"./pattern-preset\";\nimport { type CategorySlots, useCategorySlots } from \"./use-category-slots\";\nimport { isReferenceAreaVisiblePhase } from \"./y-domain-utils\";\n\nexport type WeekendsFillType = \"pattern\" | \"solid\";\n\nexport interface WeekendsProps {\n  /**\n   * Days that count as weekend, as `Date#getDay()` numbers (0 = Sunday).\n   * Default: `[6, 0]` (Saturday + Sunday). Use `[5, 6]` for Fri–Sat locales.\n   */\n  weekendDays?: readonly number[];\n  /** Default: `\"pattern\"` (diagonal stripes). */\n  fillType?: WeekendsFillType;\n  /** Band / stripe colour. Default: `var(--chart-foreground-muted)` */\n  fill?: string;\n  /** Default: 0.45 for `pattern`, 0.08 for `solid`. */\n  fillOpacity?: number;\n  /** Pattern preset when `fillType` is `\"pattern\"`. Default: `\"diagonal-up\"` */\n  pattern?: PatternPresetId;\n  /** Default: 1.5 */\n  patternScale?: number;\n  /** Default: 1 */\n  patternStrokeWidth?: number;\n  /**\n   * `\"calendar\"` shades midnight → midnight; `\"centered\"` shifts half a day so\n   * daily points / columns sit mid-band. `\"auto\"` picks `\"centered\"` when the\n   * chart has `SeriesBar` columns anchored at midnight. Band (bar) scales\n   * always shade whole columns.\n   * Default: `\"auto\"`\n   */\n  align?: \"auto\" | DateRangeAlign;\n  /** Use UTC day boundaries. Default: false (local time) */\n  utc?: boolean;\n  /** Skip rendering when one day is narrower than this many pixels. Default: 2 */\n  minDayWidth?: number;\n  /**\n   * Band (bar chart) scales only — data key holding each row's date. Default:\n   * first field holding a `Date` or `\"YYYY-MM-DD\"` string.\n   */\n  dateKey?: string;\n  className?: string;\n}\n\ninterface WeekendRect {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\nconst DAY_MS = 24 * 60 * 60 * 1000;\nconst ENTER_FADE_MS = 420;\n\n/** Center bands on midnight-anchored columns; noon-anchored columns already sit mid-day. */\nfunction resolveAutoAlign(\n  hasColumns: boolean,\n  firstX: Date | undefined,\n  utc: boolean\n): DateRangeAlign {\n  if (!(hasColumns && firstX) || Number.isNaN(firstX.getTime())) {\n    return \"calendar\";\n  }\n  const hours = utc ? firstX.getUTCHours() : firstX.getHours();\n  const minutes = utc ? firstX.getUTCMinutes() : firstX.getMinutes();\n  return hours === 0 && minutes === 0 ? \"centered\" : \"calendar\";\n}\n\ninterface PlotSize {\n  innerWidth: number;\n  innerHeight: number;\n}\n\nfunction bandScaleWeekendRects(\n  slots: CategorySlots,\n  weekendDays: number[],\n  utc: boolean,\n  minDayWidth: number,\n  { innerWidth, innerHeight }: PlotSize\n): WeekendRect[] {\n  if (slots.step < minDayWidth) {\n    return [];\n  }\n  const extent = slots.axis === \"x\" ? innerWidth : innerHeight;\n  return computeWeekendIndexRuns(slots.dates, { weekendDays, utc }).flatMap(\n    ([first, last]) => {\n      const start = Math.max(0, slots.slotStart(first));\n      const end = Math.min(extent, slots.slotStart(last) + slots.step);\n      if (end <= start) {\n        return [];\n      }\n      return slots.axis === \"x\"\n        ? [{ x: start, y: 0, width: end - start, height: innerHeight }]\n        : [{ x: 0, y: start, width: innerWidth, height: end - start }];\n    }\n  );\n}\n\nfunction timeScaleWeekendRects(\n  xScale: (value: Date) => number,\n  weekendDays: number[],\n  utc: boolean,\n  align: DateRangeAlign,\n  minDayWidth: number,\n  { innerWidth, innerHeight }: PlotSize\n): WeekendRect[] {\n  const [d0, d1] = (xScale as unknown as { domain: () => Date[] }).domain();\n  if (!(d0 && d1)) {\n    return [];\n  }\n  const spanMs = Math.abs(d1.getTime() - d0.getTime());\n  if (spanMs <= 0 || (innerWidth * DAY_MS) / spanMs < minDayWidth) {\n    return [];\n  }\n  return computeWeekendRanges(d0, d1, { weekendDays, utc, align }).flatMap(\n    ({ start, end }) => {\n      const a = xScale(start);\n      const b = xScale(end);\n      const x0 = Math.max(0, Math.min(a, b));\n      const x1 = Math.min(innerWidth, Math.max(a, b));\n      return x1 > x0\n        ? [{ x: x0, y: 0, width: x1 - x0, height: innerHeight }]\n        : [];\n    }\n  );\n}\n\n/**\n * Shades weekend spans behind the series on date x axes — time scales\n * (line / area / composed) and band scales (bar columns). Only visible spans\n * render, so brush zoom and long ranges stay cheap.\n */\nexport function Weekends({\n  weekendDays = DEFAULT_WEEKEND_DAYS,\n  fillType = \"pattern\",\n  fill = chartCssVars.foregroundMuted,\n  fillOpacity,\n  pattern = \"diagonal-up\",\n  patternScale = 1.5,\n  patternStrokeWidth = 1,\n  align = \"auto\",\n  utc = false,\n  minDayWidth = 2,\n  dateKey,\n  className,\n}: WeekendsProps) {\n  const {\n    innerWidth,\n    innerHeight,\n    xScale,\n    barScale,\n    composedBarDataKeys,\n    data,\n    xAccessor,\n    chartPhase,\n    enterTransition,\n  } = useChartStable();\n  const slots = useCategorySlots(dateKey);\n  const patternId = `chart-weekends-pattern-${useId().replace(/:/g, \"\")}`;\n\n  const firstRow = data[0];\n  const resolvedAlign: DateRangeAlign =\n    align === \"auto\"\n      ? resolveAutoAlign(\n          (composedBarDataKeys?.length ?? 0) > 0,\n          firstRow && !barScale ? xAccessor(firstRow) : undefined,\n          utc\n        )\n      : align;\n  const weekendKey = weekendDays.join(\",\");\n\n  const rects = useMemo<WeekendRect[]>(() => {\n    const days = weekendKey ? weekendKey.split(\",\").map(Number) : [];\n    if (innerWidth <= 0 || innerHeight <= 0) {\n      return [];\n    }\n    if (slots) {\n      return bandScaleWeekendRects(slots, days, utc, minDayWidth, {\n        innerWidth,\n        innerHeight,\n      });\n    }\n    if (barScale) {\n      // Band scale without parseable dates — nothing to shade.\n      return [];\n    }\n    return timeScaleWeekendRects(\n      xScale,\n      days,\n      utc,\n      resolvedAlign,\n      minDayWidth,\n      {\n        innerWidth,\n        innerHeight,\n      }\n    );\n  }, [\n    weekendKey,\n    innerWidth,\n    innerHeight,\n    slots,\n    barScale,\n    xScale,\n    utc,\n    resolvedAlign,\n    minDayWidth,\n  ]);\n\n  const usesPattern = fillType === \"pattern\" && pattern !== \"none\";\n  const patternNode = useMemo(\n    () =>\n      usesPattern\n        ? renderPatternPreset(pattern, patternId, {\n            color: fill,\n            scale: patternScale,\n            strokeWidth: patternStrokeWidth,\n          })\n        : null,\n    [usesPattern, pattern, patternId, fill, patternScale, patternStrokeWidth]\n  );\n\n  if (rects.length === 0) {\n    return null;\n  }\n\n  const opacity = fillOpacity ?? (usesPattern ? 0.45 : 0.08);\n  const rectFill = patternNode ? `url(#${patternId})` : fill;\n  const visible = isReferenceAreaVisiblePhase(chartPhase);\n  const fadeTransition =\n    enterTransition && typeof enterTransition === \"object\"\n      ? enterTransition\n      : { duration: ENTER_FADE_MS / 1000, ease: \"easeOut\" as const };\n\n  return (\n    <m.g\n      animate={{ opacity: visible ? 1 : 0 }}\n      aria-hidden=\"true\"\n      className={className ?? \"chart-weekends\"}\n      initial={{ opacity: 0 }}\n      transition={fadeTransition}\n    >\n      {patternNode}\n      <g fill={rectFill} opacity={opacity}>\n        {rects.map((rect) => (\n          <rect\n            height={rect.height}\n            key={`${rect.x}:${rect.y}`}\n            width={rect.width}\n            x={rect.x}\n            y={rect.y}\n          />\n        ))}\n      </g>\n    </m.g>\n  );\n}\n\nWeekends.displayName = \"Weekends\";\n\nexport default Weekends;\n",
      "type": "registry:component",
      "target": "components/charts/weekends.tsx"
    },
    {
      "path": "src/charts/date-ranges.ts",
      "content": "/** Day-of-week numbers as returned by `Date#getDay()` (0 = Sunday … 6 = Saturday). */\nexport type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;\n\n/** Saturday + Sunday. */\nexport const DEFAULT_WEEKEND_DAYS: readonly WeekdayIndex[] = [6, 0];\n\n/**\n * How a highlighted day maps onto the x axis.\n * - `\"calendar\"` — midnight to midnight (points at 00:00 sit on the band's left edge)\n * - `\"centered\"` — shifted half a day earlier so each day's point / column sits in the middle\n */\nexport type DateRangeAlign = \"calendar\" | \"centered\";\n\nexport interface DateRange {\n  start: Date;\n  end: Date;\n}\n\nexport interface ComputeWeekendRangesOptions {\n  weekendDays?: readonly number[];\n  /** Use UTC day boundaries instead of local time. Default: false */\n  utc?: boolean;\n  align?: DateRangeAlign;\n  /** Bail out (return `[]`) when the domain spans more days than this. Default: 20_000 */\n  maxDays?: number;\n}\n\nconst HALF_DAY_MS = 12 * 60 * 60 * 1000;\nconst DAY_MS = 24 * 60 * 60 * 1000;\n\nfunction startOfDay(date: Date, utc: boolean): Date {\n  return utc\n    ? new Date(\n        Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())\n      )\n    : new Date(date.getFullYear(), date.getMonth(), date.getDate());\n}\n\n/** Next midnight — calendar arithmetic so DST days stay aligned. */\nfunction addDays(date: Date, days: number, utc: boolean): Date {\n  return utc\n    ? new Date(\n        Date.UTC(\n          date.getUTCFullYear(),\n          date.getUTCMonth(),\n          date.getUTCDate() + days\n        )\n      )\n    : new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);\n}\n\nexport function isWeekendDate(\n  date: Date,\n  weekendDays: readonly number[] = DEFAULT_WEEKEND_DAYS,\n  utc = false\n): boolean {\n  const day = utc ? date.getUTCDay() : date.getDay();\n  return weekendDays.includes(day);\n}\n\n/**\n * Contiguous weekend spans that intersect `[domainStart, domainEnd]`, clipped\n * to the domain. Consecutive weekend days merge into one range (Sat + Sun →\n * one span; Fri + Sat locales work the same way).\n */\nexport function computeWeekendRanges(\n  domainStart: Date,\n  domainEnd: Date,\n  options: ComputeWeekendRangesOptions = {}\n): DateRange[] {\n  const {\n    weekendDays = DEFAULT_WEEKEND_DAYS,\n    utc = false,\n    align = \"calendar\",\n    maxDays = 20_000,\n  } = options;\n\n  let lo = domainStart.getTime();\n  let hi = domainEnd.getTime();\n  if (!(Number.isFinite(lo) && Number.isFinite(hi))) {\n    return [];\n  }\n  if (lo > hi) {\n    [lo, hi] = [hi, lo];\n  }\n  if (weekendDays.length === 0 || (hi - lo) / DAY_MS > maxDays) {\n    return [];\n  }\n\n  const shift = align === \"centered\" ? HALF_DAY_MS : 0;\n  const ranges: DateRange[] = [];\n  let runStart: number | null = null;\n\n  // Start one day early so a centered span (or a span already in progress at\n  // the domain start) is still picked up.\n  let day = addDays(startOfDay(new Date(lo + shift), utc), -1, utc);\n  while (day.getTime() - shift <= hi) {\n    const next = addDays(day, 1, utc);\n    if (isWeekendDate(day, weekendDays, utc)) {\n      if (runStart === null) {\n        runStart = day.getTime() - shift;\n      }\n    } else if (runStart !== null) {\n      pushClipped(ranges, runStart, day.getTime() - shift, lo, hi);\n      runStart = null;\n    }\n    day = next;\n  }\n  if (runStart !== null) {\n    pushClipped(ranges, runStart, day.getTime() - shift, lo, hi);\n  }\n  return ranges;\n}\n\nfunction pushClipped(\n  ranges: DateRange[],\n  start: number,\n  end: number,\n  lo: number,\n  hi: number\n) {\n  const s = Math.max(start, lo);\n  const e = Math.min(end, hi);\n  if (e > s) {\n    ranges.push({ start: new Date(s), end: new Date(e) });\n  }\n}\n\n/**\n * Inclusive `[first, last]` index runs of consecutive weekend rows — for band\n * (category) scales where each row is one column. Rows with no date break a run.\n */\nexport function computeWeekendIndexRuns(\n  dates: ReadonlyArray<Date | null | undefined>,\n  options: Pick<ComputeWeekendRangesOptions, \"weekendDays\" | \"utc\"> = {}\n): [number, number][] {\n  const { weekendDays = DEFAULT_WEEKEND_DAYS, utc = false } = options;\n  const runs: [number, number][] = [];\n  let runStart = -1;\n  dates.forEach((date, index) => {\n    const weekend =\n      date != null &&\n      !Number.isNaN(date.getTime()) &&\n      isWeekendDate(date, weekendDays, utc);\n    if (weekend) {\n      if (runStart < 0) {\n        runStart = index;\n      }\n    } else if (runStart >= 0) {\n      runs.push([runStart, index - 1]);\n      runStart = -1;\n    }\n  });\n  if (runStart >= 0) {\n    runs.push([runStart, dates.length - 1]);\n  }\n  return runs;\n}\n\nconst ISO_DATE_ONLY = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\n\n/**\n * Coerce a user date-ish value to a valid `Date` (or `null`). Date-only ISO\n * strings (`\"2024-01-06\"`) parse as local midnight, not UTC, so the weekday\n * matches the calendar date.\n */\nexport function toValidDate(value: unknown): Date | null {\n  if (value instanceof Date) {\n    return Number.isNaN(value.getTime()) ? null : value;\n  }\n  if (typeof value === \"string\") {\n    const match = ISO_DATE_ONLY.exec(value.trim());\n    if (match) {\n      return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));\n    }\n  }\n  if (typeof value === \"number\" || typeof value === \"string\") {\n    const date = new Date(value);\n    return Number.isNaN(date.getTime()) ? null : date;\n  }\n  return null;\n}\n\n/**\n * Key of the first field in `row` holding a `Date` (or a date-only ISO string).\n * Used to find dates on band-scale (bar) charts whose context only exposes\n * category labels.\n */\nexport function detectDateKey(\n  row: Record<string, unknown> | undefined\n): string | null {\n  if (!row) {\n    return null;\n  }\n  for (const [key, value] of Object.entries(row)) {\n    if (value instanceof Date) {\n      return key;\n    }\n    if (typeof value === \"string\" && ISO_DATE_ONLY.test(value.trim())) {\n      return key;\n    }\n  }\n  return null;\n}\n\n/** Index of the first row whose date is on or after `target`; `-1` when none. */\nexport function firstIndexOnOrAfter(\n  dates: ReadonlyArray<Date | null | undefined>,\n  target: Date\n): number {\n  const t = target.getTime();\n  return dates.findIndex((date) => date != null && date.getTime() >= t);\n}\n",
      "type": "registry:component",
      "target": "components/charts/date-ranges.ts"
    },
    {
      "path": "src/charts/use-category-slots.ts",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { useChartStable } from \"./chart-context\";\nimport { detectDateKey, toValidDate } from \"./date-ranges\";\n\nexport interface CategorySlots {\n  /** Per-row date (null when the row has no parseable date). */\n  dates: (Date | null)[];\n  /** Leading pixel edge of each row's slot, including half the inner padding. */\n  slotStart: (index: number) => number;\n  /** Full slot size (`bandwidth + inner padding`). */\n  step: number;\n  /** `\"x\"` for vertical bars (columns), `\"y\"` for horizontal bars. */\n  axis: \"x\" | \"y\";\n}\n\n/**\n * Date lookup for band (category) scales — bar charts expose only category\n * labels in context, so read dates from `dateKey` (auto-detected when omitted).\n * Returns `null` on time / linear scales.\n */\nexport function useCategorySlots(dateKey?: string): CategorySlots | null {\n  const { barScale, barXAccessor, data, orientation } = useChartStable();\n\n  return useMemo(() => {\n    if (!(barScale && barXAccessor)) {\n      return null;\n    }\n    const key = dateKey ?? detectDateKey(data[0]);\n    const dates = data.map((row) => (key ? toValidDate(row[key]) : null));\n    const step = barScale.step();\n    const gap = step - barScale.bandwidth();\n    return {\n      dates,\n      step,\n      axis: orientation === \"horizontal\" ? \"y\" : \"x\",\n      slotStart: (index: number) => {\n        const row = data[index];\n        const start = row ? barScale(barXAccessor(row)) : undefined;\n        return (start ?? 0) - gap / 2;\n      },\n    };\n  }, [barScale, barXAccessor, data, orientation, dateKey]);\n}\n",
      "type": "registry:component",
      "target": "components/charts/use-category-slots.ts"
    }
  ]
}