From 0a5f9211fb92ac55f21cfeb16ef82c3715ec336f Mon Sep 17 00:00:00 2001 From: OkeA-dev <165613103+OkeA-dev@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:04:36 +0100 Subject: [PATCH] feat(dashboard): drive learning progress from fetched data Replace the hardcoded learning-progress courses on the dashboard with data fetched from a new /api/user/learning-progress endpoint backed by the course catalog. - Add LearningProgressItem schema + type - Add GET /api/user/learning-progress returning in-progress courses - Add useLearningProgress hook - Add LearningProgressList component with loading/error/empty/retry states - Update dashboard page to render LearningProgressList - Add i18n keys (common.retry, dashboard.noCoursesInProgress) in en/es/ar - Add route, hook, and component tests Closes #937 --- .../learning-progress/__tests__/route.test.ts | 64 +++++++ src/app/api/user/learning-progress/route.ts | 33 ++++ .../dashboard/LearningProgressList.tsx | 97 +++++++++++ .../__tests__/LearningProgressList.test.tsx | 157 ++++++++++++++++++ src/app/dashboard/page.tsx | 67 +------- .../__tests__/useLearningProgress.test.tsx | 154 +++++++++++++++++ src/hooks/useLearningProgress.ts | 56 +++++++ src/locales/ar.json | 6 +- src/locales/en.json | 6 +- src/locales/es.json | 6 +- src/schemas/progress.schema.ts | 11 ++ src/types/api.ts | 7 +- 12 files changed, 593 insertions(+), 71 deletions(-) create mode 100644 src/app/api/user/learning-progress/__tests__/route.test.ts create mode 100644 src/app/api/user/learning-progress/route.ts create mode 100644 src/app/components/dashboard/LearningProgressList.tsx create mode 100644 src/app/components/dashboard/__tests__/LearningProgressList.test.tsx create mode 100644 src/hooks/__tests__/useLearningProgress.test.tsx create mode 100644 src/hooks/useLearningProgress.ts diff --git a/src/app/api/user/learning-progress/__tests__/route.test.ts b/src/app/api/user/learning-progress/__tests__/route.test.ts new file mode 100644 index 00000000..297bafbc --- /dev/null +++ b/src/app/api/user/learning-progress/__tests__/route.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from '../route'; +import { withRateLimit } from '@/lib/ratelimit'; + +vi.mock('@/lib/ratelimit', () => ({ + withRateLimit: vi.fn(() => ({ + addHeaders: (response: Response) => response, + rateLimitResponse: null, + })), +})); + +vi.mock('@/../infra/edge-config', () => ({ + edgeLog: vi.fn(), +})); + +describe('/api/user/learning-progress', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns learning progress for in-progress courses', async () => { + const request = new Request('http://localhost/api/user/learning-progress'); + const response = await GET(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(Array.isArray(json.data)).toBe(true); + expect(json.data.length).toBeGreaterThan(0); + + const item = json.data[0]; + expect(item).toHaveProperty('courseId'); + expect(item).toHaveProperty('title'); + expect(item.progress).toBeGreaterThan(0); + expect(item.progress).toBeLessThanOrEqual(100); + expect(item.timeRemaining).toBeDefined(); + expect(item.totalLessons).toBeGreaterThanOrEqual(0); + expect(item.category).toBeDefined(); + }); + + it('excludes courses with zero progress', async () => { + const request = new Request('http://localhost/api/user/learning-progress'); + const response = await GET(request); + const json = await response.json(); + + expect(json.data.length).toBeGreaterThan(0); + for (const item of json.data) { + expect(item.progress).toBeGreaterThan(0); + } + }); + + it('returns rate limited response when limit exceeded', async () => { + const rateLimitResponse = new Response('rate limited', { status: 429 }); + vi.mocked(withRateLimit).mockReturnValueOnce({ + addHeaders: (response: Response) => response, + rateLimitResponse, + }); + + const request = new Request('http://localhost/api/user/learning-progress'); + const response = await GET(request); + + expect(response.status).toBe(429); + }); +}); diff --git a/src/app/api/user/learning-progress/route.ts b/src/app/api/user/learning-progress/route.ts new file mode 100644 index 00000000..38cfccca --- /dev/null +++ b/src/app/api/user/learning-progress/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import type { ApiResponse, LearningProgressItem } from '@/types/api'; +import { withRateLimit } from '@/lib/ratelimit'; +import { edgeLog } from '@/../infra/edge-config'; +import { getAllCourses } from '@/lib/course-config'; + +export const runtime = 'edge'; + +export async function GET(request: Request) { + edgeLog('info', '/api/user/learning-progress', 'GET request received'); + const { addHeaders, rateLimitResponse } = withRateLimit(request, 'READ'); + if (rateLimitResponse) { + return rateLimitResponse as NextResponse>; + } + + const items: LearningProgressItem[] = getAllCourses() + .filter((course) => course.progress > 0) + .map((course) => ({ + courseId: course.id, + title: course.title, + progress: course.progress, + timeRemaining: (course.timeRemaining ?? course.duration).replace(/ remaining$/, ''), + totalLessons: course.totalLessons, + category: course.category, + })); + + return addHeaders( + NextResponse.json({ + success: true, + data: items, + }), + ); +} diff --git a/src/app/components/dashboard/LearningProgressList.tsx b/src/app/components/dashboard/LearningProgressList.tsx new file mode 100644 index 00000000..67c94778 --- /dev/null +++ b/src/app/components/dashboard/LearningProgressList.tsx @@ -0,0 +1,97 @@ +'use client'; + +import React from 'react'; +import { useLearningProgress } from '@/hooks/useLearningProgress'; +import { useInternationalization } from '@/hooks/useInternationalization'; +import { ListSkeleton } from '@/components/ui/LoadingSkeleton'; + +const ACCENT_COLORS = ['blue', 'green', 'purple', 'amber', 'rose', 'teal'] as const; + +const ACCENT_BORDER = { + blue: 'border-blue-500', + green: 'border-green-500', + purple: 'border-purple-500', + amber: 'border-amber-500', + rose: 'border-rose-500', + teal: 'border-teal-500', +} as const; + +const ACCENT_TEXT_HOVER = { + blue: 'group-hover:text-blue-600', + green: 'group-hover:text-green-600', + purple: 'group-hover:text-purple-600', + amber: 'group-hover:text-amber-600', + rose: 'group-hover:text-rose-600', + teal: 'group-hover:text-teal-600', +} as const; + +const ACCENT_BAR = { + blue: 'bg-blue-500', + green: 'bg-green-500', + purple: 'bg-purple-500', + amber: 'bg-amber-500', + rose: 'bg-rose-500', + teal: 'bg-teal-500', +} as const; + +export const LearningProgressList: React.FC = () => { + const { items, isLoading, error, refetch } = useLearningProgress(); + const { t } = useInternationalization(); + + return ( +
+

+ {t('dashboard.learningProgress')} +

+ + {isLoading && } + + {!isLoading && error && ( +
+

{t('errors.network')}

+ +
+ )} + + {!isLoading && !error && items.length === 0 && ( +

+ {t('dashboard.noCoursesInProgress')} +

+ )} + + {!isLoading && !error && items.length > 0 && ( +
+ {items.map((item, index) => { + const color = ACCENT_COLORS[index % ACCENT_COLORS.length]; + return ( +
+

+ {item.title} +

+

+ {t('dashboard.progressStatus', { + percent: item.progress, + remaining: item.timeRemaining, + })} +

+
+
+
+
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/app/components/dashboard/__tests__/LearningProgressList.test.tsx b/src/app/components/dashboard/__tests__/LearningProgressList.test.tsx new file mode 100644 index 00000000..8caad104 --- /dev/null +++ b/src/app/components/dashboard/__tests__/LearningProgressList.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LearningProgressList } from '../LearningProgressList'; +import type { LearningProgressItem } from '@/types/api'; + +vi.mock('@/hooks/useLearningProgress', () => ({ + useLearningProgress: vi.fn(), +})); + +vi.mock('@/hooks/useInternationalization', async () => { + const translations = (await import('@/locales/en.json')).default; + const read = (key: string) => + key.split('.').reduce((value, part) => { + if (value && typeof value === 'object' && part in (value as Record)) { + return (value as Record)[part]; + } + return key; + }, translations); + + const t = (key: string, params?: Record) => { + const value = read(key); + if (typeof value !== 'string') { + return key; + } + if (!params) { + return value; + } + + return value.replace(/\{\{(\w+)\}\}/g, (_, paramKey) => String(params[paramKey] ?? '')); + }; + + return { + useInternationalization: () => ({ + language: 'en', + t, + formatNumber: (value: number, options?: Intl.NumberFormatOptions) => + new Intl.NumberFormat('en-US', options).format(value), + formatPercentage: (value: number, decimals = 0) => + new Intl.NumberFormat('en-US', { + style: 'percent', + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }).format(value / 100), + }), + }; +}); + +import { useLearningProgress } from '@/hooks/useLearningProgress'; + +const mockItems: LearningProgressItem[] = [ + { + courseId: '1', + title: 'Web3 UX Design Principles', + progress: 68, + timeRemaining: '12h', + totalLessons: 12, + category: 'Design', + }, + { + courseId: '2', + title: 'Smart Contract Security Best Practices', + progress: 45, + timeRemaining: '18h', + totalLessons: 18, + category: 'Security', + }, +]; + +describe('LearningProgressList', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the section heading', () => { + vi.mocked(useLearningProgress).mockReturnValue({ + items: mockItems, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + + render(); + expect(screen.getByText('Learning Progress')).toBeInTheDocument(); + }); + + it('renders each course with its progress status', () => { + vi.mocked(useLearningProgress).mockReturnValue({ + items: mockItems, + isLoading: false, + error: null, + refetch: vi.fn(), + }); + + render(); + + expect(screen.getByText('Web3 UX Design Principles')).toBeInTheDocument(); + expect(screen.getByText('Smart Contract Security Best Practices')).toBeInTheDocument(); + expect(screen.getByText('68% complete • 12h remaining')).toBeInTheDocument(); + expect(screen.getByText('45% complete • 18h remaining')).toBeInTheDocument(); + }); + + it('shows a loading skeleton while fetching', () => { + vi.mocked(useLearningProgress).mockReturnValue({ + items: [], + isLoading: true, + error: null, + refetch: vi.fn(), + }); + + render(); + expect(screen.getByRole('heading', { name: 'Learning Progress' })).toBeInTheDocument(); + expect(screen.queryByText('Web3 UX Design Principles')).not.toBeInTheDocument(); + }); + + it('shows an error message with a retry button when the request fails', () => { + vi.mocked(useLearningProgress).mockReturnValue({ + items: [], + isLoading: false, + error: new Error('Network error'), + refetch: vi.fn(), + }); + + render(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + + it('calls refetch when the retry button is clicked', () => { + const refetch = vi.fn(); + vi.mocked(useLearningProgress).mockReturnValue({ + items: [], + isLoading: false, + error: new Error('Network error'), + refetch, + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it('shows an empty state when there are no courses in progress', () => { + vi.mocked(useLearningProgress).mockReturnValue({ + items: [], + isLoading: false, + error: null, + refetch: vi.fn(), + }); + + render(); + expect( + screen.getByText('You have no courses in progress yet.'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 354b4114..3fcea17e 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,17 +1,14 @@ 'use client'; import { useDashboardData } from '@/hooks/useDashboardData'; -import { CardSkeleton, ListSkeleton } from '@/components/ui/LoadingSkeleton'; import { OfflineStatusIndicator } from '@/components/offline/OfflineStatusIndicator'; import { DownloadManager } from '@/components/offline/DownloadManager'; -import { useInternationalization } from '@/hooks/useInternationalization'; import { SidebarNavigation } from '@/components/navigation/SidebarNavigation'; import { AnalyticsErrorDisplay } from '@/components/dashboard/AnalyticsErrorDisplay'; -import { useAnalyticsErrorTracking } from '@/hooks/useAnalyticsErrorTracking'; +import { LearningProgressList } from '@/app/components/dashboard/LearningProgressList'; export default function Dashboard() { - const { isLoading, errors, hasErrors, dismissError, clearAllErrors } = useDashboardData(); - const { t } = useInternationalization(); + const { hasErrors, errors, dismissError, clearAllErrors } = useDashboardData(); return (
@@ -38,65 +35,7 @@ export default function Dashboard() {
{/* Main Content */}
- {isLoading ? ( - <> - - - - ) : ( -
-

- {t('dashboard.learningProgress')} -

- -
-
-

- Web3 UX Design Principles -

-

- {t('dashboard.progressStatus', { percent: 68, remaining: '12h' })} -

-
-
-
-
- -
-

- Smart Contract Security -

-

- {t('dashboard.progressStatus', { percent: 45, remaining: '18h' })} -

-
-
-
-
- -
-

- Scaling DAPps on Starknet -

-

- {t('dashboard.progressStatus', { percent: 12, remaining: '32h' })} -

-
-
-
-
-
-
- )} +
{/* Sidebar */} diff --git a/src/hooks/__tests__/useLearningProgress.test.tsx b/src/hooks/__tests__/useLearningProgress.test.tsx new file mode 100644 index 00000000..88ce1c7c --- /dev/null +++ b/src/hooks/__tests__/useLearningProgress.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom +import React, { useEffect } from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { useLearningProgress } from '../useLearningProgress'; +import type { UseLearningProgressReturn } from '../useLearningProgress'; +import { apiClient } from '@/lib/api'; +import type { ApiResponse, LearningProgressItem } from '@/types/api'; + +vi.mock('@/lib/api', () => ({ + apiClient: { + get: vi.fn(), + }, +})); + +const mockItems: LearningProgressItem[] = [ + { + courseId: '1', + title: 'Web3 UX Design Principles', + progress: 68, + timeRemaining: '12h', + totalLessons: 12, + category: 'Design', + }, + { + courseId: '2', + title: 'Smart Contract Security Best Practices', + progress: 45, + timeRemaining: '18h', + totalLessons: 18, + category: 'Security', + }, +]; + +const mockResponse: ApiResponse = { + success: true, + data: mockItems, +}; + +const TestHarness: React.FC<{ onReady: (api: UseLearningProgressReturn) => void }> = ({ + onReady, +}) => { + const api = useLearningProgress(); + useEffect(() => { + onReady(api); + }); + return null; +}; + +describe('useLearningProgress', () => { + let container: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('fetches learning progress items on mount', async () => { + vi.mocked(apiClient.get).mockResolvedValue(mockResponse); + + let api: UseLearningProgressReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + + expect(apiClient.get).toHaveBeenCalledWith('/api/user/learning-progress'); + expect(api!.items).toEqual(mockItems); + expect(api!.isLoading).toBe(false); + expect(api!.error).toBeNull(); + }); + + it('returns empty items and clears loading when no data', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ success: true, data: [] }); + + let api: UseLearningProgressReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + + expect(api!.items).toEqual([]); + expect(api!.isLoading).toBe(false); + expect(api!.error).toBeNull(); + }); + + it('surfaces the error when the request fails', async () => { + const networkError = new Error('Network error'); + vi.mocked(apiClient.get).mockRejectedValue(networkError); + + let api: UseLearningProgressReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + + expect(api!.items).toEqual([]); + expect(api!.isLoading).toBe(false); + expect(api!.error).toBe(networkError); + }); + + it('refetches items when refetch is called', async () => { + vi.mocked(apiClient.get).mockResolvedValueOnce(mockResponse); + const updatedResponse: ApiResponse = { + success: true, + data: [mockItems[0]], + }; + vi.mocked(apiClient.get).mockResolvedValueOnce(updatedResponse); + + let api: UseLearningProgressReturn | undefined; + await act(async () => { + root.render( + { + api = a; + }} + />, + ); + }); + + await act(async () => { + await api!.refetch(); + }); + + expect(apiClient.get).toHaveBeenCalledTimes(2); + expect(api!.items).toEqual([mockItems[0]]); + }); +}); diff --git a/src/hooks/useLearningProgress.ts b/src/hooks/useLearningProgress.ts new file mode 100644 index 00000000..6d3638ab --- /dev/null +++ b/src/hooks/useLearningProgress.ts @@ -0,0 +1,56 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { apiClient } from '@/lib/api'; +import type { ApiResponse, LearningProgressItem } from '@/types/api'; + +export interface UseLearningProgressReturn { + items: LearningProgressItem[]; + isLoading: boolean; + error: Error | null; + refetch: () => void; +} + +export function useLearningProgress(): UseLearningProgressReturn { + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const mountedRef = useRef(true); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const load = useCallback(async () => { + if (mountedRef.current) { + setIsLoading(true); + setError(null); + } + + try { + const response = await apiClient.get>( + '/api/user/learning-progress', + ); + if (mountedRef.current) { + setItems(response.data); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + return { items, isLoading, error, refetch: load }; +} diff --git a/src/locales/ar.json b/src/locales/ar.json index 14e16f57..1c3f5a3a 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -17,7 +17,8 @@ "submit": "إرسال", "confirm": "تأكيد", "yes": "نعم", - "no": "لا" + "no": "لا", + "retry": "إعادة المحاولة" }, "navigation": { "home": "الرئيسية", @@ -190,7 +191,8 @@ "neutral": "ثابت" } } - } + }, + "noCoursesInProgress": "لا توجد دورات قيد التقدم بعد." }, "profile": { "editProfile": "تعديل الملف الشخصي", diff --git a/src/locales/en.json b/src/locales/en.json index 83b0271d..264b48bb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -17,7 +17,8 @@ "submit": "Submit", "confirm": "Confirm", "yes": "Yes", - "no": "No" + "no": "No", + "retry": "Retry" }, "navigation": { "home": "Home", @@ -190,7 +191,8 @@ "neutral": "Flat" } } - } + }, + "noCoursesInProgress": "You have no courses in progress yet." }, "profile": { "editProfile": "Edit Profile", diff --git a/src/locales/es.json b/src/locales/es.json index f4583df9..4f9ef6ea 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -17,7 +17,8 @@ "submit": "Enviar", "confirm": "Confirmar", "yes": "Sí", - "no": "No" + "no": "No", + "retry": "[MISSING TRANSLATION] Retry" }, "navigation": { "home": "Inicio", @@ -190,7 +191,8 @@ "neutral": "Estable" } } - } + }, + "noCoursesInProgress": "[MISSING TRANSLATION] You have no courses in progress yet." }, "profile": { "editProfile": "Editar Perfil", diff --git a/src/schemas/progress.schema.ts b/src/schemas/progress.schema.ts index 54d6fa40..fe7c949c 100644 --- a/src/schemas/progress.schema.ts +++ b/src/schemas/progress.schema.ts @@ -21,3 +21,14 @@ export const CourseProgressSchema = z.object({ }); export type CourseProgress = z.infer; + +export const LearningProgressItemSchema = z.object({ + courseId: z.string().min(1), + title: z.string().min(1), + progress: z.number().min(0).max(100), + timeRemaining: z.string().min(1), + totalLessons: z.number().int().nonnegative(), + category: z.string().min(1), +}); + +export type LearningProgressItem = z.infer; diff --git a/src/types/api.ts b/src/types/api.ts index 54334133..65c18033 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -2,7 +2,11 @@ import { User as ZodUser, UserRole as ZodUserRole } from '@/schemas/user.schema' import { Course as ZodCourse } from '@/schemas/course.schema'; import { AuthResponse as ZodAuthResponse } from '@/schemas/auth.schema'; import { AnalyticsEventPayload as ZodAnalyticsEventPayload } from '@/schemas/analytics.schema'; -import { UserProgress as ZodUserProgress, CourseProgress as ZodCourseProgress } from '@/schemas/progress.schema'; +import { + UserProgress as ZodUserProgress, + CourseProgress as ZodCourseProgress, + LearningProgressItem as ZodLearningProgressItem, +} from '@/schemas/progress.schema'; import { VideoBookmark as ZodVideoBookmark, VideoNote as ZodVideoNote, @@ -95,6 +99,7 @@ export type VideoNote = ZodVideoNote; export type UserProgress = ZodUserProgress; export type CourseProgress = ZodCourseProgress; +export type LearningProgressItem = ZodLearningProgressItem; // --------------------------------------------------------------------------- // Video analytics