{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "progress-tracker",
  "title": "Progress Tracker",
  "description": "Curriculum-level learning dashboard for modules, lessons, exercises, streaks, and earned skills.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/progress-tracker/progress-tracker.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport type { ReactNode } from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\nimport { Badge } from \"@vllnt/ui\";\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@vllnt/ui\";\nimport {\n  CHECKLIST_PROGRESS_EVENT,\n  type ChecklistItem,\n  parseChecklistStorageValue,\n} from \"@vllnt/ui\";\nimport { ProgressBar } from \"@vllnt/ui\";\n\nexport type ProgressTrackerModuleStatus =\n  | \"available\"\n  | \"completed\"\n  | \"in-progress\"\n  | \"locked\";\n\nexport type ProgressTrackerModuleItem = {\n  badge?: string;\n  checklistItems?: ChecklistItem[];\n  completedExercises?: number;\n  completedLessons?: number;\n  currentLesson?: string;\n  description?: string;\n  exercises?: number;\n  href?: string;\n  id?: string;\n  lessons: number;\n  persistKey?: string;\n  progress: number;\n  skills?: string[];\n  status: ProgressTrackerModuleStatus;\n  timeSpent?: string;\n  title: string;\n};\n\nexport type ProgressTrackerProps = React.HTMLAttributes<HTMLDivElement> & {\n  children?: ReactNode;\n  modules?: ProgressTrackerModuleItem[];\n  overallProgress: number;\n  streak?: number;\n  title?: string;\n};\n\ntype ProgressTrackerContextValue = {\n  modules: ProgressTrackerModuleItem[];\n  overallProgress: number;\n  streak: number;\n  title?: string;\n};\n\nconst ProgressTrackerContext =\n  React.createContext<null | ProgressTrackerContextValue>(null);\n\nfunction clampPercentage(value: number): number {\n  if (Number.isNaN(value)) return 0;\n  if (value <= 1) return Math.round(Math.max(0, value) * 100);\n  return Math.round(Math.min(Math.max(0, value), 100));\n}\n\nconst STATUS_LABELS: Record<ProgressTrackerModuleStatus, string> = {\n  available: \"Available\",\n  completed: \"Completed\",\n  \"in-progress\": \"In progress\",\n  locked: \"Locked\",\n};\n\nconst STATUS_CLASSES: Record<ProgressTrackerModuleStatus, string> = {\n  available: \"border-secondary bg-secondary text-secondary-foreground\",\n  completed: \"border-primary/20 bg-primary text-primary-foreground\",\n  \"in-progress\": \"border-primary/20 bg-primary/10 text-primary\",\n  locked: \"border-border bg-muted text-muted-foreground\",\n};\n\nfunction getStatusLabel(status: ProgressTrackerModuleStatus): string {\n  return STATUS_LABELS[status];\n}\n\nfunction getStatusClasses(status: ProgressTrackerModuleStatus): string {\n  return STATUS_CLASSES[status];\n}\n\nfunction readPersistedChecklistItems(persistKey?: string): string[] {\n  if (!persistKey || typeof window === \"undefined\") return [];\n\n  try {\n    const saved = localStorage.getItem(`checklist:${persistKey}`);\n    if (!saved) return [];\n    return parseChecklistStorageValue(saved);\n  } catch {\n    return [];\n  }\n}\n\nfunction areStringArraysEqual(left: string[], right: string[]): boolean {\n  return (\n    left.length === right.length &&\n    left.every((value, index) => value === right[index])\n  );\n}\n\nfunction getChecklistPersistKey(event?: Event): null | string {\n  if (!(event instanceof CustomEvent)) return null;\n\n  const detail: unknown = event.detail;\n  if (typeof detail !== \"object\" || detail === null) return null;\n  if (!(\"persistKey\" in detail)) return null;\n\n  const { persistKey } = detail;\n  return typeof persistKey === \"string\" ? persistKey : null;\n}\n\nfunction getResolvedLessonProgress(module: ProgressTrackerModuleItem): {\n  completedLessons: number;\n  totalLessons: number;\n} {\n  if (!module.persistKey || !module.checklistItems?.length) {\n    return {\n      completedLessons: module.completedLessons ?? 0,\n      totalLessons: module.lessons,\n    };\n  }\n\n  const validIds = new Set(module.checklistItems.map((item) => item.id));\n  const persistedIds = readPersistedChecklistItems(module.persistKey);\n  const completedLessons = persistedIds.filter((id) => validIds.has(id)).length;\n\n  return {\n    completedLessons,\n    totalLessons: module.checklistItems.length,\n  };\n}\n\nfunction useChecklistProgress(\n  checklistItems: ChecklistItem[] = [],\n  persistKey?: string,\n): null | { completedCount: number; progress: number; total: number } {\n  const total = checklistItems.length;\n  const [persistedIds, setPersistedIds] = React.useState<string[]>(() =>\n    readPersistedChecklistItems(persistKey),\n  );\n  const [trackedPersistKey, setTrackedPersistKey] = React.useState(persistKey);\n  const setPersistedIdsIfChanged = React.useCallback((nextIds: string[]) => {\n    setPersistedIds((currentIds) =>\n      areStringArraysEqual(currentIds, nextIds) ? currentIds : nextIds,\n    );\n  }, []);\n\n  if (trackedPersistKey !== persistKey) {\n    setTrackedPersistKey(persistKey);\n    setPersistedIdsIfChanged(readPersistedChecklistItems(persistKey));\n  }\n\n  React.useEffect(() => {\n    if (!persistKey || typeof window === \"undefined\") return;\n\n    const sync = (event?: Event): void => {\n      const eventPersistKey = getChecklistPersistKey(event);\n      if (eventPersistKey && eventPersistKey !== persistKey) return;\n\n      setPersistedIdsIfChanged(readPersistedChecklistItems(persistKey));\n    };\n    const syncEventListener: EventListener = (event) => {\n      sync(event);\n    };\n\n    window.addEventListener(\"storage\", sync);\n    window.addEventListener(\"focus\", sync);\n    window.addEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener);\n\n    return () => {\n      window.removeEventListener(\"storage\", sync);\n      window.removeEventListener(\"focus\", sync);\n      window.removeEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener);\n    };\n  }, [persistKey, setPersistedIdsIfChanged]);\n\n  if (!persistKey || total === 0) return null;\n\n  const validIds = new Set(checklistItems.map((item) => item.id));\n  const completedCount = persistedIds.filter((id) => validIds.has(id)).length;\n\n  return {\n    completedCount,\n    progress: total > 0 ? Math.round((completedCount / total) * 100) : 0,\n    total,\n  };\n}\n\nfunction useProgressTrackerContext(): ProgressTrackerContextValue {\n  const context = React.useContext(ProgressTrackerContext);\n\n  if (!context) {\n    throw new Error(\n      \"ProgressTracker compound components must be used within <ProgressTracker />.\",\n    );\n  }\n\n  return context;\n}\n\nconst EMPTY_PROGRESS_TRACKER_MODULES: ProgressTrackerModuleItem[] = [];\nconst EMPTY_PROGRESS_TRACKER_SKILLS: string[] = [];\n\nfunction ProgressTrackerRoot({\n  children,\n  className,\n  modules = EMPTY_PROGRESS_TRACKER_MODULES,\n  overallProgress,\n  streak = 0,\n  title = \"Learning progress\",\n  ...props\n}: ProgressTrackerProps): React.ReactNode {\n  const contextValue = React.useMemo(\n    () => ({\n      modules,\n      overallProgress: clampPercentage(overallProgress),\n      streak,\n      title,\n    }),\n    [modules, overallProgress, streak, title],\n  );\n\n  return (\n    <ProgressTrackerContext.Provider value={contextValue}>\n      <section\n        aria-label={title}\n        className={cn(\"grid gap-6\", className)}\n        {...props}\n      >\n        {children}\n      </section>\n    </ProgressTrackerContext.Provider>\n  );\n}\n\nexport type ProgressTrackerOverviewProps =\n  React.HTMLAttributes<HTMLDivElement> & {\n    description?: string;\n    label?: string;\n  };\n\n// eslint-disable-next-line max-lines-per-function\nfunction ProgressTrackerOverview({\n  className,\n  description = \"Track completion across modules, lessons, and exercises.\",\n  label = \"Overall progress\",\n  ...props\n}: ProgressTrackerOverviewProps): React.ReactNode {\n  const { modules, overallProgress, streak, title } =\n    useProgressTrackerContext();\n  const trackedPersistKeys = React.useMemo(\n    () =>\n      modules.reduce<string[]>((keys, module) => {\n        if (module.persistKey) {\n          keys.push(module.persistKey);\n        }\n        return keys;\n      }, []),\n    [modules],\n  );\n  const [, forceChecklistRefresh] = React.useState(0);\n\n  React.useEffect(() => {\n    if (trackedPersistKeys.length === 0 || typeof window === \"undefined\") {\n      return;\n    }\n\n    const trackedKeys = new Set(trackedPersistKeys);\n    const sync = (event?: Event): void => {\n      const eventPersistKey = getChecklistPersistKey(event);\n      if (eventPersistKey && !trackedKeys.has(eventPersistKey)) return;\n\n      forceChecklistRefresh((version) => version + 1);\n    };\n    const syncEventListener: EventListener = (event) => {\n      sync(event);\n    };\n\n    window.addEventListener(\"storage\", sync);\n    window.addEventListener(\"focus\", sync);\n    window.addEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener);\n\n    return () => {\n      window.removeEventListener(\"storage\", sync);\n      window.removeEventListener(\"focus\", sync);\n      window.removeEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener);\n    };\n  }, [trackedPersistKeys]);\n\n  const radius = 54;\n  const circumference = 2 * Math.PI * radius;\n  const offset = circumference - (overallProgress / 100) * circumference;\n  const completedModules = modules.filter(\n    (module) => module.status === \"completed\",\n  ).length;\n  const lessonTotals = modules.reduce(\n    (totals, module) => {\n      const resolvedProgress = getResolvedLessonProgress(module);\n\n      return {\n        completedLessons:\n          totals.completedLessons + resolvedProgress.completedLessons,\n        totalLessons: totals.totalLessons + resolvedProgress.totalLessons,\n      };\n    },\n    { completedLessons: 0, totalLessons: 0 },\n  );\n  const totalLessons = lessonTotals.totalLessons;\n  const completedLessons = lessonTotals.completedLessons;\n  const totalExercises = modules.reduce(\n    (sum, module) => sum + (module.exercises ?? 0),\n    0,\n  );\n  const completedExercises = modules.reduce(\n    (sum, module) => sum + (module.completedExercises ?? 0),\n    0,\n  );\n\n  return (\n    <Card className={cn(\"overflow-hidden\", className)} {...props}>\n      <CardHeader className=\"gap-4 sm:flex-row sm:items-start sm:justify-between\">\n        <div className=\"space-y-2\">\n          <Badge className=\"w-fit\" variant=\"secondary\">\n            {label}\n          </Badge>\n          <div className=\"space-y-1\">\n            <CardTitle>{title}</CardTitle>\n            <CardDescription>{description}</CardDescription>\n          </div>\n        </div>\n        <div className=\"flex items-center gap-2 rounded-full border bg-background px-3 py-1 text-sm text-muted-foreground whitespace-nowrap\">\n          <span\n            aria-hidden=\"true\"\n            className=\"inline-flex size-2.5 rounded-full bg-primary\"\n          />\n          <span>\n            <span className=\"font-semibold text-foreground\">{streak}</span> day\n            {streak === 1 ? \"\" : \"s\"} streak\n          </span>\n        </div>\n      </CardHeader>\n      <CardContent className=\"grid gap-6 lg:grid-cols-[auto,1fr] lg:items-center\">\n        <div className=\"mx-auto flex flex-col items-center gap-3 text-center\">\n          <div className=\"relative flex size-36 items-center justify-center\">\n            <svg className=\"size-36 -rotate-90\" viewBox=\"0 0 120 120\">\n              <circle\n                className=\"stroke-muted\"\n                cx=\"60\"\n                cy=\"60\"\n                fill=\"none\"\n                r={radius}\n                strokeWidth=\"10\"\n              />\n              <circle\n                aria-label={label}\n                aria-valuemax={100}\n                aria-valuemin={0}\n                aria-valuenow={overallProgress}\n                className=\"stroke-primary transition-all duration-500\"\n                cx=\"60\"\n                cy=\"60\"\n                fill=\"none\"\n                r={radius}\n                role=\"progressbar\"\n                strokeDasharray={circumference}\n                strokeDashoffset={offset}\n                strokeLinecap=\"round\"\n                strokeWidth=\"10\"\n              />\n            </svg>\n            <div className=\"absolute inset-0 flex flex-col items-center justify-center\">\n              <span className=\"text-3xl font-semibold text-foreground\">\n                {overallProgress}%\n              </span>\n              <span className=\"text-xs uppercase tracking-[0.2em] text-muted-foreground\">\n                Complete\n              </span>\n            </div>\n          </div>\n          <p className=\"max-w-52 text-sm text-muted-foreground\">\n            {completedModules} of {modules.length} modules completed.\n          </p>\n        </div>\n\n        <dl className=\"grid gap-4 sm:grid-cols-2 xl:grid-cols-4\">\n          <div className=\"rounded-xl border bg-muted/30 p-4\">\n            <dt className=\"text-sm text-muted-foreground\">Modules</dt>\n            <dd className=\"mt-2 text-2xl font-semibold text-foreground\">\n              {completedModules}/{modules.length}\n            </dd>\n          </div>\n          <div className=\"rounded-xl border bg-muted/30 p-4\">\n            <dt className=\"text-sm text-muted-foreground\">Lessons</dt>\n            <dd className=\"mt-2 text-2xl font-semibold text-foreground\">\n              {completedLessons}/{totalLessons}\n            </dd>\n          </div>\n          <div className=\"rounded-xl border bg-muted/30 p-4\">\n            <dt className=\"text-sm text-muted-foreground\">Exercises</dt>\n            <dd className=\"mt-2 text-2xl font-semibold text-foreground\">\n              {completedExercises}/{totalExercises}\n            </dd>\n          </div>\n          <div className=\"rounded-xl border bg-muted/30 p-4\">\n            <dt className=\"text-sm text-muted-foreground\">Momentum</dt>\n            <dd className=\"mt-2 text-2xl font-semibold text-foreground\">\n              {streak} day{streak === 1 ? \"\" : \"s\"}\n            </dd>\n          </div>\n        </dl>\n      </CardContent>\n    </Card>\n  );\n}\n\nexport type ProgressTrackerModulesProps = React.HTMLAttributes<HTMLDivElement>;\n\nfunction ProgressTrackerModules({\n  children,\n  className,\n  ...props\n}: ProgressTrackerModulesProps): React.ReactNode {\n  return (\n    <div\n      className={cn(\"grid gap-4 md:grid-cols-2 xl:grid-cols-3\", className)}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport type ProgressTrackerModuleProps = React.HTMLAttributes<HTMLDivElement> &\n  ProgressTrackerModuleItem;\n\n// eslint-disable-next-line max-lines-per-function\nfunction ProgressTrackerModule({\n  badge,\n  checklistItems,\n  className,\n  completedExercises,\n  completedLessons = 0,\n  currentLesson,\n  description,\n  exercises,\n  href,\n  id,\n  lessons,\n  persistKey,\n  progress,\n  skills = EMPTY_PROGRESS_TRACKER_SKILLS,\n  status,\n  timeSpent,\n  title,\n  ...props\n}: ProgressTrackerModuleProps): React.ReactNode {\n  const checklistProgress = useChecklistProgress(checklistItems, persistKey);\n  const resolvedLessons = checklistProgress?.completedCount ?? completedLessons;\n  const resolvedLessonTotal = checklistProgress?.total || lessons;\n  const progressPercent =\n    checklistProgress?.progress ?? clampPercentage(progress);\n  const progressValue = Math.min(resolvedLessons, resolvedLessonTotal);\n  const safeExerciseTotal = exercises ?? 0;\n  const safeExerciseComplete = Math.min(\n    completedExercises ?? 0,\n    safeExerciseTotal,\n  );\n  const card = (\n    <Card\n      aria-disabled={status === \"locked\"}\n      className={cn(\n        \"h-full\",\n        status === \"locked\" && \"opacity-80\",\n        href && \"transition-shadow hover:shadow-md focus-within:shadow-md\",\n        className,\n      )}\n      id={id}\n      {...props}\n    >\n      <CardHeader className=\"gap-4\">\n        <div className=\"flex items-start justify-between gap-3\">\n          <div className=\"space-y-1\">\n            <CardTitle className=\"text-xl\">{title}</CardTitle>\n            {description ? (\n              <CardDescription>{description}</CardDescription>\n            ) : null}\n          </div>\n          <Badge\n            className={cn(\"whitespace-nowrap\", getStatusClasses(status))}\n            variant=\"outline\"\n          >\n            {getStatusLabel(status)}\n          </Badge>\n        </div>\n        <div className=\"flex flex-wrap gap-2\">\n          {badge ? <Badge variant=\"secondary\">{badge}</Badge> : null}\n          {currentLesson ? (\n            <Badge variant=\"outline\">Current: {currentLesson}</Badge>\n          ) : null}\n          {timeSpent ? <Badge variant=\"outline\">{timeSpent}</Badge> : null}\n        </div>\n      </CardHeader>\n      <CardContent className=\"space-y-4\">\n        <div className=\"space-y-2\">\n          <div\n            aria-label={`${title} progress`}\n            aria-valuemax={100}\n            aria-valuemin={0}\n            aria-valuenow={progressPercent}\n            role=\"progressbar\"\n          >\n            <ProgressBar\n              completedLabel=\"lessons\"\n              currentLabel={`${progressPercent}% complete`}\n              isComplete={status === \"completed\"}\n              max={resolvedLessonTotal}\n              showLabels\n              value={progressValue}\n            />\n          </div>\n          <div className=\"grid gap-2 text-sm text-muted-foreground sm:grid-cols-2\">\n            <span>\n              Lessons:{\" \"}\n              <span className=\"font-medium text-foreground\">\n                {resolvedLessons}/{resolvedLessonTotal}\n              </span>\n            </span>\n            <span>\n              Exercises:{\" \"}\n              <span className=\"font-medium text-foreground\">\n                {safeExerciseComplete}/{safeExerciseTotal}\n              </span>\n            </span>\n          </div>\n        </div>\n        {skills.length > 0 ? (\n          <div className=\"flex flex-wrap gap-2\">\n            {skills.map((skill) => (\n              <ProgressTrackerBadge key={skill}>{skill}</ProgressTrackerBadge>\n            ))}\n          </div>\n        ) : null}\n      </CardContent>\n    </Card>\n  );\n\n  if (!href || status === \"locked\") return card;\n\n  return (\n    <a\n      className=\"block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n      href={href}\n    >\n      {card}\n    </a>\n  );\n}\n\nexport type ProgressTrackerStatsProps = React.HTMLAttributes<HTMLDivElement>;\n\nfunction ProgressTrackerStats({\n  children,\n  className,\n  ...props\n}: ProgressTrackerStatsProps): React.ReactNode {\n  return (\n    <div\n      className={cn(\"grid gap-4 sm:grid-cols-2 xl:grid-cols-4\", className)}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport type ProgressTrackerStatProps = React.HTMLAttributes<HTMLDivElement> & {\n  label: string;\n  value: ReactNode;\n};\n\nfunction ProgressTrackerStat({\n  className,\n  label,\n  value,\n  ...props\n}: ProgressTrackerStatProps): React.ReactNode {\n  return (\n    <Card className={cn(\"h-full\", className)} {...props}>\n      <CardContent className=\"flex h-full flex-col justify-center gap-2 p-6\">\n        <span className=\"text-sm text-muted-foreground\">{label}</span>\n        <span className=\"text-2xl font-semibold text-foreground\">{value}</span>\n      </CardContent>\n    </Card>\n  );\n}\n\nexport type ProgressTrackerBadgeProps = React.HTMLAttributes<HTMLSpanElement>;\n\nfunction ProgressTrackerBadge({\n  children,\n  className,\n  ...props\n}: ProgressTrackerBadgeProps): React.ReactNode {\n  return (\n    <Badge\n      className={cn(\n        \"px-3 py-1 text-[11px] uppercase tracking-wide whitespace-nowrap\",\n        className,\n      )}\n      variant=\"secondary\"\n      {...props}\n    >\n      {children}\n    </Badge>\n  );\n}\n\nconst ProgressTracker = Object.assign(ProgressTrackerRoot, {\n  Badge: ProgressTrackerBadge,\n  Module: ProgressTrackerModule,\n  Modules: ProgressTrackerModules,\n  Overview: ProgressTrackerOverview,\n  Stat: ProgressTrackerStat,\n  Stats: ProgressTrackerStats,\n});\n\nexport {\n  ProgressTracker,\n  ProgressTrackerBadge,\n  ProgressTrackerModule,\n  ProgressTrackerModules,\n  ProgressTrackerOverview,\n  ProgressTrackerStat,\n  ProgressTrackerStats,\n  useProgressTrackerContext,\n};\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component",
  "version": "0.2.1",
  "stability": "stable"
}
