website refactor

This commit is contained in:
2026-01-19 00:46:46 +01:00
parent b0431637b7
commit e1ce3bffd1
21 changed files with 297 additions and 121 deletions

View File

@@ -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',
});
}
}

View File

@@ -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),
};
}
}
}

View 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}`;
}
}

View File

@@ -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()}`;
}
}

View 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'}`;
}
}

View 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'}`;
}
}

View 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';
}
}

View File

@@ -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));
}
}
}

View 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()}`;
}
}

View File

@@ -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;

View File

@@ -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;
}