diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 8238b064b..ef0eed430 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -10,6 +10,7 @@ import LeagueSchedule from '@/components/leagues/LeagueSchedule'; import LeagueAdmin from '@/components/leagues/LeagueAdmin'; import StandingsTable from '@/components/leagues/StandingsTable'; import LeagueScoringTab from '@/components/leagues/LeagueScoringTab'; +import LeagueActivityFeed from '@/components/leagues/LeagueActivityFeed'; import DriverIdentity from '@/components/drivers/DriverIdentity'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; import { @@ -452,40 +453,49 @@ export default function LeagueDetailPage() { - {/* Quick Actions */} - -

Quick Actions

- -
- {membership ? ( - <> - - + {/* Sidebar Container */} +
+ {/* Quick Actions */} + +

Quick Actions

+ +
+ {membership ? ( + <> + + + + + ) : ( - - ) : ( - - )} -
-
+ )} +
+ + + {/* Recent Activity */} + +

Recent Activity

+ +
+
)} diff --git a/apps/website/components/leagues/LeagueActivityFeed.tsx b/apps/website/components/leagues/LeagueActivityFeed.tsx new file mode 100644 index 000000000..d82063de9 --- /dev/null +++ b/apps/website/components/leagues/LeagueActivityFeed.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react'; +import { Race, Penalty } from '@gridpilot/racing'; +import type { LeagueMembership } from '@gridpilot/racing/domain/entities/LeagueMembership'; +import { getDriverRepository, getPenaltyRepository, getRaceRepository } from '@/lib/di-container'; +import { useEffect, useState } from 'react'; +import type { Driver } from '@gridpilot/racing'; + +export type LeagueActivity = + | { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date } + | { type: 'race_scheduled'; raceId: string; raceName: string; timestamp: Date } + | { type: 'penalty_applied'; penaltyId: string; driverName: string; reason: string; points: number; timestamp: Date } + | { type: 'member_joined'; driverId: string; driverName: string; timestamp: Date } + | { type: 'member_left'; driverId: string; driverName: string; timestamp: Date } + | { type: 'role_changed'; driverId: string; driverName: string; oldRole: string; newRole: string; timestamp: Date }; + +interface LeagueActivityFeedProps { + leagueId: string; + limit?: number; +} + +function timeAgo(timestamp: Date): string { + const diffMs = Date.now() - timestamp.getTime(); + const diffMinutes = Math.floor(diffMs / 60000); + if (diffMinutes < 1) return 'Just now'; + if (diffMinutes < 60) return `${diffMinutes} min ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + return timestamp.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) { + const [activities, setActivities] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function loadActivities() { + try { + const raceRepo = getRaceRepository(); + const penaltyRepo = getPenaltyRepository(); + const driverRepo = getDriverRepository(); + + const races = await raceRepo.findByLeagueId(leagueId); + const drivers = await driverRepo.findAll(); + const driversMap = new Map(drivers.map(d => [d.id, d])); + + const activityList: LeagueActivity[] = []; + + // Add completed races + const completedRaces = races.filter(r => r.status === 'completed') + .sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()) + .slice(0, 5); + + for (const race of completedRaces) { + activityList.push({ + type: 'race_completed', + raceId: race.id, + raceName: `${race.track} - ${race.car}`, + timestamp: race.scheduledAt, + }); + + // Add penalties from this race + const racePenalties = await penaltyRepo.findByRaceId(race.id); + const appliedPenalties = racePenalties.filter(p => p.status === 'applied' && p.type === 'points_deduction'); + + for (const penalty of appliedPenalties) { + const driver = driversMap.get(penalty.driverId); + if (driver && penalty.value) { + activityList.push({ + type: 'penalty_applied', + penaltyId: penalty.id, + driverName: driver.name, + reason: penalty.reason, + points: penalty.value, + timestamp: penalty.appliedAt || penalty.issuedAt, + }); + } + } + } + + // Add scheduled races + const upcomingRaces = races.filter(r => r.status === 'scheduled') + .sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()) + .slice(0, 3); + + for (const race of upcomingRaces) { + activityList.push({ + type: 'race_scheduled', + raceId: race.id, + raceName: `${race.track} - ${race.car}`, + timestamp: new Date(race.scheduledAt.getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement + }); + } + + // Sort all activities by timestamp + activityList.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + + setActivities(activityList.slice(0, limit)); + } catch (err) { + console.error('Failed to load activities:', err); + } finally { + setLoading(false); + } + } + + loadActivities(); + }, [leagueId, limit]); + + if (loading) { + return ( +
+ Loading activities... +
+ ); + } + + if (activities.length === 0) { + return ( +
+ No recent activity +
+ ); + } + + return ( +
+ {activities.map((activity, index) => ( + + ))} +
+ ); +} + +function ActivityItem({ activity }: { activity: LeagueActivity }) { + const getIcon = () => { + switch (activity.type) { + case 'race_completed': + return ; + case 'race_scheduled': + return ; + case 'penalty_applied': + return ; + case 'member_joined': + return ; + case 'member_left': + return ; + case 'role_changed': + return ; + } + }; + + const getContent = () => { + switch (activity.type) { + case 'race_completed': + return ( + <> + Race Completed + · {activity.raceName} + + ); + case 'race_scheduled': + return ( + <> + Race Scheduled + · {activity.raceName} + + ); + case 'penalty_applied': + return ( + <> + {activity.driverName} + received a + {activity.points}-point penalty + · {activity.reason} + + ); + case 'member_joined': + return ( + <> + {activity.driverName} + joined the league + + ); + case 'member_left': + return ( + <> + {activity.driverName} + left the league + + ); + case 'role_changed': + return ( + <> + {activity.driverName} + promoted to + {activity.newRole} + + ); + } + }; + + return ( +
+
+ {getIcon()} +
+
+

+ {getContent()} +

+

+ {timeAgo(activity.timestamp)} +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/lib/di-container.ts b/apps/website/lib/di-container.ts index 09b9f0196..09524b540 100644 --- a/apps/website/lib/di-container.ts +++ b/apps/website/lib/di-container.ts @@ -354,15 +354,15 @@ class DIContainer { // If protest was upheld, create a penalty if (status === 'upheld') { - const penaltyTypes: Array<'time_penalty' | 'points_deduction' | 'warning'> = ['time_penalty', 'points_deduction', 'warning']; - const penaltyType = penaltyTypes[i % penaltyTypes.length]; + // Alternate between points deduction and time penalties for visibility + const penaltyType = i % 2 === 0 ? 'points_deduction' : 'time_penalty'; const penalty = Penalty.create({ id: `penalty-${race.id}-${i}`, raceId: race.id, driverId: accusedResult.driverId, type: penaltyType, - value: penaltyType === 'time_penalty' ? 5 : penaltyType === 'points_deduction' ? 3 : undefined, + value: penaltyType === 'points_deduction' ? 3 : 5, reason: protest.incident.description, protestId: protest.id, issuedBy: primaryDriverId, @@ -375,24 +375,48 @@ class DIContainer { } } - // Add a direct penalty (not from protest) for some races - if (raceIndex % 2 === 0 && raceResults.length > 5) { - const penalizedResult = raceResults[4]; - if (penalizedResult) { - const penalty = Penalty.create({ - id: `penalty-direct-${race.id}`, - raceId: race.id, - driverId: penalizedResult.driverId, - type: 'time_penalty', - value: 10, - reason: 'Track limits violation - gained lasting advantage', - issuedBy: primaryDriverId, - status: 'applied', - issuedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), - appliedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), - }); - - seededPenalties.push(penalty); + // Add direct penalties (not from protest) for better visibility in standings + if (raceResults.length > 5) { + // Add a points deduction penalty for some drivers + if (raceIndex % 3 === 0) { + const penalizedResult = raceResults[4]; + if (penalizedResult) { + const penalty = Penalty.create({ + id: `penalty-direct-${race.id}`, + raceId: race.id, + driverId: penalizedResult.driverId, + type: 'points_deduction', + value: 5, + reason: 'Causing avoidable collision', + issuedBy: primaryDriverId, + status: 'applied', + issuedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), + appliedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), + }); + + seededPenalties.push(penalty); + } + } + + // Add another points deduction for different driver + if (raceIndex % 3 === 1 && raceResults.length > 6) { + const penalizedResult = raceResults[5]; + if (penalizedResult) { + const penalty = Penalty.create({ + id: `penalty-direct-2-${race.id}`, + raceId: race.id, + driverId: penalizedResult.driverId, + type: 'points_deduction', + value: 2, + reason: 'Track limits violation - gained lasting advantage', + issuedBy: primaryDriverId, + status: 'applied', + issuedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), + appliedAt: new Date(Date.now() - (raceIndex + 1) * 12 * 60 * 60 * 1000), + }); + + seededPenalties.push(penalty); + } } } });