resolve todos in website

This commit is contained in:
2025-12-20 12:22:48 +01:00
parent a87cf27fb9
commit 20588e1c0b
39 changed files with 1238 additions and 359 deletions

View File

@@ -45,14 +45,14 @@ import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel
type ProfileTab = 'overview' | 'stats';
interface TeamLeagueSummary {
id: string;
name: string;
}
interface Team {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: unknown[]; // TODO: define proper type
createdAt: Date;
}
interface SocialHandle {
@@ -317,11 +317,6 @@ export default function DriverDetailPage() {
team: {
id: team.id,
name: team.name,
tag: '', // Not available in summary
description: '', // Not available in summary
ownerId: '', // Not available in summary
leagues: [], // TODO: populate if needed
createdAt: new Date(), // TODO: add to API
} as Team,
role: membership.role,
joinedAt: new Date(membership.joinedAt),

View File

@@ -180,11 +180,8 @@ export default function ProtestReviewPage() {
type: 'protest_filed',
timestamp: new Date(protest.submittedAt),
actor: protestingDriver,
content: protest.description, // TODO: Add incident description when available
metadata: {
// lap: protest.incident?.lap,
// comment: protest.comment
}
content: protest.description,
metadata: {}
}
];
@@ -242,7 +239,8 @@ export default function ProtestReviewPage() {
currentDriverId,
protest.id
);
penaltyCommand.reason = 'Protest upheld'; // TODO: Make this configurable
penaltyCommand.reason = stewardNotes || 'Protest dismissed';
await protestService.applyPenalty(penaltyCommand);
}
@@ -406,16 +404,16 @@ export default function ProtestReviewPage() {
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.formattedDate}</span>
</div>
{/* TODO: Add lap info when available */}
{/* <div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div> */}
{protest.incident?.lap && (
<div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div>
)}
</div>
</Card>
{/* TODO: Add evidence when available */}
{/* {protest.proofVideoUrl && (
{protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
@@ -429,7 +427,7 @@ export default function ProtestReviewPage() {
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)} */}
)}
{/* Quick Stats */}
<Card className="p-4">
@@ -479,13 +477,12 @@ export default function ProtestReviewPage() {
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
{/* TODO: Add comment when available */}
{/* {protest.comment && (
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p>
</div>
)} */}
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import RaceDetailPage from './page';
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
// Mocks for Next.js navigation
const mockPush = vi.fn();
const mockBack = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
}),
useParams: () => ({ id: 'race-123' }),
}));
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
// Mock sponsor mode hook to avoid rendering heavy sponsor card
vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
__esModule: true,
default: () => <div data-testid="sponsor-insights-mock" />,
MetricBuilders: {
views: vi.fn(() => ({ label: 'Views', value: '100' })),
engagement: vi.fn(() => ({ label: 'Engagement', value: '50%' })),
reach: vi.fn(() => ({ label: 'Reach', value: '1000' })),
},
SlotTemplates: {
race: vi.fn(() => []),
},
useSponsorMode: () => false,
}));
// Mock services hook to provide raceService and leagueMembershipService
const mockGetRaceDetail = vi.fn();
const mockReopenRace = vi.fn();
const mockFetchLeagueMemberships = vi.fn();
const mockGetMembership = vi.fn();
vi.mock('@/lib/services/ServiceProvider', () => ({
useServices: () => ({
raceService: {
getRaceDetail: mockGetRaceDetail,
reopenRace: mockReopenRace,
// other methods are not used in this test
},
leagueMembershipService: {
fetchLeagueMemberships: mockFetchLeagueMemberships,
getMembership: mockGetMembership,
},
}),
}));
// Mock league membership utility to control admin vs non-admin behavior
const mockIsOwnerOrAdmin = vi.fn();
vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
LeagueMembershipUtility: {
isOwnerOrAdmin: (...args: unknown[]) => mockIsOwnerOrAdmin(...args),
},
}));
const createViewModel = (status: string) => {
return new RaceDetailViewModel({
race: {
id: 'race-123',
track: 'Test Track',
car: 'Test Car',
scheduledAt: '2023-12-31T20:00:00Z',
status,
sessionType: 'race',
strengthOfField: null,
registeredCount: 0,
maxParticipants: 32,
} as any,
league: {
id: 'league-1',
name: 'Test League',
description: 'Test league description',
settings: {
maxDrivers: 32,
qualifyingFormat: 'open',
},
} as any,
entryList: [],
registration: {
isRegistered: false,
canRegister: false,
} as any,
userResult: null,
});
};
describe('RaceDetailPage - Re-open Race behavior', () => {
beforeEach(() => {
mockGetRaceDetail.mockReset();
mockReopenRace.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
mockIsOwnerOrAdmin.mockReset();
mockFetchLeagueMemberships.mockResolvedValue(undefined);
mockGetMembership.mockReturnValue(null);
});
it('shows Re-open Race button for admin when race is completed and calls reopen + reload on confirm', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('completed');
// First call: initial load, second call: after re-open
mockGetRaceDetail.mockResolvedValue(viewModel);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RaceDetailPage />);
const reopenButton = await screen.findByText('Re-open Race');
expect(reopenButton).toBeInTheDocument();
mockReopenRace.mockResolvedValue(undefined);
fireEvent.click(reopenButton);
await waitFor(() => {
expect(mockReopenRace).toHaveBeenCalledWith('race-123');
});
// loadRaceData should be called again after reopening
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
});
confirmSpy.mockRestore();
});
it('does not render Re-open Race button for non-admin viewer', async () => {
mockIsOwnerOrAdmin.mockReturnValue(false);
const viewModel = createViewModel('completed');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
it('does not render Re-open Race button when race is not completed or cancelled even for admin', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('scheduled');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
});

View File

@@ -44,6 +44,7 @@ export default function RaceDetailPage() {
const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const [registering, setRegistering] = useState(false);
const [reopening, setReopening] = useState(false);
const [ratingChange, setRatingChange] = useState<number | null>(null);
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
const [showProtestModal, setShowProtestModal] = useState(false);
@@ -174,6 +175,27 @@ export default function RaceDetailPage() {
}
};
const handleReopenRace = async () => {
const race = viewModel?.race;
if (!race || !viewModel?.canReopenRace) return;
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.',
);
if (!confirmed) return;
setReopening(true);
try {
await raceService.reopenRace(race.id);
await loadRaceData();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to re-open race');
} finally {
setReopening(false);
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
@@ -856,6 +878,19 @@ export default function RaceDetailPage() {
</>
)}
{viewModel.canReopenRace &&
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={handleReopenRace}
disabled={reopening}
>
<PlayCircle className="w-4 h-4" />
{reopening ? 'Re-opening...' : 'Re-open Race'}
</Button>
)}
{race.status === 'completed' && (
<>
<Button
@@ -884,29 +919,22 @@ export default function RaceDetailPage() {
<Scale className="w-4 h-4" />
Stewarding
</Button>
{LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<>
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={async () => {
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.'
);
if (!confirmed) return;
// TODO: Implement re-open race functionality
alert('Re-open race functionality not yet implemented');
}}
>
<PlayCircle className="w-4 h-4" />
Re-open Race
</Button>
</>
)}
</>
)}
{viewModel.canReopenRace &&
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={handleReopenRace}
disabled={reopening}
>
<PlayCircle className="w-4 h-4" />
{reopening ? 'Re-opening...' : 'Re-open Race'}
</Button>
)}
{race.status === 'running' && LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="primary"

View File

@@ -8,6 +8,7 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import type { RaceResultsDetailViewModel } from '@/lib/view-models';
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
@@ -18,7 +19,7 @@ export default function RaceResultsPage() {
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { raceResultsService } = useServices();
const { raceResultsService, leagueMembershipService } = useServices();
const [raceData, setRaceData] = useState<RaceResultsDetailViewModel | null>(null);
const [raceSOF, setRaceSOF] = useState<number | null>(null);
@@ -56,14 +57,16 @@ export default function RaceResultsPage() {
}, [raceId]);
useEffect(() => {
if (raceData?.league?.id && currentDriverId) {
const leagueId = raceData?.league?.id;
if (leagueId && currentDriverId) {
const checkAdmin = async () => {
// For now, assume admin check - this might need to be updated based on API
setIsAdmin(true); // TODO: Implement proper admin check via API
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
};
checkAdmin();
}
}, [raceData?.league?.id, currentDriverId]);
}, [raceData?.league?.id, currentDriverId, leagueMembershipService]);
const handleImportSuccess = async (importedResults: any[]) => {
setImporting(true);

View File

@@ -6,6 +6,7 @@ import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import {
AlertCircle,
AlertTriangle,
@@ -24,7 +25,7 @@ import { useEffect, useState } from 'react';
export default function RaceStewardingPage() {
const params = useParams();
const router = useRouter();
const { raceStewardingService } = useServices();
const { raceStewardingService, leagueMembershipService } = useServices();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
@@ -40,9 +41,11 @@ export default function RaceStewardingPage() {
const data = await raceStewardingService.getRaceStewardingData(raceId, currentDriverId);
setStewardingData(data);
if (data.league) {
// TODO: Implement admin check via API
setIsAdmin(true);
if (data.league?.id) {
const membership = await leagueMembershipService.getMembership(data.league.id, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} else {
setIsAdmin(false);
}
} catch (err) {
console.error('Failed to load data:', err);
@@ -52,7 +55,7 @@ export default function RaceStewardingPage() {
}
loadData();
}, [raceId, currentDriverId, raceStewardingService]);
}, [raceId, currentDriverId, raceStewardingService, leagueMembershipService]);
const pendingProtests = stewardingData?.pendingProtests ?? [];
const resolvedProtests = stewardingData?.resolvedProtests ?? [];

View File

@@ -157,11 +157,12 @@ export default function TeamDetailPage() {
const visibleTabs = tabs.filter(tab => tab.visible);
// Build sponsor insights for team
// Build sponsor insights for team using real membership and league data
const leagueCount = team.leagues?.length ?? 0;
const teamMetrics = [
MetricBuilders.members(memberships.length),
MetricBuilders.reach(memberships.length * 15),
MetricBuilders.races(0), // TODO: Get league count from team data
MetricBuilders.races(leagueCount),
MetricBuilders.engagement(82),
];
@@ -206,15 +207,27 @@ export default function TeamDetailPage() {
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{team.name}</h1>
{/* TODO: Add team tag when available */}
{team.tag && (
<span className="px-2 py-0.5 rounded-full text-xs bg-charcoal-outline text-gray-300">
[{team.tag}]
</span>
)}
</div>
<p className="text-gray-300 mb-4 max-w-2xl">{team.description}</p>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span>{memberships.length} {memberships.length === 1 ? 'member' : 'members'}</span>
{/* TODO: Add created date when available */}
{/* TODO: Add league count when available */}
{team.createdAt && (
<span>
Founded {new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
)}
{leagueCount > 0 && (
<span>
Active in {leagueCount} {leagueCount === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
</div>
</div>
@@ -259,8 +272,19 @@ export default function TeamDetailPage() {
<h3 className="text-xl font-semibold text-white mb-4">Quick Stats</h3>
<div className="space-y-3">
<StatItem label="Members" value={memberships.length.toString()} color="text-primary-blue" />
<StatItem label="Leagues" value="0" color="text-green-400" /> {/* TODO: Get league count */}
<StatItem label="Founded" value="Unknown" color="text-gray-300" /> {/* TODO: Get founded date */}
{leagueCount > 0 && (
<StatItem label="Leagues" value={leagueCount.toString()} color="text-green-400" />
)}
{team.createdAt && (
<StatItem
label="Founded"
value={new Date(team.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
color="text-gray-300"
/>
)}
</div>
</Card>
</div>
@@ -285,7 +309,7 @@ export default function TeamDetailPage() {
)}
{activeTab === 'standings' && (
<TeamStandings teamId={teamId} leagues={[]} />
<TeamStandings teamId={teamId} leagues={team.leagues} />
)}
{activeTab === 'admin' && isAdmin && (

View File

@@ -451,9 +451,29 @@ export default function TeamsPage() {
const { teamService } = useServices();
const teams = await teamService.getAllTeams();
setRealTeams(teams);
// TODO: set groups and top teams from service or compute locally
setGroupsBySkillLevel({});
setTopTeams([]);
// Derive groups by skill level from the loaded teams
const byLevel: Record<SkillLevel, TeamDisplayData[]> = {
beginner: [],
intermediate: [],
advanced: [],
pro: [],
};
teams.forEach((team) => {
const level = (team.performanceLevel as SkillLevel) || 'intermediate';
if (byLevel[level]) {
byLevel[level].push(team as TeamDisplayData);
}
});
setGroupsBySkillLevel(byLevel);
// Select top teams by rating for the preview section
const sortedByRating = [...teams].sort((a, b) => {
const aRating = typeof a.rating === 'number' && Number.isFinite(a.rating) ? a.rating : 0;
const bRating = typeof b.rating === 'number' && Number.isFinite(b.rating) ? b.rating : 0;
return bRating - aRating;
});
setTopTeams(sortedByRating.slice(0, 5));
} catch (error) {
console.error('Failed to load teams:', error);
} finally {