diff --git a/packages/ui/.snapshots/progress-tracker/progress-tracker.visual.tsx-chromium/progress-tracker-default.png b/packages/ui/.snapshots/progress-tracker/progress-tracker.visual.tsx-chromium/progress-tracker-default.png new file mode 100644 index 0000000..d08927b Binary files /dev/null and b/packages/ui/.snapshots/progress-tracker/progress-tracker.visual.tsx-chromium/progress-tracker-default.png differ diff --git a/packages/ui/src/components/checklist/checklist.tsx b/packages/ui/src/components/checklist/checklist.tsx index f7b06a3..fe5a94d 100644 --- a/packages/ui/src/components/checklist/checklist.tsx +++ b/packages/ui/src/components/checklist/checklist.tsx @@ -4,6 +4,8 @@ import { useState } from "react"; import { cn } from "../../lib/utils"; +export const CHECKLIST_PROGRESS_EVENT = "vllnt:checklist-progress-change"; + export type ChecklistItem = { description?: string; id: string; @@ -159,6 +161,11 @@ export function Checklist({ `checklist:${persistKey}`, JSON.stringify([...newChecked]), ); + window.dispatchEvent( + new CustomEvent(CHECKLIST_PROGRESS_EVENT, { + detail: { persistKey }, + }), + ); } catch { /* skip */ } diff --git a/packages/ui/src/components/checklist/index.ts b/packages/ui/src/components/checklist/index.ts index 607adc7..05e6bd1 100644 --- a/packages/ui/src/components/checklist/index.ts +++ b/packages/ui/src/components/checklist/index.ts @@ -1,5 +1,6 @@ export { Checklist, + CHECKLIST_PROGRESS_EVENT, type ChecklistItem, type ChecklistProps, } from "./checklist"; diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index e5611d1..93c90c7 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -540,6 +540,25 @@ export { type ProgressCardProgress, type ProgressCardProps, } from "./progress-card"; +export { + ProgressTracker, + ProgressTrackerBadge, + type ProgressTrackerBadgeProps, + ProgressTrackerModule, + type ProgressTrackerModuleItem, + type ProgressTrackerModuleProps, + ProgressTrackerModules, + type ProgressTrackerModulesProps, + type ProgressTrackerModuleStatus, + ProgressTrackerOverview, + type ProgressTrackerOverviewProps, + type ProgressTrackerProps, + ProgressTrackerStat, + type ProgressTrackerStatProps, + ProgressTrackerStats, + type ProgressTrackerStatsProps, + useProgressTrackerContext, +} from "./progress-tracker"; export { CommonMistake, type CommonMistakeProps, diff --git a/packages/ui/src/components/progress-tracker/index.ts b/packages/ui/src/components/progress-tracker/index.ts new file mode 100644 index 0000000..2f9674f --- /dev/null +++ b/packages/ui/src/components/progress-tracker/index.ts @@ -0,0 +1,19 @@ +export { + ProgressTracker, + ProgressTrackerBadge, + type ProgressTrackerBadgeProps, + ProgressTrackerModule, + type ProgressTrackerModuleItem, + type ProgressTrackerModuleProps, + ProgressTrackerModules, + type ProgressTrackerModulesProps, + type ProgressTrackerModuleStatus, + ProgressTrackerOverview, + type ProgressTrackerOverviewProps, + type ProgressTrackerProps, + ProgressTrackerStat, + type ProgressTrackerStatProps, + ProgressTrackerStats, + type ProgressTrackerStatsProps, + useProgressTrackerContext, +} from "./progress-tracker"; diff --git a/packages/ui/src/components/progress-tracker/progress-tracker.mdx b/packages/ui/src/components/progress-tracker/progress-tracker.mdx new file mode 100644 index 0000000..779ea89 --- /dev/null +++ b/packages/ui/src/components/progress-tracker/progress-tracker.mdx @@ -0,0 +1,49 @@ +import { Canvas, Controls, Meta, Primary } from '@storybook/addon-docs/blocks' +import * as Stories from './progress-tracker.stories' + + + +# Progress Tracker + +Curriculum-level learning dashboard for modules, lessons, exercises, streaks, and earned skills. + + + +## Installation + +```bash +pnpm dlx shadcn@latest add https://ui.vllnt.com/r/progress-tracker.json +``` + +## Import + +```tsx +import { ProgressTracker } from '@vllnt/ui' +``` + +## Usage + +```tsx + + + + + + + + + + +``` + + + +## API Reference + + diff --git a/packages/ui/src/components/progress-tracker/progress-tracker.stories.tsx b/packages/ui/src/components/progress-tracker/progress-tracker.stories.tsx new file mode 100644 index 0000000..5539ae5 --- /dev/null +++ b/packages/ui/src/components/progress-tracker/progress-tracker.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { + ProgressTracker, + ProgressTrackerModule, + ProgressTrackerModules, + ProgressTrackerOverview, + ProgressTrackerStat, + ProgressTrackerStats, + type ProgressTrackerProps, +} from "./progress-tracker"; + +const meta = { + args: { + modules: [ + { + badge: "Foundation", + completedExercises: 18, + completedLessons: 12, + currentLesson: "Final assessment", + description: "Build confidence with variables, functions, and arrays.", + exercises: 18, + lessons: 12, + progress: 1, + skills: ["Syntax", "Functions", "Arrays"], + status: "completed", + timeSpent: "8h 10m", + title: "JavaScript Basics", + }, + { + badge: "Now learning", + completedExercises: 5, + completedLessons: 3, + currentLesson: "Hooks in action", + description: "Move from JSX to reusable interactive interfaces.", + exercises: 12, + lessons: 8, + progress: 0.4, + skills: ["Components", "Hooks", "State"], + status: "in-progress", + timeSpent: "6h 20m", + title: "React Fundamentals", + }, + { + badge: "Next up", + completedExercises: 0, + completedLessons: 0, + currentLesson: "Unlock after React Fundamentals", + description: "Design resilient APIs and asynchronous application flows.", + exercises: 10, + lessons: 6, + progress: 0, + skills: ["REST", "Caching", "Error handling"], + status: "available", + timeSpent: "0h", + title: "API Design", + }, + { + badge: "Locked", + completedExercises: 0, + completedLessons: 0, + currentLesson: "Requires API Design", + description: "Bring everything together with deployment and observability.", + exercises: 14, + lessons: 9, + progress: 0, + skills: ["CI", "Monitoring", "Release"], + status: "locked", + timeSpent: "0h", + title: "Ship to production", + }, + ], + overallProgress: 0.65, + streak: 7, + title: "Frontend mastery path", + }, + component: ProgressTracker, + title: "Learning/ProgressTracker", +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function ProgressTrackerStory(args: ProgressTrackerProps): React.ReactNode { + return ( + + + + + + + + + + {args.modules?.map((module) => ( + + ))} + + + ); +} + +export const Default: Story = { + render: (args) => , +}; + +export const CompactDashboard: Story = { + args: { + overallProgress: 0.82, + streak: 12, + title: "Bootcamp progress", + }, + render: (args) => , +}; diff --git a/packages/ui/src/components/progress-tracker/progress-tracker.test.tsx b/packages/ui/src/components/progress-tracker/progress-tracker.test.tsx new file mode 100644 index 0000000..ca76a2e --- /dev/null +++ b/packages/ui/src/components/progress-tracker/progress-tracker.test.tsx @@ -0,0 +1,228 @@ +import { act, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { CHECKLIST_PROGRESS_EVENT } from "../checklist"; + +import { + ProgressTracker, + ProgressTrackerBadge, + ProgressTrackerModule, + ProgressTrackerModules, + ProgressTrackerOverview, + ProgressTrackerStat, + ProgressTrackerStats, +} from "./progress-tracker"; + +const modules = [ + { + badge: "Core", + completedExercises: 18, + completedLessons: 12, + currentLesson: "Review quiz", + exercises: 18, + lessons: 12, + progress: 1, + skills: ["Syntax", "Variables"], + status: "completed" as const, + title: "JavaScript Basics", + }, + { + badge: "Active", + completedExercises: 4, + completedLessons: 3, + currentLesson: "Hooks in action", + exercises: 10, + lessons: 8, + progress: 0.4, + skills: ["Components", "Hooks"], + status: "in-progress" as const, + timeSpent: "6h 20m", + title: "React Fundamentals", + }, +]; + +afterEach(() => { + localStorage.clear(); +}); + +describe("ProgressTracker", () => { + it("renders overview metrics and top-level title", () => { + render( + + + , + ); + + expect(screen.getByText("Learning progress")).toBeVisible(); + expect( + screen.getByRole("progressbar", { name: /overall progress/i }), + ).toHaveAttribute("aria-valuenow", "65"); + expect(screen.getByText("15/20")).toBeVisible(); + expect(screen.getByText("22/28")).toBeVisible(); + }); + + it("renders modules with progress semantics and status labels", () => { + const [firstModule, secondModule] = modules; + + if (!firstModule || !secondModule) { + throw new Error("Expected seeded modules for this test"); + } + + render( + + + + + + , + ); + + expect(screen.getByText("Completed")).toBeVisible(); + expect(screen.getByText("In progress")).toBeVisible(); + expect( + screen.getByRole("progressbar", { name: /react fundamentals progress/i }), + ).toHaveAttribute("aria-valuenow", "40"); + expect(screen.getByText("Current: Hooks in action")).toBeVisible(); + expect(screen.getByText("Hooks")).toBeVisible(); + }); + + it("uses Checklist persistence when checklist items and a persist key are provided", () => { + localStorage.setItem( + "checklist:react-fundamentals", + JSON.stringify(["intro", "components", "hooks"]), + ); + + render( + + + + + , + ); + + expect( + screen.getByRole("progressbar", { + name: /checklist-backed module progress/i, + }), + ).toHaveAttribute("aria-valuenow", "75"); + expect(screen.getByText("3/4")).toBeVisible(); + }); + + it("renders stat cards and custom badges", () => { + render( + + + + + + Consistency + , + ); + + expect(screen.getByText("Time Spent")).toBeVisible(); + expect(screen.getByText("24h")).toBeVisible(); + expect(screen.getByText("Consistency")).toBeVisible(); + }); + + it("keeps locked modules non-interactive even when href is provided", () => { + render( + + + + + , + ); + + expect( + screen.queryByRole("link", { name: /locked module/i }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Locked module")).toBeVisible(); + }); + + it("updates overview totals and module progress from same-tab checklist persistence events", () => { + render( + + + + + + , + ); + + expect(screen.getAllByText("0/4")).toHaveLength(2); + + act(() => { + localStorage.setItem( + "checklist:react-fundamentals", + JSON.stringify(["intro", "components", "hooks"]), + ); + window.dispatchEvent( + new CustomEvent(CHECKLIST_PROGRESS_EVENT, { + detail: { persistKey: "react-fundamentals" }, + }), + ); + }); + + expect(screen.getAllByText("3/4")).toHaveLength(2); + expect( + screen.getByRole("progressbar", { + name: /checklist-backed module progress/i, + }), + ).toHaveAttribute("aria-valuenow", "75"); + }); +}); diff --git a/packages/ui/src/components/progress-tracker/progress-tracker.tsx b/packages/ui/src/components/progress-tracker/progress-tracker.tsx new file mode 100644 index 0000000..37c3bcb --- /dev/null +++ b/packages/ui/src/components/progress-tracker/progress-tracker.tsx @@ -0,0 +1,635 @@ +"use client"; + +import * as React from "react"; + +import type { ReactNode } from "react"; + +import { cn } from "../../lib/utils"; +import { Badge } from "../badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../card"; +import { CHECKLIST_PROGRESS_EVENT, type ChecklistItem } from "../checklist"; +import { ProgressBar } from "../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 & { + 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); + +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 = { + available: "Available", + completed: "Completed", + "in-progress": "In progress", + locked: "Locked", +}; + +const STATUS_CLASSES: Record = { + 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 []; + const parsed = JSON.parse(saved) as unknown; + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } +} + +function areStringArraysEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + + return 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(() => + readPersistedChecklistItems(persistKey), + ); + const setPersistedIdsIfChanged = React.useCallback((nextIds: string[]) => { + setPersistedIds((currentIds) => + areStringArraysEqual(currentIds, nextIds) ? currentIds : nextIds, + ); + }, []); + + React.useEffect(() => { + setPersistedIdsIfChanged(readPersistedChecklistItems(persistKey)); + }, [persistKey, setPersistedIdsIfChanged]); + + 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.useContext(ProgressTrackerContext); + + if (!context) { + throw new Error( + "ProgressTracker compound components must be used within .", + ); + } + + return context; +} + +function ProgressTrackerRoot({ + children, + className, + 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 ( + +
+ {children} +
+
+ ); +} + +export type ProgressTrackerOverviewProps = + React.HTMLAttributes & { + 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.map((module) => module.persistKey).filter(Boolean), + [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 ( + + +
+ + {label} + +
+ {title} + {description} +
+
+
+
+
+ +
+
+ + + + +
+ + {overallProgress}% + + + Complete + +
+
+

+ {completedModules} of {modules.length} modules completed. +

+
+ +
+
+
Modules
+
+ {completedModules}/{modules.length} +
+
+
+
Lessons
+
+ {completedLessons}/{totalLessons} +
+
+
+
Exercises
+
+ {completedExercises}/{totalExercises} +
+
+
+
Momentum
+
+ {streak} day{streak === 1 ? "" : "s"} +
+
+
+
+
+ ); +} + +export type ProgressTrackerModulesProps = React.HTMLAttributes; + +function ProgressTrackerModules({ + children, + className, + ...props +}: ProgressTrackerModulesProps): React.ReactNode { + return ( +
+ {children} +
+ ); +} + +export type ProgressTrackerModuleProps = React.HTMLAttributes & + 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 = [], + 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 = ( + + +
+
+ {title} + {description ? ( + {description} + ) : null} +
+ + {getStatusLabel(status)} + +
+
+ {badge ? {badge} : null} + {currentLesson ? ( + Current: {currentLesson} + ) : null} + {timeSpent ? {timeSpent} : null} +
+
+ +
+
+ +
+
+ + Lessons:{" "} + + {resolvedLessons}/{resolvedLessonTotal} + + + + Exercises:{" "} + + {safeExerciseComplete}/{safeExerciseTotal} + + +
+
+ {skills.length > 0 ? ( +
+ {skills.map((skill) => ( + {skill} + ))} +
+ ) : null} +
+
+ ); + + if (!href || status === "locked") return card; + + return ( + + {card} + + ); +} + +export type ProgressTrackerStatsProps = React.HTMLAttributes; + +function ProgressTrackerStats({ + children, + className, + ...props +}: ProgressTrackerStatsProps): React.ReactNode { + return ( +
+ {children} +
+ ); +} + +export type ProgressTrackerStatProps = React.HTMLAttributes & { + label: string; + value: ReactNode; +}; + +function ProgressTrackerStat({ + className, + label, + value, + ...props +}: ProgressTrackerStatProps): React.ReactNode { + return ( + + + {label} + {value} + + + ); +} + +export type ProgressTrackerBadgeProps = React.HTMLAttributes; + +function ProgressTrackerBadge({ + children, + className, + ...props +}: ProgressTrackerBadgeProps): React.ReactNode { + return ( + + {children} + + ); +} + +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, +}; diff --git a/packages/ui/src/components/progress-tracker/progress-tracker.visual.tsx b/packages/ui/src/components/progress-tracker/progress-tracker.visual.tsx new file mode 100644 index 0000000..7b99d0b --- /dev/null +++ b/packages/ui/src/components/progress-tracker/progress-tracker.visual.tsx @@ -0,0 +1,79 @@ +import { expect, test } from "@playwright/experimental-ct-react"; + +import { + ProgressTracker, + ProgressTrackerModule, + ProgressTrackerModules, + ProgressTrackerOverview, + ProgressTrackerStat, + ProgressTrackerStats, +} from "./progress-tracker"; + +const modules = [ + { + badge: "Foundation", + completedExercises: 18, + completedLessons: 12, + currentLesson: "Final assessment", + description: "Build confidence with variables, functions, and arrays.", + exercises: 18, + lessons: 12, + progress: 1, + skills: ["Syntax", "Functions", "Arrays"], + status: "completed" as const, + timeSpent: "8h 10m", + title: "JavaScript Basics", + }, + { + badge: "Now learning", + completedExercises: 5, + completedLessons: 3, + currentLesson: "Hooks in action", + description: "Move from JSX to reusable interactive interfaces.", + exercises: 12, + lessons: 8, + progress: 0.4, + skills: ["Components", "Hooks", "State"], + status: "in-progress" as const, + timeSpent: "6h 20m", + title: "React Fundamentals", + }, + { + badge: "Next up", + completedExercises: 0, + completedLessons: 0, + currentLesson: "Unlock after React Fundamentals", + description: "Design resilient APIs and asynchronous application flows.", + exercises: 10, + lessons: 6, + progress: 0, + skills: ["REST", "Caching", "Error handling"], + status: "available" as const, + title: "API Design", + }, +]; + +test.describe("ProgressTracker Visual", () => { + test("default", async ({ mount, page }) => { + await mount( +
+ + + + + + + + + + {modules.map((module) => ( + + ))} + + +
, + ); + + await expect(page).toHaveScreenshot("progress-tracker-default.png"); + }); +});