docs
This commit is contained in:
95
apps/website/app/dashboard/DashboardPageClient.tsx
Normal file
95
apps/website/app/dashboard/DashboardPageClient.tsx
Normal 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} />;
|
||||
}
|
||||
70
apps/website/app/dashboard/DashboardViewData.ts
Normal file
70
apps/website/app/dashboard/DashboardViewData.ts
Normal 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;
|
||||
}
|
||||
88
apps/website/app/dashboard/DashboardViewDataBuilder.ts
Normal file
88
apps/website/app/dashboard/DashboardViewDataBuilder.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
138
apps/website/lib/display-objects/DashboardDisplay.ts
Normal file
138
apps/website/lib/display-objects/DashboardDisplay.ts
Normal 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();
|
||||
}
|
||||
261
apps/website/lib/display-objects/ProfileDisplay.ts
Normal file
261
apps/website/lib/display-objects/ProfileDisplay.ts
Normal 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;
|
||||
}
|
||||
140
apps/website/lib/page-queries/DashboardPageQuery.ts
Normal file
140
apps/website/lib/page-queries/DashboardPageQuery.ts
Normal 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)) };
|
||||
}
|
||||
}
|
||||
}
|
||||
114
apps/website/lib/page-queries/ProfilePageQuery.ts
Normal file
114
apps/website/lib/page-queries/ProfilePageQuery.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
46
docs/architecture/website/VIEW_DATA.md
Normal file
46
docs/architecture/website/VIEW_DATA.md
Normal 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.
|
||||
|
||||
@@ -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
|
||||
30
docs/architecture/website/WEBSITE_DI_RULES.md
Normal file
30
docs/architecture/website/WEBSITE_DI_RULES.md
Normal 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.
|
||||
|
||||
30
docs/architecture/website/WEBSITE_GUARDRAILS.md
Normal file
30
docs/architecture/website/WEBSITE_GUARDRAILS.md
Normal 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.
|
||||
|
||||
39
docs/architecture/website/WEBSITE_PAGE_QUERIES.md
Normal file
39
docs/architecture/website/WEBSITE_PAGE_QUERIES.md
Normal 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`.
|
||||
|
||||
60
docs/architecture/website/WEBSITE_RSC_PRESENTATION.md
Normal file
60
docs/architecture/website/WEBSITE_RSC_PRESENTATION.md
Normal 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)
|
||||
|
||||
415
plans/nextjs-rsc-viewmodels-concept.md
Normal file
415
plans/nextjs-rsc-viewmodels-concept.md
Normal 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 Object’s 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/*`.
|
||||
Reference in New Issue
Block a user