wip league admin tools

This commit is contained in:
2025-12-28 12:04:12 +01:00
parent 5dc8c2399c
commit 6edf12fda8
401 changed files with 15365 additions and 6047 deletions

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import type { LeagueAdminRosterJoinRequestViewModel } from './LeagueAdminRosterJoinRequestViewModel';
describe('LeagueAdminRosterJoinRequestViewModel', () => {
it('requires and exposes expected fields', () => {
const vm: LeagueAdminRosterJoinRequestViewModel = {
id: 'req-1',
leagueId: 'league-1',
driverId: 'driver-1',
driverName: 'Driver One',
requestedAtIso: '2025-01-02T03:04:05.000Z',
};
expect(vm.id).toBe('req-1');
expect(vm.leagueId).toBe('league-1');
expect(vm.driverId).toBe('driver-1');
expect(vm.driverName).toBe('Driver One');
expect(vm.requestedAtIso).toBe('2025-01-02T03:04:05.000Z');
expect(typeof vm.id).toBe('string');
expect(typeof vm.leagueId).toBe('string');
expect(typeof vm.driverId).toBe('string');
expect(typeof vm.driverName).toBe('string');
expect(typeof vm.requestedAtIso).toBe('string');
});
it('supports optional message', () => {
const withoutMessage: LeagueAdminRosterJoinRequestViewModel = {
id: 'req-1',
leagueId: 'league-1',
driverId: 'driver-1',
driverName: 'Driver One',
requestedAtIso: '2025-01-02T03:04:05.000Z',
};
const withMessage: LeagueAdminRosterJoinRequestViewModel = {
...withoutMessage,
message: 'Please approve',
};
expect(withoutMessage.message).toBeUndefined();
expect(withMessage.message).toBe('Please approve');
expect(typeof withMessage.message).toBe('string');
});
});

View File

@@ -0,0 +1,8 @@
export interface LeagueAdminRosterJoinRequestViewModel {
id: string;
leagueId: string;
driverId: string;
driverName: string;
requestedAtIso: string;
message?: string;
}

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import type { LeagueAdminRosterMemberViewModel } from './LeagueAdminRosterMemberViewModel';
describe('LeagueAdminRosterMemberViewModel', () => {
it('requires and exposes expected fields', () => {
const vm: LeagueAdminRosterMemberViewModel = {
driverId: 'driver-1',
driverName: 'Driver One',
role: 'member',
joinedAtIso: '2025-01-02T03:04:05.000Z',
};
expect(vm.driverId).toBe('driver-1');
expect(vm.driverName).toBe('Driver One');
expect(vm.role).toBe('member');
expect(vm.joinedAtIso).toBe('2025-01-02T03:04:05.000Z');
expect(typeof vm.driverId).toBe('string');
expect(typeof vm.driverName).toBe('string');
expect(typeof vm.joinedAtIso).toBe('string');
});
it('keeps role values stable as MembershipRole', () => {
const roles: LeagueAdminRosterMemberViewModel['role'][] = ['owner', 'admin', 'steward', 'member'];
expect(roles).toEqual(['owner', 'admin', 'steward', 'member']);
});
});

View File

@@ -0,0 +1,8 @@
import type { MembershipRole } from '@/lib/types/MembershipRole';
export interface LeagueAdminRosterMemberViewModel {
driverId: string;
driverName: string;
role: MembershipRole;
joinedAtIso: string;
}

View File

@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { LeagueAdminScheduleViewModel } from './LeagueAdminScheduleViewModel';
import type { LeagueScheduleRaceViewModel } from './LeagueScheduleViewModel';
describe('LeagueAdminScheduleViewModel', () => {
it('exposes seasonId/published/races from constructor input', () => {
const races: LeagueScheduleRaceViewModel[] = [
{
id: 'race-1',
name: 'Round 1',
scheduledAt: new Date('2025-01-02T20:00:00Z'),
isPast: false,
isUpcoming: true,
status: 'scheduled',
},
];
const vm = new LeagueAdminScheduleViewModel({
seasonId: 'season-1',
published: true,
races,
});
expect(vm.seasonId).toBe('season-1');
expect(vm.published).toBe(true);
expect(vm.races).toBe(races);
expect(vm.races).toHaveLength(1);
expect(typeof vm.seasonId).toBe('string');
expect(typeof vm.published).toBe('boolean');
expect(vm.races[0]?.scheduledAt).toBeInstanceOf(Date);
});
it('keeps published as a boolean even when false', () => {
const vm = new LeagueAdminScheduleViewModel({
seasonId: 'season-1',
published: false,
races: [],
});
expect(vm.published).toBe(false);
expect(typeof vm.published).toBe('boolean');
});
});

View File

@@ -0,0 +1,13 @@
import type { LeagueScheduleRaceViewModel } from './LeagueScheduleViewModel';
export class LeagueAdminScheduleViewModel {
readonly seasonId: string;
readonly published: boolean;
readonly races: LeagueScheduleRaceViewModel[];
constructor(input: { seasonId: string; published: boolean; races: LeagueScheduleRaceViewModel[] }) {
this.seasonId = input.seasonId;
this.published = input.published;
this.races = input.races;
}
}

View File

@@ -1,4 +1,4 @@
import { LeagueWithCapacityDTO } from '../types/generated/LeagueWithCapacityDTO';
import { LeagueWithCapacityAndScoringDTO } from '../types/generated/LeagueWithCapacityAndScoringDTO';
import { LeagueStatsDTO } from '../types/generated/LeagueStatsDTO';
import { LeagueMembershipsDTO } from '../types/generated/LeagueMembershipsDTO';
import { LeagueScheduleDTO } from '../types/generated/LeagueScheduleDTO';
@@ -93,7 +93,7 @@ export class LeagueDetailPageViewModel {
stewardSummaries: DriverSummary[];
constructor(
league: LeagueWithCapacityDTO,
league: LeagueWithCapacityAndScoringDTO,
owner: GetDriverOutputDTO | null,
scoringConfig: LeagueScoringConfigDTO | null,
drivers: GetDriverOutputDTO[],
@@ -111,9 +111,9 @@ export class LeagueDetailPageViewModel {
maxDrivers: league.settings?.maxDrivers ?? (league as any).maxDrivers,
};
this.socialLinks = {
discordUrl: league.discordUrl ?? (league as any).socialLinks?.discordUrl,
youtubeUrl: league.youtubeUrl ?? (league as any).socialLinks?.youtubeUrl,
websiteUrl: league.websiteUrl ?? (league as any).socialLinks?.websiteUrl,
discordUrl: league.socialLinks?.discordUrl ?? (league as any).socialLinks?.discordUrl,
youtubeUrl: league.socialLinks?.youtubeUrl ?? (league as any).socialLinks?.youtubeUrl,
websiteUrl: league.socialLinks?.websiteUrl ?? (league as any).socialLinks?.websiteUrl,
};
this.owner = owner;

View File

@@ -2,28 +2,36 @@ import { describe, it, expect } from 'vitest';
import { LeagueScheduleViewModel } from './LeagueScheduleViewModel';
describe('LeagueScheduleViewModel', () => {
it('maps races array from DTO', () => {
const races = [{ id: 'race-1' }, { id: 'race-2' }];
it('exposes raceCount/hasRaces based on provided races', () => {
const vm = new LeagueScheduleViewModel([
{
id: 'race-1',
name: 'Round 1',
scheduledAt: new Date('2025-01-02T20:00:00Z'),
isPast: false,
isUpcoming: true,
status: 'scheduled',
},
{
id: 'race-2',
name: 'Round 2',
scheduledAt: new Date('2024-12-31T20:00:00Z'),
isPast: true,
isUpcoming: false,
status: 'completed',
},
]);
const vm = new LeagueScheduleViewModel({ races });
expect(vm.races).toBe(races);
expect(vm.raceCount).toBe(2);
});
it('derives hasRaces correctly for non-empty schedule', () => {
const races = [{ id: 'race-1' }];
const vm = new LeagueScheduleViewModel({ races });
expect(vm.raceCount).toBe(1);
expect(vm.hasRaces).toBe(true);
expect(vm.races).toHaveLength(2);
});
it('derives hasRaces correctly for empty schedule', () => {
const vm = new LeagueScheduleViewModel({ races: [] });
it('handles empty schedules', () => {
const vm = new LeagueScheduleViewModel([]);
expect(vm.raceCount).toBe(0);
expect(vm.hasRaces).toBe(false);
expect(vm.races).toEqual([]);
});
});

View File

@@ -1,21 +1,32 @@
/**
* View Model for League Schedule
*
* Represents the league's race schedule in a UI-ready format.
* Service layer maps DTOs into these shapes; UI consumes ViewModels only.
*/
export class LeagueScheduleViewModel {
races: Array<unknown>;
export interface LeagueScheduleRaceViewModel {
id: string;
name: string;
scheduledAt: Date;
isPast: boolean;
isUpcoming: boolean;
status: string;
track?: string;
car?: string;
sessionType?: string;
isRegistered?: boolean;
}
constructor(dto: { races: Array<unknown> }) {
this.races = dto.races;
export class LeagueScheduleViewModel {
readonly races: LeagueScheduleRaceViewModel[];
constructor(races: LeagueScheduleRaceViewModel[]) {
this.races = races;
}
/** UI-specific: Number of races in the schedule */
get raceCount(): number {
return this.races.length;
}
/** UI-specific: Whether the schedule has races */
get hasRaces(): boolean {
return this.raceCount > 0;
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { LeagueScoringChampionshipViewModel } from './LeagueScoringChampionshipViewModel';
describe('LeagueScoringChampionshipViewModel', () => {
it('exposes required fields from input', () => {
const input = {
id: 'champ-1',
name: 'Drivers',
type: 'driver',
sessionTypes: ['race'],
pointsPreview: [{ sessionType: 'race', position: 1, points: 25 }],
bonusSummary: ['Pole: +1'],
dropPolicyDescription: 'Best 6 of 8',
};
const vm = new LeagueScoringChampionshipViewModel(input);
expect(vm.id).toBe('champ-1');
expect(vm.name).toBe('Drivers');
expect(vm.type).toBe('driver');
expect(vm.sessionTypes).toEqual(['race']);
expect(vm.pointsPreview).toEqual([{ sessionType: 'race', position: 1, points: 25 }]);
expect(vm.bonusSummary).toEqual(['Pole: +1']);
expect(vm.dropPolicyDescription).toBe('Best 6 of 8');
expect(typeof vm.id).toBe('string');
expect(typeof vm.name).toBe('string');
expect(typeof vm.type).toBe('string');
expect(Array.isArray(vm.sessionTypes)).toBe(true);
expect(Array.isArray(vm.pointsPreview)).toBe(true);
expect(Array.isArray(vm.bonusSummary)).toBe(true);
});
it('defaults optional extended fields deterministically', () => {
const input = {
id: 'champ-1',
name: 'Drivers',
type: 'driver',
sessionTypes: ['race'],
pointsPreview: undefined,
};
const vm = new LeagueScoringChampionshipViewModel(input);
expect(vm.pointsPreview).toEqual([]);
expect(vm.bonusSummary).toEqual([]);
expect(vm.dropPolicyDescription).toBeUndefined();
});
});

View File

@@ -1,4 +1,12 @@
import { LeagueScoringChampionshipDTO } from '@/lib/types/generated/LeagueScoringChampionshipDTO';
export type LeagueScoringChampionshipViewModelInput = {
id: string;
name: string;
type: string;
sessionTypes: string[];
pointsPreview?: Array<{ sessionType: string; position: number; points: number }> | null;
bonusSummary?: string[] | null;
dropPolicyDescription?: string;
};
/**
* LeagueScoringChampionshipViewModel
@@ -14,13 +22,13 @@ export class LeagueScoringChampionshipViewModel {
readonly bonusSummary: string[];
readonly dropPolicyDescription?: string;
constructor(dto: LeagueScoringChampionshipDTO) {
this.id = dto.id;
this.name = dto.name;
this.type = dto.type;
this.sessionTypes = dto.sessionTypes;
this.pointsPreview = (dto.pointsPreview as any) || [];
this.bonusSummary = (dto as any).bonusSummary || [];
this.dropPolicyDescription = (dto as any).dropPolicyDescription;
constructor(input: LeagueScoringChampionshipViewModelInput) {
this.id = input.id;
this.name = input.name;
this.type = input.type;
this.sessionTypes = input.sessionTypes;
this.pointsPreview = (input.pointsPreview as any) || [];
this.bonusSummary = (input as any).bonusSummary || [];
this.dropPolicyDescription = (input as any).dropPolicyDescription;
}
}

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { LeagueScoringPresetViewModel } from './LeagueScoringPresetViewModel';
describe('LeagueScoringPresetViewModel', () => {
it('exposes required fields from input', () => {
const input = {
id: 'preset-1',
name: 'Standard scoring',
sessionSummary: 'Sprint + Main',
bonusSummary: 'Pole: +1',
defaultTimings: {
practiceMinutes: 10,
qualifyingMinutes: 15,
sprintRaceMinutes: 20,
mainRaceMinutes: 40,
sessionCount: 2,
},
};
const vm = new LeagueScoringPresetViewModel(input);
expect(vm.id).toBe('preset-1');
expect(vm.name).toBe('Standard scoring');
expect(vm.sessionSummary).toBe('Sprint + Main');
expect(vm.bonusSummary).toBe('Pole: +1');
expect(vm.defaultTimings).toEqual(input.defaultTimings);
expect(typeof vm.id).toBe('string');
expect(typeof vm.name).toBe('string');
expect(typeof vm.sessionSummary).toBe('string');
expect(typeof vm.bonusSummary).toBe('string');
expect(typeof vm.defaultTimings.practiceMinutes).toBe('number');
expect(typeof vm.defaultTimings.qualifyingMinutes).toBe('number');
expect(typeof vm.defaultTimings.sprintRaceMinutes).toBe('number');
expect(typeof vm.defaultTimings.mainRaceMinutes).toBe('number');
expect(typeof vm.defaultTimings.sessionCount).toBe('number');
});
it('allows bonusSummary to be omitted', () => {
const input = {
id: 'preset-1',
name: 'Standard scoring',
sessionSummary: 'Sprint + Main',
defaultTimings: {
practiceMinutes: 10,
qualifyingMinutes: 15,
sprintRaceMinutes: 20,
mainRaceMinutes: 40,
sessionCount: 2,
},
};
const vm = new LeagueScoringPresetViewModel(input);
expect(vm.bonusSummary).toBeUndefined();
});
});

View File

@@ -1,5 +1,3 @@
import { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
export type LeagueScoringPresetTimingDefaultsViewModel = {
practiceMinutes: number;
qualifyingMinutes: number;
@@ -8,6 +6,14 @@ export type LeagueScoringPresetTimingDefaultsViewModel = {
sessionCount: number;
};
export type LeagueScoringPresetViewModelInput = {
id: string;
name: string;
sessionSummary: string;
bonusSummary?: string;
defaultTimings: LeagueScoringPresetTimingDefaultsViewModel;
};
/**
* LeagueScoringPresetViewModel
*
@@ -20,11 +26,11 @@ export class LeagueScoringPresetViewModel {
readonly bonusSummary?: string;
readonly defaultTimings: LeagueScoringPresetTimingDefaultsViewModel;
constructor(dto: LeagueScoringPresetDTO) {
this.id = dto.id;
this.name = dto.name;
this.sessionSummary = dto.sessionSummary;
this.bonusSummary = dto.bonusSummary;
this.defaultTimings = dto.defaultTimings;
constructor(input: LeagueScoringPresetViewModelInput) {
this.id = input.id;
this.name = input.name;
this.sessionSummary = input.sessionSummary;
this.bonusSummary = input.bonusSummary;
this.defaultTimings = input.defaultTimings;
}
}

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { LeagueSeasonSummaryViewModel } from './LeagueSeasonSummaryViewModel';
describe('LeagueSeasonSummaryViewModel', () => {
it('exposes required fields from input', () => {
const input = {
seasonId: 'season-1',
name: 'Season 1',
status: 'active',
isPrimary: true,
isParallelActive: false,
};
const vm = new LeagueSeasonSummaryViewModel(input);
expect(vm.seasonId).toBe('season-1');
expect(vm.name).toBe('Season 1');
expect(vm.status).toBe('active');
expect(vm.isPrimary).toBe(true);
expect(vm.isParallelActive).toBe(false);
expect(typeof vm.seasonId).toBe('string');
expect(typeof vm.name).toBe('string');
expect(typeof vm.status).toBe('string');
expect(typeof vm.isPrimary).toBe('boolean');
expect(typeof vm.isParallelActive).toBe('boolean');
});
it('keeps booleans as booleans even when false', () => {
const vm = new LeagueSeasonSummaryViewModel({
seasonId: 'season-2',
name: 'Season 2',
status: 'archived',
isPrimary: false,
isParallelActive: false,
});
expect(vm.isPrimary).toBe(false);
expect(vm.isParallelActive).toBe(false);
expect(typeof vm.isPrimary).toBe('boolean');
expect(typeof vm.isParallelActive).toBe('boolean');
});
});

View File

@@ -0,0 +1,23 @@
export type LeagueSeasonSummaryViewModelInput = {
seasonId: string;
name: string;
status: string;
isPrimary: boolean;
isParallelActive: boolean;
};
export class LeagueSeasonSummaryViewModel {
readonly seasonId: string;
readonly name: string;
readonly status: string;
readonly isPrimary: boolean;
readonly isParallelActive: boolean;
constructor(input: LeagueSeasonSummaryViewModelInput) {
this.seasonId = input.seasonId;
this.name = input.name;
this.status = input.status;
this.isPrimary = input.isPrimary;
this.isParallelActive = input.isParallelActive;
}
}

View File

@@ -0,0 +1,26 @@
import { ProtestDriverViewModel } from './ProtestDriverViewModel';
import { ProtestViewModel } from './ProtestViewModel';
import { RaceViewModel } from './RaceViewModel';
export type PenaltyTypeOptionViewModel = {
type: string;
label: string;
description: string;
requiresValue: boolean;
valueLabel: string;
defaultValue: number;
};
export type ProtestDetailViewModel = {
protest: ProtestViewModel;
race: RaceViewModel;
protestingDriver: ProtestDriverViewModel;
accusedDriver: ProtestDriverViewModel;
penaltyTypes: PenaltyTypeOptionViewModel[];
defaultReasons: {
upheld: string;
dismissed: string;
};
initialPenaltyType: string | null;
initialPenaltyValue: number;
};

View File

@@ -0,0 +1,33 @@
import { RaceDetailEntryViewModel } from './RaceDetailEntryViewModel';
import { RaceDetailUserResultViewModel } from './RaceDetailUserResultViewModel';
export type RaceDetailsRaceViewModel = {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
sessionType: string;
};
export type RaceDetailsLeagueViewModel = {
id: string;
name: string;
description?: string | null;
settings?: unknown;
};
export type RaceDetailsRegistrationViewModel = {
canRegister: boolean;
isUserRegistered: boolean;
};
export type RaceDetailsViewModel = {
race: RaceDetailsRaceViewModel | null;
league: RaceDetailsLeagueViewModel | null;
entryList: RaceDetailEntryViewModel[];
registration: RaceDetailsRegistrationViewModel;
userResult: RaceDetailUserResultViewModel | null;
canReopenRace: boolean;
error?: string;
};