website refactor
This commit is contained in:
@@ -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 (
|
||||
<Card
|
||||
variant="dark"
|
||||
@@ -48,10 +48,10 @@ export function RaceCard({
|
||||
{/* Time Column */}
|
||||
<Stack align="center" gap={1}>
|
||||
<Text size="lg" weight="bold" variant="high">
|
||||
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
{timeLabel}
|
||||
</Text>
|
||||
<Text size="xs" variant={statusConfig.intent === 'default' ? 'low' : (statusConfig.intent as any)}>
|
||||
{status === 'running' ? 'LIVE' : scheduledAtDate.toLocaleDateString()}
|
||||
<Text size="xs" variant={statusVariant === 'default' ? 'low' : (statusVariant as any)}>
|
||||
{status === 'running' ? 'LIVE' : scheduledAtLabel}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
@@ -83,8 +83,8 @@ export function RaceCard({
|
||||
</Stack>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Badge variant={statusConfig.intent}>
|
||||
{statusConfig.label}
|
||||
<Badge variant={statusVariant}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<UiRaceCard
|
||||
track={race.track}
|
||||
car={race.car}
|
||||
scheduledAt={race.scheduledAt}
|
||||
scheduledAtLabel={DateDisplay.formatShort(race.scheduledAt)}
|
||||
timeLabel={DateDisplay.formatTime(race.scheduledAt)}
|
||||
status={race.status}
|
||||
statusLabel={RaceStatusDisplay.getLabel(race.status)}
|
||||
statusVariant={RaceStatusDisplay.getVariant(race.status) as any}
|
||||
leagueName={race.leagueName}
|
||||
leagueId={race.leagueId}
|
||||
strengthOfField={race.strengthOfField}
|
||||
onClick={onClick}
|
||||
statusConfig={{
|
||||
...config,
|
||||
icon: config.icon as LucideIcon | null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<EmptyState
|
||||
@@ -62,8 +39,6 @@ export function RaceList({ racesByDate, totalCount, onRaceClick }: RaceListProps
|
||||
|
||||
<Stack gap={2}>
|
||||
{group.races.map((race) => {
|
||||
const config = statusConfig[race.status as keyof typeof statusConfig] || statusConfig.scheduled;
|
||||
|
||||
return (
|
||||
<RaceListItem
|
||||
key={race.id}
|
||||
@@ -76,7 +51,9 @@ export function RaceList({ racesByDate, totalCount, onRaceClick }: RaceListProps
|
||||
leagueHref={routes.league.detail(race.leagueId ?? '')}
|
||||
strengthOfField={race.strengthOfField}
|
||||
onClick={() => onRaceClick(race.id)}
|
||||
statusConfig={config}
|
||||
statusLabel={race.statusLabel}
|
||||
statusVariant={race.statusVariant as any}
|
||||
statusIconName={race.statusIconName}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 (
|
||||
<RaceCard onClick={onClick} isLive={isLive}>
|
||||
@@ -59,9 +67,9 @@ export function RaceListItem({
|
||||
title={track}
|
||||
subtitle={car}
|
||||
badge={
|
||||
<Badge variant={statusConfig.variant}>
|
||||
<Icon icon={statusConfig.icon} size={3.5} />
|
||||
{statusConfig.label}
|
||||
<Badge variant={statusVariant}>
|
||||
<Icon icon={StatusIcon} size={3.5} />
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
}
|
||||
meta={
|
||||
|
||||
@@ -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 (
|
||||
<RaceSummary
|
||||
track={track}
|
||||
meta={meta}
|
||||
date={DateDisplay.formatShort(date)}
|
||||
date={dateLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
}
|
||||
description={description || 'No mission statement provided.'}
|
||||
memberCount={memberCount}
|
||||
memberCountLabel={memberCountLabel}
|
||||
foundedDateLabel={foundedDateLabel}
|
||||
sideContent={
|
||||
<div className="w-32 h-32 bg-[var(--ui-color-bg-surface-muted)] border border-[var(--ui-color-border-default)] flex items-center justify-center overflow-hidden rounded-lg">
|
||||
{logoUrl ? (
|
||||
@@ -58,7 +65,7 @@ export function TeamDetailsHeader({
|
||||
},
|
||||
{
|
||||
label: 'Established',
|
||||
value: foundedDate ? new Date(foundedDate).toLocaleDateString() : 'Unknown',
|
||||
value: foundedDateLabel || 'Unknown',
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<Card>
|
||||
<Group align="start" justify="between" wrap gap={6}>
|
||||
@@ -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',
|
||||
}] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text size="xs" color="text-gray-500" font="mono">
|
||||
{new Date(member.joinedAt).toLocaleDateString()}
|
||||
{member.joinedAtLabel}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell textAlign="right">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user