This commit is contained in:
2026-01-11 13:04:33 +01:00
parent 6f2ab9fc56
commit 971aa7288b
44 changed files with 2168 additions and 1240 deletions

View File

@@ -0,0 +1,95 @@
'use client';
import React, { useState, useEffect } from 'react';
import type { DashboardViewData } from './DashboardViewData';
import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData';
import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
import { DashboardTemplate } from '@/templates/DashboardTemplate';
interface DashboardPageClientProps {
initialViewData: DashboardViewData;
dto: DashboardOverviewViewModelData;
}
/**
* Dashboard Page Client Component
*
* Two-phase render:
* 1. Initial SSR: Uses ViewData built directly from DTO (no ViewModel)
* 2. Post-hydration: Instantiates ViewModel and re-renders with enhanced data
*/
export function DashboardPageClient({ initialViewData, dto }: DashboardPageClientProps) {
const [viewData, setViewData] = useState<DashboardViewData>(initialViewData);
useEffect(() => {
// Phase 2: After hydration, instantiate ViewModel and enhance data
const viewModel = new DashboardOverviewViewModel(dto);
const enhancedViewData: DashboardViewData = {
currentDriver: {
name: viewModel.currentDriverName,
avatarUrl: viewModel.currentDriverAvatarUrl,
country: viewModel.currentDriverCountry,
rating: viewModel.currentDriverRating,
rank: viewModel.currentDriverRank,
totalRaces: viewModel.currentDriverTotalRaces,
wins: viewModel.currentDriverWins,
podiums: viewModel.currentDriverPodiums,
consistency: viewModel.currentDriverConsistency,
},
nextRace: viewModel.nextRace ? {
id: viewModel.nextRace.id,
track: viewModel.nextRace.track,
car: viewModel.nextRace.car,
scheduledAt: viewModel.nextRace.scheduledAt,
formattedDate: viewModel.nextRace.formattedDate,
formattedTime: viewModel.nextRace.formattedTime,
timeUntil: viewModel.nextRace.timeUntil,
isMyLeague: viewModel.nextRace.isMyLeague,
} : null,
upcomingRaces: viewModel.upcomingRaces.map((race) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
formattedDate: race.formattedDate,
formattedTime: race.formattedTime,
timeUntil: race.timeUntil,
isMyLeague: race.isMyLeague,
})),
leagueStandings: viewModel.leagueStandings.map((standing) => ({
leagueId: standing.leagueId,
leagueName: standing.leagueName,
position: standing.position,
points: standing.points,
totalDrivers: standing.totalDrivers,
})),
feedItems: viewModel.feedItems.map((item) => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp: item.timestamp,
formattedTime: item.formattedTime,
ctaHref: item.ctaHref,
ctaLabel: item.ctaLabel,
})),
friends: viewModel.friends.map((friend) => ({
id: friend.id,
name: friend.name,
avatarUrl: friend.avatarUrl,
country: friend.country,
})),
activeLeaguesCount: viewModel.activeLeaguesCount,
friendCount: viewModel.friendCount,
hasUpcomingRaces: viewModel.hasUpcomingRaces,
hasLeagueStandings: viewModel.hasLeagueStandings,
hasFeedItems: viewModel.hasFeedItems,
hasFriends: viewModel.hasFriends,
};
setViewData(enhancedViewData);
}, [dto]);
return <DashboardTemplate data={viewData} />;
}

View File

@@ -0,0 +1,70 @@
/**
* Dashboard ViewData
*
* SSR-safe data structure that can be built directly from DTO
* without ViewModel instantiation. Contains formatted values
* for display and ISO string timestamps for JSON serialization.
*/
export interface DashboardViewData {
currentDriver: {
name: string;
avatarUrl: string;
country: string;
rating: string;
rank: string;
totalRaces: string;
wins: string;
podiums: string;
consistency: string;
};
nextRace: {
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
formattedDate: string;
formattedTime: string;
timeUntil: string;
isMyLeague: boolean;
} | null;
upcomingRaces: Array<{
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
formattedDate: string;
formattedTime: string;
timeUntil: string;
isMyLeague: boolean;
}>;
leagueStandings: Array<{
leagueId: string;
leagueName: string;
position: string;
points: string;
totalDrivers: string;
}>;
feedItems: Array<{
id: string;
type: string;
headline: string;
body?: string;
timestamp: string; // ISO string
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}>;
friends: Array<{
id: string;
name: string;
avatarUrl: string;
country: string;
}>;
activeLeaguesCount: string;
friendCount: string;
hasUpcomingRaces: boolean;
hasLeagueStandings: boolean;
hasFeedItems: boolean;
hasFriends: boolean;
}

View File

@@ -0,0 +1,88 @@
import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData';
import type { DashboardViewData } from './DashboardViewData';
import {
formatDashboardDate,
formatRating,
formatRank,
formatConsistency,
formatRaceCount,
formatFriendCount,
formatLeaguePosition,
formatPoints,
formatTotalDrivers,
} from '@/lib/display-objects/DashboardDisplay';
/**
* Build DashboardViewData directly from ViewModelData
* Used for SSR phase - no ViewModel instantiation
*/
export function buildDashboardViewData(viewModelData: DashboardOverviewViewModelData): DashboardViewData {
return {
currentDriver: {
name: viewModelData.currentDriver?.name || '',
avatarUrl: viewModelData.currentDriver?.avatarUrl || '',
country: viewModelData.currentDriver?.country || '',
rating: viewModelData.currentDriver ? formatRating(viewModelData.currentDriver.rating) : '0.0',
rank: viewModelData.currentDriver ? formatRank(viewModelData.currentDriver.globalRank) : '0',
totalRaces: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.totalRaces) : '0',
wins: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.wins) : '0',
podiums: viewModelData.currentDriver ? formatRaceCount(viewModelData.currentDriver.podiums) : '0',
consistency: viewModelData.currentDriver ? formatConsistency(viewModelData.currentDriver.consistency) : '0%',
},
nextRace: viewModelData.nextRace ? (() => {
const dateInfo = formatDashboardDate(new Date(viewModelData.nextRace.scheduledAt));
return {
id: viewModelData.nextRace.id,
track: viewModelData.nextRace.track,
car: viewModelData.nextRace.car,
scheduledAt: viewModelData.nextRace.scheduledAt,
formattedDate: dateInfo.date,
formattedTime: dateInfo.time,
timeUntil: dateInfo.relative,
isMyLeague: viewModelData.nextRace.isMyLeague,
};
})() : null,
upcomingRaces: viewModelData.upcomingRaces.map((race) => {
const dateInfo = formatDashboardDate(new Date(race.scheduledAt));
return {
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
formattedDate: dateInfo.date,
formattedTime: dateInfo.time,
timeUntil: dateInfo.relative,
isMyLeague: race.isMyLeague,
};
}),
leagueStandings: viewModelData.leagueStandingsSummaries.map((standing) => ({
leagueId: standing.leagueId,
leagueName: standing.leagueName,
position: formatLeaguePosition(standing.position),
points: formatPoints(standing.points),
totalDrivers: formatTotalDrivers(standing.totalDrivers),
})),
feedItems: viewModelData.feedSummary.items.map((item) => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp: item.timestamp,
formattedTime: formatDashboardDate(new Date(item.timestamp)).relative,
ctaHref: item.ctaHref,
ctaLabel: item.ctaLabel,
})),
friends: viewModelData.friends.map((friend) => ({
id: friend.id,
name: friend.name,
avatarUrl: friend.avatarUrl,
country: friend.country,
})),
activeLeaguesCount: formatRaceCount(viewModelData.activeLeaguesCount),
friendCount: formatFriendCount(viewModelData.friends.length),
hasUpcomingRaces: viewModelData.upcomingRaces.length > 0,
hasLeagueStandings: viewModelData.leagueStandingsSummaries.length > 0,
hasFeedItems: viewModelData.feedSummary.items.length > 0,
hasFriends: viewModelData.friends.length > 0,
};
}

View File

@@ -1,17 +1,31 @@
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { DashboardTemplate } from '@/templates/DashboardTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DASHBOARD_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { DashboardService } from '@/lib/services/dashboard/DashboardService';
import { notFound, redirect } from 'next/navigation';
import { DashboardPageQuery } from '@/lib/page-queries/DashboardPageQuery';
import { DashboardPageClient } from './DashboardPageClient';
import { buildDashboardViewData } from './DashboardViewDataBuilder';
export default async function DashboardPage() {
const data = await PageDataFetcher.fetch<DashboardService, 'getDashboardOverview'>(
DASHBOARD_SERVICE_TOKEN,
'getDashboardOverview'
);
const result = await DashboardPageQuery.execute();
if (!data) notFound();
return <PageWrapper data={data} Template={DashboardTemplate} />;
// Handle result based on status
switch (result.status) {
case 'ok':
const viewModelData = result.data;
// Build SSR ViewData directly from ViewModelData
const ssrViewData = buildDashboardViewData(viewModelData);
// Pass both ViewData (for SSR) and ViewModelData (for client enhancement)
return <DashboardPageClient initialViewData={ssrViewData} dto={viewModelData} />;
case 'notFound':
notFound();
case 'redirect':
redirect(result.destination);
case 'error':
// For now, treat as notFound. Could also show error page
console.error('Dashboard error:', result.error);
notFound();
}
}

View File

@@ -2,9 +2,19 @@ import { Activity, Trophy, Medal, UserPlus, Heart, Flag, Play } from 'lucide-rea
import Button from '@/components/ui/Button';
import Link from 'next/link';
import { timeAgo } from '@/lib/utilities/time';
import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
function FeedItemRow({ item }: { item: DashboardFeedItemSummaryViewModel }) {
interface FeedItemData {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
function FeedItemRow({ item }: { item: FeedItemData }) {
const getActivityIcon = (type: string) => {
if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' };
if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' };

View File

@@ -2,7 +2,17 @@ import { useEffect, useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Image from 'next/image';
import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
interface FeedItemData {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
function timeAgo(timestamp: Date | string): string {
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
@@ -16,14 +26,14 @@ function timeAgo(timestamp: Date | string): string {
return `${diffDays} d ago`;
}
async function resolveActor(_item: DashboardFeedItemSummaryViewModel) {
async function resolveActor(_item: FeedItemData) {
// Actor resolution is not wired through the API in this build.
// Keep rendering deterministic and decoupled (no core repos).
return null;
}
interface FeedItemCardProps {
item: DashboardFeedItemSummaryViewModel;
item: FeedItemData;
}
export default function FeedItemCard({ item }: FeedItemCardProps) {
@@ -92,4 +102,4 @@ export default function FeedItemCard({ item }: FeedItemCardProps) {
</div>
</div>
);
}
}

View File

@@ -1,9 +1,19 @@
import Card from '@/components/ui/Card';
import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
import FeedList from '@/components/feed/FeedList';
import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';
interface FeedItemData {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
type FeedUpcomingRace = {
id: string;
track: string;
@@ -20,7 +30,7 @@ type FeedLatestResult = {
};
interface FeedLayoutProps {
feedItems: DashboardFeedItemSummaryViewModel[];
feedItems: FeedItemData[];
upcomingRaces: FeedUpcomingRace[];
latestResults: FeedLatestResult[];
}
@@ -53,4 +63,4 @@ export default function FeedLayout({
</div>
</section>
);
}
}

View File

@@ -1,9 +1,19 @@
import FeedEmptyState from '@/components/feed/FeedEmptyState';
import FeedItemCard from '@/components/feed/FeedItemCard';
import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
interface FeedItemData {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
interface FeedListProps {
items: DashboardFeedItemSummaryViewModel[];
items: FeedItemData[];
}
export default function FeedList({ items }: FeedListProps) {
@@ -18,4 +28,4 @@ export default function FeedList({ items }: FeedListProps) {
))}
</div>
);
}
}

View File

@@ -0,0 +1,138 @@
/**
* Dashboard Display Objects
*
* Deterministic formatting for dashboard data without Intl.* or toLocale*
*/
export interface DashboardStatDisplayData {
icon: string;
color: string;
label: string;
}
export interface DashboardDateDisplayData {
date: string;
time: string;
relative: string;
}
/**
* Stat card display configurations
*/
export const dashboardStatDisplay = {
wins: {
icon: 'Trophy',
color: 'bg-performance-green/20 text-performance-green',
label: 'Wins',
},
podiums: {
icon: 'Medal',
color: 'bg-warning-amber/20 text-warning-amber',
label: 'Podiums',
},
consistency: {
icon: 'Target',
color: 'bg-primary-blue/20 text-primary-blue',
label: 'Consistency',
},
activeLeagues: {
icon: 'Users',
color: 'bg-purple-500/20 text-purple-400',
label: 'Active Leagues',
},
} as const;
/**
* Format date for display (deterministic, no Intl)
*/
export function formatDashboardDate(date: Date): DashboardDateDisplayData {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const dayName = days[date.getDay()];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// Calculate relative time (deterministic)
const now = new Date();
const diffMs = date.getTime() - now.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
let relative: string;
if (diffHours < 0) {
relative = 'Past';
} else if (diffHours === 0) {
relative = 'Now';
} else if (diffHours < 24) {
relative = `${diffHours}h`;
} else {
relative = `${diffDays}d`;
}
return {
date: `${dayName}, ${month} ${day}, ${year}`,
time: `${hours}:${minutes}`,
relative,
};
}
/**
* Format rating for display
*/
export function formatRating(rating: number): string {
return rating.toFixed(1);
}
/**
* Format rank for display
*/
export function formatRank(rank: number): string {
return rank.toString();
}
/**
* Format consistency percentage
*/
export function formatConsistency(consistency: number): string {
return `${consistency}%`;
}
/**
* Format race count
*/
export function formatRaceCount(count: number): string {
return count.toString();
}
/**
* Format friend count
*/
export function formatFriendCount(count: number): string {
return count.toString();
}
/**
* Format league position
*/
export function formatLeaguePosition(position: number): string {
return `#${position}`;
}
/**
* Format points
*/
export function formatPoints(points: number): string {
return points.toString();
}
/**
* Format total drivers
*/
export function formatTotalDrivers(total: number): string {
return total.toString();
}

View File

@@ -0,0 +1,261 @@
/**
* Profile Display Objects
*
* Deterministic formatting for profile data.
* NO Intl.*, NO Date.toLocale*, NO dynamic formatting.
*/
// ============================================================================
// COUNTRY FLAG DISPLAY
// ============================================================================
export interface CountryFlagDisplayData {
flag: string;
label: string;
}
export const countryFlagDisplay: Record<string, CountryFlagDisplayData> = {
// Common country codes - add as needed
US: { flag: '🇺🇸', label: 'United States' },
GB: { flag: '🇬🇧', label: 'United Kingdom' },
DE: { flag: '🇩🇪', label: 'Germany' },
FR: { flag: '🇫🇷', label: 'France' },
IT: { flag: '🇮🇹', label: 'Italy' },
ES: { flag: '🇪🇸', label: 'Spain' },
JP: { flag: '🇯🇵', label: 'Japan' },
AU: { flag: '🇦🇺', label: 'Australia' },
CA: { flag: '🇨🇦', label: 'Canada' },
BR: { flag: '🇧🇷', label: 'Brazil' },
// Fallback for unknown codes
DEFAULT: { flag: '🏁', label: 'Unknown' },
} as const;
export function getCountryFlagDisplay(countryCode: string): CountryFlagDisplayData {
const code = countryCode.toUpperCase();
return countryFlagDisplay[code] || countryFlagDisplay.DEFAULT;
}
// ============================================================================
// ACHIEVEMENT RARITY DISPLAY
// ============================================================================
export interface AchievementRarityDisplayData {
text: string;
badgeClasses: string;
borderClasses: string;
}
export const achievementRarityDisplay: Record<string, AchievementRarityDisplayData> = {
common: {
text: 'Common',
badgeClasses: 'bg-gray-400/10 text-gray-400',
borderClasses: 'border-gray-400/30',
},
rare: {
text: 'Rare',
badgeClasses: 'bg-primary-blue/10 text-primary-blue',
borderClasses: 'border-primary-blue/30',
},
epic: {
text: 'Epic',
badgeClasses: 'bg-purple-400/10 text-purple-400',
borderClasses: 'border-purple-400/30',
},
legendary: {
text: 'Legendary',
badgeClasses: 'bg-yellow-400/10 text-yellow-400',
borderClasses: 'border-yellow-400/30',
},
} as const;
export function getAchievementRarityDisplay(rarity: string): AchievementRarityDisplayData {
return achievementRarityDisplay[rarity] || achievementRarityDisplay.common;
}
// ============================================================================
// ACHIEVEMENT ICON DISPLAY
// ============================================================================
export type AchievementIconType = 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
export interface AchievementIconDisplayData {
name: string;
// Icon component will be resolved in UI layer
}
export const achievementIconDisplay: Record<AchievementIconType, AchievementIconDisplayData> = {
trophy: { name: 'Trophy' },
medal: { name: 'Medal' },
star: { name: 'Star' },
crown: { name: 'Crown' },
target: { name: 'Target' },
zap: { name: 'Zap' },
} as const;
export function getAchievementIconDisplay(icon: string): AchievementIconDisplayData {
return achievementIconDisplay[icon as AchievementIconType] || achievementIconDisplay.trophy;
}
// ============================================================================
// SOCIAL PLATFORM DISPLAY
// ============================================================================
export interface SocialPlatformDisplayData {
name: string;
hoverClasses: string;
}
export const socialPlatformDisplay: Record<string, SocialPlatformDisplayData> = {
twitter: {
name: 'Twitter',
hoverClasses: 'hover:text-sky-400 hover:bg-sky-400/10',
},
youtube: {
name: 'YouTube',
hoverClasses: 'hover:text-red-500 hover:bg-red-500/10',
},
twitch: {
name: 'Twitch',
hoverClasses: 'hover:text-purple-400 hover:bg-purple-400/10',
},
discord: {
name: 'Discord',
hoverClasses: 'hover:text-indigo-400 hover:bg-indigo-400/10',
},
} as const;
export function getSocialPlatformDisplay(platform: string): SocialPlatformDisplayData {
return socialPlatformDisplay[platform] || socialPlatformDisplay.discord;
}
// ============================================================================
// DATE FORMATTING (DETERMINISTIC)
// ============================================================================
/**
* Format date string to "Month Year" format
* Input: ISO date string (e.g., "2024-01-15T10:30:00Z")
* Output: "Jan 2024"
*/
export function formatMonthYear(dateString: string): string {
const date = new Date(dateString);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[date.getUTCMonth()];
const year = date.getUTCFullYear();
return `${month} ${year}`;
}
/**
* Format date string to "Month Day, Year" format
* Input: ISO date string
* Output: "Jan 15, 2024"
*/
export function formatMonthDayYear(dateString: string): string {
const date = new Date(dateString);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[date.getUTCMonth()];
const day = date.getUTCDate();
const year = date.getUTCFullYear();
return `${month} ${day}, ${year}`;
}
// ============================================================================
// STATISTICS FORMATTING
// ============================================================================
export interface StatDisplayData {
value: string;
label: string;
}
/**
* Format percentage with 1 decimal place
* Input: 0.1234
* Output: "12.3%"
*/
export function formatPercentage(value: number | null): string {
if (value === null || value === undefined) return '0.0%';
return `${(value * 100).toFixed(1)}%`;
}
/**
* Format finish position
* Input: 1
* Output: "P1"
*/
export function formatFinishPosition(position: number | null): string {
if (position === null || position === undefined) return 'P-';
return `P${position}`;
}
/**
* Format average finish with 1 decimal place
* Input: 3.456
* Output: "P3.5"
*/
export function formatAvgFinish(avg: number | null): string {
if (avg === null || avg === undefined) return 'P-';
return `P${avg.toFixed(1)}`;
}
/**
* Format rating (whole number)
* Input: 1234.56
* Output: "1235"
*/
export function formatRating(rating: number | null): string {
if (rating === null || rating === undefined) return '0';
return Math.round(rating).toString();
}
/**
* Format consistency percentage
* Input: 87.5
* Output: "88%"
*/
export function formatConsistency(consistency: number | null): string {
if (consistency === null || consistency === undefined) return '0%';
return `${Math.round(consistency)}%`;
}
/**
* Format percentile
* Input: 15.5
* Output: "Top 16%"
*/
export function formatPercentile(percentile: number | null): string {
if (percentile === null || percentile === undefined) return 'Top -%';
return `Top ${Math.round(percentile)}%`;
}
// ============================================================================
// TEAM ROLE DISPLAY
// ============================================================================
export interface TeamRoleDisplayData {
text: string;
badgeClasses: string;
}
export const teamRoleDisplay: Record<string, TeamRoleDisplayData> = {
owner: {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
},
admin: {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
},
steward: {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
member: {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
},
} as const;
export function getTeamRoleDisplay(role: string): TeamRoleDisplayData {
return teamRoleDisplay[role] || teamRoleDisplay.member;
}

View File

@@ -0,0 +1,140 @@
import { notFound, redirect } from 'next/navigation';
import { ContainerManager } from '@/lib/di/container';
import { DASHBOARD_API_CLIENT_TOKEN } from '@/lib/di/tokens';
import type { DashboardApiClient } from '@/lib/api/dashboard/DashboardApiClient';
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
import type { DashboardOverviewViewModelData } from '@/lib/view-models/DashboardOverviewViewModelData';
/**
* PageQueryResult discriminated union for SSR page queries
*/
export type PageQueryResult<TData> =
| { status: 'ok'; data: TData }
| { status: 'notFound' }
| { status: 'redirect'; destination: string }
| { status: 'error'; error: Error };
/**
* Transform DashboardOverviewDTO to DashboardOverviewViewModelData
* Converts string dates to ISO strings for JSON serialization
*/
function transformDtoToViewModelData(dto: DashboardOverviewDTO): DashboardOverviewViewModelData {
return {
currentDriver: dto.currentDriver ? {
id: dto.currentDriver.id,
name: dto.currentDriver.name,
avatarUrl: dto.currentDriver.avatarUrl || '',
country: dto.currentDriver.country,
totalRaces: dto.currentDriver.totalRaces,
wins: dto.currentDriver.wins,
podiums: dto.currentDriver.podiums,
rating: dto.currentDriver.rating ?? 0,
globalRank: dto.currentDriver.globalRank ?? 0,
consistency: dto.currentDriver.consistency ?? 0,
} : undefined,
myUpcomingRaces: dto.myUpcomingRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: new Date(race.scheduledAt).toISOString(),
status: race.status,
isMyLeague: race.isMyLeague,
})),
otherUpcomingRaces: dto.otherUpcomingRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: new Date(race.scheduledAt).toISOString(),
status: race.status,
isMyLeague: race.isMyLeague,
})),
upcomingRaces: dto.upcomingRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: new Date(race.scheduledAt).toISOString(),
status: race.status,
isMyLeague: race.isMyLeague,
})),
activeLeaguesCount: dto.activeLeaguesCount,
nextRace: dto.nextRace ? {
id: dto.nextRace.id,
track: dto.nextRace.track,
car: dto.nextRace.car,
scheduledAt: new Date(dto.nextRace.scheduledAt).toISOString(),
status: dto.nextRace.status,
isMyLeague: dto.nextRace.isMyLeague,
} : undefined,
recentResults: dto.recentResults.map(result => ({
id: result.raceId,
track: result.raceName,
car: '', // Not in DTO, will need to handle
position: result.position,
date: new Date(result.finishedAt).toISOString(),
})),
leagueStandingsSummaries: dto.leagueStandingsSummaries.map(standing => ({
leagueId: standing.leagueId,
leagueName: standing.leagueName,
position: standing.position,
points: standing.points,
totalDrivers: standing.totalDrivers,
})),
feedSummary: {
notificationCount: dto.feedSummary.notificationCount,
items: dto.feedSummary.items.map(item => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp: new Date(item.timestamp).toISOString(),
ctaHref: item.ctaHref,
ctaLabel: item.ctaLabel,
})),
},
friends: dto.friends.map(friend => ({
id: friend.id,
name: friend.name,
avatarUrl: friend.avatarUrl || '',
country: friend.country,
})),
};
}
/**
* Dashboard page query that returns transformed ViewModelData
* Returns a discriminated union instead of nullable data
*/
export class DashboardPageQuery {
static async execute(): Promise<PageQueryResult<DashboardOverviewViewModelData>> {
try {
const container = ContainerManager.getInstance().getContainer();
const apiClient = container.get<DashboardApiClient>(DASHBOARD_API_CLIENT_TOKEN);
const dto = await apiClient.getDashboardOverview();
if (!dto) {
return { status: 'notFound' };
}
const viewModelData = transformDtoToViewModelData(dto);
return { status: 'ok', data: viewModelData };
} catch (error) {
// Handle specific error types
if (error instanceof Error) {
// Check if it's a not found error
if (error.message.includes('not found') || (error as any).statusCode === 404) {
return { status: 'notFound' };
}
// Check if it's a redirect error
if (error.message.includes('redirect') || (error as any).statusCode === 302) {
return { status: 'redirect', destination: '/' };
}
return { status: 'error', error };
}
return { status: 'error', error: new Error(String(error)) };
}
}
}

View File

@@ -0,0 +1,114 @@
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { DriverService } from '@/lib/services/drivers/DriverService';
import type { DriverProfileViewModelData } from '@/lib/view-models/DriverProfileViewModel';
// ============================================================================
// TYPES
// ============================================================================
export type PageQueryResult =
| { status: 'ok'; dto: DriverProfileViewModelData }
| { status: 'notFound' }
| { status: 'redirect'; to: string }
| { status: 'error'; errorId: string };
// ============================================================================
// SERVER QUERY CLASS
// ============================================================================
/**
* ProfilePageQuery
*
* Server-side data fetcher for the profile page.
* Returns a discriminated union with all possible page states.
* Ensures JSON-serializable DTO with no null leakage.
*/
export class ProfilePageQuery {
/**
* Execute the profile page query
*
* @param driverId - The driver ID to fetch profile for
* @returns PageQueryResult with discriminated union of states
*/
static async execute(driverId: string | null): Promise<PageQueryResult> {
// Handle missing driver ID
if (!driverId) {
return { status: 'notFound' };
}
try {
// Fetch using PageDataFetcher to avoid direct DI in page
const driverService = await PageDataFetcher.fetchManual(async () => {
const container = (await import('@/lib/di/container')).ContainerManager.getInstance().getContainer();
return container.get<DriverService>(DRIVER_SERVICE_TOKEN);
});
if (!driverService) {
return { status: 'error', errorId: 'SERVICE_UNAVAILABLE' };
}
const viewModel = await driverService.getDriverProfile(driverId);
// Convert to DTO and ensure JSON-serializable
const dto = this.toSerializableDTO(viewModel.toDTO());
if (!dto.currentDriver) {
return { status: 'notFound' };
}
return { status: 'ok', dto };
} catch (error) {
console.error('ProfilePageQuery failed:', error);
return { status: 'error', errorId: 'FETCH_FAILED' };
}
}
/**
* Convert DTO to ensure JSON-serializability
* - Dates become ISO strings
* - Undefined becomes null
* - No Date objects remain
*/
private static toSerializableDTO(dto: DriverProfileViewModelData): DriverProfileViewModelData {
return {
currentDriver: dto.currentDriver ? {
...dto.currentDriver,
joinedAt: dto.currentDriver.joinedAt, // Already ISO string
} : null,
stats: dto.stats ? {
...dto.stats,
// Ensure all nullable numbers are properly handled
avgFinish: dto.stats.avgFinish ?? null,
bestFinish: dto.stats.bestFinish ?? null,
worstFinish: dto.stats.worstFinish ?? null,
finishRate: dto.stats.finishRate ?? null,
winRate: dto.stats.winRate ?? null,
podiumRate: dto.stats.podiumRate ?? null,
percentile: dto.stats.percentile ?? null,
rating: dto.stats.rating ?? null,
consistency: dto.stats.consistency ?? null,
overallRank: dto.stats.overallRank ?? null,
} : null,
finishDistribution: dto.finishDistribution ? { ...dto.finishDistribution } : null,
teamMemberships: dto.teamMemberships.map(m => ({
...m,
joinedAt: m.joinedAt, // Already ISO string
})),
socialSummary: {
friendsCount: dto.socialSummary.friendsCount,
friends: dto.socialSummary.friends.map(f => ({
...f,
})),
},
extendedProfile: dto.extendedProfile ? {
...dto.extendedProfile,
achievements: dto.extendedProfile.achievements.map(a => ({
...a,
earnedAt: a.earnedAt, // Already ISO string
})),
} : null,
};
}
}

View File

@@ -1,5 +1,7 @@
import { DashboardOverviewViewModel } from '../../view-models/DashboardOverviewViewModel';
import { DashboardApiClient } from '../../api/dashboard/DashboardApiClient';
import type { DashboardOverviewDTO } from '../../types/generated/DashboardOverviewDTO';
import type { DashboardOverviewViewModelData } from '../../view-models/DashboardOverviewViewModelData';
/**
* Dashboard Service
@@ -14,9 +16,98 @@ export class DashboardService {
/**
* Get dashboard overview data with view model transformation
* Returns the ViewModel for backward compatibility
*/
async getDashboardOverview(): Promise<DashboardOverviewViewModel> {
const dto = await this.apiClient.getDashboardOverview();
return new DashboardOverviewViewModel(dto);
// Convert DTO to ViewModelData format for the ViewModel
const viewModelData: DashboardOverviewViewModelData = {
currentDriver: dto.currentDriver ? {
id: dto.currentDriver.id,
name: dto.currentDriver.name,
avatarUrl: dto.currentDriver.avatarUrl || '',
country: dto.currentDriver.country,
totalRaces: dto.currentDriver.totalRaces,
wins: dto.currentDriver.wins,
podiums: dto.currentDriver.podiums,
rating: dto.currentDriver.rating ?? 0,
globalRank: dto.currentDriver.globalRank ?? 0,
consistency: dto.currentDriver.consistency ?? 0,
} : undefined,
myUpcomingRaces: dto.myUpcomingRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: new Date(race.scheduledAt).toISOString(),
status: race.status,
isMyLeague: race.isMyLeague,
})),
otherUpcomingRaces: dto.otherUpcomingRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: new Date(race.scheduledAt).toISOString(),
status: race.status,
isMyLeague: race.isMyLeague,
})),
upcomingRaces: dto.upcomingRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: new Date(race.scheduledAt).toISOString(),
status: race.status,
isMyLeague: race.isMyLeague,
})),
activeLeaguesCount: dto.activeLeaguesCount,
nextRace: dto.nextRace ? {
id: dto.nextRace.id,
track: dto.nextRace.track,
car: dto.nextRace.car,
scheduledAt: new Date(dto.nextRace.scheduledAt).toISOString(),
status: dto.nextRace.status,
isMyLeague: dto.nextRace.isMyLeague,
} : undefined,
recentResults: dto.recentResults.map(result => ({
id: result.raceId,
track: result.raceName,
car: '',
position: result.position,
date: new Date(result.finishedAt).toISOString(),
})),
leagueStandingsSummaries: dto.leagueStandingsSummaries.map(standing => ({
leagueId: standing.leagueId,
leagueName: standing.leagueName,
position: standing.position,
points: standing.points,
totalDrivers: standing.totalDrivers,
})),
feedSummary: {
notificationCount: dto.feedSummary.notificationCount,
items: dto.feedSummary.items.map(item => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp: new Date(item.timestamp).toISOString(),
ctaHref: item.ctaHref,
ctaLabel: item.ctaLabel,
})),
},
friends: dto.friends.map(friend => ({
id: friend.id,
name: friend.name,
avatarUrl: friend.avatarUrl || '',
country: friend.country,
})),
};
return new DashboardOverviewViewModel(viewModelData);
}
/**
* Get raw DTO for page queries
*/
async getDashboardOverviewDTO(): Promise<DashboardOverviewDTO> {
return await this.apiClient.getDashboardOverview();
}
}

View File

@@ -1,217 +1,198 @@
import type { DashboardOverviewDTO } from '@/lib/types/generated/DashboardOverviewDTO';
import type { DashboardDriverSummaryDTO } from '@/lib/types/generated/DashboardDriverSummaryDTO';
import type { DashboardRaceSummaryDTO } from '@/lib/types/generated/DashboardRaceSummaryDTO';
import type { DashboardLeagueStandingSummaryDTO } from '@/lib/types/generated/DashboardLeagueStandingSummaryDTO';
import type { DashboardFeedItemSummaryDTO } from '@/lib/types/generated/DashboardFeedItemSummaryDTO';
import type { DashboardFriendSummaryDTO } from '@/lib/types/generated/DashboardFriendSummaryDTO';
export class DashboardDriverSummaryViewModel {
constructor(private readonly dto: DashboardDriverSummaryDTO) {}
get id(): string {
return this.dto.id;
}
get name(): string {
return this.dto.name;
}
get avatarUrl(): string {
return this.dto.avatarUrl || '';
}
get country(): string {
return this.dto.country;
}
get totalRaces(): number {
return this.dto.totalRaces;
}
get wins(): number {
return this.dto.wins;
}
get podiums(): number {
return this.dto.podiums;
}
get rating(): number {
return this.dto.rating ?? 0;
}
get globalRank(): number {
return this.dto.globalRank ?? 0;
}
get consistency(): number {
return this.dto.consistency ?? 0;
}
}
export class DashboardRaceSummaryViewModel {
constructor(private readonly dto: DashboardRaceSummaryDTO) {}
get id(): string {
return this.dto.id;
}
get leagueId(): string {
return (this.dto as any).leagueId ?? '';
}
get leagueName(): string {
return (this.dto as any).leagueName ?? '';
}
get track(): string {
return this.dto.track;
}
get car(): string {
return this.dto.car;
}
get scheduledAt(): Date {
return new Date(this.dto.scheduledAt);
}
get status(): string {
return this.dto.status;
}
get isMyLeague(): boolean {
return this.dto.isMyLeague;
}
}
export class DashboardLeagueStandingSummaryViewModel {
constructor(private readonly dto: DashboardLeagueStandingSummaryDTO) {}
get leagueId(): string {
return this.dto.leagueId;
}
get leagueName(): string {
return this.dto.leagueName;
}
get position(): number {
return this.dto.position;
}
get points(): number {
return this.dto.points;
}
get totalDrivers(): number {
return this.dto.totalDrivers;
}
}
export class DashboardFeedItemSummaryViewModel {
constructor(private readonly dto: DashboardFeedItemSummaryDTO) {}
get id(): string {
return this.dto.id;
}
get type(): string {
return this.dto.type;
}
get headline(): string {
return this.dto.headline;
}
get body(): string | undefined {
return this.dto.body;
}
get timestamp(): Date {
return new Date(this.dto.timestamp);
}
get ctaHref(): string | undefined {
return this.dto.ctaHref;
}
get ctaLabel(): string | undefined {
return this.dto.ctaLabel;
}
}
export class DashboardFriendSummaryViewModel {
constructor(private readonly dto: DashboardFriendSummaryDTO) {}
get id(): string {
return this.dto.id;
}
get name(): string {
return this.dto.name;
}
get avatarUrl(): string {
return this.dto.avatarUrl || '';
}
get country(): string {
return this.dto.country;
}
}
import type { DashboardOverviewViewModelData } from './DashboardOverviewViewModelData';
import {
dashboardStatDisplay,
formatDashboardDate,
formatRating,
formatRank,
formatConsistency,
formatRaceCount,
formatFriendCount,
formatLeaguePosition,
formatPoints,
formatTotalDrivers,
} from '@/lib/display-objects/DashboardDisplay';
/**
* Dashboard Overview ViewModel
*
* Clean class that accepts DTO only and exposes derived values.
* This is client-only and instantiated after hydration.
*/
export class DashboardOverviewViewModel {
constructor(private readonly dto: DashboardOverviewDTO) {}
constructor(private readonly dto: DashboardOverviewViewModelData) {}
get currentDriver(): DashboardDriverSummaryViewModel {
// DTO uses optional property; enforce a consistent object for the UI
return new DashboardDriverSummaryViewModel(
(this.dto as any).currentDriver ?? {
id: '',
name: '',
country: '',
avatarUrl: '',
totalRaces: 0,
wins: 0,
podiums: 0,
},
);
// Current Driver - Derived Values
get currentDriverName(): string {
return this.dto.currentDriver?.name || '';
}
get nextRace(): DashboardRaceSummaryViewModel | null {
const nextRace = (this.dto as any).nextRace;
return nextRace ? new DashboardRaceSummaryViewModel(nextRace) : null;
get currentDriverAvatarUrl(): string {
return this.dto.currentDriver?.avatarUrl || '';
}
get upcomingRaces(): DashboardRaceSummaryViewModel[] {
const upcomingRaces = (this.dto as any).upcomingRaces ?? [];
return upcomingRaces.map((r: any) => new DashboardRaceSummaryViewModel(r));
get currentDriverCountry(): string {
return this.dto.currentDriver?.country || '';
}
get leagueStandings(): DashboardLeagueStandingSummaryViewModel[] {
const leagueStandings = (this.dto as any).leagueStandingsSummaries ?? (this.dto as any).leagueStandings ?? [];
return leagueStandings.map((s: any) => new DashboardLeagueStandingSummaryViewModel(s));
get currentDriverRating(): string {
return this.dto.currentDriver ? formatRating(this.dto.currentDriver.rating) : '0.0';
}
get feedItems(): DashboardFeedItemSummaryViewModel[] {
const feedItems = (this.dto as any).feedSummary?.items ?? (this.dto as any).feedItems ?? [];
return feedItems.map((i: any) => new DashboardFeedItemSummaryViewModel(i));
get currentDriverRank(): string {
return this.dto.currentDriver ? formatRank(this.dto.currentDriver.globalRank) : '0';
}
get friends(): DashboardFriendSummaryViewModel[] {
const friends = (this.dto as any).friends ?? [];
return friends.map((f: any) => new DashboardFriendSummaryViewModel(f));
get currentDriverTotalRaces(): string {
return this.dto.currentDriver ? formatRaceCount(this.dto.currentDriver.totalRaces) : '0';
}
get activeLeaguesCount(): number {
return (this.dto as any).activeLeaguesCount ?? 0;
get currentDriverWins(): string {
return this.dto.currentDriver ? formatRaceCount(this.dto.currentDriver.wins) : '0';
}
}
export {
DashboardDriverSummaryViewModel as DriverViewModel,
DashboardRaceSummaryViewModel as RaceViewModel,
DashboardLeagueStandingSummaryViewModel as LeagueStandingViewModel,
DashboardFriendSummaryViewModel as FriendViewModel,
};
get currentDriverPodiums(): string {
return this.dto.currentDriver ? formatRaceCount(this.dto.currentDriver.podiums) : '0';
}
get currentDriverConsistency(): string {
return this.dto.currentDriver ? formatConsistency(this.dto.currentDriver.consistency) : '0%';
}
// Next Race - Derived Values
get nextRace(): {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
isMyLeague: boolean;
formattedDate: string;
formattedTime: string;
timeUntil: string;
} | null {
if (!this.dto.nextRace) return null;
const dateInfo = formatDashboardDate(new Date(this.dto.nextRace.scheduledAt));
return {
id: this.dto.nextRace.id,
track: this.dto.nextRace.track,
car: this.dto.nextRace.car,
scheduledAt: this.dto.nextRace.scheduledAt,
status: this.dto.nextRace.status,
isMyLeague: this.dto.nextRace.isMyLeague,
formattedDate: dateInfo.date,
formattedTime: dateInfo.time,
timeUntil: dateInfo.relative,
};
}
// Upcoming Races - Derived Values
get upcomingRaces(): Array<{
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
isMyLeague: boolean;
formattedDate: string;
formattedTime: string;
timeUntil: string;
}> {
return this.dto.upcomingRaces.map((race) => {
const dateInfo = formatDashboardDate(new Date(race.scheduledAt));
return {
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status,
isMyLeague: race.isMyLeague,
formattedDate: dateInfo.date,
formattedTime: dateInfo.time,
timeUntil: dateInfo.relative,
};
});
}
// League Standings - Derived Values
get leagueStandings(): Array<{
leagueId: string;
leagueName: string;
position: string;
points: string;
totalDrivers: string;
}> {
return this.dto.leagueStandingsSummaries.map((standing) => ({
leagueId: standing.leagueId,
leagueName: standing.leagueName,
position: formatLeaguePosition(standing.position),
points: formatPoints(standing.points),
totalDrivers: formatTotalDrivers(standing.totalDrivers),
}));
}
// Feed Items - Derived Values
get feedItems(): Array<{
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
ctaHref?: string;
ctaLabel?: string;
formattedTime: string;
}> {
return this.dto.feedSummary.items.map((item) => ({
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp: item.timestamp,
ctaHref: item.ctaHref,
ctaLabel: item.ctaLabel,
formattedTime: formatDashboardDate(new Date(item.timestamp)).relative,
}));
}
// Friends - Derived Values
get friends(): Array<{
id: string;
name: string;
avatarUrl: string;
country: string;
}> {
// No additional formatting needed for friends
return this.dto.friends;
}
// Active Leagues Count
get activeLeaguesCount(): string {
return formatRaceCount(this.dto.activeLeaguesCount);
}
// Convenience getters for display
get hasNextRace(): boolean {
return this.dto.nextRace !== undefined;
}
get hasUpcomingRaces(): boolean {
return this.dto.upcomingRaces.length > 0;
}
get hasLeagueStandings(): boolean {
return this.dto.leagueStandingsSummaries.length > 0;
}
get hasFeedItems(): boolean {
return this.dto.feedSummary.items.length > 0;
}
get hasFriends(): boolean {
return this.dto.friends.length > 0;
}
get friendCount(): string {
return formatFriendCount(this.dto.friends.length);
}
}

View File

@@ -0,0 +1,84 @@
/**
* Dashboard Page DTO
* This is the data transfer object that gets passed from server to client
* Contains ISO string timestamps for JSON serialization
*/
export interface DashboardOverviewViewModelData {
currentDriver?: {
id: string;
name: string;
avatarUrl: string;
country: string;
totalRaces: number;
wins: number;
podiums: number;
rating: number;
globalRank: number;
consistency: number;
};
myUpcomingRaces: Array<{
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
status: string;
isMyLeague: boolean;
}>;
otherUpcomingRaces: Array<{
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
status: string;
isMyLeague: boolean;
}>;
upcomingRaces: Array<{
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
status: string;
isMyLeague: boolean;
}>;
activeLeaguesCount: number;
nextRace?: {
id: string;
track: string;
car: string;
scheduledAt: string; // ISO string
status: string;
isMyLeague: boolean;
};
recentResults: Array<{
id: string;
track: string;
car: string;
position: number;
date: string; // ISO string
}>;
leagueStandingsSummaries: Array<{
leagueId: string;
leagueName: string;
position: number;
points: number;
totalDrivers: number;
}>;
feedSummary: {
notificationCount: number;
items: Array<{
id: string;
type: string;
headline: string;
body?: string;
timestamp: string; // ISO string
ctaHref?: string;
ctaLabel?: string;
}>;
};
friends: Array<{
id: string;
name: string;
avatarUrl: string;
country: string;
}>;
}

View File

@@ -10,11 +10,6 @@ export * from './CreateLeagueViewModel';
export * from './CreateTeamViewModel';
export {
DashboardOverviewViewModel,
DashboardDriverSummaryViewModel,
DashboardRaceSummaryViewModel,
DashboardLeagueStandingSummaryViewModel,
DashboardFeedItemSummaryViewModel,
DashboardFriendSummaryViewModel,
} from './DashboardOverviewViewModel';
export * from './DeleteMediaViewModel';
export * from './DriverLeaderboardItemViewModel';

View File

@@ -27,12 +27,73 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { getCountryFlag } from '@/lib/utilities/country';
import { getGreeting, timeUntil } from '@/lib/utilities/time';
import { getGreeting } from '@/lib/utilities/time';
import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
interface DashboardViewData {
currentDriver: {
name: string;
avatarUrl: string;
country: string;
rating: string;
rank: string;
totalRaces: string;
wins: string;
podiums: string;
consistency: string;
};
nextRace: {
id: string;
track: string;
car: string;
scheduledAt: string;
formattedDate: string;
formattedTime: string;
timeUntil: string;
isMyLeague: boolean;
} | null;
upcomingRaces: Array<{
id: string;
track: string;
car: string;
scheduledAt: string;
formattedDate: string;
formattedTime: string;
timeUntil: string;
isMyLeague: boolean;
}>;
leagueStandings: Array<{
leagueId: string;
leagueName: string;
position: string;
points: string;
totalDrivers: string;
}>;
feedItems: Array<{
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}>;
friends: Array<{
id: string;
name: string;
avatarUrl: string;
country: string;
}>;
activeLeaguesCount: string;
friendCount: string;
hasUpcomingRaces: boolean;
hasLeagueStandings: boolean;
hasFeedItems: boolean;
hasFriends: boolean;
}
interface DashboardTemplateProps {
data: DashboardOverviewViewModel;
data: DashboardViewData;
}
export function DashboardTemplate({ data }: DashboardTemplateProps) {
@@ -44,7 +105,7 @@ export function DashboardTemplate({ data }: DashboardTemplateProps) {
const friends = data.friends;
const activeLeaguesCount = data.activeLeaguesCount;
const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver;
const { totalRaces, wins, podiums, rating, rank, consistency } = currentDriver;
return (
<main className="min-h-screen bg-deep-graphite">
@@ -89,7 +150,7 @@ export function DashboardTemplate({ data }: DashboardTemplateProps) {
</div>
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-yellow-400/10 border border-yellow-400/30">
<Trophy className="w-3.5 h-3.5 text-yellow-400" />
<span className="text-sm font-semibold text-yellow-400">#{globalRank}</span>
<span className="text-sm font-semibold text-yellow-400">#{rank}</span>
</div>
<span className="text-xs text-gray-500">{totalRaces} races completed</span>
</div>
@@ -129,61 +190,54 @@ export function DashboardTemplate({ data }: DashboardTemplateProps) {
{/* Left Column - Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Next Race Card */}
{nextRace && (
<Card className="relative overflow-hidden bg-gradient-to-br from-iron-gray to-iron-gray/80 border-primary-blue/30">
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/20 to-transparent rounded-bl-full" />
<div className="relative">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary-blue/20 border border-primary-blue/30">
<Play className="w-3.5 h-3.5 text-primary-blue" />
<span className="text-xs font-semibold text-primary-blue uppercase tracking-wider">Next Race</span>
</div>
{nextRace.isMyLeague && (
<span className="px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
Your League
</span>
)}
</div>
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{nextRace.track}</h2>
<p className="text-gray-400 mb-3">{nextRace.car}</p>
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-gray-400">
<Calendar className="w-4 h-4" />
{nextRace.scheduledAt.toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric',
})}
</span>
<span className="flex items-center gap-1.5 text-gray-400">
<Clock className="w-4 h-4" />
{nextRace.scheduledAt.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-3">
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Starts in</p>
<p className="text-3xl font-bold text-primary-blue font-mono">{timeUntil(nextRace.scheduledAt)}</p>
</div>
<Link href={`/races/${nextRace.id}`}>
<Button variant="primary" className="flex items-center gap-2">
View Details
<ChevronRight className="w-4 h-4" />
</Button>
</Link>
</div>
</div>
</div>
</Card>
)}
{nextRace && (
<Card className="relative overflow-hidden bg-gradient-to-br from-iron-gray to-iron-gray/80 border-primary-blue/30">
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/20 to-transparent rounded-bl-full" />
<div className="relative">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary-blue/20 border border-primary-blue/30">
<Play className="w-3.5 h-3.5 text-primary-blue" />
<span className="text-xs font-semibold text-primary-blue uppercase tracking-wider">Next Race</span>
</div>
{nextRace.isMyLeague && (
<span className="px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
Your League
</span>
)}
</div>
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{nextRace.track}</h2>
<p className="text-gray-400 mb-3">{nextRace.car}</p>
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-gray-400">
<Calendar className="w-4 h-4" />
{nextRace.formattedDate}
</span>
<span className="flex items-center gap-1.5 text-gray-400">
<Clock className="w-4 h-4" />
{nextRace.formattedTime}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-3">
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Starts in</p>
<p className="text-3xl font-bold text-primary-blue font-mono">{nextRace.timeUntil}</p>
</div>
<Link href={`/races/${nextRace.id}`}>
<Button variant="primary" className="flex items-center gap-2">
View Details
<ChevronRight className="w-4 h-4" />
</Button>
</Link>
</div>
</div>
</div>
</Card>
)}
{/* League Standings Preview */}
{leagueStandingsSummaries.length > 0 && (