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 && (

View File

@@ -1,287 +0,0 @@
# Authentication & Authorization Refactor Summary
## Problem Statement
The website had a "fucking unpredictable mess" of authorization and authentication layers:
- **RouteGuard** (old, complex)
- **AuthGuard** (old, complex)
- **AuthGateway** (deprecated)
- **AuthorizationBlocker** (deprecated)
- **Middleware** with hardcoded paths
- **Role logic scattered** across client and server
- **Inconsistent patterns** across routes
## The Clean Solution
### 1. Centralized Route Configuration
**File:** `apps/website/lib/routing/RouteConfig.ts`
```typescript
// Single source of truth for ALL routes
export const routes = {
dashboard: {
path: '/dashboard',
auth: true,
roles: ['driver', 'team_manager', 'sponsor'],
redirect: '/login'
},
admin: {
path: '/admin',
auth: true,
roles: ['admin'],
redirect: '/unauthorized'
},
// ... and more
}
```
**Benefits:**
- ✅ No hardcoded paths anywhere
- ✅ Type-safe route definitions
- ✅ i18n-ready (switch locales by changing config)
- ✅ Easy to maintain
### 2. Clean Middleware
**File:** `apps/website/middleware.ts`
```typescript
// Before: Complex logic with hardcoded paths
// After: Simple cookie check + redirect using route config
export async function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
// Find matching route
const route = routes.getRouteByPath(pathname);
if (route?.auth && !hasAuthCookie(req)) {
return NextResponse.redirect(new URL(route.redirect, req.url));
}
return NextResponse.next();
}
```
**Benefits:**
- ✅ Uses route config exclusively
- ✅ No role logic in middleware
- ✅ Predictable flow
- ✅ Easy to debug
### 3. Clean Guards (TDD Implementation)
#### AuthGuard
**File:** `apps/website/lib/guards/AuthGuard.tsx`
```typescript
// Only checks authentication
export class AuthGuard {
async check(session: Session | null): Promise<boolean> {
return session !== null;
}
async enforce(session: Session | null): Promise<void> {
if (!await this.check(session)) {
throw new AuthError('Not authenticated');
}
}
}
```
#### RoleGuard
**File:** `apps/website/lib/guards/RoleGuard.tsx`
```typescript
// Only checks roles
export class RoleGuard {
async check(session: Session | null, requiredRoles: string[]): Promise<boolean> {
if (!session?.user?.roles) return false;
return requiredRoles.some(role => session.user.roles.includes(role));
}
async enforce(session: Session | null, requiredRoles: string[]): Promise<void> {
if (!await this.check(session, requiredRoles)) {
throw new AuthorizationError('Insufficient permissions');
}
}
}
```
**Benefits:**
- ✅ Single responsibility
- ✅ Class-based (easy to test)
- ✅ Full TDD coverage
- ✅ Predictable behavior
### 4. Updated Route Layouts
**All 7 layouts updated:**
```typescript
// Before: Mixed old guards, hardcoded paths
import { RouteGuard } from '@/lib/gateways/RouteGuard';
import { AuthGateway } from '@/lib/gateways/AuthGateway';
// After: Clean guards with route config
import { AuthGuard } from '@/lib/guards/AuthGuard';
import { RoleGuard } from '@/lib/guards/RoleGuard';
import { routes } from '@/lib/routing/RouteConfig';
export default async function DashboardLayout({ children }) {
const session = await getSession();
const authGuard = new AuthGuard();
const roleGuard = new RoleGuard();
await authGuard.enforce(session);
await roleGuard.enforce(session, routes.dashboard.roles);
return <>{children}</>;
}
```
### 5. Comprehensive Tests
**TDD Applied:**
- `AuthGuard.test.tsx` - Full coverage
- `RoleGuard.test.tsx` - Full coverage
- `auth-flow-clean.test.ts` - Integration tests
**Test Structure:**
```typescript
describe('AuthGuard', () => {
it('should pass when authenticated', async () => {
const guard = new AuthGuard();
const result = await guard.check(mockSession);
expect(result).toBe(true);
});
it('should fail when not authenticated', async () => {
const guard = new AuthGuard();
await expect(guard.enforce(null)).rejects.toThrow(AuthError);
});
});
```
## Architecture Flow
### Request Flow (Clean)
```
1. User requests /dashboard
2. Middleware checks route config
3. If auth required → check cookie
4. If no cookie → redirect to login
5. If authenticated → load layout
6. AuthGuard.enforce() → verify session
7. RoleGuard.enforce() → verify roles
8. Render page
```
### Old Flow (Chaotic)
```
1. User requests /dashboard
2. Middleware checks hardcoded paths
3. RouteGuard checks (complex logic)
4. AuthGuard checks (duplicate logic)
5. AuthGateway checks (deprecated)
6. AuthorizationBlocker checks
7. Layout guards check again
8. Maybe render, maybe not
```
## Files Created
### New Files
- `apps/website/lib/routing/RouteConfig.ts` - Central routing
- `apps/website/lib/guards/AuthGuard.tsx` - Auth guard
- `apps/website/lib/guards/AuthGuard.test.tsx` - Tests
- `apps/website/lib/guards/RoleGuard.tsx` - Role guard
- `apps/website/lib/guards/RoleGuard.test.tsx` - Tests
- `tests/integration/website/auth-flow-clean.test.ts` - Integration
- `docs/architecture/CLEAN_AUTH_SOLUTION.md` - Architecture guide
### Modified Files
- `apps/website/middleware.ts` - Clean middleware
- `apps/website/app/dashboard/layout.tsx` - Updated
- `apps/website/app/profile/layout.tsx` - Updated
- `apps/website/app/sponsor/layout.tsx` - Updated
- `apps/website/app/onboarding/layout.tsx` - Updated
- `apps/website/app/admin/layout.tsx` - Updated
- `apps/website/app/admin/users/page.tsx` - Updated
### Deleted Files
-`apps/website/lib/gateways/` (entire directory)
-`apps/website/lib/blockers/AuthorizationBlocker.ts`
## Key Benefits
### ✅ Predictability
- One clear path for every request
- No hidden logic
- Easy to trace
### ✅ Maintainability
- Single source of truth (RouteConfig)
- No duplication
- Easy to add new routes
### ✅ Testability
- Class-based guards
- Full TDD coverage
- Integration tests
### ✅ Flexibility
- i18n ready
- Role-based access
- Easy to extend
### ✅ Developer Experience
- Type-safe
- Clear errors
- Good documentation
## Migration Checklist
- [x] Analyze current chaos
- [x] Define responsibilities
- [x] Design unified concept
- [x] Create RouteConfig.ts
- [x] Update middleware.ts
- [x] Create AuthGuard
- [x] Create RoleGuard
- [x] Update all layouts
- [x] Write comprehensive tests
- [x] Document architecture
- [x] Verify compilation
- [x] Remove old files
## Next Steps
1. **Start API server** for full integration testing
2. **Run tests** to verify everything works
3. **Test edge cases** (expired sessions, role changes)
4. **Monitor production** for any issues
5. **Document any additional patterns** discovered
## Summary
This refactor transforms the "unpredictable mess" into a **clean, predictable, and maintainable** authentication system:
- **1 central config** instead of scattered paths
- **2 clean guards** instead of 5+ overlapping layers
- **Full TDD coverage** for reliability
- **Clear separation** of concerns
- **Easy to debug** and extend
The architecture is now ready for i18n, new routes, and future enhancements without adding complexity.

View File

@@ -1,374 +0,0 @@
# Clean Authentication & Authorization Solution
## Overview
This document describes the **clean, predictable, and maintainable** authentication and authorization architecture that replaces the previous "fucking unpredictable mess."
## The Problem
**Before:**
- Multiple overlapping protection layers (middleware, RouteGuard, AuthGuard, Blockers, Gateways)
- Hardcoded paths scattered throughout codebase
- Mixed responsibilities between server and client
- Inconsistent patterns across routes
- Role logic in both client and server
- Debugging nightmare with unclear flow
## The Solution
### Core Principle: **Single Source of Truth**
All routing decisions flow through **one centralized configuration system**:
```typescript
// apps/website/lib/routing/RouteConfig.ts
export const routes = {
auth: {
login: '/auth/login',
signup: '/auth/signup',
// ... all auth routes
},
public: {
home: '/',
leagues: '/leagues',
// ... all public routes
},
protected: {
dashboard: '/dashboard',
// ... all protected routes
},
sponsor: {
dashboard: '/sponsor/dashboard',
// ... sponsor routes
},
admin: {
root: '/admin',
users: '/admin/users',
},
league: {
detail: (id: string) => `/leagues/${id}`,
// ... parameterized routes
},
// ... etc
};
```
### Architecture Layers
#### 1. **Edge Middleware** (Simple & Clean)
```typescript
// apps/website/middleware.ts
export function middleware(request: NextRequest) {
const hasAuthCookie = request.cookies.has('gp_session');
// Public routes from config
const publicRoutes = [
routes.public.home,
routes.public.leagues,
routes.auth.login,
// ... etc
];
if (publicRoutes.includes(pathname)) {
// Handle auth route redirects
return NextResponse.next();
}
// Protected routes
if (!hasAuthCookie) {
const loginUrl = new URL(routes.auth.login, request.url);
loginUrl.searchParams.set('returnTo', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
```
**Responsibilities:**
- ✅ Check session cookie
- ✅ Allow public routes
- ✅ Redirect to login if no cookie
- ❌ No role checking
- ❌ No hardcoded paths
#### 2. **Client Guards** (UX Enhancement)
```typescript
// apps/website/lib/guards/AuthGuard.tsx
export function AuthGuard({ children, requireAuth = true }: AuthGuardProps) {
const { session, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (requireAuth && !loading && !session) {
const url = new URL(routes.auth.login, window.location.origin);
url.searchParams.set('returnTo', window.location.pathname);
router.push(url.toString());
}
}, [session, loading]);
if (loading) return <LoadingState />;
if (!session && requireAuth) return null;
return <>{children}</>;
}
// apps/website/lib/guards/RoleGuard.tsx
export function RoleGuard({ children, requiredRoles }: RoleGuardProps) {
const { session, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && session && !requiredRoles.includes(session.role)) {
router.push(routes.protected.dashboard);
}
}, [session, loading]);
if (loading) return <LoadingState />;
if (!session || !requiredRoles.includes(session.role)) return null;
return <>{children}</>;
}
```
**Responsibilities:**
- ✅ Verify session exists
- ✅ Show loading states
- ✅ Redirect if unauthorized
- ✅ Hide UI elements
- ❌ Make security decisions
#### 3. **API Guards** (Source of Truth)
```typescript
// apps/api/src/domain/auth/AuthorizationGuard.ts
@Injectable()
export class AuthorizationGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getMetadata('public', handler);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const userId = request.user?.userId;
if (!userId) {
throw new UnauthorizedException('Authentication required');
}
const rolesMetadata = this.reflector.getMetadata('roles', handler);
if (rolesMetadata) {
const userRoles = this.authorizationService.getRolesForUser(userId);
const hasRole = rolesMetadata.some(r => userRoles.includes(r));
if (!hasRole) {
throw new ForbiddenException('Access denied');
}
}
return true;
}
}
```
**Responsibilities:**
- ✅ Verify authentication
- ✅ Check permissions
- ✅ Return 401/403
- ❌ Redirect
- ❌ Trust client
### Usage Examples
#### Public Route
```typescript
// app/leagues/page.tsx
export default function LeaguesPage() {
return <LeaguesList />;
}
// No protection needed
```
#### Authenticated Route
```typescript
// app/dashboard/layout.tsx
import { AuthGuard } from '@/lib/guards/AuthGuard';
export default function DashboardLayout({ children }) {
return (
<AuthGuard>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
);
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return <DashboardContent />;
}
```
#### Role-Protected Route
```typescript
// app/admin/layout.tsx
import { AuthGuard } from '@/lib/guards/AuthGuard';
import { RoleGuard } from '@/lib/guards/RoleGuard';
export default function AdminLayout({ children }) {
return (
<AuthGuard>
<RoleGuard requiredRoles={['owner', 'admin']}>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</RoleGuard>
</AuthGuard>
);
}
```
#### Scoped Route (League Admin)
```typescript
// app/leagues/[id]/settings/layout.tsx
import { AuthGuard } from '@/lib/guards/AuthGuard';
import { LeagueAccessGuard } from '@/components/leagues/LeagueAccessGuard';
export default function LeagueSettingsLayout({ children, params }) {
return (
<AuthGuard>
<LeagueAccessGuard leagueId={params.id}>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</LeagueAccessGuard>
</AuthGuard>
);
}
```
#### API Endpoint
```typescript
// apps/api/src/domain/league/LeagueController.ts
@Controller('leagues')
export class LeagueController {
@Get(':leagueId/admin')
@RequireAuthenticatedUser()
@RequireRoles('admin')
getLeagueAdmin(@Param('leagueId') leagueId: string) {
// Service verifies league-specific permissions
return this.leagueService.getAdminData(leagueId);
}
}
```
## Benefits
### 1. **Predictable Flow**
```
User Request → Middleware (check cookie) → API (auth + authz) → Controller → Response → Client (handle errors)
```
### 2. **Easy Debugging**
```bash
# Check middleware
curl -I http://localhost:3000/dashboard
# Check API auth
curl -I http://localhost:3000/api/admin/users \
-H "Cookie: gp_session=token"
# Check client session
# Browser console: console.log(useAuth().session)
```
### 3. **i18n Ready**
```typescript
// Future: Switch locales by changing config
const routesDe = { ...routes, auth: { login: '/de/auth/login' } };
const routesEs = { ...routes, auth: { login: '/es/auth/login' } };
// All code uses routes.auth.login, so switching is trivial
```
### 4. **Type Safety**
```typescript
// Compile-time checking
routes.league.detail('123'); // ✅ Works
routes.league.detail(); // ❌ Error: requires string
// Parameter validation
const path = buildPath('league.detail', { id: '123' }); // ✅
const path = buildPath('league.detail', {}); // ❌ Error
```
### 5. **Maintainable**
- **One file** to change all routes
- **No hardcoded paths** anywhere else
- **Clear separation** of concerns
- **Easy to test** each layer independently
## Migration Checklist
### Phase 1: Foundation (1 day)
- [x] Create `RouteConfig.ts` with all routes
- [x] Update `middleware.ts` to use route config
- [x] Remove hardcoded paths from middleware
### Phase 2: Guards (2 days)
- [x] Create `AuthGuard.tsx` with route config
- [x] Create `RoleGuard.tsx` with route config
- [x] Remove old `RouteGuard` and `AuthGuard` files
- [x] Remove `AuthGateway` and `AuthorizationBlocker`
### Phase 3: Route Updates (2 days)
- [ ] Update all route layouts to use new guards
- [ ] Remove redundant page-level checks
- [ ] Test all redirect flows
### Phase 4: API Verification (1 day)
- [ ] Ensure all endpoints have proper decorators
- [ ] Add missing `@Public()` or `@RequireRoles()`
- [ ] Test 401/403 responses
### Phase 5: Documentation & Testing (1 day)
- [ ] Update all route protection docs
- [ ] Create testing checklist
- [ ] Verify all scenarios work
## Testing Checklist
### Unauthenticated User
- [ ] `/dashboard` → Redirects to `/auth/login?returnTo=/dashboard`
- [ ] `/admin` → Redirects to `/auth/login?returnTo=/admin`
- [ ] `/leagues` → Works (public)
- [ ] `/auth/login` → Works (public)
### Authenticated User (Regular)
- [ ] `/dashboard` → Works
- [ ] `/admin` → Redirects to `/dashboard` (no role)
- [ ] `/leagues` → Works (public)
- [ ] `/auth/login` → Redirects to `/dashboard`
### Authenticated User (Admin)
- [ ] `/dashboard` → Works
- [ ] `/admin` → Works
- [ ] `/admin/users` → Works
### Session Expiry
- [ ] Navigate to protected route with expired session → Redirect to login
- [ ] Return to original route after login → Works
### API Direct Calls
- [ ] Call protected endpoint without auth → 401
- [ ] Call admin endpoint without role → 403
- [ ] Call public endpoint → 200
## Summary
This architecture eliminates the chaos by:
1. **One Source of Truth**: All routes in `RouteConfig.ts`
2. **Clear Layers**: Middleware → API → Guards → Controller
3. **No Hardcoded Paths**: Everything uses the config
4. **i18n Ready**: Easy to add localized routes
5. **Type Safe**: Compile-time route validation
6. **Easy to Debug**: Each layer has one job
**Result**: Clean, predictable, secure authentication that just works.

View File

@@ -1,276 +0,0 @@
# Quick Reference: Clean Authentication & Authorization
## The Golden Rules
1. **API is the source of truth** - Never trust the client for security
2. **Client is UX only** - Redirect, show loading, hide buttons
3. **One clear flow** - Middleware → API → Guard → Controller
4. **Roles are server-side** - Client only knows "can access" or "can't"
## What Goes Where
### Server-Side (API)
```typescript
// ✅ DO: Check permissions
@RequireRoles('admin')
@Get('users')
getUsers() { ... }
// ✅ DO: Return 401/403
throw new UnauthorizedException('Auth required')
throw new ForbiddenException('No permission')
// ❌ DON'T: Redirect
res.redirect('/login') // Never do this
// ❌ DON'T: Trust client identity
const userId = req.body.userId // Wrong!
const userId = req.user.userId // Correct
```
### Client-Side (Website)
```typescript
// ✅ DO: Redirect unauthenticated users
if (!session && !loading) {
router.push('/auth/login')
}
// ✅ DO: Show loading states
if (loading) return <Loading />
// ✅ DO: Hide UI elements
{canAccess && <button>Delete</button>}
// ❌ DON'T: Make security decisions
if (user.role === 'admin') // Wrong! API decides
// ❌ DON'T: Trust your own checks
// Client checks are UX only, API is the gatekeeper
```
## Route Protection Patterns
### Public Route
```typescript
// app/leagues/page.tsx
export default function LeaguesPage() {
return <LeaguesList />;
}
// No protection needed - accessible by all
```
### Authenticated Route
```typescript
// app/dashboard/layout.tsx
import { AuthLayout } from '@/lib/guards/AuthLayout';
export default function DashboardLayout({ children }) {
return <AuthLayout>{children}</AuthLayout>;
}
// app/dashboard/page.tsx
export default function DashboardPage() {
return <DashboardContent />;
}
// Layout handles auth check, page is clean
```
### Role-Protected Route
```typescript
// app/admin/layout.tsx
import { RoleLayout } from '@/lib/guards/RoleLayout';
export default function AdminLayout({ children }) {
return (
<RoleLayout requiredRoles={['owner', 'admin']}>
{children}
</RoleLayout>
);
}
// app/admin/page.tsx
export default function AdminPage() {
return <AdminDashboard />;
}
// Layout handles role check
```
### Scoped Route (League Admin)
```typescript
// app/leagues/[id]/settings/layout.tsx
import { AuthLayout } from '@/lib/guards/AuthLayout';
import { LeagueAccessGuard } from '@/components/leagues/LeagueAccessGuard';
export default function LeagueSettingsLayout({ children, params }) {
return (
<AuthLayout>
<LeagueAccessGuard leagueId={params.id}>
{children}
</LeagueAccessGuard>
</AuthLayout>
);
}
// Multiple guards for complex scenarios
```
## API Endpoint Patterns
### Public Endpoint
```typescript
@Public()
@Get('pricing')
getPricing() { ... }
// No auth required
```
### Authenticated Endpoint
```typescript
@RequireAuthenticatedUser()
@Get('profile')
getProfile(@User() user: UserEntity) { ... }
// Any logged-in user
```
### Role-Protected Endpoint
```typescript
@RequireRoles('admin')
@Get('users')
getUsers() { ... }
// Only admins
```
### Scoped Endpoint
```typescript
@RequireAuthenticatedUser()
@Get('leagues/:leagueId/admin')
getLeagueAdmin(
@Param('leagueId') leagueId: string,
@User() user: UserEntity
) {
// Check if user is league admin
this.leagueService.verifyLeagueAdmin(leagueId, user.id);
...
}
// Check scope in service
```
## Error Handling
### API Returns
- **401 Unauthorized**: No/invalid session
- **403 Forbidden**: Has session but no permission
- **404 Not Found**: Resource doesn't exist OR non-disclosure
### Client Handles
```typescript
try {
const data = await apiFetch('/api/admin/users');
return data;
} catch (error) {
if (error.message.includes('401')) {
// Redirect to login
window.location.href = '/auth/login';
} else if (error.message.includes('403')) {
// Show access denied
toast.error('You need admin access');
router.push('/dashboard');
} else {
// Show error
toast.error(error.message);
}
}
```
## Common Mistakes
### ❌ Wrong
```typescript
// Client making security decisions
function AdminPage() {
const { session } = useAuth();
if (session?.role !== 'admin') return <AccessDenied />;
return <AdminDashboard />;
}
// API trusting client
@Post('delete')
deleteUser(@Body() body: { userId: string }) {
const userId = body.userId; // Could be anyone!
...
}
// Middleware doing too much
if (user.role === 'admin') { // Wrong place for this!
return NextResponse.next();
}
```
### ✅ Correct
```typescript
// Client handles UX only
function AdminPage() {
return (
<RoleLayout requiredRoles={['admin']}>
<AdminDashboard />
</RoleLayout>
);
}
// API is source of truth
@Post('delete')
@RequireRoles('admin')
deleteUser(@User() user: UserEntity, @Body() body: { userId: string }) {
// user.id is from session, body.userId is target
// Service verifies permissions
...
}
// Middleware only checks auth
if (!hasAuthCookie) {
return redirect('/login');
}
// Let API handle roles
```
## Testing Checklist
### Before Deploy
- [ ] Unauthenticated user can't access protected routes
- [ ] Authenticated user can access their routes
- [ ] Wrong role gets redirected/denied
- [ ] Session expiry redirects to login
- [ ] API returns proper 401/403 codes
- [ ] Public routes work without login
### Quick Test Commands
```bash
# Test API directly
curl -I http://localhost:3000/api/admin/users
# Should return 401 (no auth)
# Test with session
curl -I http://localhost:3000/api/admin/users \
-H "Cookie: gp_session=valid_token"
# Should return 200 or 403 depending on role
# Test public route
curl -I http://localhost:3000/api/leagues/all
# Should return 200
```
## Migration Steps
1. **Simplify middleware** - Remove role logic
2. **Create clean guards** - AuthLayout, RoleLayout
3. **Update layouts** - Replace old RouteGuard
4. **Test all routes** - Check redirects work
5. **Verify API** - All endpoints have proper decorators
## Remember
- **Server**: Security, permissions, data filtering
- **Client**: UX, loading states, redirects
- **Flow**: Always the same, always predictable
- **Debug**: Check each layer in order
**When in doubt**: The API decides. The client just shows what the API says.

View File

@@ -241,10 +241,13 @@ export class CreateLeaguePresenter implements CreateLeagueOutputPort {
The frontend layer contains UI-specific data shapes. None of these cross into the Core.
There are three distinct frontend data concepts:
Important: `apps/website` is a Next.js delivery app with SSR/RSC. This introduces one additional presentation concept to keep server/client boundaries correct.
There are four distinct frontend data concepts:
1. API DTOs (transport)
2. Command Models (user input / form state)
3. View Models (UI display state)
3. View Models (client-only presentation classes)
4. ViewData (template input, serializable)
@@ -311,6 +314,10 @@ Rules:
• No domain logic
• No mutation after construction
SSR/RSC rule (website-only):
• View Models are client-only and MUST NOT cross server-to-client boundaries.
• Templates MUST NOT accept View Models.
8.4 Website Presenters (DTO → ViewModel)
@@ -427,8 +434,9 @@ UI Component
• Core has NO Models, DTOs, or ViewModels
• API talks ONLY to Application Services
• Controllers NEVER call Use Cases directly
• Frontend Components see ONLY View Models
• DTOs never cross into UI components
• Frontend Components see ONLY ViewData (Templates) or ViewModels (Client orchestrators)
API DTOs never cross into Templates
• View Models never cross into Templates
@@ -439,4 +447,22 @@ Application Services orchestrate.
Adapters translate.
UI presents.
If a class violates more than one of these roles, it is incorrectly placed.
If a class violates more than one of these roles, it is incorrectly placed.
8.3.1 ViewData (Template Input)
ViewData is the only allowed input for Templates in `apps/website`.
Definition:
• JSON-serializable data structure
• Contains only primitives/arrays/plain objects
• Ready to render: Templates perform no formatting and no derived computation
Rules:
• ViewData is built in client code from:
1) Page DTO (initial SSR-safe render)
2) ViewModel (post-hydration enhancement)
• ViewData MUST NOT contain ViewModel instances or Display Object instances.
Authoritative details:
• [docs/architecture/website/VIEW_DATA.md](docs/architecture/website/VIEW_DATA.md:1)
• [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1)

View File

@@ -4,6 +4,13 @@
A **Display Object** encapsulates **reusable, UI-only display logic**.
In this codebase, a Display Object is a **Frontend Value Object**:
- class-based
- immutable
- deterministic
- side-effect free
It answers the question:
> “How should this specific piece of information be shown?”
@@ -23,12 +30,23 @@ A Display Object MAY:
- encapsulate UI display conventions
- be reused across multiple View Models
In addition, a Display Object MAY:
- normalize presentation inputs (for example trimming/casing)
- expose multiple explicit display variants (for example `shortLabel`, `longLabel`)
A Display Object MUST:
- be deterministic
- be side-effect free
- operate only on presentation data
A Display Object MUST:
- be implemented as a **class** with a small, explicit API
- accept only primitives/plain data in its constructor (or static factory)
- expose only primitive outputs (strings/numbers/booleans)
---
## Restrictions
@@ -42,6 +60,13 @@ A Display Object MUST NOT:
- be sent back to the server
- depend on backend or infrastructure concerns
In this repository, a Display Object MUST NOT:
- call `Intl.*`
- call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()`
Reason: these are runtime-locale/timezone dependent and cause SSR/hydration mismatches.
If a rule affects system correctness or persistence,
it does not belong in a Display Object.
@@ -53,6 +78,10 @@ it does not belong in a Display Object.
- They are frontend-only
- They are not shared with the backend or core
Placement rule (strict):
- Display Objects live under `apps/website/lib/display-objects/*`.
---
## Relationship to View Models
@@ -62,6 +91,13 @@ it does not belong in a Display Object.
- Display Objects represent **parts**
- View Models represent **screens**
Additional strict rules:
- View Models SHOULD compose Display Objects.
- Display Objects MUST NOT be serialized or passed across boundaries.
- They must not appear in server-to-client DTOs.
- Templates should receive primitive display outputs, not Display Object instances.
---
## Testing
@@ -72,6 +108,11 @@ Display Objects SHOULD be tested because they often contain:
- formatting rules
- edge cases visible to users
Additionally:
- test determinism by running the same inputs under Node and browser contexts (where applicable)
- test boundary rules (no `Intl.*`, no `toLocale*`)
---
## Summary
@@ -79,4 +120,6 @@ Display Objects SHOULD be tested because they often contain:
- Display Objects encapsulate **how something looks**
- View Models encapsulate **what a screen needs**
- Both are presentation concerns
- Neither contains business truth
- Neither contains business truth
In one sentence: Display Objects are **Value Objects for UI display**, not utility functions.

View File

@@ -0,0 +1,46 @@
# ViewData (Website Templates)
ViewData is the **only** allowed input type for Templates in `apps/website`.
## 1) Definition
ViewData is a JSON-serializable, template-ready data structure:
- primitives (strings/numbers/booleans)
- arrays and plain objects
- `null` for missing values
## 2) What ViewData is NOT
ViewData is not:
- a Page DTO (raw transport)
- a ViewModel (client-only class)
- a Display Object instance
## 3) Construction rules
ViewData MUST be created in client code:
1) Initial SSR-safe render: `ViewData = fromDTO(PageDTO)`
2) Post-hydration render: `ViewData = fromViewModel(ViewModel)`
Templates MUST NOT compute derived values.
## 4) Determinism rules
Any formatting used to produce ViewData MUST be deterministic.
Forbidden anywhere in formatting code paths:
- `Intl.*`
- `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()`
Reason: SSR and browser outputs can differ.
## 5) Relationship to Display Objects
Display Objects are used to implement formatting/mapping, but their instances MUST NOT be stored inside ViewData.
Only primitive outputs produced by Display Objects may appear in ViewData.

View File

@@ -24,6 +24,8 @@ A View Model MAY:
- handle localization and presentation logic
- use Display Objects for reusable UI concerns
In the website SSR/RSC architecture, View Models MAY compute view-only derived values, but MUST NOT be the type passed into Templates.
A View Model MUST:
- be fully usable by the UI without further computation
@@ -58,11 +60,20 @@ that logic belongs in the Core, not here.
## Creation Rules
- View Models are created from API DTOs
- DTOs never reach pages/components; map DTO → ViewModel in website services
- UI components must never construct View Models themselves
- Construction happens in services or presentation layers
- The UI only consumes View Models, never DTOs
This repository distinguishes **Page DTO**, **ViewModel**, and **ViewData**:
- Page DTO: server-to-client payload (JSON-serializable)
- ViewModel: client-only class (never serialized)
- ViewData: template input (JSON-serializable)
Rules (website):
1) View Models are created in client code only.
2) View Models are created from Page DTOs.
3) Templates MUST NOT accept View Models; Templates accept ViewData only.
4) View Models MUST compose Display Objects and produce ViewData (primitive outputs only).
Authoritative reference: [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1).
---
@@ -83,4 +94,4 @@ View Models do NOT need tests if they only expose data without logic.
- View Models describe **UI state**
- They are **presentation-focused**, not business-focused
- They reduce complexity in components
- They form a stable contract for the UI
- They form a stable contract for the UI

View File

@@ -0,0 +1,30 @@
# Website DI Rules (Inversify)
This repo uses Inversify DI under [apps/website/lib/di](apps/website/lib/di/index.ts:1).
## 1) Non-negotiable safety rule
No stateful service instances may be shared across requests.
Reason: Next.js server execution is concurrent; shared state causes cross-request leakage.
## 2) Rules by module type
### 2.1 `page.tsx` (server)
- MUST NOT access the DI container directly.
- MUST call a PageQuery only.
### 2.2 Page Queries (server)
- SHOULD prefer explicit construction (manual wiring).
- MAY use DI only if all resolved services are stateless and safe for concurrent requests.
### 2.3 Client modules
- MAY use DI via `ContainerProvider` and hooks (example: `useInject`).
## 3) Container singleton warning
[`ContainerManager`](apps/website/lib/di/container.ts:61) holds a singleton container. Treat it as **unsafe for server request scope** unless proven otherwise.

View File

@@ -0,0 +1,30 @@
# Website Guardrails (Mandatory)
This document defines architecture guardrails that must be enforced via tests + ESLint.
## 1) RSC boundary guardrails
Fail CI if any `apps/website/app/**/page.tsx`:
- imports from `apps/website/lib/view-models/*`
- calls `Intl.*` or `toLocale*`
- performs sorting/filtering (`sort`, `filter`, `reduce`) beyond trivial null checks
## 2) Template purity guardrails
Fail CI if any `apps/website/templates/**`:
- imports from `apps/website/lib/view-models/*`
- imports from `apps/website/lib/display-objects/*`
- calls `Intl.*` or `toLocale*`
Templates accept ViewData only.
## 3) Display Object guardrails
Fail CI if any `apps/website/lib/display-objects/**`:
- calls `Intl.*` or `toLocale*`
Display Objects must be deterministic.

View File

@@ -0,0 +1,39 @@
# Website Page Queries (Server)
This document defines the only allowed server-side data fetching shape for `apps/website` routes.
## 1) Purpose
Page Queries are server-side composition classes that:
- call services that call `apps/api`
- assemble a Page DTO
- return an explicit result describing route outcome
They do not implement business rules.
## 2) Result type (no null)
Page Queries MUST return a discriminated union (`PageQueryResult`):
- `ok` with `{ dto }`
- `notFound`
- `redirect` with `{ to }`
- `error` with `{ errorId }`
Pages MUST switch on this result and call:
- `notFound()` for `notFound`
- `redirect()` for `redirect`
## 3) Forbidden responsibilities
Page Queries MUST NOT:
- format values for display
- sort/filter (canonical or view-only)
- instantiate ViewModels
- instantiate Display Objects
If sorting/filtering is needed, it MUST be added to `apps/api`.

View File

@@ -0,0 +1,60 @@
# Website RSC Presentation Architecture (Strict)
This document defines the only allowed presentation architecture for `apps/website` (Next.js App Router).
It is **website-only** and does not change `apps/api` or `core` architecture.
## 1) Core rule: API owns business truth
- `apps/api` is the only source of truth for business rules and canonical filtering/sorting.
- `apps/website` is presentation infrastructure: composition, routing, caching, and rendering.
## 2) The three website presentation data types
### 2.1 Page DTO
**Purpose:** server-to-client payload.
**Rules:**
- JSON-serializable only.
- Contains raw values only (ISO date strings, numbers, codes).
- MUST NOT contain class instances.
### 2.2 ViewModel
**Purpose:** client-only presentation model.
**Rules:**
- Class-based.
- Instantiated only in `'use client'` modules.
- Composes Display Objects.
- NEVER passed into Templates.
### 2.3 ViewData
**Purpose:** Template input.
**Rules:**
- JSON-serializable only.
- Contains only values ready to render (mostly strings/numbers).
- Built from Page DTO (initial render) and from ViewModel (post-hydration).
## 3) Required per-route structure
Every route MUST follow:
1) `page.tsx` (server): calls a PageQuery and passes Page DTO
2) `*PageClient.tsx` (client): builds ViewData and renders Template
3) `*Template.tsx` (pure UI): renders ViewData only
## 4) Authoritative specification
This document is an entry point only.
The authoritative, test-enforced spec lives at:
- [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1)

View File

@@ -0,0 +1,415 @@
# Next.js RSC + Client ViewModels + Display Objects (STRICT)
This document is FINAL and STRICT. No alternative interpretations.
## 1) System boundary (non-negotiable)
1. `apps/api` is the single source of truth for:
- business rules
- validation
- authorization decisions
- canonical filtering and canonical sorting
2. `apps/website` is presentation infrastructure:
- renders UI using Next.js App Router
- consumes `apps/api` via existing clients/services
- performs routing/session/caching/composition
- MUST NOT replicate business truth
## 2) Layering rules
### 2.1 Server route entry modules are composition-only
All `page.tsx` modules under [apps/website/app](apps/website/app/page.tsx:1) are composition-only.
`page.tsx` modules MAY:
- read `params` / `searchParams`
- call [`redirect()`](apps/website/app/leaderboards/page.tsx:7) or [`notFound()`](apps/website/app/dashboard/page.tsx:1)
- call a server-side query class
- render server and client components
`page.tsx` modules MUST NOT:
- instantiate ViewModels (example forbidden: [`new DriverProfileViewModel()`](apps/website/lib/view-models/DriverProfileViewModel.ts:108))
- implement formatting (dates, localization, percent, currency)
- implement filtering/sorting (canonical or view-only)
- map API payloads into UI-specific shapes
- define reusable helper functions
### 2.2 Website server query classes (presentation queries)
Each route MUST have exactly one server query class:
- `apps/website/lib/page-queries/<RouteName>PageQuery.ts`
The query class MUST:
- call services that call `apps/api` (example current service: [`DashboardService`](apps/website/lib/services/dashboard/DashboardService.ts:10))
- return a Page DTO (defined below)
- contain no formatting/filtering/sorting
The query class MUST NOT:
- contain business rules
- contain canonical ordering decisions
If ordering/filtering is needed, it MUST be implemented in `apps/api`.
### 2.3 Client ViewModels
ViewModels live in [apps/website/lib/view-models](apps/website/lib/view-models/DriverProfileViewModel.ts:1).
ViewModels MUST:
- be instantiated only in client modules (`'use client'`)
- accept DTOs only (plain data)
- expose view-only derived values (never business truth)
ViewModels MUST NOT be passed into Templates.
ViewModels SHOULD be the primary place that *composes* Display Objects.
### 2.4 Display Objects
Display Objects follow [docs/architecture/DISPLAY_OBJECTS.md](docs/architecture/DISPLAY_OBJECTS.md:1).
Display Objects MUST:
- live under `apps/website/lib/display-objects/*` (example existing: [apps/website/lib/display-objects/LeagueRoleDisplay.ts](apps/website/lib/display-objects/LeagueRoleDisplay.ts:1))
- be deterministic and side-effect free
- be the ONLY place where formatting/mapping conventions exist
Pages MUST NOT format. Templates MUST NOT format.
Display Objects lifecycle (strict):
- Display Objects are created in client code.
- Display Objects are typically created by ViewModels (recommended), and their primitive outputs are used to build ViewData.
- Display Object instances MUST NOT cross any serialization boundary (RSC boundary, network, storage).
#### 2.4.1 Display Objects are Frontend Value Objects (strict definition)
Treat Display Objects like Domain Value Objects, but for the **presentation layer**.
Display Objects are:
- **Class-based**
- **Immutable**
- **Small** (one concept per object)
- **Deterministic** (same input -> same output everywhere)
- **Side-effect free**
Display Objects are NOT:
- utility modules of exported functions
- global lookup tables exported for ad hoc access
- a place to hide page logic
Why strict class-based?
- Naming: an object name communicates the concept (example: `MoneyDisplay`, `CountryDisplay`, `MonthYearDisplay`)
- Encapsulation: invariants and normalization live in one constructor path
- Reuse: ViewModels can share them without duplicating logic
#### 2.4.2 Allowed responsibilities
Display Objects MAY:
- format raw values into **display strings** (date, number, money)
- map codes -> labels/icons/style tokens
- expose variants explicitly (for example `asShortLabel()`, `asLongLabel()`)
Display Objects MUST NOT:
- contain business rules (those live in `apps/api`)
- validate domain invariants
- call network or storage
- depend on framework runtime (React, Next.js)
- depend on runtime locale/timezone formatting APIs (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) “Deterministic formatting requirement for Display Objects”)
#### 2.4.3 Strict API shape
Each Display Object class MUST:
- have a single responsibility
- accept only primitives in its constructor (or static constructor)
- expose only primitive outputs (strings/numbers/booleans)
Recommended pattern:
- `private constructor(...)`
- `static fromX(value: ...)` factory for normalization
- instance methods like `toString()`, `label()`, `cssClassToken()`, `ariaLabel()`
Non-negotiable: no exported free functions as the primary API.
#### 2.4.4 Serialization boundary rule
Display Objects MUST NEVER appear in:
- Page DTOs crossing server -> client
- ViewData passed into Templates
Only the Display Objects primitive outputs may be copied into ViewData.
## 3) ViewData for Templates (strict)
Templates MUST render **ViewData**, not ViewModels.
Definitions:
- **Page DTO**: the serializable data returned by a server query and passed across the RSC boundary.
- **ViewModel**: client-only object that encapsulates view-only derivations and composes Display Objects.
- **ViewData**: a JSON-serializable, template-ready data structure that Templates render.
Rules:
1) ViewData MUST be JSON-serializable (same restrictions as Page DTO in [Section 3](plans/nextjs-rsc-viewmodels-concept.md:83)).
2) ViewData MUST contain only values ready for display. Templates MUST NOT format.
3) ViewData MUST be produced in client code:
- Initial render: from Page DTO (SSR-safe)
- Post-hydration: from ViewModel (client-only)
4) Formatting implementation MUST live in Display Objects in `apps/website/lib/display-objects/*`.
5) ViewData MUST NOT contain Display Object instances. ViewData contains only primitives (mostly strings) that were produced by Display Objects.
Rationale: Display Objects are classes/value objects and are not safe to serialize across the Next.js Client Component boundary. They are used as deterministic formatters/mappers, but only their primitive outputs may enter ViewData.
## 4) DTO boundary (RSC boundary)
### 4.1 Page DTO definition
The ONLY data that may cross from a server component into a client component is a Page DTO.
Page DTOs MUST:
- be JSON-serializable
- contain only primitives, arrays, and plain objects
- use ISO strings for timestamps
- use `null` for missing values (no `undefined`)
Page DTOs MUST NOT contain:
- ViewModels
- Display Objects
- `Date`
- `Map` / `Set`
- functions
### 4.2 DTO types
When a ViewModel already defines its raw data type, that raw data type IS the Page DTO.
Example (profile): [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93).
Dashboard MUST define an equivalent `DashboardOverviewViewModelData` (or analogous) next to the dashboard ViewModel.
## 4.3 Deterministic formatting requirement for Display Objects
Because ViewData is rendered during SSR and re-rendered after hydration, any formatting used to produce ViewData MUST be deterministic across Node and the browser.
Therefore Display Objects MUST NOT use locale-dependent runtime formatting APIs, including:
- `Intl.*`
- `Date.toLocaleString()` / `Date.toLocaleDateString()`
This policy is strict and global for `apps/website`: `Intl.*` and `toLocale*` are forbidden everywhere in rendering codepaths (pages, templates, components, view models, display objects). If formatting is required, it MUST be implemented deterministically via explicit algorithms/lookup tables.
Display Objects MAY use:
- explicit lookup tables (example: month names)
- numeric formatting implemented without locale APIs
This is the only way to guarantee identical SSR and client outputs.
## 4.4 ViewData and Display Objects (serialization rule)
Display Objects are classes/value objects. They are NOT guaranteed to be serializable.
Therefore:
- ViewData MUST NOT contain Display Object instances.
- ViewData contains only primitives (usually strings) produced by Display Objects.
## 5) Query result contract (no `null`)
Rationale: returning `null` from server-side fetch orchestration conflates “not found”, “unauthorized/redirect”, and “unexpected error”. This makes route behavior ambiguous and encourages pages to implement policy via ad hoc checks.
Therefore, this concept forbids `null` as a query outcome.
### 5.1 Mandatory `PageQueryResult` discriminated union
Every server query class (see [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:42)) MUST return a discriminated union:
- `ok` with `{ dto: PageDTO }`
- `notFound`
- `redirect` with `{ to: string }`
- `error` with `{ errorId: string }` (and logging done server-side)
Pages MUST switch on this result and decide:
- `notFound` -> [`notFound()`](apps/website/app/dashboard/page.tsx:1)
- `redirect` -> [`redirect()`](apps/website/app/leaderboards/page.tsx:7)
- `error` -> throw to Next.js error boundary or render route error boundary
### 5.2 `PageDataFetcher` usage rule
The current [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) and [`PageDataFetcher.fetchManual()`](apps/website/lib/page/PageDataFetcher.ts:36) return `null` on error.
In the new architecture:
- Server page modules MUST NOT consume `null`-returning APIs for route decisions.
- Server query classes MUST wrap any usage of [`PageDataFetcher`](apps/website/lib/page/PageDataFetcher.ts:9) into `PageQueryResult` and MUST NOT leak `null` upward.
If `PageDataFetcher` is refactored later, its single-fetch methods MUST return a result type (similar to [`FetchResult`](apps/website/lib/page/PageDataFetcher.ts:3)) rather than `null`.
## 5.3 DI usage (strict)
This repo uses Inversify DI under [apps/website/lib/di](apps/website/lib/di/index.ts:1).
Rules:
1) `page.tsx` modules MUST NOT access the DI container directly (no [`ContainerManager.getInstance()`](apps/website/lib/di/container.ts:67)).
2) Server query classes MAY use DI, but only if all resolved services are stateless and safe for concurrent requests.
3) Because [`ContainerManager`](apps/website/lib/di/container.ts:61) holds a singleton container, server query classes SHOULD prefer explicit construction (manual wiring) over using the singleton container.
4) Client components MAY use DI via `ContainerProvider` + hooks like [`useInject`](apps/website/lib/di/hooks/useInject.ts:1).
Non-negotiable: no stateful service instances may be shared across requests via the singleton container.
## 6) Required component shape per route
Every route MUST be structured as:
1) `page.tsx` (Server Component)
2) `*PageClient.tsx` (Client Component)
3) `*Template.tsx` (pure stateless UI)
### 6.1 Server `page.tsx`
Server `page.tsx` MUST:
- call the route query class
- pass only the Page DTO into the client component
Server `page.tsx` MUST NOT:
- import from `apps/website/lib/view-models/*`
- instantiate ViewModels
### 6.2 Client `*PageClient.tsx`
Client `*PageClient.tsx` MUST:
- start with `'use client'`
- accept the Page DTO as prop
- render the Template with **ViewData**
Client `*PageClient.tsx` MUST implement a two-phase render:
1) Initial render (SSR-safe):
- MUST NOT instantiate ViewModels
- MUST create initial ViewData directly from Page DTO
- MUST render Template with initial ViewData
2) Post-hydration (client-only):
- MUST instantiate the ViewModel
- MUST derive enhanced ViewData from the ViewModel (using Display Objects)
- MUST re-render Template with enhanced ViewData
## 6.4 Initial SSR ViewData policy (non-optional)
Initial SSR ViewData MUST be **fully populated**, but only using deterministic formatting as defined in [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1) under “Deterministic formatting requirement for Display Objects”.
This yields:
- SSR delivers meaningful content (no skeleton-only pages)
- Hydration stays stable because the same deterministic Display Objects run on both SSR and client
### 6.3 `*Template.tsx` (pure UI)
Templates MUST:
- be pure and stateless
- accept `ViewData` only
- contain no formatting logic
- contain no filtering/sorting logic
Templates MAY be imported by server or client modules.
Templates MUST NOT import:
- `apps/website/lib/view-models/*`
- `apps/website/lib/display-objects/*`
## 7) Hydration safety (strict)
Hydration mismatch warnings are treated as build-breaking defects.
Forbidden in any `page.tsx` module under [apps/website/app](apps/website/app/page.tsx:1):
- [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430)
- any other locale/timezone dependent formatting
- any non-determinism (`Math.random`, `Date.now`) during render
All human-readable formatting MUST be done via Display Objects in the client.
Additionally forbidden anywhere Display Objects are executed to produce ViewData:
- `Intl.*`
- `Date.toLocaleString()` / `Date.toLocaleDateString()`
## 8) Guardrails (mandatory)
### 8.1 Boundary tests
Extend [apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts](apps/website/lib/services/pagesViewModelsOnly.boundary.test.ts:1) with tests that FAIL when:
- any `apps/website/app/**/page.tsx` imports from `apps/website/lib/view-models/*`
- any `apps/website/app/**/page.tsx` contains banned formatting calls (including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430))
- any `apps/website/app/**/page.tsx` contains sorting/filtering logic (`sort`, `filter`, `reduce`) outside trivial null checks
Add template boundary tests that FAIL when:
- any `apps/website/templates/**` imports from `apps/website/lib/view-models/*`
- any `apps/website/templates/**` imports from `apps/website/lib/display-objects/*`
### 8.2 ESLint restrictions
Add ESLint restrictions that enforce the same rules at authoring time.
## 9) Migration steps (dashboard first, then profile)
### 9.1 Dashboard
Starting point: [apps/website/app/dashboard/page.tsx](apps/website/app/dashboard/page.tsx:1).
Steps:
1) Introduce `DashboardPageQuery` under `apps/website/lib/page-queries/*` that returns a Dashboard Page DTO.
2) Change the dashboard server page to call the query and render `DashboardPageClient`.
3) Create `DashboardPageClient` as client module:
- Initial render: builds ViewData from DTO and renders [`DashboardTemplate`](apps/website/templates/DashboardTemplate.tsx:1).
- Post-hydration: instantiates dashboard ViewModel, builds enhanced ViewData, re-renders template.
4) Ensure any display formatting is implemented as Display Objects.
### 9.2 Profile
Starting point: [apps/website/app/profile/page.tsx](apps/website/app/profile/page.tsx:1).
Steps:
1) Move all helper logic out of the page module into a template and Display Objects.
2) Make profile `page.tsx` a server component that calls a query class returning [`DriverProfileViewModelData`](apps/website/lib/view-models/DriverProfileViewModel.ts:93).
3) Create `ProfilePageClient` as client module:
- Initial render: builds ViewData from DTO and renders the template.
- Post-hydration: instantiates [`DriverProfileViewModel`](apps/website/lib/view-models/DriverProfileViewModel.ts:108), builds enhanced ViewData, re-renders template.
4) Remove all formatting in the page module, including [`Date.toLocaleDateString()`](apps/website/app/profile/page.tsx:430).
## 10) Acceptance criteria
1) No hydration mismatch warnings on dashboard and profile.
2) No ViewModel instantiation in server modules.
3) No formatting/sorting/filtering logic in any module under [apps/website/app](apps/website/app/page.tsx:1).
4) All formatting is encapsulated by Display Objects under `apps/website/lib/display-objects/*`.