website refactor
This commit is contained in:
@@ -14,31 +14,31 @@ interface RaceCardProps {
|
|||||||
track: string;
|
track: string;
|
||||||
car: string;
|
car: string;
|
||||||
scheduledAt: string;
|
scheduledAt: string;
|
||||||
|
scheduledAtLabel: string;
|
||||||
|
timeLabel: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
statusLabel: string;
|
||||||
|
statusVariant: 'primary' | 'success' | 'warning' | 'critical' | 'default' | 'secondary' | 'info' | 'danger';
|
||||||
leagueName: string;
|
leagueName: string;
|
||||||
leagueId?: string;
|
leagueId?: string;
|
||||||
strengthOfField?: number | null;
|
strengthOfField?: number | null;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
statusConfig: {
|
|
||||||
intent: 'primary' | 'success' | 'warning' | 'critical' | 'default' | 'secondary' | 'info' | 'danger';
|
|
||||||
icon: LucideIcon | null;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RaceCard({
|
export function RaceCard({
|
||||||
track,
|
track,
|
||||||
car,
|
car,
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
|
scheduledAtLabel,
|
||||||
|
timeLabel,
|
||||||
status,
|
status,
|
||||||
|
statusLabel,
|
||||||
|
statusVariant,
|
||||||
leagueName,
|
leagueName,
|
||||||
leagueId,
|
leagueId,
|
||||||
strengthOfField,
|
strengthOfField,
|
||||||
onClick,
|
onClick,
|
||||||
statusConfig,
|
|
||||||
}: RaceCardProps) {
|
}: RaceCardProps) {
|
||||||
const scheduledAtDate = new Date(scheduledAt);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
variant="dark"
|
variant="dark"
|
||||||
@@ -48,10 +48,10 @@ export function RaceCard({
|
|||||||
{/* Time Column */}
|
{/* Time Column */}
|
||||||
<Stack align="center" gap={1}>
|
<Stack align="center" gap={1}>
|
||||||
<Text size="lg" weight="bold" variant="high">
|
<Text size="lg" weight="bold" variant="high">
|
||||||
{scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{timeLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" variant={statusConfig.intent === 'default' ? 'low' : (statusConfig.intent as any)}>
|
<Text size="xs" variant={statusVariant === 'default' ? 'low' : (statusVariant as any)}>
|
||||||
{status === 'running' ? 'LIVE' : scheduledAtDate.toLocaleDateString()}
|
{status === 'running' ? 'LIVE' : scheduledAtLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -83,8 +83,8 @@ export function RaceCard({
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
<Badge variant={statusConfig.intent}>
|
<Badge variant={statusVariant}>
|
||||||
{statusConfig.label}
|
{statusLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { LucideIcon } from 'lucide-react';
|
import { RaceStatusDisplay } from '@/lib/display-objects/RaceStatusDisplay';
|
||||||
import { raceStatusConfig } from '@/lib/utilities/raceStatus';
|
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||||
import { RaceCard as UiRaceCard } from './RaceCard';
|
import { RaceCard as UiRaceCard } from './RaceCard';
|
||||||
|
|
||||||
interface RaceCardProps {
|
interface RaceCardProps {
|
||||||
@@ -18,28 +18,20 @@ interface RaceCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RaceCard({ race, onClick }: 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 (
|
return (
|
||||||
<UiRaceCard
|
<UiRaceCard
|
||||||
track={race.track}
|
track={race.track}
|
||||||
car={race.car}
|
car={race.car}
|
||||||
scheduledAt={race.scheduledAt}
|
scheduledAt={race.scheduledAt}
|
||||||
|
scheduledAtLabel={DateDisplay.formatShort(race.scheduledAt)}
|
||||||
|
timeLabel={DateDisplay.formatTime(race.scheduledAt)}
|
||||||
status={race.status}
|
status={race.status}
|
||||||
|
statusLabel={RaceStatusDisplay.getLabel(race.status)}
|
||||||
|
statusVariant={RaceStatusDisplay.getVariant(race.status) as any}
|
||||||
leagueName={race.leagueName}
|
leagueName={race.leagueName}
|
||||||
leagueId={race.leagueId}
|
leagueId={race.leagueId}
|
||||||
strengthOfField={race.strengthOfField}
|
strengthOfField={race.strengthOfField}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
statusConfig={{
|
|
||||||
...config,
|
|
||||||
icon: config.icon as LucideIcon | null,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,29 +19,6 @@ interface RaceListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RaceList({ racesByDate, totalCount, onRaceClick }: 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) {
|
if (racesByDate.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -62,8 +39,6 @@ export function RaceList({ racesByDate, totalCount, onRaceClick }: RaceListProps
|
|||||||
|
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
{group.races.map((race) => {
|
{group.races.map((race) => {
|
||||||
const config = statusConfig[race.status as keyof typeof statusConfig] || statusConfig.scheduled;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RaceListItem
|
<RaceListItem
|
||||||
key={race.id}
|
key={race.id}
|
||||||
@@ -76,7 +51,9 @@ export function RaceList({ racesByDate, totalCount, onRaceClick }: RaceListProps
|
|||||||
leagueHref={routes.league.detail(race.leagueId ?? '')}
|
leagueHref={routes.league.detail(race.leagueId ?? '')}
|
||||||
strengthOfField={race.strengthOfField}
|
strengthOfField={race.strengthOfField}
|
||||||
onClick={() => onRaceClick(race.id)}
|
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 { Link } from '@/ui/Link';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
import { RaceCard, RaceTimeColumn, RaceInfo } from '@/ui/RaceCard';
|
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 React from 'react';
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
Clock,
|
||||||
|
PlayCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
HelpCircle,
|
||||||
|
};
|
||||||
|
|
||||||
interface RaceListItemProps {
|
interface RaceListItemProps {
|
||||||
track: string;
|
track: string;
|
||||||
@@ -17,15 +24,13 @@ interface RaceListItemProps {
|
|||||||
dateLabel?: string;
|
dateLabel?: string;
|
||||||
dayLabel?: string;
|
dayLabel?: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
statusLabel: string;
|
||||||
|
statusVariant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
statusIconName: string;
|
||||||
leagueName?: string | null;
|
leagueName?: string | null;
|
||||||
leagueHref?: string;
|
leagueHref?: string;
|
||||||
strengthOfField?: number | null;
|
strengthOfField?: number | null;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
statusConfig: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
variant: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RaceListItem({
|
export function RaceListItem({
|
||||||
@@ -36,13 +41,16 @@ export function RaceListItem({
|
|||||||
dateLabel,
|
dateLabel,
|
||||||
dayLabel,
|
dayLabel,
|
||||||
status,
|
status,
|
||||||
|
statusLabel,
|
||||||
|
statusVariant,
|
||||||
|
statusIconName,
|
||||||
leagueName,
|
leagueName,
|
||||||
leagueHref,
|
leagueHref,
|
||||||
strengthOfField,
|
strengthOfField,
|
||||||
onClick,
|
onClick,
|
||||||
statusConfig,
|
|
||||||
}: RaceListItemProps) {
|
}: RaceListItemProps) {
|
||||||
const isLive = status === 'running';
|
const isLive = status === 'running';
|
||||||
|
const StatusIcon = ICON_MAP[statusIconName as keyof typeof ICON_MAP] || HelpCircle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RaceCard onClick={onClick} isLive={isLive}>
|
<RaceCard onClick={onClick} isLive={isLive}>
|
||||||
@@ -59,9 +67,9 @@ export function RaceListItem({
|
|||||||
title={track}
|
title={track}
|
||||||
subtitle={car}
|
subtitle={car}
|
||||||
badge={
|
badge={
|
||||||
<Badge variant={statusConfig.variant}>
|
<Badge variant={statusVariant}>
|
||||||
<Icon icon={statusConfig.icon} size={3.5} />
|
<Icon icon={StatusIcon} size={3.5} />
|
||||||
{statusConfig.label}
|
{statusLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
meta={
|
meta={
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
import { RaceSummary } from '@/ui/RaceSummary';
|
import { RaceSummary } from '@/ui/RaceSummary';
|
||||||
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
interface RaceSummaryItemProps {
|
interface RaceSummaryItemProps {
|
||||||
track: string;
|
track: string;
|
||||||
meta: string;
|
meta: string;
|
||||||
date: Date;
|
dateLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RaceSummaryItem({ track, meta, date }: RaceSummaryItemProps) {
|
export function RaceSummaryItem({ track, meta, dateLabel }: RaceSummaryItemProps) {
|
||||||
return (
|
return (
|
||||||
<RaceSummary
|
<RaceSummary
|
||||||
track={track}
|
track={track}
|
||||||
meta={meta}
|
meta={meta}
|
||||||
date={DateDisplay.formatShort(date)}
|
date={dateLabel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ interface TeamDetailsHeaderProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
|
memberCountLabel?: string;
|
||||||
foundedDate?: string;
|
foundedDate?: string;
|
||||||
|
foundedDateLabel?: string;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
onAdminClick?: () => void;
|
onAdminClick?: () => void;
|
||||||
}
|
}
|
||||||
@@ -25,7 +27,9 @@ export function TeamDetailsHeader({
|
|||||||
description,
|
description,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
memberCount,
|
memberCount,
|
||||||
|
memberCountLabel,
|
||||||
foundedDate,
|
foundedDate,
|
||||||
|
foundedDateLabel,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onAdminClick,
|
onAdminClick,
|
||||||
}: TeamDetailsHeaderProps) {
|
}: TeamDetailsHeaderProps) {
|
||||||
@@ -38,6 +42,9 @@ export function TeamDetailsHeader({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
description={description || 'No mission statement provided.'}
|
description={description || 'No mission statement provided.'}
|
||||||
|
memberCount={memberCount}
|
||||||
|
memberCountLabel={memberCountLabel}
|
||||||
|
foundedDateLabel={foundedDateLabel}
|
||||||
sideContent={
|
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">
|
<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 ? (
|
{logoUrl ? (
|
||||||
@@ -58,7 +65,7 @@ export function TeamDetailsHeader({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Established',
|
label: 'Established',
|
||||||
value: foundedDate ? new Date(foundedDate).toLocaleDateString() : 'Unknown',
|
value: foundedDateLabel || 'Unknown',
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ interface TeamHeroProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
foundedDateLabel?: string;
|
||||||
leagues: { id: string }[];
|
leagues: { id: string }[];
|
||||||
};
|
};
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
|
memberCountLabel?: string;
|
||||||
|
leagueCountLabel?: string;
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeamHero({ team, memberCount, onUpdate }: TeamHeroProps) {
|
export function TeamHero({ team, memberCount, memberCountLabel, leagueCountLabel, onUpdate }: TeamHeroProps) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<Group align="start" justify="between" wrap gap={6}>
|
<Group align="start" justify="between" wrap gap={6}>
|
||||||
@@ -44,20 +47,20 @@ export function TeamHero({ team, memberCount, onUpdate }: TeamHeroProps) {
|
|||||||
stats={[
|
stats={[
|
||||||
{
|
{
|
||||||
label: 'Personnel',
|
label: 'Personnel',
|
||||||
value: `${memberCount} ${memberCount === 1 ? 'member' : 'members'}`,
|
value: memberCountLabel || 'Unknown',
|
||||||
},
|
},
|
||||||
...(team.category ? [{
|
...(team.category ? [{
|
||||||
label: 'Category',
|
label: 'Category',
|
||||||
value: team.category,
|
value: team.category,
|
||||||
intent: 'primary' as const,
|
intent: 'primary' as const,
|
||||||
}] : []),
|
}] : []),
|
||||||
...(team.createdAt ? [{
|
...(team.foundedDateLabel ? [{
|
||||||
label: 'Founded',
|
label: 'Founded',
|
||||||
value: new Date(team.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
value: team.foundedDateLabel,
|
||||||
}] : []),
|
}] : []),
|
||||||
...(team.leagues && team.leagues.length > 0 ? [{
|
...(team.leagues && team.leagues.length > 0 ? [{
|
||||||
label: 'Activity',
|
label: 'Activity',
|
||||||
value: `${team.leagues.length} ${team.leagues.length === 1 ? 'league' : 'leagues'}`,
|
value: leagueCountLabel || 'Unknown',
|
||||||
}] : []),
|
}] : []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface Member {
|
|||||||
driverName: string;
|
driverName: string;
|
||||||
role: string;
|
role: string;
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
|
joinedAtLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamMembersTableProps {
|
interface TeamMembersTableProps {
|
||||||
@@ -49,7 +50,7 @@ export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembe
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Text size="xs" color="text-gray-500" font="mono">
|
<Text size="xs" color="text-gray-500" font="mono">
|
||||||
{new Date(member.joinedAt).toLocaleDateString()}
|
{member.joinedAtLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell textAlign="right">
|
<TableCell textAlign="right">
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
import type { RacesPageDataDTO } from '@/lib/types/generated/RacesPageDataDTO';
|
import type { RacesPageDataDTO } from '@/lib/types/generated/RacesPageDataDTO';
|
||||||
import type { RacesViewData, RaceViewData } from '@/lib/view-data/RacesViewData';
|
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 {
|
export class RacesViewDataBuilder {
|
||||||
static build(apiDto: RacesPageDataDTO): RacesViewData {
|
static build(apiDto: RacesPageDataDTO): RacesViewData {
|
||||||
|
const now = new Date();
|
||||||
const races = apiDto.races.map((race): RaceViewData => {
|
const races = apiDto.races.map((race): RaceViewData => {
|
||||||
const scheduledAt = new Date(race.scheduledAt);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: race.id,
|
id: race.id,
|
||||||
track: race.track,
|
track: race.track,
|
||||||
car: race.car,
|
car: race.car,
|
||||||
scheduledAt: race.scheduledAt,
|
scheduledAt: race.scheduledAt,
|
||||||
scheduledAtLabel: scheduledAt.toLocaleDateString('en-US', {
|
scheduledAtLabel: DateDisplay.formatShort(race.scheduledAt),
|
||||||
weekday: 'short',
|
timeLabel: DateDisplay.formatTime(race.scheduledAt),
|
||||||
month: 'short',
|
relativeTimeLabel: RelativeTimeDisplay.format(race.scheduledAt, now),
|
||||||
day: 'numeric',
|
|
||||||
}),
|
|
||||||
timeLabel: scheduledAt.toLocaleTimeString('en-US', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}),
|
|
||||||
relativeTimeLabel: this.getRelativeTime(scheduledAt),
|
|
||||||
status: race.status as RaceViewData['status'],
|
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',
|
sessionType: 'Race',
|
||||||
leagueId: race.leagueId,
|
leagueId: race.leagueId,
|
||||||
leagueName: race.leagueName,
|
leagueName: race.leagueName,
|
||||||
@@ -69,32 +66,4 @@ export class RacesViewDataBuilder {
|
|||||||
racesByDate,
|
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 { TeamDetailPageDto } from '@/lib/page-queries/TeamDetailPageQuery';
|
||||||
import type { TeamDetailViewData, TeamDetailData, TeamMemberData, SponsorMetric, TeamTab } from '@/lib/view-data/TeamDetailViewData';
|
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
|
* TeamDetailViewDataBuilder - Transforms TeamDetailPageDto into ViewData
|
||||||
@@ -15,6 +18,7 @@ export class TeamDetailViewDataBuilder {
|
|||||||
ownerId: apiDto.team.ownerId,
|
ownerId: apiDto.team.ownerId,
|
||||||
leagues: apiDto.team.leagues,
|
leagues: apiDto.team.leagues,
|
||||||
createdAt: apiDto.team.createdAt,
|
createdAt: apiDto.team.createdAt,
|
||||||
|
foundedDateLabel: apiDto.team.createdAt ? DateDisplay.formatMonthYear(apiDto.team.createdAt) : 'Unknown',
|
||||||
specialization: apiDto.team.specialization,
|
specialization: apiDto.team.specialization,
|
||||||
region: apiDto.team.region,
|
region: apiDto.team.region,
|
||||||
languages: apiDto.team.languages,
|
languages: apiDto.team.languages,
|
||||||
@@ -28,6 +32,7 @@ export class TeamDetailViewDataBuilder {
|
|||||||
driverName: membership.driverName,
|
driverName: membership.driverName,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
joinedAt: membership.joinedAt,
|
joinedAt: membership.joinedAt,
|
||||||
|
joinedAtLabel: DateDisplay.formatShort(membership.joinedAt),
|
||||||
isActive: membership.isActive,
|
isActive: membership.isActive,
|
||||||
avatarUrl: membership.avatarUrl,
|
avatarUrl: membership.avatarUrl,
|
||||||
}));
|
}));
|
||||||
@@ -80,6 +85,8 @@ export class TeamDetailViewDataBuilder {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
teamMetrics,
|
teamMetrics,
|
||||||
tabs,
|
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 {
|
export class DateDisplay {
|
||||||
|
/**
|
||||||
|
* Formats a date as "Jan 18, 2026" using UTC.
|
||||||
|
*/
|
||||||
static formatShort(date: string | Date): string {
|
static formatShort(date: string | Date): string {
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
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 {
|
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;
|
relativeTimeLabel: string;
|
||||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
|
statusVariant: string;
|
||||||
|
statusIconName: string;
|
||||||
sessionType: string;
|
sessionType: string;
|
||||||
leagueId: string | null;
|
leagueId: string | null;
|
||||||
leagueName: string | null;
|
leagueName: string | null;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface TeamDetailData {
|
|||||||
ownerId: string;
|
ownerId: string;
|
||||||
leagues: string[];
|
leagues: string[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
foundedDateLabel?: string;
|
||||||
specialization?: string;
|
specialization?: string;
|
||||||
region?: string;
|
region?: string;
|
||||||
languages?: string[];
|
languages?: string[];
|
||||||
@@ -39,6 +40,7 @@ export interface TeamMemberData {
|
|||||||
driverName: string;
|
driverName: string;
|
||||||
role: 'owner' | 'manager' | 'member';
|
role: 'owner' | 'manager' | 'member';
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
|
joinedAtLabel: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
}
|
}
|
||||||
@@ -56,4 +58,6 @@ export interface TeamDetailViewData {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
teamMetrics: SponsorMetric[];
|
teamMetrics: SponsorMetric[];
|
||||||
tabs: TeamTab[];
|
tabs: TeamTab[];
|
||||||
|
memberCountLabel: string;
|
||||||
|
leagueCountLabel: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ export function TeamDetailTemplate({
|
|||||||
tag={team.tag}
|
tag={team.tag}
|
||||||
description={team.description}
|
description={team.description}
|
||||||
memberCount={viewData.memberships.length}
|
memberCount={viewData.memberships.length}
|
||||||
|
memberCountLabel={viewData.memberCountLabel}
|
||||||
foundedDate={team.createdAt}
|
foundedDate={team.createdAt}
|
||||||
|
foundedDateLabel={team.foundedDateLabel}
|
||||||
isAdmin={viewData.isAdmin}
|
isAdmin={viewData.isAdmin}
|
||||||
onAdminClick={() => onTabChange('admin')}
|
onAdminClick={() => onTabChange('admin')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -196,6 +196,19 @@ The following patterns were identified in `apps/website/components` and SHOULD b
|
|||||||
|
|
||||||
### 5. Pluralization
|
### 5. Pluralization
|
||||||
- **Member Count:** `${count} ${count === 1 ? 'member' : 'members'}` → `MemberDisplay.formatCount(count)`
|
- **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