From 56261c4b80fc3382b212eb0a5a7b37e65bd9971b Mon Sep 17 00:00:00 2001 From: longyi2 Date: Fri, 28 Aug 2026 06:50:07 +0000 Subject: [PATCH] feat: add course progress tracking page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/dashboard/courses/[id]/learn/page.tsx | 10 +- src/app/dashboard/progress/page.tsx | 35 ++++ src/components/dashboard/ProgressList.tsx | 153 ++++++++++++++++++ src/components/layout/Sidebar.tsx | 1 + 4 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 src/app/dashboard/progress/page.tsx create mode 100644 src/components/dashboard/ProgressList.tsx diff --git a/src/app/dashboard/courses/[id]/learn/page.tsx b/src/app/dashboard/courses/[id]/learn/page.tsx index 541ff31..71b78f6 100644 --- a/src/app/dashboard/courses/[id]/learn/page.tsx +++ b/src/app/dashboard/courses/[id]/learn/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState, useRef } from "react"; -import { useParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; import Link from "next/link"; import { CheckCircle2, @@ -67,6 +67,7 @@ const DEFAULT_QUIZ_QUESTIONS: QuizQuestion[] = [ export default function LearnPage() { const { id } = useParams<{ id: string }>(); + const searchParams = useSearchParams(); const [course, setCourse] = useState(null); const [enrollment, setEnrollment] = useState(null); @@ -94,8 +95,11 @@ export default function LearnPage() { const completedIds = new Set( e.lessonProgress?.filter((p) => p.completed).map((p) => p.lessonId), ); + const requestedLesson = searchParams.get("lesson"); const first = - allLessons.find((l) => !completedIds.has(l.id)) ?? allLessons[0]; + allLessons.find((l) => l.id === requestedLesson) ?? + allLessons.find((l) => !completedIds.has(l.id)) ?? + allLessons[0]; if (first) setActiveLesson(first); }) .catch(console.error) @@ -104,7 +108,7 @@ export default function LearnPage() { return () => { if (progressRef.current) clearInterval(progressRef.current); }; - }, [id]); + }, [id, searchParams]); useEffect(() => { if (!course || !enrollment) return; diff --git a/src/app/dashboard/progress/page.tsx b/src/app/dashboard/progress/page.tsx new file mode 100644 index 0000000..e2dbe0d --- /dev/null +++ b/src/app/dashboard/progress/page.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import ProgressList from '@/components/dashboard/ProgressList'; +import { enrollmentsApi } from '@/lib/api/services'; +import type { Enrollment } from '@/types'; + +export default function ProgressPage() { + const [enrollments, setEnrollments] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + enrollmentsApi.getMy(1, 100) + .then((response) => setEnrollments(response.data)) + .catch(console.error) + .finally(() => setLoading(false)); + }, []); + + return ( +
+
+

Learning Progress

+

Track your journey across every enrolled course.

+
+ {loading ? ( +
+ +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/dashboard/ProgressList.tsx b/src/components/dashboard/ProgressList.tsx new file mode 100644 index 0000000..c18f4c5 --- /dev/null +++ b/src/components/dashboard/ProgressList.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import Link from 'next/link'; +import { BookOpen, CalendarDays, CheckCircle2, Clock3, PlayCircle } from 'lucide-react'; +import { formatDate } from '@/lib/utils'; +import type { Enrollment, Lesson } from '@/types'; + +type ProgressFilter = 'all' | 'in-progress' | 'completed' | 'not-started'; +type ProgressSort = 'recent' | 'completion' | 'enrollment'; + +interface ProgressItem { + enrollment: Enrollment; + percent: number; + completed: number; + total: number; + lastActivity: string | null; + nextLesson: Lesson | undefined; +} + +const getLessons = (enrollment: Enrollment) => + enrollment.course?.modules?.flatMap((module) => module.lessons) ?? []; + +function getProgressItem(enrollment: Enrollment): ProgressItem { + const lessons = getLessons(enrollment); + const completedIds = new Set( + enrollment.lessonProgress?.filter((progress) => progress.completed).map((progress) => progress.lessonId), + ); + const completed = lessons.length > 0 + ? lessons.filter((lesson) => completedIds.has(lesson.id)).length + : enrollment.lessonProgress?.filter((progress) => progress.completed).length ?? 0; + const total = lessons.length || enrollment.course?.totalLessons || enrollment.lessonProgress?.length || 0; + const percent = enrollment.status === 'COMPLETED' + ? 100 + : Math.min(100, Math.max(0, Math.round(enrollment.progressPercent ?? (total > 0 ? (completed / total) * 100 : 0)))); + const activities = enrollment.lessonProgress + ?.map((progress) => progress.completedAt) + .filter((date): date is string => Boolean(date)) + .sort((a, b) => new Date(b).getTime() - new Date(a).getTime()) ?? []; + + return { + enrollment, + percent, + completed, + total, + lastActivity: activities[0] ?? (percent > 0 ? enrollment.enrolledAt : null), + nextLesson: lessons.find((lesson) => !completedIds.has(lesson.id)) ?? lessons[0], + }; +} + +function formatHours(enrollments: Enrollment[]) { + const seconds = enrollments.reduce( + (total, enrollment) => total + (enrollment.lessonProgress ?? []).reduce( + (lessonTotal, progress) => lessonTotal + (progress.watchedSecs || 0), + 0, + ), + 0, + ); + return `${(seconds / 3600).toFixed(1)}h`; +} + +export default function ProgressList({ enrollments }: { enrollments: Enrollment[] }) { + const [filter, setFilter] = useState('all'); + const [sort, setSort] = useState('recent'); + const items = useMemo(() => enrollments.map(getProgressItem), [enrollments]); + const visibleItems = useMemo(() => { + const filtered = items.filter(({ percent }) => { + if (filter === 'in-progress') return percent > 0 && percent < 100; + if (filter === 'completed') return percent >= 100; + if (filter === 'not-started') return percent === 0; + return true; + }); + return [...filtered].sort((a, b) => { + if (sort === 'completion') return b.percent - a.percent; + if (sort === 'enrollment') { + return new Date(b.enrollment.enrolledAt).getTime() - new Date(a.enrollment.enrolledAt).getTime(); + } + return new Date(b.lastActivity ?? b.enrollment.enrolledAt).getTime() + - new Date(a.lastActivity ?? a.enrollment.enrolledAt).getTime(); + }); + }, [filter, items, sort]); + + return ( +
+
+
+

Total hours learned

+

{formatHours(enrollments)}

+
+
+ + + + +
+
+ + {visibleItems.length === 0 ? ( +
+ +

No courses match this filter

+

Try another progress category.

+
+ ) : ( +
+ {visibleItems.map(({ enrollment, percent, completed, total, lastActivity, nextLesson }) => { + const href = nextLesson + ? `/dashboard/courses/${enrollment.courseId}/learn?lesson=${encodeURIComponent(nextLesson.id)}` + : `/dashboard/courses/${enrollment.courseId}/learn`; + const isComplete = percent >= 100; + return ( + +
+ {enrollment.course.thumbnailUrl ? ( + + ) : ( +
+ )} +
+
+
+
+

{enrollment.course.category}

+

{enrollment.course.title}

+
+ {isComplete ? : } +
+
+
{percent}% complete{completed} of {total} lectures
+
+
+
+ Last activity: {formatDate(lastActivity)} + Enrolled: {formatDate(enrollment.enrolledAt)} +
+
+ + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 8d12075..87291bf 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -29,6 +29,7 @@ interface NavItem { const STUDENT_NAV: NavItem[] = [ { href: '/dashboard/my-courses', label: 'My Courses', icon: BookOpen }, + { href: '/dashboard/progress', label: 'Progress', icon: BarChart2 }, { href: '/dashboard/wishlist', label: 'Wishlist', icon: Heart, showWishlistCount: true }, { href: '/dashboard/certificates',label: 'Certificates', icon: Award }, { href: '/dashboard/profile', label: 'Profile', icon: User },