From e1ce3bffd17f805af1ce2a7edeb85cccfd9a2e3c Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 19 Jan 2026 00:46:46 +0100 Subject: [PATCH] website refactor --- apps/website/components/races/RaceCard.tsx | 26 +++++----- .../components/races/RaceCardWrapper.tsx | 20 +++----- apps/website/components/races/RaceList.tsx | 29 ++--------- .../website/components/races/RaceListItem.tsx | 30 +++++++---- .../components/races/RaceSummaryItem.tsx | 7 ++- .../components/teams/TeamDetailsHeader.tsx | 9 +++- apps/website/components/teams/TeamHero.tsx | 13 +++-- .../components/teams/TeamMembersTable.tsx | 3 +- .../view-data/RacesViewDataBuilder.ts | 51 ++++--------------- .../view-data/TeamDetailViewDataBuilder.ts | 9 +++- .../lib/display-objects/CurrencyDisplay.ts | 36 +++++++++++++ .../lib/display-objects/DateDisplay.ts | 33 +++++++++++- .../lib/display-objects/LeagueDisplay.ts | 15 ++++++ .../lib/display-objects/MemberDisplay.ts | 22 ++++++++ .../lib/display-objects/RaceStatusDisplay.ts | 44 ++++++++++++++++ .../lib/display-objects/RatingDisplay.ts | 13 +++-- .../display-objects/RelativeTimeDisplay.ts | 37 ++++++++++++++ apps/website/lib/view-data/RacesViewData.ts | 2 + .../lib/view-data/TeamDetailViewData.ts | 4 ++ apps/website/templates/TeamDetailTemplate.tsx | 2 + docs/architecture/website/DISPLAY_OBJECTS.md | 13 +++++ 21 files changed, 297 insertions(+), 121 deletions(-) create mode 100644 apps/website/lib/display-objects/CurrencyDisplay.ts create mode 100644 apps/website/lib/display-objects/LeagueDisplay.ts create mode 100644 apps/website/lib/display-objects/MemberDisplay.ts create mode 100644 apps/website/lib/display-objects/RaceStatusDisplay.ts create mode 100644 apps/website/lib/display-objects/RelativeTimeDisplay.ts diff --git a/apps/website/components/races/RaceCard.tsx b/apps/website/components/races/RaceCard.tsx index 97119da75..57926bbfe 100644 --- a/apps/website/components/races/RaceCard.tsx +++ b/apps/website/components/races/RaceCard.tsx @@ -14,31 +14,31 @@ interface RaceCardProps { track: string; car: string; scheduledAt: string; + scheduledAtLabel: string; + timeLabel: string; status: string; + statusLabel: string; + statusVariant: 'primary' | 'success' | 'warning' | 'critical' | 'default' | 'secondary' | 'info' | 'danger'; leagueName: string; leagueId?: string; strengthOfField?: number | null; onClick?: () => void; - statusConfig: { - intent: 'primary' | 'success' | 'warning' | 'critical' | 'default' | 'secondary' | 'info' | 'danger'; - icon: LucideIcon | null; - label: string; - }; } export function RaceCard({ track, car, scheduledAt, + scheduledAtLabel, + timeLabel, status, + statusLabel, + statusVariant, leagueName, leagueId, strengthOfField, onClick, - statusConfig, }: RaceCardProps) { - const scheduledAtDate = new Date(scheduledAt); - return ( - {scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {timeLabel} - - {status === 'running' ? 'LIVE' : scheduledAtDate.toLocaleDateString()} + + {status === 'running' ? 'LIVE' : scheduledAtLabel} @@ -83,8 +83,8 @@ export function RaceCard({ {/* Status Badge */} - - {statusConfig.label} + + {statusLabel} diff --git a/apps/website/components/races/RaceCardWrapper.tsx b/apps/website/components/races/RaceCardWrapper.tsx index 8ae8e68eb..cca31c46e 100644 --- a/apps/website/components/races/RaceCardWrapper.tsx +++ b/apps/website/components/races/RaceCardWrapper.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { LucideIcon } from 'lucide-react'; -import { raceStatusConfig } from '@/lib/utilities/raceStatus'; +import { RaceStatusDisplay } from '@/lib/display-objects/RaceStatusDisplay'; +import { DateDisplay } from '@/lib/display-objects/DateDisplay'; import { RaceCard as UiRaceCard } from './RaceCard'; interface RaceCardProps { @@ -18,28 +18,20 @@ interface RaceCardProps { } export function RaceCard({ race, onClick }: RaceCardProps) { - const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig] || { - border: 'border-charcoal-outline', - bg: 'bg-charcoal-outline', - color: 'text-gray-400', - icon: null, - label: 'Scheduled', - }; - return ( ); } diff --git a/apps/website/components/races/RaceList.tsx b/apps/website/components/races/RaceList.tsx index f3bf8f737..b3729aad1 100644 --- a/apps/website/components/races/RaceList.tsx +++ b/apps/website/components/races/RaceList.tsx @@ -19,29 +19,6 @@ interface RaceListProps { } export function RaceList({ racesByDate, totalCount, onRaceClick }: RaceListProps) { - const statusConfig = { - scheduled: { - icon: Clock, - variant: 'primary' as const, - label: 'Scheduled', - }, - running: { - icon: PlayCircle, - variant: 'success' as const, - label: 'LIVE', - }, - completed: { - icon: CheckCircle2, - variant: 'default' as const, - label: 'Completed', - }, - cancelled: { - icon: XCircle, - variant: 'warning' as const, - label: 'Cancelled', - }, - }; - if (racesByDate.length === 0) { return ( {group.races.map((race) => { - const config = statusConfig[race.status as keyof typeof statusConfig] || statusConfig.scheduled; - return ( onRaceClick(race.id)} - statusConfig={config} + statusLabel={race.statusLabel} + statusVariant={race.statusVariant as any} + statusIconName={race.statusIconName} /> ); })} diff --git a/apps/website/components/races/RaceListItem.tsx b/apps/website/components/races/RaceListItem.tsx index c85ae2edd..65f10e4c9 100644 --- a/apps/website/components/races/RaceListItem.tsx +++ b/apps/website/components/races/RaceListItem.tsx @@ -5,9 +5,16 @@ import { Icon } from '@/ui/Icon'; import { Link } from '@/ui/Link'; import { Text } from '@/ui/Text'; import { RaceCard, RaceTimeColumn, RaceInfo } from '@/ui/RaceCard'; -import { Car, Trophy, Zap, ArrowRight } from 'lucide-react'; +import { Car, Trophy, Zap, ArrowRight, Clock, PlayCircle, CheckCircle2, XCircle, HelpCircle } from 'lucide-react'; import React from 'react'; -import { LucideIcon } from 'lucide-react'; + +const ICON_MAP = { + Clock, + PlayCircle, + CheckCircle2, + XCircle, + HelpCircle, +}; interface RaceListItemProps { track: string; @@ -17,15 +24,13 @@ interface RaceListItemProps { dateLabel?: string; dayLabel?: string; status: string; + statusLabel: string; + statusVariant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info'; + statusIconName: string; leagueName?: string | null; leagueHref?: string; strengthOfField?: number | null; onClick: () => void; - statusConfig: { - icon: LucideIcon; - variant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info'; - label: string; - }; } export function RaceListItem({ @@ -36,13 +41,16 @@ export function RaceListItem({ dateLabel, dayLabel, status, + statusLabel, + statusVariant, + statusIconName, leagueName, leagueHref, strengthOfField, onClick, - statusConfig, }: RaceListItemProps) { const isLive = status === 'running'; + const StatusIcon = ICON_MAP[statusIconName as keyof typeof ICON_MAP] || HelpCircle; return ( @@ -59,9 +67,9 @@ export function RaceListItem({ title={track} subtitle={car} badge={ - - - {statusConfig.label} + + + {statusLabel} } meta={ diff --git a/apps/website/components/races/RaceSummaryItem.tsx b/apps/website/components/races/RaceSummaryItem.tsx index 8562c93af..f88c63300 100644 --- a/apps/website/components/races/RaceSummaryItem.tsx +++ b/apps/website/components/races/RaceSummaryItem.tsx @@ -1,19 +1,18 @@ import { RaceSummary } from '@/ui/RaceSummary'; -import { DateDisplay } from '@/lib/display-objects/DateDisplay'; import React from 'react'; interface RaceSummaryItemProps { track: string; meta: string; - date: Date; + dateLabel: string; } -export function RaceSummaryItem({ track, meta, date }: RaceSummaryItemProps) { +export function RaceSummaryItem({ track, meta, dateLabel }: RaceSummaryItemProps) { return ( ); } diff --git a/apps/website/components/teams/TeamDetailsHeader.tsx b/apps/website/components/teams/TeamDetailsHeader.tsx index 5ba589039..5637d894b 100644 --- a/apps/website/components/teams/TeamDetailsHeader.tsx +++ b/apps/website/components/teams/TeamDetailsHeader.tsx @@ -14,7 +14,9 @@ interface TeamDetailsHeaderProps { description?: string; logoUrl?: string; memberCount: number; + memberCountLabel?: string; foundedDate?: string; + foundedDateLabel?: string; isAdmin?: boolean; onAdminClick?: () => void; } @@ -25,7 +27,9 @@ export function TeamDetailsHeader({ description, logoUrl, memberCount, + memberCountLabel, foundedDate, + foundedDateLabel, isAdmin, onAdminClick, }: TeamDetailsHeaderProps) { @@ -38,6 +42,9 @@ export function TeamDetailsHeader({ } description={description || 'No mission statement provided.'} + memberCount={memberCount} + memberCountLabel={memberCountLabel} + foundedDateLabel={foundedDateLabel} sideContent={
{logoUrl ? ( @@ -58,7 +65,7 @@ export function TeamDetailsHeader({ }, { label: 'Established', - value: foundedDate ? new Date(foundedDate).toLocaleDateString() : 'Unknown', + value: foundedDateLabel || 'Unknown', } ]} /> diff --git a/apps/website/components/teams/TeamHero.tsx b/apps/website/components/teams/TeamHero.tsx index b9933c466..4a3fe2550 100644 --- a/apps/website/components/teams/TeamHero.tsx +++ b/apps/website/components/teams/TeamHero.tsx @@ -17,13 +17,16 @@ interface TeamHeroProps { description?: string; category?: string | null; createdAt?: string; + foundedDateLabel?: string; leagues: { id: string }[]; }; memberCount: number; + memberCountLabel?: string; + leagueCountLabel?: string; onUpdate: () => void; } -export function TeamHero({ team, memberCount, onUpdate }: TeamHeroProps) { +export function TeamHero({ team, memberCount, memberCountLabel, leagueCountLabel, onUpdate }: TeamHeroProps) { return ( @@ -44,20 +47,20 @@ export function TeamHero({ team, memberCount, onUpdate }: TeamHeroProps) { stats={[ { label: 'Personnel', - value: `${memberCount} ${memberCount === 1 ? 'member' : 'members'}`, + value: memberCountLabel || 'Unknown', }, ...(team.category ? [{ label: 'Category', value: team.category, intent: 'primary' as const, }] : []), - ...(team.createdAt ? [{ + ...(team.foundedDateLabel ? [{ label: 'Founded', - value: new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), + value: team.foundedDateLabel, }] : []), ...(team.leagues && team.leagues.length > 0 ? [{ label: 'Activity', - value: `${team.leagues.length} ${team.leagues.length === 1 ? 'league' : 'leagues'}`, + value: leagueCountLabel || 'Unknown', }] : []), ]} /> diff --git a/apps/website/components/teams/TeamMembersTable.tsx b/apps/website/components/teams/TeamMembersTable.tsx index 3c477c070..08a9e1a4b 100644 --- a/apps/website/components/teams/TeamMembersTable.tsx +++ b/apps/website/components/teams/TeamMembersTable.tsx @@ -10,6 +10,7 @@ interface Member { driverName: string; role: string; joinedAt: string; + joinedAtLabel: string; } interface TeamMembersTableProps { @@ -49,7 +50,7 @@ export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembe - {new Date(member.joinedAt).toLocaleDateString()} + {member.joinedAtLabel} diff --git a/apps/website/lib/builders/view-data/RacesViewDataBuilder.ts b/apps/website/lib/builders/view-data/RacesViewDataBuilder.ts index 8cade32aa..e78f81f58 100644 --- a/apps/website/lib/builders/view-data/RacesViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/RacesViewDataBuilder.ts @@ -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', - }); - } } diff --git a/apps/website/lib/builders/view-data/TeamDetailViewDataBuilder.ts b/apps/website/lib/builders/view-data/TeamDetailViewDataBuilder.ts index 41b6b34d2..3c35d11d3 100644 --- a/apps/website/lib/builders/view-data/TeamDetailViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/TeamDetailViewDataBuilder.ts @@ -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), }; } -} \ No newline at end of file +} diff --git a/apps/website/lib/display-objects/CurrencyDisplay.ts b/apps/website/lib/display-objects/CurrencyDisplay.ts new file mode 100644 index 000000000..b033c385f --- /dev/null +++ b/apps/website/lib/display-objects/CurrencyDisplay.ts @@ -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}`; + } +} diff --git a/apps/website/lib/display-objects/DateDisplay.ts b/apps/website/lib/display-objects/DateDisplay.ts index 9d4fd8187..f834719c4 100644 --- a/apps/website/lib/display-objects/DateDisplay.ts +++ b/apps/website/lib/display-objects/DateDisplay.ts @@ -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()}`; } } diff --git a/apps/website/lib/display-objects/LeagueDisplay.ts b/apps/website/lib/display-objects/LeagueDisplay.ts new file mode 100644 index 000000000..fbebfde88 --- /dev/null +++ b/apps/website/lib/display-objects/LeagueDisplay.ts @@ -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'}`; + } +} diff --git a/apps/website/lib/display-objects/MemberDisplay.ts b/apps/website/lib/display-objects/MemberDisplay.ts new file mode 100644 index 000000000..8a4edc7fc --- /dev/null +++ b/apps/website/lib/display-objects/MemberDisplay.ts @@ -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'}`; + } +} diff --git a/apps/website/lib/display-objects/RaceStatusDisplay.ts b/apps/website/lib/display-objects/RaceStatusDisplay.ts new file mode 100644 index 000000000..c175274e4 --- /dev/null +++ b/apps/website/lib/display-objects/RaceStatusDisplay.ts @@ -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 = { + 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'; + } +} diff --git a/apps/website/lib/display-objects/RatingDisplay.ts b/apps/website/lib/display-objects/RatingDisplay.ts index f09bc5cfe..14c5da623 100644 --- a/apps/website/lib/display-objects/RatingDisplay.ts +++ b/apps/website/lib/display-objects/RatingDisplay.ts @@ -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)); } -} \ No newline at end of file +} diff --git a/apps/website/lib/display-objects/RelativeTimeDisplay.ts b/apps/website/lib/display-objects/RelativeTimeDisplay.ts new file mode 100644 index 000000000..27c7b270e --- /dev/null +++ b/apps/website/lib/display-objects/RelativeTimeDisplay.ts @@ -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()}`; + } +} diff --git a/apps/website/lib/view-data/RacesViewData.ts b/apps/website/lib/view-data/RacesViewData.ts index e5a91a6e1..db6b83017 100644 --- a/apps/website/lib/view-data/RacesViewData.ts +++ b/apps/website/lib/view-data/RacesViewData.ts @@ -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; diff --git a/apps/website/lib/view-data/TeamDetailViewData.ts b/apps/website/lib/view-data/TeamDetailViewData.ts index abb83bd04..992802153 100644 --- a/apps/website/lib/view-data/TeamDetailViewData.ts +++ b/apps/website/lib/view-data/TeamDetailViewData.ts @@ -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; } diff --git a/apps/website/templates/TeamDetailTemplate.tsx b/apps/website/templates/TeamDetailTemplate.tsx index 87fbeae19..bd4c6e9d0 100644 --- a/apps/website/templates/TeamDetailTemplate.tsx +++ b/apps/website/templates/TeamDetailTemplate.tsx @@ -109,7 +109,9 @@ export function TeamDetailTemplate({ tag={team.tag} description={team.description} memberCount={viewData.memberships.length} + memberCountLabel={viewData.memberCountLabel} foundedDate={team.createdAt} + foundedDateLabel={team.foundedDateLabel} isAdmin={viewData.isAdmin} onAdminClick={() => onTabChange('admin')} /> diff --git a/docs/architecture/website/DISPLAY_OBJECTS.md b/docs/architecture/website/DISPLAY_OBJECTS.md index 68206d8a2..8442f794a 100644 --- a/docs/architecture/website/DISPLAY_OBJECTS.md +++ b/docs/architecture/website/DISPLAY_OBJECTS.md @@ -196,6 +196,19 @@ The following patterns were identified in `apps/website/components` and SHOULD b ### 5. Pluralization - **Member Count:** `${count} ${count === 1 ? 'member' : 'members'}` → `MemberDisplay.formatCount(count)` +- **League Count:** `${count} ${count === 1 ? 'league' : 'leagues'}` → `LeagueDisplay.formatCount(count)` + +--- + +## Existing Display Objects + +- **[`DateDisplay`](apps/website/lib/display-objects/DateDisplay.ts)**: UTC-based date and time formatting. +- **[`CurrencyDisplay`](apps/website/lib/display-objects/CurrencyDisplay.ts)**: Deterministic currency formatting. +- **[`RaceStatusDisplay`](apps/website/lib/display-objects/RaceStatusDisplay.ts)**: Race status labels, variants, and icons. +- **[`RatingDisplay`](apps/website/lib/display-objects/RatingDisplay.ts)**: Rounded rating formatting with thousands separators. +- **[`RelativeTimeDisplay`](apps/website/lib/display-objects/RelativeTimeDisplay.ts)**: Deterministic relative time (requires "now" argument). +- **[`MemberDisplay`](apps/website/lib/display-objects/MemberDisplay.ts)**: Member count pluralization. +- **[`LeagueDisplay`](apps/website/lib/display-objects/LeagueDisplay.ts)**: League count pluralization. ---