wip
This commit is contained in:
@@ -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() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{membership ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
>
|
||||
View Schedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('standings')}
|
||||
>
|
||||
View Standings
|
||||
</Button>
|
||||
{/* Sidebar Container */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Quick Actions</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{membership ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
>
|
||||
View Schedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => setActiveTab('standings')}
|
||||
>
|
||||
View Standings
|
||||
</Button>
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<JoinLeagueButton
|
||||
leagueId={leagueId}
|
||||
onMembershipChange={handleMembershipChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Recent Activity</h2>
|
||||
<LeagueActivityFeed leagueId={leagueId} limit={8} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
221
apps/website/components/leagues/LeagueActivityFeed.tsx
Normal file
221
apps/website/components/leagues/LeagueActivityFeed.tsx
Normal file
@@ -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<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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user