{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-timeline",
  "title": "Progress Timeline",
  "author": "ilinxa",
  "description": "Horizontal progress bar with a current-position marker and start, state-aware center, and end captions — derives its state from three dates.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "progress"
  ],
  "files": [
    {
      "path": "src/registry/components/data/progress-timeline/progress-timeline.tsx",
      "content": "\"use client\";\n\nimport { memo, useId, useMemo, type ReactNode } from \"react\";\nimport { Timer } from \"lucide-react\";\nimport { Progress } from \"@/components/ui/progress\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  DEFAULT_PROGRESS_TIMELINE_LABELS,\n  type ProgressTimelineProps,\n  type TimelineLabelText,\n  type TimelineState,\n} from \"./types\";\nimport { deriveTimelineState } from \"./lib/timeline-state\";\n\nfunction resolveLabel(\n  text: TimelineLabelText | undefined,\n  state: TimelineState,\n): ReactNode {\n  if (text == null) return null;\n  return typeof text === \"function\" ? text(state) : text;\n}\n\nfunction ProgressTimelineInner({\n  start,\n  end,\n  now,\n  value,\n  statusOverride,\n  heading,\n  headingAs = \"h3\",\n  headingIcon: HeadingIconProp,\n  framed = true,\n  marker = \"dot\",\n  labels: labelsProp,\n  renderCenterLabel,\n  className,\n  headingClassName,\n  barClassName,\n  markerClassName,\n  captionsClassName,\n}: ProgressTimelineProps) {\n  const headingId = useId();\n  const HeadingTag = headingAs;\n  const HeadingIcon =\n    HeadingIconProp === undefined ? Timer : HeadingIconProp;\n\n  const labels = useMemo<typeof DEFAULT_PROGRESS_TIMELINE_LABELS>(\n    () => ({ ...DEFAULT_PROGRESS_TIMELINE_LABELS, ...labelsProp }),\n    [labelsProp],\n  );\n\n  const state = useMemo(\n    () => deriveTimelineState(start, end, now),\n    [start, end, now],\n  );\n\n  const effectivePercent = value ?? state.percent;\n  const effectiveStatus = statusOverride ?? state.status;\n\n  const centerLabel: ReactNode = renderCenterLabel\n    ? renderCenterLabel(state)\n    : effectiveStatus === \"before\"\n      ? resolveLabel(labels.beforeText, state)\n      : effectiveStatus === \"after\"\n        ? resolveLabel(labels.afterText, state)\n        : resolveLabel(labels.activeText, state);\n\n  return (\n    <section\n      aria-labelledby={heading ? headingId : undefined}\n      className={cn(\n        framed && \"bg-card rounded-2xl p-6 border border-border/50\",\n        className,\n      )}\n    >\n      {heading && (\n        <HeadingTag\n          id={headingId}\n          className={cn(\n            \"text-lg font-semibold text-foreground mb-4 flex items-center gap-2\",\n            headingClassName,\n          )}\n        >\n          {HeadingIcon ? (\n            <HeadingIcon\n              aria-hidden=\"true\"\n              className=\"w-5 h-5 text-primary\"\n            />\n          ) : null}\n          {heading}\n        </HeadingTag>\n      )}\n\n      <div className=\"space-y-4\">\n        <div className={cn(\"relative\", barClassName)}>\n          <Progress\n            value={effectivePercent}\n            className={cn(\n              \"h-4 rounded-full\",\n              effectiveStatus === \"before\" &&\n                \"**:data-[slot=progress-indicator]:bg-muted-foreground/30\",\n              effectiveStatus === \"after\" &&\n                \"**:data-[slot=progress-indicator]:bg-muted-foreground/40\",\n            )}\n            aria-label={labels.ariaLabel}\n          />\n          {marker === \"dot\" && (\n            <div\n              aria-hidden=\"true\"\n              className=\"absolute top-0 h-4 flex items-center justify-center pointer-events-none\"\n              style={{\n                left: `${effectivePercent}%`,\n                transform: \"translateX(-50%)\",\n              }}\n            >\n              <div\n                className={cn(\n                  \"w-6 h-6 rounded-full border-4 border-background shadow-lg\",\n                  effectiveStatus === \"active\"\n                    ? \"bg-primary\"\n                    : effectiveStatus === \"after\"\n                      ? \"bg-foreground\"\n                      : \"bg-muted-foreground\",\n                  markerClassName,\n                )}\n              />\n            </div>\n          )}\n        </div>\n\n        <div\n          className={cn(\n            \"flex justify-between text-sm\",\n            captionsClassName,\n          )}\n        >\n          <span className=\"text-muted-foreground\">{labels.startLabel}</span>\n          <span className=\"text-foreground font-medium\">{centerLabel}</span>\n          <span className=\"text-muted-foreground\">{labels.endLabel}</span>\n        </div>\n      </div>\n    </section>\n  );\n}\n\nconst ProgressTimeline = memo(ProgressTimelineInner);\nProgressTimeline.displayName = \"ProgressTimeline\";\n\nexport { ProgressTimeline };\nexport default ProgressTimeline;\n",
      "type": "registry:component",
      "target": "components/progress-timeline/progress-timeline.tsx"
    },
    {
      "path": "src/registry/components/data/progress-timeline/index.ts",
      "content": "export { ProgressTimeline, default } from \"./progress-timeline\";\n\nexport type {\n  ProgressTimelineProps,\n  ProgressTimelineLabels,\n  TimelineLabelText,\n  TimelineState,\n  TimelineStatus,\n} from \"./types\";\n\nexport { DEFAULT_PROGRESS_TIMELINE_LABELS } from \"./types\";\n\nexport { deriveTimelineState } from \"./lib/timeline-state\";\n\n",
      "type": "registry:component",
      "target": "components/progress-timeline/index.ts"
    },
    {
      "path": "src/registry/components/data/progress-timeline/types.ts",
      "content": "import type { ComponentType, ReactNode } from \"react\";\nimport type {\n  TimelineState,\n  TimelineStatus,\n} from \"./lib/timeline-state\";\n\nexport type { TimelineState, TimelineStatus };\n\nexport type TimelineLabelText =\n  | string\n  | ((state: TimelineState) => ReactNode);\n\nexport interface ProgressTimelineLabels {\n  /** Default: \"Start\". Static left-side caption. */\n  startLabel?: ReactNode;\n  /** Default: \"End\". Static right-side caption. */\n  endLabel?: ReactNode;\n  /** Default: state-aware — \"{n} days until start\". When `now < start`. */\n  beforeText?: TimelineLabelText;\n  /** Default: state-aware — \"{n} days left\". When `now ∈ [start, end]`. */\n  activeText?: TimelineLabelText;\n  /** Default: \"Ended\". When `now > end`. */\n  afterText?: TimelineLabelText;\n  /** aria-label on the progress bar. Default: \"Timeline progress\". */\n  ariaLabel?: string;\n}\n\nexport interface ProgressTimelineProps {\n  /** Start of the timeline window. ISO string or Date. Required. */\n  start: Date | string;\n  /** End of the timeline window. ISO string or Date. Required. */\n  end: Date | string;\n\n  /** Inject \"now\" for deterministic state (tests, live clocks). Default: new Date() at render. */\n  now?: Date;\n\n  /** Escape hatch: explicit 0-100 percentage. Overrides start/end-derived percent (state still derives from dates). */\n  value?: number;\n\n  /** Override the auto-derived state. Rare. */\n  statusOverride?: TimelineStatus;\n\n  // ─── Heading ─────────────────────────────────────────────────────\n  /** Optional section heading. */\n  heading?: string;\n  /** Heading semantic level. Default: 'h3'. */\n  headingAs?: \"h2\" | \"h3\" | \"h4\";\n  /** Heading icon (default Timer). Pass null to omit. */\n  headingIcon?: ComponentType<{\n    className?: string;\n    \"aria-hidden\"?: boolean | \"true\" | \"false\";\n  }> | null;\n\n  // ─── Visual ──────────────────────────────────────────────────────\n  /** Wrap in card chrome (`bg-card rounded-2xl p-6 border ...`). Default: true. */\n  framed?: boolean;\n  /** Marker dot variant. Default: 'dot'. */\n  marker?: \"dot\" | \"none\";\n\n  /** Localized labels + render-function overrides. */\n  labels?: ProgressTimelineLabels;\n\n  /** Custom center-label renderer — full takeover of the dynamic center caption. Receives derived TimelineState. */\n  renderCenterLabel?: (state: TimelineState) => ReactNode;\n\n  // ─── Style overrides ─────────────────────────────────────────────\n  className?: string;\n  headingClassName?: string;\n  barClassName?: string;\n  markerClassName?: string;\n  captionsClassName?: string;\n}\n\n/** Default English labels — exported for consumer composition. */\nexport const DEFAULT_PROGRESS_TIMELINE_LABELS: {\n  startLabel: ReactNode;\n  endLabel: ReactNode;\n  beforeText: TimelineLabelText;\n  activeText: TimelineLabelText;\n  afterText: TimelineLabelText;\n  ariaLabel: string;\n} = {\n  startLabel: \"Start\",\n  endLabel: \"End\",\n  beforeText: (state) =>\n    `${state.daysToStart} day${state.daysToStart === 1 ? \"\" : \"s\"} until start`,\n  activeText: (state) =>\n    `${state.daysToEnd} day${state.daysToEnd === 1 ? \"\" : \"s\"} left`,\n  afterText: \"Ended\",\n  ariaLabel: \"Timeline progress\",\n};\n",
      "type": "registry:component",
      "target": "components/progress-timeline/types.ts"
    },
    {
      "path": "src/registry/components/data/progress-timeline/lib/timeline-state.ts",
      "content": "export type TimelineStatus = \"before\" | \"active\" | \"after\";\n\nexport interface TimelineState {\n  /** Derived state — time-window position. */\n  status: TimelineStatus;\n  /** Percent elapsed (0-100), clamped. */\n  percent: number;\n  /** Days until start (negative if start is past). */\n  daysToStart: number;\n  /** Days from start (negative if start is future). */\n  daysFromStart: number;\n  /** Days to end (negative if end is past). */\n  daysToEnd: number;\n  /** Total days in the window (always positive; clamped to 1 minimum). */\n  totalDays: number;\n}\n\nconst MS_PER_DAY = 1000 * 60 * 60 * 24;\n\n/**\n * Pure function. Derives timeline state from start + end + an optional `now`.\n * Invalid dates clamp gracefully; out-of-window times return 0% / 100%.\n *\n * @example\n * deriveTimelineState(\"2026-04-01\", \"2026-06-30\")\n *   // → { status: \"active\", percent: 67, daysToStart: -61, daysToEnd: 29, ... }\n */\nexport function deriveTimelineState(\n  start: Date | string,\n  end: Date | string,\n  now: Date = new Date(),\n): TimelineState {\n  const startDate = new Date(start);\n  const endDate = new Date(end);\n\n  // Soft-failure on invalid dates\n  const validStart = isNaN(startDate.getTime()) ? now : startDate;\n  const validEnd = isNaN(endDate.getTime())\n    ? validStart\n    : endDate < validStart\n      ? validStart\n      : endDate;\n\n  const totalMs = Math.max(1, validEnd.getTime() - validStart.getTime());\n  const elapsedMs = now.getTime() - validStart.getTime();\n  const percent = Math.min(100, Math.max(0, (elapsedMs / totalMs) * 100));\n\n  const daysToStart = Math.ceil(\n    (validStart.getTime() - now.getTime()) / MS_PER_DAY,\n  );\n  const daysFromStart = Math.floor(\n    (now.getTime() - validStart.getTime()) / MS_PER_DAY,\n  );\n  const daysToEnd = Math.ceil(\n    (validEnd.getTime() - now.getTime()) / MS_PER_DAY,\n  );\n  const totalDays = Math.max(1, Math.ceil(totalMs / MS_PER_DAY));\n\n  let status: TimelineStatus;\n  if (now < validStart) status = \"before\";\n  else if (now > validEnd) status = \"after\";\n  else status = \"active\";\n\n  return {\n    status,\n    percent,\n    daysToStart,\n    daysFromStart,\n    daysToEnd,\n    totalDays,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/progress-timeline/lib/timeline-state.ts"
    }
  ],
  "categories": [
    "data",
    "events"
  ],
  "type": "registry:block"
}