Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/app/api/user/learning-progress/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
33 changes: 33 additions & 0 deletions src/app/api/user/learning-progress/route.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<LearningProgressItem[]>>;
}

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,
}),
);
}
97 changes: 97 additions & 0 deletions src/app/components/dashboard/LearningProgressList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border dark:border-gray-800 p-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-8">
{t('dashboard.learningProgress')}
</h2>

{isLoading && <ListSkeleton count={3} />}

{!isLoading && error && (
<div role="alert">
<p className="text-sm text-red-600 dark:text-red-400">{t('errors.network')}</p>
<button
onClick={refetch}
className="mt-4 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
{t('common.retry')}
</button>
</div>
)}

{!isLoading && !error && items.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('dashboard.noCoursesInProgress')}
</p>
)}

{!isLoading && !error && items.length > 0 && (
<div className="space-y-8">
{items.map((item, index) => {
const color = ACCENT_COLORS[index % ACCENT_COLORS.length];
return (
<div key={item.courseId} className={`border-s-4 ${ACCENT_BORDER[color]} ps-6 group`}>
<h3
className={`text-lg font-bold text-gray-900 dark:text-white ${ACCENT_TEXT_HOVER[color]} transition-colors`}
>
{item.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{t('dashboard.progressStatus', {
percent: item.progress,
remaining: item.timeRemaining,
})}
</p>
<div className="mt-4 w-full bg-gray-100 dark:bg-gray-800 rounded-full h-3">
<div
className={`${ACCENT_BAR[color]} h-3 rounded-full transition-all duration-1000 ease-out`}
style={{ width: `${item.progress}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
157 changes: 157 additions & 0 deletions src/app/components/dashboard/__tests__/LearningProgressList.test.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>((value, part) => {
if (value && typeof value === 'object' && part in (value as Record<string, unknown>)) {
return (value as Record<string, unknown>)[part];
}
return key;
}, translations);

const t = (key: string, params?: Record<string, string | number>) => {
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(<LearningProgressList />);
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(<LearningProgressList />);

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(<LearningProgressList />);
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(<LearningProgressList />);
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(<LearningProgressList />);
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(<LearningProgressList />);
expect(
screen.getByText('You have no courses in progress yet.'),
).toBeInTheDocument();
});
});
Loading
Loading