Skip to content
Closed
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
59 changes: 57 additions & 2 deletions src/components/leaderboard/LeaderboardTable.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
'use client';

import { Trophy, AlertCircle, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { LeaderboardEntry } from '@/types';

interface LeaderboardTableProps {
entries: LeaderboardEntry[];
currentUserId?: string | null;
currentUserEntry?: LeaderboardEntry | null;
isLoading?: boolean;
error?: string | null;
onRetry?: () => void;
}

const MEDAL: Record<number, { emoji: string; label: string; rowClass: string; rankClass: string }> = {
Expand Down Expand Up @@ -138,6 +142,9 @@ export function LeaderboardTable({
entries,
currentUserId,
currentUserEntry,
isLoading = false,
error = null,
onRetry,
}: LeaderboardTableProps) {
const isCurrentUser = (entry: LeaderboardEntry) =>
!!currentUserId && entry.userId === currentUserId;
Expand All @@ -147,6 +154,47 @@ export function LeaderboardTable({
? entries.some((e) => e.userId === currentUserEntry.userId)
: false;

if (isLoading) {
return (
<div
role="status"
aria-label="Loading leaderboard"
data-testid="leaderboard-loading"
className="overflow-hidden rounded-2xl border border-ink-100 bg-white p-6 shadow-sm space-y-3"
>
{Array.from({ length: 5 }).map((_, idx) => (
<div key={idx} className="h-11 w-full rounded-lg bg-ink-50 animate-pulse" />
))}
</div>
);
}

if (error) {
return (
<div
role="alert"
data-testid="leaderboard-error"
className="overflow-hidden rounded-2xl border border-red-200 bg-red-50 p-6 text-center space-y-3"
>
<AlertCircle className="h-8 w-8 text-red-500 mx-auto" />
<div>
<p className="text-sm font-semibold text-red-900">Unable to load leaderboard</p>
<p className="text-xs text-red-700 mt-1">{error}</p>
</div>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="inline-flex items-center gap-1.5 rounded-lg bg-red-100 px-3.5 py-2 text-xs font-semibold text-red-800 hover:bg-red-200 transition-colors"
>
<RefreshCw className="h-3.5 w-3.5" />
Retry
</button>
)}
</div>
);
}

return (
<div className="overflow-hidden rounded-2xl border border-ink-100 bg-white shadow-sm">
<div className="overflow-x-auto">
Expand Down Expand Up @@ -216,8 +264,15 @@ export function LeaderboardTable({
</div>

{entries.length === 0 && (
<div className="py-16 text-center text-sm text-ink-400">
No data for this period yet. Keep learning!
<div
data-testid="leaderboard-empty"
className="py-16 px-6 text-center space-y-2"
>
<Trophy className="h-9 w-9 text-ink-300 mx-auto" />
<p className="text-sm font-semibold text-ink-800">No rankings for this period yet</p>
<p className="text-xs text-ink-400">
No data for this period yet. Keep learning to claim the top spot!
</p>
</div>
)}
</div>
Expand Down
33 changes: 33 additions & 0 deletions src/components/leaderboard/__tests__/LeaderboardTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { LeaderboardTable } from '../LeaderboardTable';

describe('LeaderboardTable empty, loading, and error states (#206)', () => {
it('renders loading skeleton when isLoading is true', () => {
render(<LeaderboardTable entries={[]} isLoading />);
expect(screen.getByTestId('leaderboard-loading')).toBeInTheDocument();
});

it('renders error state with retry callback when error is provided', () => {
const onRetry = vi.fn();
render(
<LeaderboardTable
entries={[]}
error="Could not fetch leaderboard rankings"
onRetry={onRetry}
/>
);
expect(screen.getByTestId('leaderboard-error')).toBeInTheDocument();
expect(screen.getByText('Could not fetch leaderboard rankings')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Retry/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
});

it('renders empty state when entries is empty', () => {
render(<LeaderboardTable entries={[]} />);
expect(screen.getByTestId('leaderboard-empty')).toBeInTheDocument();
expect(screen.getByText('No rankings for this period yet')).toBeInTheDocument();
});
});