Progress Tracker
Tableau de bord d'apprentissage au niveau du programme pour modules, lecons, exercices, series et competences acquises. Installez Progress Tracker depuis le registre VLLNT UI avec la CLI shadcn.
Tableau de bord d'apprentissage au niveau du programme pour modules, lecons, exercices, series et competences acquises. Fait partie de la famille learning dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.
Installation
Ajoutez Progress Tracker a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/progress-tracker.jsonSource
"use client";
import * as React from "react";
import type { ReactNode } from "react";
import { cn } from "../../lib/utils";
import { Badge } from "../badge/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../card/card";
import {
CHECKLIST_PROGRESS_EVENT,
type ChecklistItem,
parseChecklistStorageValue,
} from "../checklist/checklist";
import { ProgressBar } from "../progress-bar/progress-bar";
export type ProgressTrackerModuleStatus =
| "available"
| "completed"
| "in-progress"
| "locked";
export type ProgressTrackerModuleItem = {
badge?: string;
checklistItems?: ChecklistItem[];
completedExercises?: number;
completedLessons?: number;
currentLesson?: string;
description?: string;
exercises?: number;
href?: string;
id?: string;
lessons: number;
persistKey?: string;
progress: number;
skills?: string[];
status: ProgressTrackerModuleStatus;
timeSpent?: string;
title: string;
};
export type ProgressTrackerProps = React.HTMLAttributes<HTMLDivElement> & {
children?: ReactNode;
modules?: ProgressTrackerModuleItem[];
overallProgress: number;
streak?: number;
title?: string;
};
type ProgressTrackerContextValue = {
modules: ProgressTrackerModuleItem[];
overallProgress: number;
streak: number;
title?: string;
};
const ProgressTrackerContext =
React.createContext<null | ProgressTrackerContextValue>(null);
function clampPercentage(value: number): number {
if (Number.isNaN(value)) return 0;
if (value <= 1) return Math.round(Math.max(0, value) * 100);
return Math.round(Math.min(Math.max(0, value), 100));
}
const STATUS_LABELS: Record<ProgressTrackerModuleStatus, string> = {
available: "Available",
completed: "Completed",
"in-progress": "In progress",
locked: "Locked",
};
const STATUS_CLASSES: Record<ProgressTrackerModuleStatus, string> = {
available: "border-secondary bg-secondary text-secondary-foreground",
completed: "border-primary/20 bg-primary text-primary-foreground",
"in-progress": "border-primary/20 bg-primary/10 text-primary",
locked: "border-border bg-muted text-muted-foreground",
};
function getStatusLabel(status: ProgressTrackerModuleStatus): string {
return STATUS_LABELS[status];
}
function getStatusClasses(status: ProgressTrackerModuleStatus): string {
return STATUS_CLASSES[status];
}
function readPersistedChecklistItems(persistKey?: string): string[] {
if (!persistKey || typeof window === "undefined") return [];
try {
const saved = localStorage.getItem(`checklist:${persistKey}`);
if (!saved) return [];
return parseChecklistStorageValue(saved);
} catch {
return [];
}
}
function areStringArraysEqual(left: string[], right: string[]): boolean {
return (
left.length === right.length &&
left.every((value, index) => value === right[index])
);
}
function getChecklistPersistKey(event?: Event): null | string {
if (!(event instanceof CustomEvent)) return null;
const detail: unknown = event.detail;
if (typeof detail !== "object" || detail === null) return null;
if (!("persistKey" in detail)) return null;
const { persistKey } = detail;
return typeof persistKey === "string" ? persistKey : null;
}
function getResolvedLessonProgress(module: ProgressTrackerModuleItem): {
completedLessons: number;
totalLessons: number;
} {
if (!module.persistKey || !module.checklistItems?.length) {
return {
completedLessons: module.completedLessons ?? 0,
totalLessons: module.lessons,
};
}
const validIds = new Set(module.checklistItems.map((item) => item.id));
const persistedIds = readPersistedChecklistItems(module.persistKey);
const completedLessons = persistedIds.filter((id) => validIds.has(id)).length;
return {
completedLessons,
totalLessons: module.checklistItems.length,
};
}
function useChecklistProgress(
checklistItems: ChecklistItem[] = [],
persistKey?: string,
): null | { completedCount: number; progress: number; total: number } {
const total = checklistItems.length;
const [persistedIds, setPersistedIds] = React.useState<string[]>(() =>
readPersistedChecklistItems(persistKey),
);
const [trackedPersistKey, setTrackedPersistKey] = React.useState(persistKey);
const setPersistedIdsIfChanged = React.useCallback((nextIds: string[]) => {
setPersistedIds((currentIds) =>
areStringArraysEqual(currentIds, nextIds) ? currentIds : nextIds,
);
}, []);
if (trackedPersistKey !== persistKey) {
setTrackedPersistKey(persistKey);
setPersistedIdsIfChanged(readPersistedChecklistItems(persistKey));
}
React.useEffect(() => {
if (!persistKey || typeof window === "undefined") return;
const sync = (event?: Event): void => {
const eventPersistKey = getChecklistPersistKey(event);
if (eventPersistKey && eventPersistKey !== persistKey) return;
setPersistedIdsIfChanged(readPersistedChecklistItems(persistKey));
};
const syncEventListener: EventListener = (event) => {
sync(event);
};
window.addEventListener("storage", sync);
window.addEventListener("focus", sync);
window.addEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener);
return () => {
window.removeEventListener("storage", sync);
window.removeEventListener("focus", sync);
window.removeEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener);
};
}, [persistKey, setPersistedIdsIfChanged]);
if (!persistKey || total === 0) return null;
const validIds = new Set(checklistItems.map((item) => item.id));
const completedCount = persistedIds.filter((id) => validIds.has(id)).length;
return {
completedCount,
progress: total > 0 ? Math.round((completedCount / total) * 100) : 0,
total,
};
}
function useProgressTrackerContext(): ProgressTrackerContextValue {
const context = React.use(ProgressTrackerContext);
if (!context) {
throw new Error(
"ProgressTracker compound components must be used within <ProgressTracker />.",
);
}
return context;
}
const EMPTY_PROGRESS_TRACKER_MODULES: ProgressTrackerModuleItem[] = [];
const EMPTY_PROGRESS_TRACKER_SKILLS: string[] = [];
function ProgressTrackerRoot({
children,
className,
modules = EMPTY_PROGRESS_TRACKER_MODULES,
overallProgress,
streak = 0,
title = "Learning progress",
...props
}: ProgressTrackerProps): React.ReactNode {
const contextValue = React.useMemo(
() => ({
modules,
overallProgress: clampPercentage(overallProgress),
streak,
title,
}),
[modules, overallProgress, streak, title],
);
return (
<ProgressTrackerContext.Provider value={contextValue}>
<section
aria-label={title}
className={cn("grid gap-6", className)}
{...props}
>
{children}
</section>
</ProgressTrackerContext.Provider>
);
}
export type ProgressTrackerOverviewProps =
React.HTMLAttributes<HTMLDivElement> & {
description?: string;
label?: string;
};
// eslint-disable-next-line max-lines-per-function
function ProgressTrackerOverview({
className,
description = "Track completion across modules, lessons, and exercises.",
label = "Overall progress",
...props
}: ProgressTrackerOverviewProps): React.ReactNode {
const { modules, overallProgress, streak, title } =
useProgressTrackerContext();
const trackedPersistKeys = React.useMemo(
() =>
modules.reduce<string[]>((keys, module) => {
if (module.persistKey) {
keys.push(module.persistKey);
}
return keys;
}, []),
[modules],
);
const [, forceChecklistRefresh] = React.useState(0);
React.useEffect(() => {
if (trackedPersistKeys.length === 0 || typeof window === "undefined") {
return;
}
const trackedKeys = new Set(trackedPersistKeys);
const sync = (event?: Event): void => {
const eventPersistKey = getChecklistPersistKey(event);
if (eventPersistKey && !trackedKeys.has(eventPersistKey)) return;
forceChecklistRefresh((version) => version + 1);
};
const syncEventListener: EventListener = (event) => {
sync(event);
};
window.addEventListener("storage", sync);
window.addEventListener("focus", sync);
window.addEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener);
return () => {
window.removeEventListener("storage", sync);
window.removeEventListener("focus", sync);
window.removeEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener);
};
}, [trackedPersistKeys]);
const radius = 54;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (overallProgress / 100) * circumference;
const completedModules = modules.filter(
(module) => module.status === "completed",
).length;
const lessonTotals = modules.reduce(
(totals, module) => {
const resolvedProgress = getResolvedLessonProgress(module);
return {
completedLessons:
totals.completedLessons + resolvedProgress.completedLessons,
totalLessons: totals.totalLessons + resolvedProgress.totalLessons,
};
},
{ completedLessons: 0, totalLessons: 0 },
);
const totalLessons = lessonTotals.totalLessons;
const completedLessons = lessonTotals.completedLessons;
const totalExercises = modules.reduce(
(sum, module) => sum + (module.exercises ?? 0),
0,
);
const completedExercises = modules.reduce(
(sum, module) => sum + (module.completedExercises ?? 0),
0,
);
return (
<Card className={cn("overflow-hidden", className)} {...props}>
<CardHeader className="gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<Badge className="w-fit" variant="secondary">
{label}
</Badge>
<div className="space-y-1">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
</div>
<div className="flex items-center gap-2 rounded-full border bg-background px-3 py-1 text-sm text-muted-foreground whitespace-nowrap">
<span
aria-hidden="true"
className="inline-flex size-2.5 rounded-full bg-primary"
/>
<span>
<span className="font-semibold text-foreground">{streak}</span> day
{streak === 1 ? "" : "s"} streak
</span>
</div>
</CardHeader>
<CardContent className="grid gap-6 lg:grid-cols-[auto,1fr] lg:items-center">
<div className="mx-auto flex flex-col items-center gap-3 text-center">
<div className="relative flex size-36 items-center justify-center">
<svg className="size-36 -rotate-90" viewBox="0 0 120 120">
<circle
className="stroke-muted"
cx="60"
cy="60"
fill="none"
r={radius}
strokeWidth="10"
/>
<circle
aria-label={label}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={overallProgress}
className="stroke-primary transition-all duration-500"
cx="60"
cy="60"
fill="none"
r={radius}
role="progressbar"
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
strokeWidth="10"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-3xl font-semibold text-foreground">
{overallProgress}%
</span>
<span className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
Complete
</span>
</div>
</div>
<p className="max-w-52 text-sm text-muted-foreground">
{completedModules} of {modules.length} modules completed.
</p>
</div>
<dl className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border bg-muted/30 p-4">
<dt className="text-sm text-muted-foreground">Modules</dt>
<dd className="mt-2 text-2xl font-semibold text-foreground">
{completedModules}/{modules.length}
</dd>
</div>
<div className="rounded-xl border bg-muted/30 p-4">
<dt className="text-sm text-muted-foreground">Lessons</dt>
<dd className="mt-2 text-2xl font-semibold text-foreground">
{completedLessons}/{totalLessons}
</dd>
</div>
<div className="rounded-xl border bg-muted/30 p-4">
<dt className="text-sm text-muted-foreground">Exercises</dt>
<dd className="mt-2 text-2xl font-semibold text-foreground">
{completedExercises}/{totalExercises}
</dd>
</div>
<div className="rounded-xl border bg-muted/30 p-4">
<dt className="text-sm text-muted-foreground">Momentum</dt>
<dd className="mt-2 text-2xl font-semibold text-foreground">
{streak} day{streak === 1 ? "" : "s"}
</dd>
</div>
</dl>
</CardContent>
</Card>
);
}
export type ProgressTrackerModulesProps = React.HTMLAttributes<HTMLDivElement>;
function ProgressTrackerModules({
children,
className,
...props
}: ProgressTrackerModulesProps): React.ReactNode {
return (
<div
className={cn("grid gap-4 md:grid-cols-2 xl:grid-cols-3", className)}
{...props}
>
{children}
</div>
);
}
export type ProgressTrackerModuleProps = React.HTMLAttributes<HTMLDivElement> &
ProgressTrackerModuleItem;
// eslint-disable-next-line max-lines-per-function
function ProgressTrackerModule({
badge,
checklistItems,
className,
completedExercises,
completedLessons = 0,
currentLesson,
description,
exercises,
href,
id,
lessons,
persistKey,
progress,
skills = EMPTY_PROGRESS_TRACKER_SKILLS,
status,
timeSpent,
title,
...props
}: ProgressTrackerModuleProps): React.ReactNode {
const checklistProgress = useChecklistProgress(checklistItems, persistKey);
const resolvedLessons = checklistProgress?.completedCount ?? completedLessons;
const resolvedLessonTotal = checklistProgress?.total || lessons;
const progressPercent =
checklistProgress?.progress ?? clampPercentage(progress);
const progressValue = Math.min(resolvedLessons, resolvedLessonTotal);
const safeExerciseTotal = exercises ?? 0;
const safeExerciseComplete = Math.min(
completedExercises ?? 0,
safeExerciseTotal,
);
const card = (
<Card
aria-disabled={status === "locked"}
className={cn(
"h-full",
status === "locked" && "opacity-80",
href && "transition-shadow hover:shadow-md focus-within:shadow-md",
className,
)}
id={id}
{...props}
>
<CardHeader className="gap-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-xl">{title}</CardTitle>
{description ? (
<CardDescription>{description}</CardDescription>
) : null}
</div>
<Badge
className={cn("whitespace-nowrap", getStatusClasses(status))}
variant="outline"
>
{getStatusLabel(status)}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
{badge ? <Badge variant="secondary">{badge}</Badge> : null}
{currentLesson ? (
<Badge variant="outline">Current: {currentLesson}</Badge>
) : null}
{timeSpent ? <Badge variant="outline">{timeSpent}</Badge> : null}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div
aria-label={`${title} progress`}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progressPercent}
role="progressbar"
>
<ProgressBar
completedLabel="lessons"
currentLabel={`${progressPercent}% complete`}
isComplete={status === "completed"}
max={resolvedLessonTotal}
showLabels
value={progressValue}
/>
</div>
<div className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2">
<span>
Lessons:{" "}
<span className="font-medium text-foreground">
{resolvedLessons}/{resolvedLessonTotal}
</span>
</span>
<span>
Exercises:{" "}
<span className="font-medium text-foreground">
{safeExerciseComplete}/{safeExerciseTotal}
</span>
</span>
</div>
</div>
{skills.length > 0 ? (
<div className="flex flex-wrap gap-2">
{skills.map((skill) => (
<ProgressTrackerBadge key={skill}>{skill}</ProgressTrackerBadge>
))}
</div>
) : null}
</CardContent>
</Card>
);
if (!href || status === "locked") return card;
return (
<a
className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
href={href}
>
{card}
</a>
);
}
export type ProgressTrackerStatsProps = React.HTMLAttributes<HTMLDivElement>;
function ProgressTrackerStats({
children,
className,
...props
}: ProgressTrackerStatsProps): React.ReactNode {
return (
<div
className={cn("grid gap-4 sm:grid-cols-2 xl:grid-cols-4", className)}
{...props}
>
{children}
</div>
);
}
export type ProgressTrackerStatProps = React.HTMLAttributes<HTMLDivElement> & {
label: string;
value: ReactNode;
};
function ProgressTrackerStat({
className,
label,
value,
...props
}: ProgressTrackerStatProps): React.ReactNode {
return (
<Card className={cn("h-full", className)} {...props}>
<CardContent className="flex h-full flex-col justify-center gap-2 p-6">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-2xl font-semibold text-foreground">{value}</span>
</CardContent>
</Card>
);
}
export type ProgressTrackerBadgeProps = React.HTMLAttributes<HTMLSpanElement>;
function ProgressTrackerBadge({
children,
className,
...props
}: ProgressTrackerBadgeProps): React.ReactNode {
return (
<Badge
className={cn(
"px-3 py-1 text-[11px] uppercase tracking-wide whitespace-nowrap",
className,
)}
variant="secondary"
{...props}
>
{children}
</Badge>
);
}
const ProgressTracker = Object.assign(ProgressTrackerRoot, {
Badge: ProgressTrackerBadge,
Module: ProgressTrackerModule,
Modules: ProgressTrackerModules,
Overview: ProgressTrackerOverview,
Stat: ProgressTrackerStat,
Stats: ProgressTrackerStats,
});
export {
ProgressTracker,
ProgressTrackerBadge,
ProgressTrackerModule,
ProgressTrackerModules,
ProgressTrackerOverview,
ProgressTrackerStat,
ProgressTrackerStats,
useProgressTrackerContext,
};
Stories
Explorez chaque variante et etat dans le Storybook interactif :
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.