wip
This commit is contained in:
16
apps/website/lib/presenters/AllRacesPagePresenter.ts
Normal file
16
apps/website/lib/presenters/AllRacesPagePresenter.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type {
|
||||
IAllRacesPagePresenter,
|
||||
AllRacesPageViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IAllRacesPagePresenter';
|
||||
|
||||
export class AllRacesPagePresenter implements IAllRacesPagePresenter {
|
||||
private viewModel: AllRacesPageViewModel | null = null;
|
||||
|
||||
present(viewModel: AllRacesPageViewModel): void {
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): AllRacesPageViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
16
apps/website/lib/presenters/DashboardOverviewPresenter.ts
Normal file
16
apps/website/lib/presenters/DashboardOverviewPresenter.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type {
|
||||
IDashboardOverviewPresenter,
|
||||
DashboardOverviewViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IDashboardOverviewPresenter';
|
||||
|
||||
export class DashboardOverviewPresenter implements IDashboardOverviewPresenter {
|
||||
private viewModel: DashboardOverviewViewModel | null = null;
|
||||
|
||||
present(viewModel: DashboardOverviewViewModel): void {
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): DashboardOverviewViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IEntitySponsorshipPricingPresenter } from '@racing/application/presenters/IEntitySponsorshipPricingPresenter';
|
||||
import type { GetEntitySponsorshipPricingResultDTO } from '@racing/application/use-cases/GetEntitySponsorshipPricingQuery';
|
||||
import type { GetEntitySponsorshipPricingResultDTO } from '@racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
|
||||
|
||||
export class EntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter {
|
||||
private data: GetEntitySponsorshipPricingResultDTO | null = null;
|
||||
|
||||
17
apps/website/lib/presenters/ImportRaceResultsPresenter.ts
Normal file
17
apps/website/lib/presenters/ImportRaceResultsPresenter.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
IImportRaceResultsPresenter,
|
||||
ImportRaceResultsSummaryViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IImportRaceResultsPresenter';
|
||||
|
||||
export class ImportRaceResultsPresenter implements IImportRaceResultsPresenter {
|
||||
private viewModel: ImportRaceResultsSummaryViewModel | null = null;
|
||||
|
||||
present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel {
|
||||
this.viewModel = viewModel;
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): ImportRaceResultsSummaryViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
292
apps/website/lib/presenters/LeagueAdminPresenter.ts
Normal file
292
apps/website/lib/presenters/LeagueAdminPresenter.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||
import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||
import type { MembershipRole } from '@/lib/leagueMembership';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import {
|
||||
getLeagueMembershipRepository,
|
||||
getDriverRepository,
|
||||
getGetLeagueFullConfigUseCase,
|
||||
getRaceRepository,
|
||||
getProtestRepository,
|
||||
getDriverStats,
|
||||
getAllDriverRankings,
|
||||
} from '@/lib/di-container';
|
||||
|
||||
export interface LeagueJoinRequestViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
driver?: DriverDTO;
|
||||
}
|
||||
|
||||
export interface ProtestDriverSummary {
|
||||
[driverId: string]: DriverDTO;
|
||||
}
|
||||
|
||||
export interface ProtestRaceSummary {
|
||||
[raceId: string]: Race;
|
||||
}
|
||||
|
||||
export interface LeagueOwnerSummaryViewModel {
|
||||
driver: DriverDTO;
|
||||
rating: number | null;
|
||||
rank: number | null;
|
||||
}
|
||||
|
||||
export interface LeagueAdminProtestsViewModel {
|
||||
protests: Protest[];
|
||||
racesById: ProtestRaceSummary;
|
||||
driversById: ProtestDriverSummary;
|
||||
}
|
||||
|
||||
export interface LeagueAdminConfigViewModel {
|
||||
form: LeagueConfigFormModel | null;
|
||||
}
|
||||
|
||||
export interface LeagueAdminPermissionsViewModel {
|
||||
canRemoveMember: boolean;
|
||||
canUpdateRoles: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueAdminViewModel {
|
||||
joinRequests: LeagueJoinRequestViewModel[];
|
||||
ownerSummary: LeagueOwnerSummaryViewModel | null;
|
||||
config: LeagueAdminConfigViewModel;
|
||||
protests: LeagueAdminProtestsViewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load join requests plus requester driver DTOs for a league.
|
||||
*/
|
||||
export async function loadLeagueJoinRequests(leagueId: string): Promise<LeagueJoinRequestViewModel[]> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const requests = await membershipRepo.getJoinRequests(leagueId);
|
||||
|
||||
const driverRepo = getDriverRepository();
|
||||
const uniqueDriverIds = Array.from(new Set(requests.map((r) => r.driverId)));
|
||||
const driverEntities = await Promise.all(uniqueDriverIds.map((id) => driverRepo.findById(id)));
|
||||
const driverDtos = driverEntities
|
||||
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
||||
.filter((dto): dto is DriverDTO => dto !== null);
|
||||
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const dto of driverDtos) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
|
||||
return requests.map((request) => ({
|
||||
id: request.id,
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
requestedAt: request.requestedAt,
|
||||
message: request.message,
|
||||
driver: driversById[request.driverId],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a league join request and return updated join requests.
|
||||
*/
|
||||
export async function approveLeagueJoinRequest(
|
||||
leagueId: string,
|
||||
requestId: string
|
||||
): Promise<LeagueJoinRequestViewModel[]> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const requests = await membershipRepo.getJoinRequests(leagueId);
|
||||
const request = requests.find((r) => r.id === requestId);
|
||||
if (!request) {
|
||||
throw new Error('Join request not found');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
leagueId: request.leagueId,
|
||||
driverId: request.driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
await membershipRepo.removeJoinRequest(requestId);
|
||||
|
||||
return loadLeagueJoinRequests(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a league join request (alpha: just remove).
|
||||
*/
|
||||
export async function rejectLeagueJoinRequest(
|
||||
leagueId: string,
|
||||
requestId: string
|
||||
): Promise<LeagueJoinRequestViewModel[]> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
await membershipRepo.removeJoinRequest(requestId);
|
||||
return loadLeagueJoinRequests(leagueId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute permissions for a performer on league membership actions.
|
||||
*/
|
||||
export async function getLeagueAdminPermissions(
|
||||
leagueId: string,
|
||||
performerDriverId: string
|
||||
): Promise<LeagueAdminPermissionsViewModel> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
||||
|
||||
const isOwner = performer?.role === 'owner';
|
||||
const isAdmin = performer?.role === 'admin';
|
||||
|
||||
return {
|
||||
canRemoveMember: Boolean(isOwner || isAdmin),
|
||||
canUpdateRoles: Boolean(isOwner),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from the league, enforcing simple role rules.
|
||||
*/
|
||||
export async function removeLeagueMember(
|
||||
leagueId: string,
|
||||
performerDriverId: string,
|
||||
targetDriverId: string
|
||||
): Promise<void> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
|
||||
throw new Error('Only owners or admins can remove members');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot remove the league owner');
|
||||
}
|
||||
|
||||
await membershipRepo.removeMembership(leagueId, targetDriverId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's role, enforcing simple owner-only rules.
|
||||
*/
|
||||
export async function updateLeagueMemberRole(
|
||||
leagueId: string,
|
||||
performerDriverId: string,
|
||||
targetDriverId: string,
|
||||
newRole: MembershipRole
|
||||
): Promise<void> {
|
||||
const membershipRepo = getLeagueMembershipRepository();
|
||||
const performer = await membershipRepo.getMembership(leagueId, performerDriverId);
|
||||
if (!performer || performer.role !== 'owner') {
|
||||
throw new Error('Only the league owner can update roles');
|
||||
}
|
||||
|
||||
const membership = await membershipRepo.getMembership(leagueId, targetDriverId);
|
||||
if (!membership) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
if (membership.role === 'owner') {
|
||||
throw new Error('Cannot change the owner role');
|
||||
}
|
||||
|
||||
await membershipRepo.saveMembership({
|
||||
...membership,
|
||||
role: newRole,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load owner summary (DTO + rating/rank) for a league.
|
||||
*/
|
||||
export async function loadLeagueOwnerSummary(league: League): Promise<LeagueOwnerSummaryViewModel | null> {
|
||||
const driverRepo = getDriverRepository();
|
||||
const entity = await driverRepo.findById(league.ownerId);
|
||||
if (!entity) return null;
|
||||
|
||||
const ownerDriver = EntityMappers.toDriverDTO(entity);
|
||||
const stats = getDriverStats(ownerDriver.id);
|
||||
const allRankings = getAllDriverRankings();
|
||||
|
||||
let rating: number | null = stats?.rating ?? null;
|
||||
let rank: number | null = null;
|
||||
|
||||
if (stats) {
|
||||
if (typeof stats.overallRank === 'number' && stats.overallRank > 0) {
|
||||
rank = stats.overallRank;
|
||||
} else {
|
||||
const indexInGlobal = allRankings.findIndex((stat) => stat.driverId === stats.driverId);
|
||||
if (indexInGlobal !== -1) {
|
||||
rank = indexInGlobal + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (rating === null) {
|
||||
const globalEntry = allRankings.find((stat) => stat.driverId === stats.driverId);
|
||||
if (globalEntry) {
|
||||
rating = globalEntry.rating;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
driver: ownerDriver,
|
||||
rating,
|
||||
rank,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load league full config form.
|
||||
*/
|
||||
export async function loadLeagueConfig(leagueId: string): Promise<LeagueAdminConfigViewModel> {
|
||||
const useCase = getGetLeagueFullConfigUseCase();
|
||||
const form = await useCase.execute({ leagueId });
|
||||
return { form };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load protests, related races and driver DTOs for a league.
|
||||
*/
|
||||
export async function loadLeagueProtests(leagueId: string): Promise<LeagueAdminProtestsViewModel> {
|
||||
const raceRepo = getRaceRepository();
|
||||
const protestRepo = getProtestRepository();
|
||||
const driverRepo = getDriverRepository();
|
||||
|
||||
const leagueRaces = await raceRepo.findByLeagueId(leagueId);
|
||||
|
||||
const allProtests: Protest[] = [];
|
||||
const racesById: Record<string, Race> = {};
|
||||
|
||||
for (const race of leagueRaces) {
|
||||
racesById[race.id] = race;
|
||||
const raceProtests = await protestRepo.findByRaceId(race.id);
|
||||
allProtests.push(...raceProtests);
|
||||
}
|
||||
|
||||
const driverIds = new Set<string>();
|
||||
allProtests.forEach((p) => {
|
||||
driverIds.add(p.protestingDriverId);
|
||||
driverIds.add(p.accusedDriverId);
|
||||
});
|
||||
|
||||
const driverEntities = await Promise.all(Array.from(driverIds).map((id) => driverRepo.findById(id)));
|
||||
const driverDtos = driverEntities
|
||||
.map((driver) => (driver ? EntityMappers.toDriverDTO(driver) : null))
|
||||
.filter((dto): dto is DriverDTO => dto !== null);
|
||||
|
||||
const driversById: Record<string, DriverDTO> = {};
|
||||
for (const dto of driverDtos) {
|
||||
driversById[dto.id] = dto;
|
||||
}
|
||||
|
||||
return {
|
||||
protests: allProtests,
|
||||
racesById,
|
||||
driversById,
|
||||
};
|
||||
}
|
||||
107
apps/website/lib/presenters/LeagueSchedulePresenter.ts
Normal file
107
apps/website/lib/presenters/LeagueSchedulePresenter.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import {
|
||||
getRaceRepository,
|
||||
getIsDriverRegisteredForRaceQuery,
|
||||
getRegisterForRaceUseCase,
|
||||
getWithdrawFromRaceUseCase,
|
||||
} from '@/lib/di-container';
|
||||
|
||||
export interface LeagueScheduleRaceItemViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType: string;
|
||||
scheduledAt: Date;
|
||||
status: Race['status'];
|
||||
isUpcoming: boolean;
|
||||
isPast: boolean;
|
||||
isRegistered: boolean;
|
||||
}
|
||||
|
||||
export interface LeagueScheduleViewModel {
|
||||
races: LeagueScheduleRaceItemViewModel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load league schedule with registration status for a given driver.
|
||||
*/
|
||||
export async function loadLeagueSchedule(
|
||||
leagueId: string,
|
||||
driverId: string,
|
||||
): Promise<LeagueScheduleViewModel> {
|
||||
const raceRepo = getRaceRepository();
|
||||
const isRegisteredQuery = getIsDriverRegisteredForRaceQuery();
|
||||
|
||||
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(),
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const registrationStates: Record<string, boolean> = {};
|
||||
await Promise.all(
|
||||
leagueRaces.map(async (race) => {
|
||||
const registered = await isRegisteredQuery.execute({
|
||||
raceId: race.id,
|
||||
driverId,
|
||||
});
|
||||
registrationStates[race.id] = registered;
|
||||
}),
|
||||
);
|
||||
|
||||
const races: LeagueScheduleRaceItemViewModel[] = leagueRaces.map((race) => {
|
||||
const raceDate = new Date(race.scheduledAt);
|
||||
const isPast = race.status === 'completed' || raceDate <= now;
|
||||
const isUpcoming = race.status === 'scheduled' && raceDate > now;
|
||||
|
||||
return {
|
||||
id: race.id,
|
||||
leagueId: race.leagueId,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
sessionType: race.sessionType,
|
||||
scheduledAt: raceDate,
|
||||
status: race.status,
|
||||
isUpcoming,
|
||||
isPast,
|
||||
isRegistered: registrationStates[race.id] ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
return { races };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the driver for a race.
|
||||
*/
|
||||
export async function registerForRace(
|
||||
raceId: string,
|
||||
leagueId: string,
|
||||
driverId: string,
|
||||
): Promise<void> {
|
||||
const useCase = getRegisterForRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId,
|
||||
leagueId,
|
||||
driverId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw the driver from a race.
|
||||
*/
|
||||
export async function withdrawFromRace(
|
||||
raceId: string,
|
||||
driverId: string,
|
||||
): Promise<void> {
|
||||
const useCase = getWithdrawFromRaceUseCase();
|
||||
await useCase.execute({
|
||||
raceId,
|
||||
driverId,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IPendingSponsorshipRequestsPresenter } from '@racing/application/presenters/IPendingSponsorshipRequestsPresenter';
|
||||
import type { GetPendingSponsorshipRequestsResultDTO } from '@racing/application/use-cases/GetPendingSponsorshipRequestsQuery';
|
||||
import type { GetPendingSponsorshipRequestsResultDTO } from '@racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
|
||||
export class PendingSponsorshipRequestsPresenter implements IPendingSponsorshipRequestsPresenter {
|
||||
private data: GetPendingSponsorshipRequestsResultDTO | null = null;
|
||||
|
||||
16
apps/website/lib/presenters/ProfileOverviewPresenter.ts
Normal file
16
apps/website/lib/presenters/ProfileOverviewPresenter.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type {
|
||||
IProfileOverviewPresenter,
|
||||
ProfileOverviewViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
|
||||
|
||||
export class ProfileOverviewPresenter implements IProfileOverviewPresenter {
|
||||
private viewModel: ProfileOverviewViewModel | null = null;
|
||||
|
||||
present(viewModel: ProfileOverviewViewModel): void {
|
||||
this.viewModel = viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): ProfileOverviewViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
17
apps/website/lib/presenters/RaceDetailPresenter.ts
Normal file
17
apps/website/lib/presenters/RaceDetailPresenter.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
IRaceDetailPresenter,
|
||||
RaceDetailViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IRaceDetailPresenter';
|
||||
|
||||
export class RaceDetailPresenter implements IRaceDetailPresenter {
|
||||
private viewModel: RaceDetailViewModel | null = null;
|
||||
|
||||
present(viewModel: RaceDetailViewModel): RaceDetailViewModel {
|
||||
this.viewModel = viewModel;
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): RaceDetailViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
17
apps/website/lib/presenters/RaceResultsDetailPresenter.ts
Normal file
17
apps/website/lib/presenters/RaceResultsDetailPresenter.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
IRaceResultsDetailPresenter,
|
||||
RaceResultsDetailViewModel,
|
||||
} from '@gridpilot/racing/application/presenters/IRaceResultsDetailPresenter';
|
||||
|
||||
export class RaceResultsDetailPresenter implements IRaceResultsDetailPresenter {
|
||||
private viewModel: RaceResultsDetailViewModel | null = null;
|
||||
|
||||
present(viewModel: RaceResultsDetailViewModel): RaceResultsDetailViewModel {
|
||||
this.viewModel = viewModel;
|
||||
return this.viewModel;
|
||||
}
|
||||
|
||||
getViewModel(): RaceResultsDetailViewModel | null {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
71
apps/website/lib/presenters/ScheduleRaceFormPresenter.ts
Normal file
71
apps/website/lib/presenters/ScheduleRaceFormPresenter.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||
import { InMemoryRaceRepository } from '@gridpilot/racing/infrastructure/repositories/InMemoryRaceRepository';
|
||||
import { getRaceRepository, getLeagueRepository } from '@/lib/di-container';
|
||||
|
||||
export type SessionType = 'practice' | 'qualifying' | 'race';
|
||||
|
||||
export interface ScheduleRaceFormData {
|
||||
leagueId: string;
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType: SessionType;
|
||||
scheduledDate: string;
|
||||
scheduledTime: string;
|
||||
}
|
||||
|
||||
export interface ScheduledRaceViewModel {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
track: string;
|
||||
car: string;
|
||||
sessionType: SessionType;
|
||||
scheduledAt: Date;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface LeagueOptionViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presenter/Facade for the schedule race form.
|
||||
* Encapsulates all domain/repository access so the component can stay purely presentational.
|
||||
*/
|
||||
export async function loadScheduleRaceFormLeagues(): Promise<LeagueOptionViewModel[]> {
|
||||
const leagueRepo = getLeagueRepository();
|
||||
const allLeagues = await leagueRepo.findAll();
|
||||
return allLeagues.map((league) => ({
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function scheduleRaceFromForm(
|
||||
formData: ScheduleRaceFormData
|
||||
): Promise<ScheduledRaceViewModel> {
|
||||
const raceRepo = getRaceRepository();
|
||||
const scheduledAt = new Date(`${formData.scheduledDate}T${formData.scheduledTime}`);
|
||||
|
||||
const race = Race.create({
|
||||
id: InMemoryRaceRepository.generateId(),
|
||||
leagueId: formData.leagueId,
|
||||
track: formData.track.trim(),
|
||||
car: formData.car.trim(),
|
||||
sessionType: formData.sessionType,
|
||||
scheduledAt,
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
const createdRace = await raceRepo.create(race);
|
||||
|
||||
return {
|
||||
id: createdRace.id,
|
||||
leagueId: createdRace.leagueId,
|
||||
track: createdRace.track,
|
||||
car: createdRace.car,
|
||||
sessionType: createdRace.sessionType as SessionType,
|
||||
scheduledAt: createdRace.scheduledAt,
|
||||
status: createdRace.status,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ISponsorDashboardPresenter } from '@racing/application/presenters/ISponsorDashboardPresenter';
|
||||
import type { SponsorDashboardDTO } from '@racing/application/use-cases/GetSponsorDashboardQuery';
|
||||
import type { SponsorDashboardDTO } from '@racing/application/use-cases/GetSponsorDashboardUseCase';
|
||||
|
||||
export class SponsorDashboardPresenter implements ISponsorDashboardPresenter {
|
||||
private data: SponsorDashboardDTO | null = null;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ISponsorSponsorshipsPresenter } from '@racing/application/presenters/ISponsorSponsorshipsPresenter';
|
||||
import type { SponsorSponsorshipsDTO } from '@racing/application/use-cases/GetSponsorSponsorshipsQuery';
|
||||
import type { SponsorSponsorshipsDTO } from '@racing/application/use-cases/GetSponsorSponsorshipsUseCase';
|
||||
|
||||
export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
|
||||
private data: SponsorSponsorshipsDTO | null = null;
|
||||
|
||||
59
apps/website/lib/presenters/TeamRosterPresenter.ts
Normal file
59
apps/website/lib/presenters/TeamRosterPresenter.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { TeamMembership, TeamRole } from '@gridpilot/racing';
|
||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
|
||||
|
||||
export interface TeamRosterMemberViewModel {
|
||||
driver: DriverDTO;
|
||||
role: TeamRole;
|
||||
joinedAt: string;
|
||||
rating: number | null;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface TeamRosterViewModel {
|
||||
members: TeamRosterMemberViewModel[];
|
||||
averageRating: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presenter/facade for team roster.
|
||||
* Encapsulates repository and stats access so the TeamRoster component can remain a pure view.
|
||||
*/
|
||||
export async function getTeamRosterViewModel(
|
||||
memberships: TeamMembership[]
|
||||
): Promise<TeamRosterViewModel> {
|
||||
const driverRepo = getDriverRepository();
|
||||
const allDrivers = await driverRepo.findAll();
|
||||
const members: TeamRosterMemberViewModel[] = [];
|
||||
|
||||
for (const membership of memberships) {
|
||||
const driver = allDrivers.find((d) => d.id === membership.driverId);
|
||||
if (!driver) continue;
|
||||
|
||||
const dto = EntityMappers.toDriverDTO(driver);
|
||||
if (!dto) continue;
|
||||
|
||||
const stats = getDriverStats(membership.driverId);
|
||||
|
||||
members.push({
|
||||
driver: dto,
|
||||
role: membership.role,
|
||||
joinedAt: membership.joinedAt.toISOString(),
|
||||
rating: stats?.rating ?? null,
|
||||
overallRank: typeof stats?.overallRank === 'number' ? stats.overallRank : null,
|
||||
});
|
||||
}
|
||||
|
||||
const averageRating =
|
||||
members.length > 0
|
||||
? Math.round(
|
||||
members.reduce((sum, m) => sum + (m.rating ?? 0), 0) / members.length
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
members,
|
||||
averageRating,
|
||||
};
|
||||
}
|
||||
@@ -9,9 +9,35 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
|
||||
private viewModel: TeamsLeaderboardViewModel | null = null;
|
||||
|
||||
present(teams: any[], recruitingCount: number): void {
|
||||
const transformedTeams = teams.map((team) => this.transformTeam(team));
|
||||
|
||||
const groupsBySkillLevel = transformedTeams.reduce<Record<SkillLevel, TeamLeaderboardItemViewModel[]>>(
|
||||
(acc, team) => {
|
||||
if (!acc[team.performanceLevel]) {
|
||||
acc[team.performanceLevel] = [];
|
||||
}
|
||||
acc[team.performanceLevel]!.push(team);
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
beginner: [],
|
||||
intermediate: [],
|
||||
advanced: [],
|
||||
pro: [],
|
||||
},
|
||||
);
|
||||
|
||||
const topTeams = transformedTeams
|
||||
.filter((t) => t.rating !== null)
|
||||
.slice()
|
||||
.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0))
|
||||
.slice(0, 5);
|
||||
|
||||
this.viewModel = {
|
||||
teams: teams.map((team) => this.transformTeam(team)),
|
||||
teams: transformedTeams,
|
||||
recruitingCount,
|
||||
groupsBySkillLevel,
|
||||
topTeams,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user