This commit is contained in:
2025-12-04 15:15:24 +01:00
parent b7d5551ea7
commit c698a0b893
119 changed files with 1167 additions and 2652 deletions

View File

@@ -0,0 +1,8 @@
import { faker as baseFaker } from '@faker-js/faker';
const faker = baseFaker;
// Fixed seed so demo data is stable across builds
faker.seed(20240317);
export { faker };

View File

@@ -0,0 +1,47 @@
const DRIVER_AVATARS = [
'/images/avatars/avatar-1.svg',
'/images/avatars/avatar-2.svg',
'/images/avatars/avatar-3.svg',
'/images/avatars/avatar-4.svg',
'/images/avatars/avatar-5.svg',
'/images/avatars/avatar-6.svg',
] as const;
const TEAM_LOGOS = [
'/images/logos/team-1.svg',
'/images/logos/team-2.svg',
'/images/logos/team-3.svg',
'/images/logos/team-4.svg',
] as const;
const LEAGUE_BANNERS = [
'/images/header.jpeg',
'/images/ff1600.jpeg',
'/images/lmp3.jpeg',
'/images/porsche.jpeg',
] as const;
function hashString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash * 31 + input.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
export function getDriverAvatar(driverId: string): string {
const index = hashString(driverId) % DRIVER_AVATARS.length;
return DRIVER_AVATARS[index];
}
export function getTeamLogo(teamId: string): string {
const index = hashString(teamId) % TEAM_LOGOS.length;
return TEAM_LOGOS[index];
}
export function getLeagueBanner(leagueId: string): string {
const index = hashString(leagueId) % LEAGUE_BANNERS.length;
return LEAGUE_BANNERS[index];
}
export { DRIVER_AVATARS, TEAM_LOGOS, LEAGUE_BANNERS };

View File

@@ -0,0 +1,508 @@
import { Driver } from '@gridpilot/racing/domain/entities/Driver';
import { League } from '@gridpilot/racing/domain/entities/League';
import { Race } from '@gridpilot/racing/domain/entities/Race';
import { Result } from '@gridpilot/racing/domain/entities/Result';
import { Standing } from '@gridpilot/racing/domain/entities/Standing';
import type { FeedItem } from '@gridpilot/social/domain/entities/FeedItem';
import type { FriendDTO } from '@gridpilot/social/application/dto/FriendDTO';
import { faker } from '@gridpilot/testing-support';
import { getTeamLogo, getLeagueBanner, getDriverAvatar } from '@gridpilot/testing-support';
export type RacingMembership = {
driverId: string;
leagueId: string;
teamId?: string;
};
export type Friendship = {
driverId: string;
friendId: string;
};
export interface DemoTeamDTO {
id: string;
name: string;
tag: string;
description: string;
logoUrl: string;
primaryLeagueId: string;
memberCount: number;
}
export type RacingSeedData = {
drivers: Driver[];
leagues: League[];
races: Race[];
results: Result[];
standings: Standing[];
memberships: RacingMembership[];
friendships: Friendship[];
feedEvents: FeedItem[];
teams: DemoTeamDTO[];
};
const POINTS_TABLE: Record<number, number> = {
1: 25,
2: 18,
3: 15,
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
};
function pickOne<T>(items: readonly T[]): T {
return items[Math.floor(faker.number.int({ min: 0, max: items.length - 1 }))];
}
function createDrivers(count: number): Driver[] {
const drivers: Driver[] = [];
for (let i = 0; i < count; i++) {
const id = `driver-${i + 1}`;
const name = faker.person.fullName();
const country = faker.location.countryCode('alpha-2');
const iracingId = faker.string.numeric(6);
drivers.push(
Driver.create({
id,
iracingId,
name,
country,
bio: faker.lorem.sentence(),
joinedAt: faker.date.past(),
}),
);
}
return drivers;
}
function createLeagues(ownerIds: string[]): League[] {
const leagueNames = [
'Global GT Masters',
'Midnight Endurance Series',
'Virtual Touring Cup',
'Sprint Challenge League',
'Club Racers Collective',
'Sim Racing Alliance',
'Pacific Time Attack',
'Nordic Night Series',
];
const leagues: League[] = [];
const leagueCount = 6 + faker.number.int({ min: 0, max: 2 });
for (let i = 0; i < leagueCount; i++) {
const id = `league-${i + 1}`;
const name = leagueNames[i] ?? faker.company.name();
const ownerId = pickOne(ownerIds);
const settings = {
pointsSystem: faker.helpers.arrayElement(['f1-2024', 'indycar']),
sessionDuration: faker.helpers.arrayElement([45, 60, 90, 120]),
qualifyingFormat: faker.helpers.arrayElement(['open', 'single-lap']),
};
leagues.push(
League.create({
id,
name,
description: faker.lorem.sentence(),
ownerId,
settings,
createdAt: faker.date.past(),
}),
);
}
return leagues;
}
function createTeams(leagues: League[]): DemoTeamDTO[] {
const teams: DemoTeamDTO[] = [];
const teamCount = 24 + faker.number.int({ min: 0, max: 12 });
for (let i = 0; i < teamCount; i++) {
const id = `team-${i + 1}`;
const primaryLeague = pickOne(leagues);
const name = faker.company.name();
const tag = faker.string.alpha({ length: 4 }).toUpperCase();
const memberCount = faker.number.int({ min: 2, max: 8 });
teams.push({
id,
name,
tag,
description: faker.lorem.sentence(),
logoUrl: getTeamLogo(id),
primaryLeagueId: primaryLeague.id,
memberCount,
});
}
return teams;
}
function createMemberships(
drivers: Driver[],
leagues: League[],
teams: DemoTeamDTO[],
): RacingMembership[] {
const memberships: RacingMembership[] = [];
const teamsByLeague = new Map<string, DemoTeamDTO[]>();
teams.forEach((team) => {
const list = teamsByLeague.get(team.primaryLeagueId) ?? [];
list.push(team);
teamsByLeague.set(team.primaryLeagueId, list);
});
drivers.forEach((driver) => {
// Each driver participates in 13 leagues
const leagueSampleSize = faker.number.int({ min: 1, max: Math.min(3, leagues.length) });
const shuffledLeagues = faker.helpers.shuffle(leagues).slice(0, leagueSampleSize);
shuffledLeagues.forEach((league) => {
const leagueTeams = teamsByLeague.get(league.id) ?? [];
const team =
leagueTeams.length > 0 && faker.datatype.boolean()
? pickOne(leagueTeams)
: undefined;
memberships.push({
driverId: driver.id,
leagueId: league.id,
teamId: team?.id,
});
});
});
return memberships;
}
function createRaces(leagues: League[]): Race[] {
const races: Race[] = [];
const raceCount = 60 + faker.number.int({ min: 0, max: 20 });
const tracks = [
'Monza GP',
'Spa-Francorchamps',
'Suzuka',
'Mount Panorama',
'Silverstone GP',
'Interlagos',
'Imola',
'Laguna Seca',
];
const cars = [
'GT3 Porsche 911',
'GT3 BMW M4',
'LMP3 Prototype',
'GT4 Alpine',
'Touring Civic',
];
const baseDate = new Date();
for (let i = 0; i < raceCount; i++) {
const id = `race-${i + 1}`;
const league = pickOne(leagues);
const offsetDays = faker.number.int({ min: -30, max: 45 });
const scheduledAt = new Date(baseDate.getTime() + offsetDays * 24 * 60 * 60 * 1000);
const status = scheduledAt.getTime() < baseDate.getTime() ? 'completed' : 'scheduled';
races.push(
Race.create({
id,
leagueId: league.id,
scheduledAt,
track: faker.helpers.arrayElement(tracks),
car: faker.helpers.arrayElement(cars),
sessionType: 'race',
status,
}),
);
}
return races;
}
function createResults(drivers: Driver[], races: Race[]): Result[] {
const results: Result[] = [];
const completedRaces = races.filter((race) => race.status === 'completed');
completedRaces.forEach((race) => {
const participantCount = faker.number.int({ min: 20, max: 32 });
const shuffledDrivers = faker.helpers.shuffle(drivers).slice(0, participantCount);
shuffledDrivers.forEach((driver, index) => {
const position = index + 1;
const startPosition = faker.number.int({ min: 1, max: participantCount });
const fastestLap = 90_000 + index * 250 + faker.number.int({ min: 0, max: 2_000 });
const incidents = faker.number.int({ min: 0, max: 6 });
results.push(
Result.create({
id: `${race.id}-${driver.id}`,
raceId: race.id,
driverId: driver.id,
position,
startPosition,
fastestLap,
incidents,
}),
);
});
});
return results;
}
function createStandings(leagues: League[], results: Result[]): Standing[] {
const standingsByLeague = new Map<string, Standing[]>();
leagues.forEach((league) => {
const leagueRaceIds = new Set(
results
.filter((result) => {
return result.raceId.startsWith('race-');
})
.map((result) => result.raceId),
);
const leagueResults = results.filter((result) => leagueRaceIds.has(result.raceId));
const standingsMap = new Map<string, Standing>();
leagueResults.forEach((result) => {
const key = result.driverId;
let standing = standingsMap.get(key);
if (!standing) {
standing = Standing.create({
leagueId: league.id,
driverId: result.driverId,
});
}
standing = standing.addRaceResult(result.position, POINTS_TABLE);
standingsMap.set(key, standing);
});
const sortedStandings = Array.from(standingsMap.values()).sort((a, b) => {
if (b.points !== a.points) {
return b.points - a.points;
}
if (b.wins !== a.wins) {
return b.wins - a.wins;
}
return b.racesCompleted - a.racesCompleted;
});
const finalizedStandings = sortedStandings.map((standing, index) =>
standing.updatePosition(index + 1),
);
standingsByLeague.set(league.id, finalizedStandings);
});
return Array.from(standingsByLeague.values()).flat();
}
function createFriendships(drivers: Driver[]): Friendship[] {
const friendships: Friendship[] = [];
drivers.forEach((driver, index) => {
const friendCount = faker.number.int({ min: 3, max: 8 });
for (let offset = 1; offset <= friendCount; offset++) {
const friendIndex = (index + offset) % drivers.length;
const friend = drivers[friendIndex];
if (friend.id === driver.id) continue;
friendships.push({
driverId: driver.id,
friendId: friend.id,
});
}
});
return friendships;
}
function createFeedEvents(
drivers: Driver[],
leagues: League[],
races: Race[],
friendships: Friendship[],
): FeedItem[] {
const events: FeedItem[] = [];
const now = new Date();
const completedRaces = races.filter((race) => race.status === 'completed');
const globalDrivers = faker.helpers.shuffle(drivers).slice(0, 10);
globalDrivers.forEach((driver, index) => {
const league = pickOne(leagues);
const race = completedRaces[index % Math.max(1, completedRaces.length)];
const minutesAgo = 15 + index * 10;
const baseTimestamp = new Date(now.getTime() - minutesAgo * 60 * 1000);
events.push({
id: `friend-joined-league:${driver.id}:${minutesAgo}`,
type: 'friend-joined-league',
timestamp: baseTimestamp,
actorDriverId: driver.id,
leagueId: league.id,
headline: `${driver.name} joined ${league.name}`,
body: 'They are now registered for the full season.',
ctaLabel: 'View league',
ctaHref: `/leagues/${league.id}`,
});
events.push({
id: `friend-finished-race:${driver.id}:${minutesAgo}`,
type: 'friend-finished-race',
timestamp: new Date(baseTimestamp.getTime() - 10 * 60 * 1000),
actorDriverId: driver.id,
leagueId: race.leagueId,
raceId: race.id,
position: (index % 5) + 1,
headline: `${driver.name} finished P${(index % 5) + 1} at ${race.track}`,
body: `${driver.name} secured a strong result in ${race.car}.`,
ctaLabel: 'View results',
ctaHref: `/races/${race.id}/results`,
});
events.push({
id: `league-highlight:${league.id}:${minutesAgo}`,
type: 'league-highlight',
timestamp: new Date(baseTimestamp.getTime() - 30 * 60 * 1000),
leagueId: league.id,
headline: `${league.name} active with ${drivers.length}+ drivers`,
body: 'Participation is growing. Perfect time to join the grid.',
ctaLabel: 'Explore league',
ctaHref: `/leagues/${league.id}`,
});
});
const sorted = events
.slice()
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
return sorted;
}
export function createStaticRacingSeed(seed: number): RacingSeedData {
faker.seed(seed);
const drivers = createDrivers(96);
const leagues = createLeagues(drivers.slice(0, 12).map((d) => d.id));
const teams = createTeams(leagues);
const memberships = createMemberships(drivers, leagues, teams);
const races = createRaces(leagues);
const results = createResults(drivers, races);
const friendships = createFriendships(drivers);
const feedEvents = createFeedEvents(drivers, leagues, races, friendships);
const standings = createStandings(leagues, results);
return {
drivers,
leagues,
races,
results,
standings,
memberships,
friendships,
feedEvents,
teams,
};
}
/**
* Singleton seed used by website demo helpers.
* This mirrors the previous apps/website/lib/demo-data/index.ts behavior.
*/
const staticSeed = createStaticRacingSeed(42);
export const drivers = staticSeed.drivers;
export const leagues = staticSeed.leagues;
export const races = staticSeed.races;
export const results = staticSeed.results;
export const standings = staticSeed.standings;
export const teams = staticSeed.teams;
export const memberships = staticSeed.memberships;
export const friendships = staticSeed.friendships;
export const feedEvents = staticSeed.feedEvents;
/**
* Derived friend DTOs for UI consumption.
* This preserves the previous demo-data `friends` shape.
*/
export const friends: FriendDTO[] = staticSeed.drivers.map((driver) => ({
driverId: driver.id,
displayName: driver.name,
avatarUrl: getDriverAvatar(driver.id),
isOnline: true,
lastSeen: new Date(),
primaryLeagueId: staticSeed.memberships.find((m) => m.driverId === driver.id)?.leagueId,
primaryTeamId: staticSeed.memberships.find((m) => m.driverId === driver.id)?.teamId,
}));
export const topLeagues = leagues.map((league) => ({
...league,
bannerUrl: getLeagueBanner(league.id),
}));
export type RaceWithResultsDTO = {
raceId: string;
track: string;
car: string;
scheduledAt: Date;
winnerDriverId: string;
winnerName: string;
};
export function getUpcomingRaces(limit?: number): readonly Race[] {
const upcoming = races.filter((race) => race.status === 'scheduled');
const sorted = upcoming
.slice()
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
}
export function getLatestResults(limit?: number): readonly RaceWithResultsDTO[] {
const completedRaces = races.filter((race) => race.status === 'completed');
const joined = completedRaces.map((race) => {
const raceResults = results
.filter((result) => result.raceId === race.id)
.slice()
.sort((a, b) => a.position - b.position);
const winner = raceResults[0];
const winnerDriver =
winner && drivers.find((driver) => driver.id === winner.driverId);
return {
raceId: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
winnerDriverId: winner?.driverId ?? '',
winnerName: winnerDriver?.name ?? 'Winner',
};
});
const sorted = joined
.slice()
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
return typeof limit === 'number' ? sorted.slice(0, limit) : sorted;
}