wip
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthService } from '@/lib/auth';
|
||||
import {
|
||||
DemoFaceValidationAdapter,
|
||||
import {
|
||||
DemoFaceValidationAdapter,
|
||||
DemoAvatarGenerationAdapter,
|
||||
InMemoryAvatarGenerationRepository
|
||||
} from '@gridpilot/demo-infrastructure';
|
||||
InMemoryAvatarGenerationRepository
|
||||
} from '@gridpilot/testing-support';
|
||||
import { RequestAvatarGenerationUseCase } from '@gridpilot/media';
|
||||
|
||||
// Create singleton instances
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { DemoFaceValidationAdapter } from '@gridpilot/demo-infrastructure';
|
||||
import { DemoFaceValidationAdapter } from '@gridpilot/testing-support';
|
||||
|
||||
const faceValidation = new DemoFaceValidationAdapter();
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Image from 'next/image';
|
||||
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
|
||||
import { getDriverRepository, getImageService, getSocialRepository } from '@/lib/di-container';
|
||||
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
|
||||
import { getDriverRepository, getImageService } from '@/lib/di-container';
|
||||
|
||||
function timeAgo(timestamp: Date): string {
|
||||
const diffMs = Date.now() - timestamp.getTime();
|
||||
@@ -15,44 +15,32 @@ function timeAgo(timestamp: Date): string {
|
||||
return `${diffDays} d ago`;
|
||||
}
|
||||
|
||||
async function resolveActor(item: FeedItem) {
|
||||
async function resolveActor(item: FeedItemDTO) {
|
||||
const driverRepo = getDriverRepository();
|
||||
const imageService = getImageService();
|
||||
const socialRepo = getSocialRepository();
|
||||
|
||||
if (item.actorFriendId) {
|
||||
// Try social graph first (friend display name/avatar)
|
||||
try {
|
||||
const friend = await socialRepo.getFriendByDriverId?.(item.actorFriendId);
|
||||
if (friend) {
|
||||
return {
|
||||
name: friend.displayName ?? friend.driverName ?? `Driver ${item.actorFriendId}`,
|
||||
avatarUrl: friend.avatarUrl ?? imageService.getDriverAvatar(item.actorFriendId),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to driver lookup
|
||||
}
|
||||
const actorId = item.actorFriendId ?? item.actorDriverId;
|
||||
if (!actorId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fallback to driver entity + image service
|
||||
try {
|
||||
const driver = await driverRepo.findById(item.actorFriendId);
|
||||
if (driver) {
|
||||
return {
|
||||
name: driver.name,
|
||||
avatarUrl: imageService.getDriverAvatar(driver.id),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore and return null below
|
||||
try {
|
||||
const driver = await driverRepo.findById(actorId);
|
||||
if (driver) {
|
||||
return {
|
||||
name: driver.name,
|
||||
avatarUrl: imageService.getDriverAvatar(driver.id),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore and fall through to generic rendering
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface FeedItemCardProps {
|
||||
item: FeedItem;
|
||||
item: FeedItemDTO;
|
||||
}
|
||||
|
||||
export default function FeedItemCard({ item }: FeedItemCardProps) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
|
||||
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { RaceWithResultsDTO } from '@gridpilot/testing-support';
|
||||
import FeedList from '@/components/feed/FeedList';
|
||||
@@ -7,7 +7,7 @@ import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
|
||||
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';
|
||||
|
||||
interface FeedLayoutProps {
|
||||
feedItems: FeedItem[];
|
||||
feedItems: FeedItemDTO[];
|
||||
upcomingRaces: Race[];
|
||||
latestResults: RaceWithResultsDTO[];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import FeedEmptyState from '@/components/feed/FeedEmptyState';
|
||||
import FeedItemCard from '@/components/feed/FeedItemCard';
|
||||
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
|
||||
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
|
||||
|
||||
interface FeedListProps {
|
||||
items: FeedItem[];
|
||||
items: FeedItemDTO[];
|
||||
}
|
||||
|
||||
export default function FeedList({ items }: FeedListProps) {
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import {
|
||||
getRaceRepository,
|
||||
getIsDriverRegisteredForRaceQuery,
|
||||
getRegisterForRaceUseCase,
|
||||
getWithdrawFromRaceUseCase,
|
||||
} from '@/lib/di-container';
|
||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||
import {
|
||||
loadLeagueSchedule,
|
||||
registerForRace,
|
||||
withdrawFromRace,
|
||||
type LeagueScheduleRaceItemViewModel,
|
||||
} from '@/lib/presenters/LeagueSchedulePresenter';
|
||||
|
||||
interface LeagueScheduleProps {
|
||||
leagueId: string;
|
||||
@@ -17,7 +16,7 @@ interface LeagueScheduleProps {
|
||||
|
||||
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
const router = useRouter();
|
||||
const [races, setRaces] = useState<Race[]>([]);
|
||||
const [races, setRaces] = useState<LeagueScheduleRaceItemViewModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
||||
const [registrationStates, setRegistrationStates] = useState<Record<string, boolean>>({});
|
||||
@@ -25,30 +24,16 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
|
||||
const loadRaces = useCallback(async () => {
|
||||
const loadRacesCallback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const raceRepo = getRaceRepository();
|
||||
const allRaces = await raceRepo.findAll();
|
||||
const leagueRaces = allRaces
|
||||
.filter((race) => race.leagueId === leagueId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
|
||||
);
|
||||
setRaces(leagueRaces);
|
||||
const viewModel = await loadLeagueSchedule(leagueId, currentDriverId);
|
||||
setRaces(viewModel.races);
|
||||
|
||||
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
|
||||
const states: Record<string, boolean> = {};
|
||||
await Promise.all(
|
||||
leagueRaces.map(async (race) => {
|
||||
const registered = await isRegisteredQuery.execute({
|
||||
raceId: race.id,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
states[race.id] = registered;
|
||||
}),
|
||||
);
|
||||
for (const race of viewModel.races) {
|
||||
states[race.id] = race.isRegistered;
|
||||
}
|
||||
setRegistrationStates(states);
|
||||
} catch (error) {
|
||||
console.error('Failed to load races:', error);
|
||||
@@ -58,27 +43,19 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
}, [leagueId, currentDriverId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRaces();
|
||||
}, [loadRaces]);
|
||||
void loadRacesCallback();
|
||||
}, [loadRacesCallback]);
|
||||
|
||||
|
||||
const handleRegister = async (race: Race, e: React.MouseEvent) => {
|
||||
const handleRegister = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Register for ${race.track}?`
|
||||
);
|
||||
|
||||
const confirmed = window.confirm(`Register for ${race.track}?`);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setProcessingRace(race.id);
|
||||
try {
|
||||
const useCase = getRegisterForRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId: race.id,
|
||||
leagueId,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
await registerForRace(race.id, leagueId, currentDriverId);
|
||||
setRegistrationStates((prev) => ({ ...prev, [race.id]: true }));
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to register');
|
||||
@@ -87,22 +64,16 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleWithdraw = async (race: Race, e: React.MouseEvent) => {
|
||||
const handleWithdraw = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
const confirmed = window.confirm(
|
||||
'Withdraw from this race?'
|
||||
);
|
||||
const confirmed = window.confirm('Withdraw from this race?');
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
setProcessingRace(race.id);
|
||||
try {
|
||||
const useCase = getWithdrawFromRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId: race.id,
|
||||
driverId: currentDriverId,
|
||||
});
|
||||
await withdrawFromRace(race.id, currentDriverId);
|
||||
setRegistrationStates((prev) => ({ ...prev, [race.id]: false }));
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to withdraw');
|
||||
@@ -111,18 +82,17 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const upcomingRaces = races.filter(race => race.status === 'scheduled' && new Date(race.scheduledAt) > now);
|
||||
const pastRaces = races.filter(race => race.status === 'completed' || new Date(race.scheduledAt) <= now);
|
||||
const upcomingRaces = races.filter((race) => race.isUpcoming);
|
||||
const pastRaces = races.filter((race) => race.isPast);
|
||||
|
||||
const getDisplayRaces = () => {
|
||||
switch (filter) {
|
||||
case 'upcoming':
|
||||
return upcomingRaces;
|
||||
case 'past':
|
||||
return pastRaces.reverse();
|
||||
return [...pastRaces].reverse();
|
||||
case 'all':
|
||||
return [...upcomingRaces, ...pastRaces.reverse()];
|
||||
return [...upcomingRaces, ...[...pastRaces].reverse()];
|
||||
default:
|
||||
return races;
|
||||
}
|
||||
@@ -190,8 +160,8 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{displayRaces.map((race) => {
|
||||
const isPast = race.status === 'completed' || new Date(race.scheduledAt) <= now;
|
||||
const isUpcoming = race.status === 'scheduled' && new Date(race.scheduledAt) > now;
|
||||
const isPast = race.isPast;
|
||||
const isUpcoming = race.isUpcoming;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -231,19 +201,19 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="text-white font-medium">
|
||||
{new Date(race.scheduledAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{new Date(race.scheduledAt).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-white font-medium">
|
||||
{race.scheduledAt.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{race.scheduledAt.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
{isPast && race.status === 'completed' && (
|
||||
<p className="text-xs text-primary-blue mt-1">View Results →</p>
|
||||
)}
|
||||
|
||||
@@ -4,24 +4,28 @@ import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
getDriverRepository,
|
||||
getGetTeamJoinRequestsUseCase,
|
||||
getApproveTeamJoinRequestUseCase,
|
||||
getRejectTeamJoinRequestUseCase,
|
||||
getUpdateTeamUseCase,
|
||||
} from '@/lib/di-container';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { Team, TeamJoinRequest } from '@gridpilot/racing';
|
||||
import {
|
||||
loadTeamAdminViewModel,
|
||||
approveTeamJoinRequestAndReload,
|
||||
rejectTeamJoinRequestAndReload,
|
||||
updateTeamDetails,
|
||||
type TeamAdminJoinRequestViewModel,
|
||||
} from '@/lib/presenters/TeamAdminPresenter';
|
||||
|
||||
interface TeamAdminProps {
|
||||
team: Team;
|
||||
team: {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
};
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
const [joinRequests, setJoinRequests] = useState<TeamJoinRequest[]>([]);
|
||||
const [joinRequests, setJoinRequests] = useState<TeamAdminJoinRequestViewModel[]>([]);
|
||||
const [requestDrivers, setRequestDrivers] = useState<Record<string, DriverDTO>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
@@ -32,38 +36,38 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void loadJoinRequests();
|
||||
}, [team.id]);
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const viewModel = await loadTeamAdminViewModel(team as any);
|
||||
setJoinRequests(viewModel.requests);
|
||||
|
||||
const loadJoinRequests = async () => {
|
||||
const useCase = getGetTeamJoinRequestsUseCase();
|
||||
await useCase.execute({ teamId: team.id });
|
||||
const viewModel = useCase.presenter.getViewModel();
|
||||
setJoinRequests(viewModel.requests);
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driverMap: Record<string, DriverDTO> = {};
|
||||
|
||||
for (const request of viewModel.requests) {
|
||||
const driver = allDrivers.find(d => d.id === request.driverId);
|
||||
if (driver) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
driverMap[request.driverId] = dto;
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const request of viewModel.requests) {
|
||||
if (request.driver) {
|
||||
driversById[request.driverId] = request.driver;
|
||||
}
|
||||
}
|
||||
setRequestDrivers(driversById);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setRequestDrivers(driverMap);
|
||||
setLoading(false);
|
||||
};
|
||||
void load();
|
||||
}, [team.id, team.name, team.tag, team.description, team.ownerId]);
|
||||
|
||||
const handleApprove = async (requestId: string) => {
|
||||
try {
|
||||
const useCase = getApproveTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
await loadJoinRequests();
|
||||
const updated = await approveTeamJoinRequestAndReload(requestId, team.id);
|
||||
setJoinRequests(updated);
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const request of updated) {
|
||||
if (request.driver) {
|
||||
driversById[request.driverId] = request.driver;
|
||||
}
|
||||
}
|
||||
setRequestDrivers(driversById);
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to approve request');
|
||||
@@ -72,9 +76,15 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
|
||||
const handleReject = async (requestId: string) => {
|
||||
try {
|
||||
const useCase = getRejectTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
await loadJoinRequests();
|
||||
const updated = await rejectTeamJoinRequestAndReload(requestId, team.id);
|
||||
setJoinRequests(updated);
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const request of updated) {
|
||||
if (request.driver) {
|
||||
driversById[request.driverId] = request.driver;
|
||||
}
|
||||
}
|
||||
setRequestDrivers(driversById);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : 'Failed to reject request');
|
||||
}
|
||||
@@ -82,15 +92,12 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
try {
|
||||
const useCase = getUpdateTeamUseCase();
|
||||
await useCase.execute({
|
||||
await updateTeamDetails({
|
||||
teamId: team.id,
|
||||
updates: {
|
||||
name: editedTeam.name,
|
||||
tag: editedTeam.tag,
|
||||
description: editedTeam.description,
|
||||
},
|
||||
updatedBy: team.ownerId,
|
||||
name: editedTeam.name,
|
||||
tag: editedTeam.tag,
|
||||
description: editedTeam.description,
|
||||
updatedByDriverId: team.ownerId,
|
||||
});
|
||||
setEditMode(false);
|
||||
onUpdate();
|
||||
@@ -194,7 +201,7 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||
) : joinRequests.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{joinRequests.map((request) => {
|
||||
const driver = requestDrivers[request.driverId];
|
||||
const driver = requestDrivers[request.driverId] ?? request.driver;
|
||||
if (!driver) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,85 +2,31 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { getStandingRepository, getLeagueRepository, getTeamMembershipRepository } from '@/lib/di-container';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import type { LeagueDTO } from '@gridpilot/racing/application/dto/LeagueDTO';
|
||||
import {
|
||||
loadTeamStandings,
|
||||
type TeamLeagueStandingViewModel,
|
||||
} from '@/lib/presenters/TeamStandingsPresenter';
|
||||
|
||||
interface TeamStandingsProps {
|
||||
teamId: string;
|
||||
leagues: string[];
|
||||
}
|
||||
|
||||
interface TeamLeagueStanding {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
|
||||
const [standings, setStandings] = useState<TeamLeagueStanding[]>([]);
|
||||
const [standings, setStandings] = useState<TeamLeagueStandingViewModel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadStandings = async () => {
|
||||
const standingRepo = getStandingRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const teamMembershipRepo = getTeamMembershipRepository();
|
||||
const members = await teamMembershipRepo.getTeamMembers(teamId);
|
||||
const memberIds = members.map(m => m.driverId);
|
||||
|
||||
const teamStandings: TeamLeagueStanding[] = [];
|
||||
|
||||
for (const leagueId of leagues) {
|
||||
const league = await leagueRepo.findById(leagueId);
|
||||
if (!league) continue;
|
||||
|
||||
const leagueStandings = await standingRepo.findByLeagueId(leagueId);
|
||||
|
||||
// Calculate team points (sum of all team members)
|
||||
let totalPoints = 0;
|
||||
let totalWins = 0;
|
||||
let totalRaces = 0;
|
||||
|
||||
for (const standing of leagueStandings) {
|
||||
if (memberIds.includes(standing.driverId)) {
|
||||
totalPoints += standing.points;
|
||||
totalWins += standing.wins;
|
||||
totalRaces = Math.max(totalRaces, standing.racesCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate team position (simplified - based on total points)
|
||||
const allTeamPoints = leagueStandings
|
||||
.filter(s => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
|
||||
const position = leagueStandings
|
||||
.filter((_, idx, arr) => {
|
||||
const teamPoints = arr
|
||||
.filter(s => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
return teamPoints > allTeamPoints;
|
||||
}).length + 1;
|
||||
|
||||
teamStandings.push({
|
||||
leagueId,
|
||||
leagueName: league.name,
|
||||
position,
|
||||
points: totalPoints,
|
||||
wins: totalWins,
|
||||
racesCompleted: totalRaces,
|
||||
});
|
||||
const load = async () => {
|
||||
try {
|
||||
const viewModel = await loadTeamStandings(teamId, leagues);
|
||||
setStandings(viewModel.standings);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
setStandings(teamStandings);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadStandings();
|
||||
void load();
|
||||
}, [teamId, leagues]);
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -43,7 +43,7 @@ import type { INotificationRepository, INotificationPreferenceRepository } from
|
||||
import {
|
||||
SendNotificationUseCase,
|
||||
MarkNotificationReadUseCase,
|
||||
GetUnreadNotificationsQuery
|
||||
GetUnreadNotificationsUseCase
|
||||
} from '@gridpilot/notifications/application';
|
||||
import {
|
||||
InMemoryNotificationRepository,
|
||||
@@ -81,7 +81,7 @@ import {
|
||||
InMemoryFeedRepository,
|
||||
InMemorySocialGraphRepository,
|
||||
} from '@gridpilot/social/infrastructure/inmemory/InMemorySocialAndFeed';
|
||||
import { DemoImageServiceAdapter } from '@gridpilot/demo-infrastructure';
|
||||
import { DemoImageServiceAdapter } from '@gridpilot/testing-support';
|
||||
|
||||
// Application use cases and queries
|
||||
import {
|
||||
@@ -174,7 +174,7 @@ import { LeagueSchedulePreviewPresenter } from './presenters/LeagueSchedulePrevi
|
||||
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
|
||||
import { ProfileOverviewPresenter } from './presenters/ProfileOverviewPresenter';
|
||||
|
||||
// Testing support
|
||||
// Demo infrastructure (runtime demo seed & helpers)
|
||||
import {
|
||||
createStaticRacingSeed,
|
||||
getDemoLeagueArchetypeByName,
|
||||
@@ -1246,8 +1246,8 @@ export function configureDIContainer(): void {
|
||||
|
||||
// Register queries - Notifications
|
||||
container.registerInstance(
|
||||
DI_TOKENS.GetUnreadNotificationsQuery,
|
||||
new GetUnreadNotificationsQuery(notificationRepository)
|
||||
DI_TOKENS.GetUnreadNotificationsUseCase,
|
||||
new GetUnreadNotificationsUseCase(notificationRepository)
|
||||
);
|
||||
|
||||
// Register use cases - Sponsors
|
||||
|
||||
@@ -35,7 +35,7 @@ import type { INotificationRepository, INotificationPreferenceRepository } from
|
||||
import type {
|
||||
SendNotificationUseCase,
|
||||
MarkNotificationReadUseCase,
|
||||
GetUnreadNotificationsQuery
|
||||
GetUnreadNotificationsUseCase
|
||||
} from '@gridpilot/notifications/application';
|
||||
import type {
|
||||
JoinLeagueUseCase,
|
||||
@@ -457,9 +457,9 @@ class DIContainer {
|
||||
return getDIContainer().resolve<MarkNotificationReadUseCase>(DI_TOKENS.MarkNotificationReadUseCase);
|
||||
}
|
||||
|
||||
get getUnreadNotificationsQuery(): GetUnreadNotificationsQuery {
|
||||
get getUnreadNotificationsUseCase(): GetUnreadNotificationsUseCase {
|
||||
this.ensureInitialized();
|
||||
return getDIContainer().resolve<GetUnreadNotificationsQuery>(DI_TOKENS.GetUnreadNotificationsQuery);
|
||||
return getDIContainer().resolve<GetUnreadNotificationsUseCase>(DI_TOKENS.GetUnreadNotificationsUseCase);
|
||||
}
|
||||
|
||||
get fileProtestUseCase(): FileProtestUseCase {
|
||||
@@ -801,8 +801,8 @@ export function getMarkNotificationReadUseCase(): MarkNotificationReadUseCase {
|
||||
return DIContainer.getInstance().markNotificationReadUseCase;
|
||||
}
|
||||
|
||||
export function getGetUnreadNotificationsQuery(): GetUnreadNotificationsQuery {
|
||||
return DIContainer.getInstance().getUnreadNotificationsQuery;
|
||||
export function getGetUnreadNotificationsUseCase(): GetUnreadNotificationsUseCase {
|
||||
return DIContainer.getInstance().getUnreadNotificationsUseCase;
|
||||
}
|
||||
|
||||
export function getFileProtestUseCase(): FileProtestUseCase {
|
||||
|
||||
@@ -101,7 +101,7 @@ export const DI_TOKENS = {
|
||||
GetRacePenaltiesUseCase: Symbol.for('GetRacePenaltiesUseCase'),
|
||||
|
||||
// Queries - Notifications
|
||||
GetUnreadNotificationsQuery: Symbol.for('GetUnreadNotificationsQuery'),
|
||||
GetUnreadNotificationsUseCase: Symbol.for('GetUnreadNotificationsUseCase'),
|
||||
|
||||
// Use Cases - Sponsors
|
||||
GetSponsorDashboardUseCase: Symbol.for('GetSponsorDashboardUseCase'),
|
||||
|
||||
121
apps/website/lib/presenters/TeamAdminPresenter.ts
Normal file
121
apps/website/lib/presenters/TeamAdminPresenter.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Team, TeamJoinRequest } from '@gridpilot/racing';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
getDriverRepository,
|
||||
getGetTeamJoinRequestsUseCase,
|
||||
getApproveTeamJoinRequestUseCase,
|
||||
getRejectTeamJoinRequestUseCase,
|
||||
getUpdateTeamUseCase,
|
||||
} from '@/lib/di-container';
|
||||
|
||||
export interface TeamAdminJoinRequestViewModel {
|
||||
id: string;
|
||||
teamId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
driver?: DriverDTO;
|
||||
}
|
||||
|
||||
export interface TeamAdminTeamSummaryViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface TeamAdminViewModel {
|
||||
team: TeamAdminTeamSummaryViewModel;
|
||||
requests: TeamAdminJoinRequestViewModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load join requests plus driver DTOs for a team.
|
||||
*/
|
||||
export async function loadTeamAdminViewModel(team: Team): Promise<TeamAdminViewModel> {
|
||||
const requests = await loadTeamJoinRequests(team.id);
|
||||
return {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
tag: team.tag,
|
||||
description: team.description,
|
||||
ownerId: team.ownerId,
|
||||
},
|
||||
requests,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadTeamJoinRequests(teamId: string): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const getRequestsUseCase = getGetTeamJoinRequestsUseCase();
|
||||
await getRequestsUseCase.execute({ teamId });
|
||||
const presenterVm = getRequestsUseCase.presenter.getViewModel();
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
|
||||
for (const driver of allDrivers) {
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (dto) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
}
|
||||
|
||||
return presenterVm.requests.map((req) => ({
|
||||
id: req.requestId,
|
||||
teamId: req.teamId,
|
||||
driverId: req.driverId,
|
||||
requestedAt: new Date(req.requestedAt),
|
||||
message: req.message,
|
||||
driver: driversById[req.driverId],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a team join request and return updated request view models.
|
||||
*/
|
||||
export async function approveTeamJoinRequestAndReload(
|
||||
requestId: string,
|
||||
teamId: string,
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const useCase = getApproveTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
return loadTeamJoinRequests(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a team join request and return updated request view models.
|
||||
*/
|
||||
export async function rejectTeamJoinRequestAndReload(
|
||||
requestId: string,
|
||||
teamId: string,
|
||||
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||
const useCase = getRejectTeamJoinRequestUseCase();
|
||||
await useCase.execute({ requestId });
|
||||
return loadTeamJoinRequests(teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update team basic details.
|
||||
*/
|
||||
export async function updateTeamDetails(params: {
|
||||
teamId: string;
|
||||
name: string;
|
||||
tag: string;
|
||||
description: string;
|
||||
updatedByDriverId: string;
|
||||
}): Promise<void> {
|
||||
const useCase = getUpdateTeamUseCase();
|
||||
await useCase.execute({
|
||||
teamId: params.teamId,
|
||||
updates: {
|
||||
name: params.name,
|
||||
tag: params.tag,
|
||||
description: params.description,
|
||||
},
|
||||
updatedBy: params.updatedByDriverId,
|
||||
});
|
||||
}
|
||||
76
apps/website/lib/presenters/TeamStandingsPresenter.ts
Normal file
76
apps/website/lib/presenters/TeamStandingsPresenter.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { getStandingRepository, getLeagueRepository, getTeamMembershipRepository } from '@/lib/di-container';
|
||||
|
||||
export interface TeamLeagueStandingViewModel {
|
||||
leagueId: string;
|
||||
leagueName: string;
|
||||
position: number;
|
||||
points: number;
|
||||
wins: number;
|
||||
racesCompleted: number;
|
||||
}
|
||||
|
||||
export interface TeamStandingsViewModel {
|
||||
standings: TeamLeagueStandingViewModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute team standings across the given leagues for a team.
|
||||
* Mirrors the previous TeamStandings component logic but keeps it out of the UI layer.
|
||||
*/
|
||||
export async function loadTeamStandings(
|
||||
teamId: string,
|
||||
leagues: string[],
|
||||
): Promise<TeamStandingsViewModel> {
|
||||
const standingRepo = getStandingRepository();
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const teamMembershipRepo = getTeamMembershipRepository();
|
||||
|
||||
const members = await teamMembershipRepo.getTeamMembers(teamId);
|
||||
const memberIds = members.map((m) => m.driverId);
|
||||
|
||||
const teamStandings: TeamLeagueStandingViewModel[] = [];
|
||||
|
||||
for (const leagueId of leagues) {
|
||||
const league = await leagueRepo.findById(leagueId);
|
||||
if (!league) continue;
|
||||
|
||||
const leagueStandings = await standingRepo.findByLeagueId(leagueId);
|
||||
|
||||
let totalPoints = 0;
|
||||
let totalWins = 0;
|
||||
let totalRaces = 0;
|
||||
|
||||
for (const standing of leagueStandings) {
|
||||
if (memberIds.includes(standing.driverId)) {
|
||||
totalPoints += standing.points;
|
||||
totalWins += standing.wins;
|
||||
totalRaces = Math.max(totalRaces, standing.racesCompleted);
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified team position based on total points (same spirit as previous logic)
|
||||
const allTeamPoints = leagueStandings
|
||||
.filter((s) => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
|
||||
const position =
|
||||
leagueStandings
|
||||
.filter((_, idx, arr) => {
|
||||
const teamPoints = arr
|
||||
.filter((s) => memberIds.includes(s.driverId))
|
||||
.reduce((sum, s) => sum + s.points, 0);
|
||||
return teamPoints > allTeamPoints;
|
||||
}).length + 1;
|
||||
|
||||
teamStandings.push({
|
||||
leagueId,
|
||||
leagueName: league.name,
|
||||
position,
|
||||
points: totalPoints,
|
||||
wins: totalWins,
|
||||
racesCompleted: totalRaces,
|
||||
});
|
||||
}
|
||||
|
||||
return { standings: teamStandings };
|
||||
}
|
||||
@@ -24,8 +24,7 @@
|
||||
"@gridpilot/racing/*": ["../../packages/racing/*"],
|
||||
"@gridpilot/social/*": ["../../packages/social/*"],
|
||||
"@gridpilot/testing-support": ["../../packages/testing-support"],
|
||||
"@gridpilot/media": ["../../packages/media"],
|
||||
"@gridpilot/demo-infrastructure": ["../../packages/demo-infrastructure"]
|
||||
"@gridpilot/media": ["../../packages/media"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
|
||||
Reference in New Issue
Block a user