website refactor
This commit is contained in:
@@ -1,28 +1,25 @@
|
||||
import type { RacesPageDataDTO } from '@/lib/types/generated/RacesPageDataDTO';
|
||||
import type { RacesViewData, RaceViewData } from '@/lib/view-data/RacesViewData';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { RaceStatusDisplay } from '@/lib/display-objects/RaceStatusDisplay';
|
||||
import { RelativeTimeDisplay } from '@/lib/display-objects/RelativeTimeDisplay';
|
||||
|
||||
export class RacesViewDataBuilder {
|
||||
static build(apiDto: RacesPageDataDTO): RacesViewData {
|
||||
const now = new Date();
|
||||
const races = apiDto.races.map((race): RaceViewData => {
|
||||
const scheduledAt = new Date(race.scheduledAt);
|
||||
|
||||
return {
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
scheduledAtLabel: scheduledAt.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
timeLabel: scheduledAt.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
relativeTimeLabel: this.getRelativeTime(scheduledAt),
|
||||
scheduledAtLabel: DateDisplay.formatShort(race.scheduledAt),
|
||||
timeLabel: DateDisplay.formatTime(race.scheduledAt),
|
||||
relativeTimeLabel: RelativeTimeDisplay.format(race.scheduledAt, now),
|
||||
status: race.status as RaceViewData['status'],
|
||||
statusLabel: this.getStatusLabel(race.status),
|
||||
statusLabel: RaceStatusDisplay.getLabel(race.status),
|
||||
statusVariant: RaceStatusDisplay.getVariant(race.status),
|
||||
statusIconName: RaceStatusDisplay.getIconName(race.status),
|
||||
sessionType: 'Race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
@@ -69,32 +66,4 @@ export class RacesViewDataBuilder {
|
||||
racesByDate,
|
||||
};
|
||||
}
|
||||
|
||||
private static getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'scheduled': return 'Scheduled';
|
||||
case 'running': return 'LIVE';
|
||||
case 'completed': return 'Completed';
|
||||
case 'cancelled': return 'Cancelled';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
private static getRelativeTime(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMs < 0) return 'Past';
|
||||
if (diffHours < 1) return 'Starting soon';
|
||||
if (diffHours < 24) return `In ${diffHours}h`;
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 7) return `In ${diffDays} days`;
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { TeamDetailPageDto } from '@/lib/page-queries/TeamDetailPageQuery';
|
||||
import type { TeamDetailViewData, TeamDetailData, TeamMemberData, SponsorMetric, TeamTab } from '@/lib/view-data/TeamDetailViewData';
|
||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||
import { MemberDisplay } from '@/lib/display-objects/MemberDisplay';
|
||||
import { LeagueDisplay } from '@/lib/display-objects/LeagueDisplay';
|
||||
|
||||
/**
|
||||
* TeamDetailViewDataBuilder - Transforms TeamDetailPageDto into ViewData
|
||||
@@ -15,6 +18,7 @@ export class TeamDetailViewDataBuilder {
|
||||
ownerId: apiDto.team.ownerId,
|
||||
leagues: apiDto.team.leagues,
|
||||
createdAt: apiDto.team.createdAt,
|
||||
foundedDateLabel: apiDto.team.createdAt ? DateDisplay.formatMonthYear(apiDto.team.createdAt) : 'Unknown',
|
||||
specialization: apiDto.team.specialization,
|
||||
region: apiDto.team.region,
|
||||
languages: apiDto.team.languages,
|
||||
@@ -28,6 +32,7 @@ export class TeamDetailViewDataBuilder {
|
||||
driverName: membership.driverName,
|
||||
role: membership.role,
|
||||
joinedAt: membership.joinedAt,
|
||||
joinedAtLabel: DateDisplay.formatShort(membership.joinedAt),
|
||||
isActive: membership.isActive,
|
||||
avatarUrl: membership.avatarUrl,
|
||||
}));
|
||||
@@ -80,6 +85,8 @@ export class TeamDetailViewDataBuilder {
|
||||
isAdmin,
|
||||
teamMetrics,
|
||||
tabs,
|
||||
memberCountLabel: MemberDisplay.formatCount(memberships.length),
|
||||
leagueCountLabel: LeagueDisplay.formatCount(leagueCount),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
36
apps/website/lib/display-objects/CurrencyDisplay.ts
Normal file
36
apps/website/lib/display-objects/CurrencyDisplay.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* CurrencyDisplay
|
||||
*
|
||||
* Deterministic currency formatting for display.
|
||||
* Avoids Intl and toLocaleString to prevent SSR/hydration mismatches.
|
||||
*/
|
||||
|
||||
export class CurrencyDisplay {
|
||||
/**
|
||||
* Formats an amount as currency (e.g., "$10.00").
|
||||
* Default currency is USD.
|
||||
*/
|
||||
static format(amount: number, currency: string = 'USD'): string {
|
||||
const symbol = currency === 'USD' ? '$' : currency + ' ';
|
||||
const formattedAmount = amount.toFixed(2);
|
||||
|
||||
// Add thousands separators
|
||||
const parts = formattedAmount.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
return `${symbol}${parts.join('.')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an amount as a compact currency (e.g., "$10").
|
||||
*/
|
||||
static formatCompact(amount: number, currency: string = 'USD'): string {
|
||||
const symbol = currency === 'USD' ? '$' : currency + ' ';
|
||||
const roundedAmount = Math.round(amount);
|
||||
|
||||
// Add thousands separators
|
||||
const formattedAmount = roundedAmount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
|
||||
return `${symbol}${formattedAmount}`;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,38 @@
|
||||
export class DateDisplay {
|
||||
/**
|
||||
* Formats a date as "Jan 18, 2026" using UTC.
|
||||
*/
|
||||
static formatShort(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${months[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
|
||||
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as "Jan 2026" using UTC.
|
||||
*/
|
||||
static formatMonthYear(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as "15:00" using UTC.
|
||||
*/
|
||||
static formatTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const hours = d.getUTCHours().toString().padStart(2, '0');
|
||||
const minutes = d.getUTCMinutes().toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date as "Jan 18" using UTC.
|
||||
*/
|
||||
static formatMonthDay(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}`;
|
||||
}
|
||||
}
|
||||
|
||||
15
apps/website/lib/display-objects/LeagueDisplay.ts
Normal file
15
apps/website/lib/display-objects/LeagueDisplay.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* LeagueDisplay
|
||||
*
|
||||
* Deterministic display logic for leagues.
|
||||
*/
|
||||
|
||||
export class LeagueDisplay {
|
||||
/**
|
||||
* Formats a league count with pluralization.
|
||||
* Example: 1 -> "1 league", 2 -> "2 leagues"
|
||||
*/
|
||||
static formatCount(count: number): string {
|
||||
return `${count} ${count === 1 ? 'league' : 'leagues'}`;
|
||||
}
|
||||
}
|
||||
22
apps/website/lib/display-objects/MemberDisplay.ts
Normal file
22
apps/website/lib/display-objects/MemberDisplay.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* MemberDisplay
|
||||
*
|
||||
* Deterministic display logic for members.
|
||||
*/
|
||||
|
||||
export class MemberDisplay {
|
||||
/**
|
||||
* Formats a member count with pluralization.
|
||||
* Example: 1 -> "1 member", 2 -> "2 members"
|
||||
*/
|
||||
static formatCount(count: number): string {
|
||||
return `${count} ${count === 1 ? 'member' : 'members'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a member count as "Units" (used in some contexts).
|
||||
*/
|
||||
static formatUnits(count: number): string {
|
||||
return `${count} ${count === 1 ? 'Unit' : 'Units'}`;
|
||||
}
|
||||
}
|
||||
44
apps/website/lib/display-objects/RaceStatusDisplay.ts
Normal file
44
apps/website/lib/display-objects/RaceStatusDisplay.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* RaceStatusDisplay
|
||||
*
|
||||
* Deterministic display logic for race statuses.
|
||||
*/
|
||||
|
||||
export type RaceStatusVariant = 'info' | 'success' | 'neutral' | 'warning' | 'primary' | 'default';
|
||||
|
||||
export class RaceStatusDisplay {
|
||||
private static readonly CONFIG: Record<string, { variant: RaceStatusVariant; label: string; icon: string }> = {
|
||||
scheduled: {
|
||||
variant: 'primary',
|
||||
label: 'Scheduled',
|
||||
icon: 'Clock',
|
||||
},
|
||||
running: {
|
||||
variant: 'success',
|
||||
label: 'LIVE',
|
||||
icon: 'PlayCircle',
|
||||
},
|
||||
completed: {
|
||||
variant: 'default',
|
||||
label: 'Completed',
|
||||
icon: 'CheckCircle2',
|
||||
},
|
||||
cancelled: {
|
||||
variant: 'warning',
|
||||
label: 'Cancelled',
|
||||
icon: 'XCircle',
|
||||
},
|
||||
};
|
||||
|
||||
static getLabel(status: string): string {
|
||||
return this.CONFIG[status]?.label || status.toUpperCase();
|
||||
}
|
||||
|
||||
static getVariant(status: string): RaceStatusVariant {
|
||||
return this.CONFIG[status]?.variant || 'neutral';
|
||||
}
|
||||
|
||||
static getIconName(status: string): string {
|
||||
return this.CONFIG[status]?.icon || 'HelpCircle';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { NumberDisplay } from './NumberDisplay';
|
||||
|
||||
export class RatingDisplay {
|
||||
static format(rating: number): string {
|
||||
return rating.toString();
|
||||
/**
|
||||
* Formats a rating as a rounded number with thousands separators.
|
||||
* Example: 1234.56 -> "1,235"
|
||||
*/
|
||||
static format(rating: number | null | undefined): string {
|
||||
if (rating === null || rating === undefined) return '—';
|
||||
return NumberDisplay.format(Math.round(rating));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
apps/website/lib/display-objects/RelativeTimeDisplay.ts
Normal file
37
apps/website/lib/display-objects/RelativeTimeDisplay.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* RelativeTimeDisplay
|
||||
*
|
||||
* Deterministic relative time formatting.
|
||||
*/
|
||||
|
||||
export class RelativeTimeDisplay {
|
||||
/**
|
||||
* Formats a date relative to "now".
|
||||
* "now" must be passed as an argument for determinism.
|
||||
*/
|
||||
static format(date: string | Date, now: Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
const diffMs = d.getTime() - now.getTime();
|
||||
const diffHours = Math.floor(Math.abs(diffMs) / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(Math.abs(diffMs) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMs < 0) {
|
||||
if (diffHours < 1) return 'Just now';
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return this.formatAbsolute(d);
|
||||
} else {
|
||||
if (diffHours < 1) return 'Starting soon';
|
||||
if (diffHours < 24) return `In ${diffHours}h`;
|
||||
if (diffDays === 1) return 'Tomorrow';
|
||||
if (diffDays < 7) return `In ${diffDays} days`;
|
||||
return this.formatAbsolute(d);
|
||||
}
|
||||
}
|
||||
|
||||
private static formatAbsolute(date: Date): string {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return `${months[date.getUTCMonth()]} ${date.getUTCDate()}`;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export interface RaceViewData {
|
||||
relativeTimeLabel: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
statusLabel: string;
|
||||
statusVariant: string;
|
||||
statusIconName: string;
|
||||
sessionType: string;
|
||||
leagueId: string | null;
|
||||
leagueName: string | null;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface TeamDetailData {
|
||||
ownerId: string;
|
||||
leagues: string[];
|
||||
createdAt?: string;
|
||||
foundedDateLabel?: string;
|
||||
specialization?: string;
|
||||
region?: string;
|
||||
languages?: string[];
|
||||
@@ -39,6 +40,7 @@ export interface TeamMemberData {
|
||||
driverName: string;
|
||||
role: 'owner' | 'manager' | 'member';
|
||||
joinedAt: string;
|
||||
joinedAtLabel: string;
|
||||
isActive: boolean;
|
||||
avatarUrl: string;
|
||||
}
|
||||
@@ -56,4 +58,6 @@ export interface TeamDetailViewData {
|
||||
isAdmin: boolean;
|
||||
teamMetrics: SponsorMetric[];
|
||||
tabs: TeamTab[];
|
||||
memberCountLabel: string;
|
||||
leagueCountLabel: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user