website refactor
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO';
|
||||
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
import { FinishDisplay } from '@/lib/display-objects/FinishDisplay';
|
||||
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
||||
|
||||
/**
|
||||
* DriverProfileViewDataBuilder
|
||||
@@ -17,26 +22,38 @@ export class DriverProfileViewDataBuilder {
|
||||
avatarUrl: apiDto.currentDriver.avatarUrl || '',
|
||||
iracingId: typeof apiDto.currentDriver.iracingId === 'string' ? parseInt(apiDto.currentDriver.iracingId, 10) : (apiDto.currentDriver.iracingId ?? null),
|
||||
joinedAt: apiDto.currentDriver.joinedAt,
|
||||
joinedAtLabel: DateDisplay.formatMonthYear(apiDto.currentDriver.joinedAt),
|
||||
rating: apiDto.currentDriver.rating ?? null,
|
||||
ratingLabel: RatingDisplay.format(apiDto.currentDriver.rating),
|
||||
globalRank: apiDto.currentDriver.globalRank ?? null,
|
||||
globalRankLabel: apiDto.currentDriver.globalRank != null ? `#${apiDto.currentDriver.globalRank}` : '—',
|
||||
consistency: apiDto.currentDriver.consistency ?? null,
|
||||
bio: apiDto.currentDriver.bio ?? null,
|
||||
totalDrivers: apiDto.currentDriver.totalDrivers ?? null,
|
||||
} : null,
|
||||
stats: apiDto.stats ? {
|
||||
totalRaces: apiDto.stats.totalRaces,
|
||||
totalRacesLabel: NumberDisplay.format(apiDto.stats.totalRaces),
|
||||
wins: apiDto.stats.wins,
|
||||
winsLabel: NumberDisplay.format(apiDto.stats.wins),
|
||||
podiums: apiDto.stats.podiums,
|
||||
podiumsLabel: NumberDisplay.format(apiDto.stats.podiums),
|
||||
dnfs: apiDto.stats.dnfs,
|
||||
dnfsLabel: NumberDisplay.format(apiDto.stats.dnfs),
|
||||
avgFinish: apiDto.stats.avgFinish ?? null,
|
||||
avgFinishLabel: FinishDisplay.formatAverage(apiDto.stats.avgFinish),
|
||||
bestFinish: apiDto.stats.bestFinish ?? null,
|
||||
bestFinishLabel: FinishDisplay.format(apiDto.stats.bestFinish),
|
||||
worstFinish: apiDto.stats.worstFinish ?? null,
|
||||
worstFinishLabel: FinishDisplay.format(apiDto.stats.worstFinish),
|
||||
finishRate: apiDto.stats.finishRate ?? null,
|
||||
winRate: apiDto.stats.winRate ?? null,
|
||||
podiumRate: apiDto.stats.podiumRate ?? null,
|
||||
percentile: apiDto.stats.percentile ?? null,
|
||||
rating: apiDto.stats.rating ?? null,
|
||||
ratingLabel: RatingDisplay.format(apiDto.stats.rating),
|
||||
consistency: apiDto.stats.consistency ?? null,
|
||||
consistencyLabel: PercentDisplay.formatWhole(apiDto.stats.consistency),
|
||||
overallRank: apiDto.stats.overallRank ?? null,
|
||||
} : null,
|
||||
finishDistribution: apiDto.finishDistribution ? {
|
||||
@@ -53,6 +70,7 @@ export class DriverProfileViewDataBuilder {
|
||||
teamTag: m.teamTag ?? null,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
joinedAtLabel: DateDisplay.formatMonthYear(m.joinedAt),
|
||||
isCurrent: m.isCurrent,
|
||||
})),
|
||||
socialSummary: {
|
||||
@@ -76,7 +94,9 @@ export class DriverProfileViewDataBuilder {
|
||||
description: a.description,
|
||||
icon: a.icon,
|
||||
rarity: a.rarity,
|
||||
rarityLabel: a.rarity,
|
||||
earnedAt: a.earnedAt,
|
||||
earnedAtLabel: DateDisplay.formatShort(a.earnedAt),
|
||||
})),
|
||||
racingStyle: apiDto.extendedProfile.racingStyle,
|
||||
favoriteTrack: apiDto.extendedProfile.favoriteTrack,
|
||||
@@ -88,4 +108,4 @@ export class DriverProfileViewDataBuilder {
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
|
||||
export class DriversViewDataBuilder {
|
||||
static build(dto: DriversLeaderboardDTO): DriversViewData {
|
||||
@@ -8,6 +10,7 @@ export class DriversViewDataBuilder {
|
||||
id: driver.id,
|
||||
name: driver.name,
|
||||
rating: driver.rating,
|
||||
ratingLabel: RatingDisplay.format(driver.rating),
|
||||
skillLevel: driver.skillLevel,
|
||||
category: driver.category,
|
||||
nationality: driver.nationality,
|
||||
@@ -19,8 +22,12 @@ export class DriversViewDataBuilder {
|
||||
avatarUrl: driver.avatarUrl,
|
||||
})),
|
||||
totalRaces: dto.totalRaces,
|
||||
totalRacesLabel: NumberDisplay.format(dto.totalRaces),
|
||||
totalWins: dto.totalWins,
|
||||
totalWinsLabel: NumberDisplay.format(dto.totalWins),
|
||||
activeCount: dto.activeCount,
|
||||
activeCountLabel: NumberDisplay.format(dto.activeCount),
|
||||
totalDriversLabel: NumberDisplay.format(dto.drivers.length),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
|
||||
import type { LeagueRosterAdminViewData, RosterMemberData, JoinRequestData } from '@/lib/view-data/LeagueRosterAdminViewData';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
|
||||
/**
|
||||
* LeagueRosterAdminViewDataBuilder
|
||||
@@ -25,6 +26,7 @@ export class LeagueRosterAdminViewDataBuilder {
|
||||
},
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt,
|
||||
formattedJoinedAt: DateDisplay.formatShort(member.joinedAt),
|
||||
}));
|
||||
|
||||
// Transform join requests
|
||||
@@ -35,6 +37,7 @@ export class LeagueRosterAdminViewDataBuilder {
|
||||
name: 'Unknown Driver', // driver field is unknown type
|
||||
},
|
||||
requestedAt: req.requestedAt,
|
||||
formattedRequestedAt: DateDisplay.formatShort(req.requestedAt),
|
||||
message: req.message,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { LeagueSponsorshipsViewData } from '@/lib/view-data/leagues/LeagueSponsorshipsViewData';
|
||||
import { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { StatusDisplay } from '@/lib/display-objects/StatusDisplay';
|
||||
|
||||
export class LeagueSponsorshipsViewDataBuilder {
|
||||
static build(apiDto: LeagueSponsorshipsApiDto): LeagueSponsorshipsViewData {
|
||||
@@ -9,7 +11,11 @@ export class LeagueSponsorshipsViewDataBuilder {
|
||||
onTabChange: () => {},
|
||||
league: apiDto.league,
|
||||
sponsorshipSlots: apiDto.sponsorshipSlots,
|
||||
sponsorshipRequests: apiDto.sponsorshipRequests,
|
||||
sponsorshipRequests: apiDto.sponsorshipRequests.map(r => ({
|
||||
...r,
|
||||
formattedRequestedAt: DateDisplay.formatShort(r.requestedAt),
|
||||
statusLabel: StatusDisplay.protestStatus(r.status), // Reusing protest status for now
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { LeagueWalletViewData, LeagueWalletTransactionViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
|
||||
import { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto';
|
||||
import { CurrencyDisplay } from '@/lib/display-objects/CurrencyDisplay';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
|
||||
export class LeagueWalletViewDataBuilder {
|
||||
static build(apiDto: LeagueWalletApiDto): LeagueWalletViewData {
|
||||
const transactions: LeagueWalletTransactionViewData[] = apiDto.transactions.map(t => ({
|
||||
...t,
|
||||
formattedAmount: `${t.amount} ${apiDto.currency}`,
|
||||
formattedAmount: CurrencyDisplay.format(t.amount, apiDto.currency),
|
||||
amountColor: t.amount >= 0 ? 'green' : 'red',
|
||||
formattedDate: new Date(t.createdAt).toLocaleDateString(),
|
||||
formattedDate: DateDisplay.formatShort(t.createdAt),
|
||||
statusColor: t.status === 'completed' ? 'green' : t.status === 'pending' ? 'yellow' : 'red',
|
||||
typeColor: 'blue',
|
||||
}));
|
||||
@@ -15,13 +17,13 @@ export class LeagueWalletViewDataBuilder {
|
||||
return {
|
||||
leagueId: apiDto.leagueId,
|
||||
balance: apiDto.balance,
|
||||
formattedBalance: `${apiDto.balance} ${apiDto.currency}`,
|
||||
formattedBalance: CurrencyDisplay.format(apiDto.balance, apiDto.currency),
|
||||
totalRevenue: apiDto.balance, // Mock
|
||||
formattedTotalRevenue: `${apiDto.balance} ${apiDto.currency}`,
|
||||
formattedTotalRevenue: CurrencyDisplay.format(apiDto.balance, apiDto.currency),
|
||||
totalFees: 0, // Mock
|
||||
formattedTotalFees: `0 ${apiDto.currency}`,
|
||||
formattedTotalFees: CurrencyDisplay.format(0, apiDto.currency),
|
||||
pendingPayouts: 0, // Mock
|
||||
formattedPendingPayouts: `0 ${apiDto.currency}`,
|
||||
formattedPendingPayouts: CurrencyDisplay.format(0, apiDto.currency),
|
||||
currency: apiDto.currency,
|
||||
transactions,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverP
|
||||
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
|
||||
import { mediaConfig } from '@/lib/config/mediaConfig';
|
||||
import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { FinishDisplay } from '@/lib/display-objects/FinishDisplay';
|
||||
import { PercentDisplay } from '@/lib/display-objects/PercentDisplay';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
|
||||
export class ProfileViewDataBuilder {
|
||||
static build(apiDto: GetDriverProfileOutputDTO): ProfileViewData {
|
||||
@@ -29,11 +34,6 @@ export class ProfileViewDataBuilder {
|
||||
const socialSummary = apiDto.socialSummary;
|
||||
const extended = apiDto.extendedProfile ?? null;
|
||||
|
||||
const joinedAtLabel = new Date(driver.joinedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
return {
|
||||
driver: {
|
||||
id: driver.id,
|
||||
@@ -42,22 +42,22 @@ export class ProfileViewDataBuilder {
|
||||
countryFlag: CountryFlagDisplay.fromCountryCode(driver.country).toString(),
|
||||
avatarUrl: driver.avatarUrl || mediaConfig.avatars.defaultFallback,
|
||||
bio: driver.bio || null,
|
||||
iracingId: driver.iracingId || null,
|
||||
joinedAtLabel,
|
||||
iracingId: driver.iracingId ? String(driver.iracingId) : null,
|
||||
joinedAtLabel: DateDisplay.formatMonthYear(driver.joinedAt),
|
||||
},
|
||||
stats: stats
|
||||
? {
|
||||
ratingLabel: stats.rating != null ? String(stats.rating) : '0',
|
||||
ratingLabel: RatingDisplay.format(stats.rating),
|
||||
globalRankLabel: driver.globalRank != null ? `#${driver.globalRank}` : '—',
|
||||
totalRacesLabel: String(stats.totalRaces),
|
||||
winsLabel: String(stats.wins),
|
||||
podiumsLabel: String(stats.podiums),
|
||||
dnfsLabel: String(stats.dnfs),
|
||||
bestFinishLabel: stats.bestFinish != null ? `P${stats.bestFinish}` : '—',
|
||||
worstFinishLabel: stats.worstFinish != null ? `P${stats.worstFinish}` : '—',
|
||||
avgFinishLabel: stats.avgFinish != null ? `P${stats.avgFinish.toFixed(1)}` : '—',
|
||||
consistencyLabel: stats.consistency != null ? `${stats.consistency}%` : '0%',
|
||||
percentileLabel: stats.percentile != null ? `${stats.percentile}%` : '—',
|
||||
totalRacesLabel: NumberDisplay.format(stats.totalRaces),
|
||||
winsLabel: NumberDisplay.format(stats.wins),
|
||||
podiumsLabel: NumberDisplay.format(stats.podiums),
|
||||
dnfsLabel: NumberDisplay.format(stats.dnfs),
|
||||
bestFinishLabel: FinishDisplay.format(stats.bestFinish),
|
||||
worstFinishLabel: FinishDisplay.format(stats.worstFinish),
|
||||
avgFinishLabel: FinishDisplay.formatAverage(stats.avgFinish),
|
||||
consistencyLabel: PercentDisplay.formatWhole(stats.consistency),
|
||||
percentileLabel: PercentDisplay.format(stats.percentile),
|
||||
}
|
||||
: null,
|
||||
teamMemberships: apiDto.teamMemberships.map((m) => ({
|
||||
@@ -65,10 +65,7 @@ export class ProfileViewDataBuilder {
|
||||
teamName: m.teamName,
|
||||
teamTag: m.teamTag || null,
|
||||
roleLabel: m.role,
|
||||
joinedAtLabel: new Date(m.joinedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}),
|
||||
joinedAtLabel: DateDisplay.formatMonthYear(m.joinedAt),
|
||||
href: `/teams/${m.teamId}`,
|
||||
})),
|
||||
extendedProfile: extended
|
||||
@@ -89,12 +86,8 @@ export class ProfileViewDataBuilder {
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
earnedAtLabel: new Date(a.earnedAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
icon: a.icon as NonNullable<ProfileViewData['extendedProfile']>['achievements'][number]['icon'],
|
||||
earnedAtLabel: DateDisplay.formatShort(a.earnedAt),
|
||||
icon: a.icon as any,
|
||||
rarityLabel: a.rarity,
|
||||
})),
|
||||
friends: socialSummary.friends.slice(0, 8).map((f) => ({
|
||||
@@ -104,7 +97,7 @@ export class ProfileViewDataBuilder {
|
||||
avatarUrl: f.avatarUrl || mediaConfig.avatars.defaultFallback,
|
||||
href: `/drivers/${f.id}`,
|
||||
})),
|
||||
friendsCountLabel: String(socialSummary.friendsCount),
|
||||
friendsCountLabel: NumberDisplay.format(socialSummary.friendsCount),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { SponsorDashboardDTO } from '@/lib/types/generated/SponsorDashboardDTO';
|
||||
import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardViewData';
|
||||
import { CurrencyDisplay } from '@/lib/display-objects/CurrencyDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
|
||||
/**
|
||||
* Sponsor Dashboard ViewData Builder
|
||||
@@ -9,26 +11,28 @@ import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardV
|
||||
*/
|
||||
export class SponsorDashboardViewDataBuilder {
|
||||
static build(apiDto: SponsorDashboardDTO): SponsorDashboardViewData {
|
||||
const totalInvestmentValue = apiDto.investment.activeSponsorships * 1000;
|
||||
|
||||
return {
|
||||
sponsorName: apiDto.sponsorName,
|
||||
totalImpressions: apiDto.metrics.impressions.toString(),
|
||||
totalInvestment: `$${apiDto.investment.activeSponsorships * 1000}`, // Mock calculation
|
||||
totalImpressions: NumberDisplay.format(apiDto.metrics.impressions),
|
||||
totalInvestment: CurrencyDisplay.format(totalInvestmentValue),
|
||||
metrics: {
|
||||
impressionsChange: apiDto.metrics.impressions > 1000 ? 15 : -5,
|
||||
viewersChange: 8,
|
||||
exposureChange: 12,
|
||||
},
|
||||
categoryData: {
|
||||
leagues: { count: 2, impressions: 1500 },
|
||||
teams: { count: 1, impressions: 800 },
|
||||
drivers: { count: 3, impressions: 2200 },
|
||||
races: { count: 1, impressions: 500 },
|
||||
platform: { count: 0, impressions: 0 },
|
||||
leagues: { count: 2, countLabel: '2 active', impressions: 1500, impressionsLabel: '1,500' },
|
||||
teams: { count: 1, countLabel: '1 active', impressions: 800, impressionsLabel: '800' },
|
||||
drivers: { count: 3, countLabel: '3 active', impressions: 2200, impressionsLabel: '2,200' },
|
||||
races: { count: 1, countLabel: '1 active', impressions: 500, impressionsLabel: '500' },
|
||||
platform: { count: 0, countLabel: '0 active', impressions: 0, impressionsLabel: '0' },
|
||||
},
|
||||
sponsorships: apiDto.sponsorships,
|
||||
activeSponsorships: apiDto.investment.activeSponsorships,
|
||||
formattedTotalInvestment: `$${apiDto.investment.activeSponsorships * 1000}`,
|
||||
costPerThousandViews: '$50',
|
||||
formattedTotalInvestment: CurrencyDisplay.format(totalInvestmentValue),
|
||||
costPerThousandViews: CurrencyDisplay.format(50),
|
||||
upcomingRenewals: [], // Mock empty for now
|
||||
recentActivity: [], // Mock empty for now
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TeamDetailViewData, TeamDetailData, TeamMemberData, SponsorMetric,
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { MemberDisplay } from '@/lib/display-objects/MemberDisplay';
|
||||
import { LeagueDisplay } from '@/lib/display-objects/LeagueDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
|
||||
/**
|
||||
* TeamDetailViewDataBuilder - Transforms TeamDetailPageDto into ViewData
|
||||
@@ -47,19 +48,19 @@ export class TeamDetailViewDataBuilder {
|
||||
{
|
||||
icon: 'users',
|
||||
label: 'Members',
|
||||
value: memberships.length,
|
||||
value: NumberDisplay.format(memberships.length),
|
||||
color: 'text-primary-blue',
|
||||
},
|
||||
{
|
||||
icon: 'zap',
|
||||
label: 'Est. Reach',
|
||||
value: memberships.length * 15,
|
||||
value: NumberDisplay.format(memberships.length * 15),
|
||||
color: 'text-purple-400',
|
||||
},
|
||||
{
|
||||
icon: 'calendar',
|
||||
label: 'Races',
|
||||
value: leagueCount,
|
||||
value: NumberDisplay.format(leagueCount),
|
||||
color: 'text-neon-aqua',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { TeamsPageDto } from '@/lib/page-queries/TeamsPageQuery';
|
||||
import type { TeamsViewData, TeamSummaryData } from '@/lib/view-data/TeamsViewData';
|
||||
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
|
||||
import { RatingDisplay } from '@/lib/display-objects/RatingDisplay';
|
||||
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
|
||||
|
||||
/**
|
||||
* TeamsViewDataBuilder - Transforms TeamsPageDto into ViewData for TeamsTemplate
|
||||
@@ -14,6 +16,9 @@ export class TeamsViewDataBuilder {
|
||||
leagueName: team.leagues[0] || '',
|
||||
memberCount: team.memberCount,
|
||||
logoUrl: team.logoUrl,
|
||||
ratingLabel: RatingDisplay.format(team.rating),
|
||||
winsLabel: NumberDisplay.format(team.totalWins || 0),
|
||||
racesLabel: NumberDisplay.format(team.totalRaces || 0),
|
||||
}));
|
||||
|
||||
return { teams };
|
||||
|
||||
24
apps/website/lib/display-objects/DurationDisplay.ts
Normal file
24
apps/website/lib/display-objects/DurationDisplay.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* DurationDisplay
|
||||
*
|
||||
* Deterministic formatting for time durations.
|
||||
*/
|
||||
|
||||
export class DurationDisplay {
|
||||
/**
|
||||
* Formats milliseconds as "123.45ms".
|
||||
*/
|
||||
static formatMs(ms: number): string {
|
||||
return `${ms.toFixed(2)}ms`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats seconds as "M:SS.mmm".
|
||||
* Example: 65.123 -> "1:05.123"
|
||||
*/
|
||||
static formatSeconds(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = (seconds % 60).toFixed(3);
|
||||
return `${minutes}:${remainingSeconds.padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
24
apps/website/lib/display-objects/FinishDisplay.ts
Normal file
24
apps/website/lib/display-objects/FinishDisplay.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* FinishDisplay
|
||||
*
|
||||
* Deterministic formatting for race finish positions.
|
||||
*/
|
||||
|
||||
export class FinishDisplay {
|
||||
/**
|
||||
* Formats a finish position as "P1", "P2", etc.
|
||||
*/
|
||||
static format(position: number | null | undefined): string {
|
||||
if (position === null || position === undefined) return '—';
|
||||
return `P${position.toFixed(0)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an average finish position with one decimal place.
|
||||
* Example: 5.4 -> "P5.4"
|
||||
*/
|
||||
static formatAverage(avg: number | null | undefined): string {
|
||||
if (avg === null || avg === undefined) return '—';
|
||||
return `P${avg.toFixed(1)}`;
|
||||
}
|
||||
}
|
||||
21
apps/website/lib/display-objects/MemoryDisplay.ts
Normal file
21
apps/website/lib/display-objects/MemoryDisplay.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* MemoryDisplay
|
||||
*
|
||||
* Deterministic formatting for memory usage.
|
||||
*/
|
||||
|
||||
export class MemoryDisplay {
|
||||
/**
|
||||
* Formats bytes as "123.4MB".
|
||||
*/
|
||||
static formatMB(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats bytes as "123.4KB".
|
||||
*/
|
||||
static formatKB(bytes: number): string {
|
||||
return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
}
|
||||
}
|
||||
@@ -15,4 +15,17 @@ export class NumberDisplay {
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return parts.join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number in compact form (e.g., 1.2k, 1.5M).
|
||||
*/
|
||||
static formatCompact(value: number): string {
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
25
apps/website/lib/display-objects/PercentDisplay.ts
Normal file
25
apps/website/lib/display-objects/PercentDisplay.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* PercentDisplay
|
||||
*
|
||||
* Deterministic formatting for percentages.
|
||||
*/
|
||||
|
||||
export class PercentDisplay {
|
||||
/**
|
||||
* Formats a decimal value as a percentage string.
|
||||
* Example: 0.1234 -> "12.3%"
|
||||
*/
|
||||
static format(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '0.0%';
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a whole number as a percentage string.
|
||||
* Example: 85 -> "85%"
|
||||
*/
|
||||
static formatWhole(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '0%';
|
||||
return `${Math.round(value)}%`;
|
||||
}
|
||||
}
|
||||
44
apps/website/lib/display-objects/StatusDisplay.ts
Normal file
44
apps/website/lib/display-objects/StatusDisplay.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* StatusDisplay
|
||||
*
|
||||
* Deterministic mapping of status codes to human-readable labels.
|
||||
*/
|
||||
|
||||
export class StatusDisplay {
|
||||
/**
|
||||
* Maps transaction status to label.
|
||||
*/
|
||||
static transactionStatus(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
paid: 'Paid',
|
||||
pending: 'Pending',
|
||||
overdue: 'Overdue',
|
||||
failed: 'Failed',
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps race status to label.
|
||||
*/
|
||||
static raceStatus(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
scheduled: 'Scheduled',
|
||||
running: 'Live',
|
||||
completed: 'Completed',
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps protest status to label.
|
||||
*/
|
||||
static protestStatus(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
under_review: 'Under Review',
|
||||
resolved: 'Resolved',
|
||||
};
|
||||
return map[status] || status;
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,9 @@ export class WinRateDisplay {
|
||||
const rate = (wins / racesCompleted) * 100;
|
||||
return rate.toFixed(1);
|
||||
}
|
||||
|
||||
static format(rate: number | null | undefined): string {
|
||||
if (rate === null || rate === undefined) return '0.0%';
|
||||
return `${rate.toFixed(1)}%`;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { Result } from '@/lib/contracts/Result';
|
||||
import type { Service } from '@/lib/contracts/services/Service';
|
||||
import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO';
|
||||
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
|
||||
/**
|
||||
* HomeService
|
||||
*
|
||||
@@ -54,7 +56,7 @@ export class HomeService implements Service {
|
||||
id: r.id,
|
||||
track: r.track,
|
||||
car: r.car,
|
||||
formattedDate: new Date(r.scheduledAt).toLocaleDateString(),
|
||||
formattedDate: DateDisplay.formatShort(r.scheduledAt),
|
||||
})),
|
||||
topLeagues: leaguesDto.leagues.slice(0, 4).map(l => ({
|
||||
id: l.id,
|
||||
|
||||
@@ -6,26 +6,38 @@ export interface DriverProfileViewData {
|
||||
avatarUrl: string;
|
||||
iracingId: number | null;
|
||||
joinedAt: string;
|
||||
joinedAtLabel: string;
|
||||
rating: number | null;
|
||||
ratingLabel: string;
|
||||
globalRank: number | null;
|
||||
globalRankLabel: string;
|
||||
consistency: number | null;
|
||||
bio: string | null;
|
||||
totalDrivers: number | null;
|
||||
} | null;
|
||||
stats: {
|
||||
totalRaces: number;
|
||||
totalRacesLabel: string;
|
||||
wins: number;
|
||||
winsLabel: string;
|
||||
podiums: number;
|
||||
podiumsLabel: string;
|
||||
dnfs: number;
|
||||
dnfsLabel: string;
|
||||
avgFinish: number | null;
|
||||
avgFinishLabel: string;
|
||||
bestFinish: number | null;
|
||||
bestFinishLabel: string;
|
||||
worstFinish: number | null;
|
||||
worstFinishLabel: string;
|
||||
finishRate: number | null;
|
||||
winRate: number | null;
|
||||
podiumRate: number | null;
|
||||
percentile: number | null;
|
||||
rating: number | null;
|
||||
ratingLabel: string;
|
||||
consistency: number | null;
|
||||
consistencyLabel: string;
|
||||
overallRank: number | null;
|
||||
} | null;
|
||||
finishDistribution: {
|
||||
@@ -42,6 +54,7 @@ export interface DriverProfileViewData {
|
||||
teamTag: string | null;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
joinedAtLabel: string;
|
||||
isCurrent: boolean;
|
||||
}[];
|
||||
socialSummary: {
|
||||
@@ -65,7 +78,9 @@ export interface DriverProfileViewData {
|
||||
description: string;
|
||||
icon: string;
|
||||
rarity: string;
|
||||
rarityLabel: string;
|
||||
earnedAt: string;
|
||||
earnedAtLabel: string;
|
||||
}[];
|
||||
racingStyle: string;
|
||||
favoriteTrack: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface DriversViewData {
|
||||
id: string;
|
||||
name: string;
|
||||
rating: number;
|
||||
ratingLabel: string;
|
||||
skillLevel: string;
|
||||
category?: string;
|
||||
nationality: string;
|
||||
@@ -14,6 +15,10 @@ export interface DriversViewData {
|
||||
avatarUrl?: string;
|
||||
}[];
|
||||
totalRaces: number;
|
||||
totalRacesLabel: string;
|
||||
totalWins: number;
|
||||
totalWinsLabel: string;
|
||||
activeCount: number;
|
||||
activeCountLabel: string;
|
||||
totalDriversLabel: string;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface RosterMemberData {
|
||||
};
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
formattedJoinedAt: string;
|
||||
}
|
||||
|
||||
export interface JoinRequestData {
|
||||
@@ -20,6 +21,7 @@ export interface JoinRequestData {
|
||||
name: string;
|
||||
};
|
||||
requestedAt: string;
|
||||
formattedRequestedAt: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ export interface SponsorDashboardViewData {
|
||||
exposureChange: number;
|
||||
};
|
||||
categoryData: {
|
||||
leagues: { count: number; impressions: number };
|
||||
teams: { count: number; impressions: number };
|
||||
drivers: { count: number; impressions: number };
|
||||
races: { count: number; impressions: number };
|
||||
platform: { count: number; impressions: number };
|
||||
leagues: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
|
||||
teams: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
|
||||
drivers: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
|
||||
races: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
|
||||
platform: { count: number; countLabel: string; impressions: number; impressionsLabel: string };
|
||||
};
|
||||
sponsorships: Record<string, unknown>; // From DTO
|
||||
activeSponsorships: number;
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
export interface SponsorMetric {
|
||||
icon: string; // Icon name (e.g. 'users', 'zap', 'calendar')
|
||||
label: string;
|
||||
value: string | number;
|
||||
value: string;
|
||||
color?: string;
|
||||
trend?: {
|
||||
value: number;
|
||||
value: string;
|
||||
isPositive: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface TeamSummaryData {
|
||||
leagueName: string;
|
||||
memberCount: number;
|
||||
logoUrl?: string;
|
||||
ratingLabel: string;
|
||||
winsLabel: string;
|
||||
racesLabel: string;
|
||||
}
|
||||
|
||||
export interface TeamsViewData extends ViewData {
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface LeagueSponsorshipsViewData {
|
||||
sponsorId: string;
|
||||
sponsorName: string;
|
||||
requestedAt: string;
|
||||
formattedRequestedAt: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
statusLabel: string;
|
||||
}>;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface LeagueScheduleRaceViewModel {
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledAt: Date;
|
||||
formattedDate: string;
|
||||
formattedTime: string;
|
||||
isPast: boolean;
|
||||
isUpcoming: boolean;
|
||||
status: string;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ProtestDTO } from '@/lib/types/generated/ProtestDTO';
|
||||
import { RaceProtestDTO } from '@/lib/types/generated/RaceProtestDTO';
|
||||
import { DateDisplay } from '../display-objects/DateDisplay';
|
||||
import { StatusDisplay } from '../display-objects/StatusDisplay';
|
||||
|
||||
/**
|
||||
* Protest view model
|
||||
@@ -96,11 +98,11 @@ export class ProtestViewModel {
|
||||
|
||||
/** UI-specific: Formatted submitted date */
|
||||
get formattedSubmittedAt(): string {
|
||||
return new Date(this.submittedAt).toLocaleString();
|
||||
return DateDisplay.formatShort(this.submittedAt);
|
||||
}
|
||||
|
||||
/** UI-specific: Status display */
|
||||
get statusDisplay(): string {
|
||||
return 'Pending';
|
||||
return StatusDisplay.protestStatus(this.status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO';
|
||||
import { FinishDisplay } from '../display-objects/FinishDisplay';
|
||||
|
||||
export class RaceResultViewModel {
|
||||
driverId!: string;
|
||||
@@ -42,7 +43,7 @@ export class RaceResultViewModel {
|
||||
|
||||
/** UI-specific: Badge for position */
|
||||
get positionBadge(): string {
|
||||
return this.position.toString();
|
||||
return FinishDisplay.format(this.position);
|
||||
}
|
||||
|
||||
/** UI-specific: Color for incidents badge */
|
||||
@@ -66,6 +67,25 @@ export class RaceResultViewModel {
|
||||
return this.positionChange;
|
||||
}
|
||||
|
||||
get formattedPosition(): string {
|
||||
return FinishDisplay.format(this.position);
|
||||
}
|
||||
|
||||
get formattedStartPosition(): string {
|
||||
return FinishDisplay.format(this.startPosition);
|
||||
}
|
||||
|
||||
get formattedIncidents(): string {
|
||||
return `${this.incidents}x incidents`;
|
||||
}
|
||||
|
||||
get formattedPositionsGained(): string | undefined {
|
||||
if (this.position < this.startPosition) {
|
||||
return `+${this.startPosition - this.position} positions`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Note: The generated DTO doesn't have id or raceId
|
||||
// These will need to be added when the OpenAPI spec is updated
|
||||
id: string = '';
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { CurrencyDisplay } from '../display-objects/CurrencyDisplay';
|
||||
import { DateDisplay } from '../display-objects/DateDisplay';
|
||||
import { NumberDisplay } from '../display-objects/NumberDisplay';
|
||||
|
||||
/**
|
||||
* Interface for sponsorship data input
|
||||
*/
|
||||
@@ -69,11 +73,11 @@ export class SponsorshipViewModel {
|
||||
}
|
||||
|
||||
get formattedImpressions(): string {
|
||||
return this.impressions.toLocaleString();
|
||||
return NumberDisplay.format(this.impressions);
|
||||
}
|
||||
|
||||
get formattedPrice(): string {
|
||||
return `$${this.price}`;
|
||||
return CurrencyDisplay.format(this.price);
|
||||
}
|
||||
|
||||
get daysRemaining(): number {
|
||||
@@ -109,8 +113,8 @@ export class SponsorshipViewModel {
|
||||
}
|
||||
|
||||
get periodDisplay(): string {
|
||||
const start = this.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
const end = this.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
|
||||
const start = DateDisplay.formatMonthYear(this.startDate);
|
||||
const end = DateDisplay.formatMonthYear(this.endDate);
|
||||
return `${start} - ${end}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user