Files
gridpilot.gg/apps/website/components/leagues/LeagueActivityFeed.tsx
2025-12-09 13:19:48 +01:00

221 lines
7.7 KiB
TypeScript

'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<LeagueActivity[]>([]);
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 (
<div className="text-center text-gray-400 py-8">
Loading activities...
</div>
);
}
if (activities.length === 0) {
return (
<div className="text-center text-gray-400 py-8">
No recent activity
</div>
);
}
return (
<div className="space-y-4">
{activities.map((activity, index) => (
<ActivityItem key={`${activity.type}-${index}`} activity={activity} />
))}
</div>
);
}
function ActivityItem({ activity }: { activity: LeagueActivity }) {
const getIcon = () => {
switch (activity.type) {
case 'race_completed':
return <Flag className="w-4 h-4 text-performance-green" />;
case 'race_scheduled':
return <Calendar className="w-4 h-4 text-primary-blue" />;
case 'penalty_applied':
return <AlertTriangle className="w-4 h-4 text-warning-amber" />;
case 'member_joined':
return <UserPlus className="w-4 h-4 text-performance-green" />;
case 'member_left':
return <UserMinus className="w-4 h-4 text-gray-400" />;
case 'role_changed':
return <Shield className="w-4 h-4 text-primary-blue" />;
}
};
const getContent = () => {
switch (activity.type) {
case 'race_completed':
return (
<>
<span className="text-white font-medium">Race Completed</span>
<span className="text-gray-400"> · {activity.raceName}</span>
</>
);
case 'race_scheduled':
return (
<>
<span className="text-white font-medium">Race Scheduled</span>
<span className="text-gray-400"> · {activity.raceName}</span>
</>
);
case 'penalty_applied':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> received a </span>
<span className="text-warning-amber">{activity.points}-point penalty</span>
<span className="text-gray-400"> · {activity.reason}</span>
</>
);
case 'member_joined':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> joined the league</span>
</>
);
case 'member_left':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> left the league</span>
</>
);
case 'role_changed':
return (
<>
<span className="text-white font-medium">{activity.driverName}</span>
<span className="text-gray-400"> promoted to </span>
<span className="text-primary-blue">{activity.newRole}</span>
</>
);
}
};
return (
<div className="flex items-start gap-3 py-3 border-b border-charcoal-outline/30 last:border-0">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-iron-gray/50 flex items-center justify-center">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm leading-relaxed">
{getContent()}
</p>
<p className="text-xs text-gray-500 mt-1">
{timeAgo(activity.timestamp)}
</p>
</div>
</div>
);
}