website refactor

This commit is contained in:
2026-01-16 01:32:55 +01:00
parent a98e3e3166
commit b533de8486
23 changed files with 651 additions and 159 deletions

View File

@@ -199,7 +199,8 @@
"gridpilot-rules/client-only-must-have-directive": "error",
"gridpilot-rules/server-actions-must-use-mutations": "error",
"gridpilot-rules/server-actions-return-result": "error",
"gridpilot-rules/server-actions-interface": "error"
"gridpilot-rules/server-actions-interface": "error",
"gridpilot-rules/no-use-mutation-in-client": "error"
}
},
{

View File

@@ -2,8 +2,8 @@
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import { routes } from '@/lib/routing/RouteConfig';
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
@@ -375,27 +375,7 @@ function LeagueSlider({
hideScrollbar
>
{leagues.map((league) => {
// TODO wtf we have builders for this
// Convert ViewData to ViewModel for LeagueCard
const viewModel: LeagueSummaryViewModel = {
id: league.id,
name: league.name,
description: league.description ?? '',
logoUrl: league.logoUrl,
ownerId: league.ownerId,
createdAt: league.createdAt,
maxDrivers: league.maxDrivers,
usedDriverSlots: league.usedDriverSlots,
maxTeams: league.maxTeams ?? 0,
usedTeamSlots: league.usedTeamSlots ?? 0,
structureSummary: league.structureSummary,
timingSummary: league.timingSummary,
category: league.category ?? undefined,
scoring: league.scoring ? {
...league.scoring,
primaryChampionshipType: league.scoring.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy',
} : undefined,
};
const viewModel = LeagueSummaryViewModelBuilder.build(league);
return (
<Box key={league.id} flexShrink={0} w="320px" h="full">
@@ -621,26 +601,7 @@ export function LeaguesPageClient({
</Box>
<Grid cols={1} mdCols={2} lgCols={3} gap={6}>
{categoryFilteredLeagues.map((league) => {
// Convert ViewData to ViewModel for LeagueCard
const viewModel: LeagueSummaryViewModel = {
id: league.id,
name: league.name,
description: league.description ?? '',
logoUrl: league.logoUrl,
ownerId: league.ownerId,
createdAt: league.createdAt,
maxDrivers: league.maxDrivers,
usedDriverSlots: league.usedDriverSlots,
maxTeams: league.maxTeams ?? 0,
usedTeamSlots: league.usedTeamSlots ?? 0,
structureSummary: league.structureSummary,
timingSummary: league.timingSummary,
category: league.category ?? undefined,
scoring: league.scoring ? {
...league.scoring,
primaryChampionshipType: league.scoring.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy',
} : undefined,
};
const viewModel = LeagueSummaryViewModelBuilder.build(league);
return (
<GridItem key={league.id}>
@@ -677,4 +638,4 @@ export function LeaguesPageClient({
)}
</Container>
);
}
}

View File

@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
import { Text } from '@/ui/Text';
export default async function LeagueLayout({
@@ -42,7 +43,16 @@ export default async function LeagueLayout({
);
}
const viewData = result.unwrap();
const data = result.unwrap();
const viewData = LeagueDetailViewDataBuilder.build({
league: data.league,
owner: null,
scoringConfig: null,
memberships: { members: [] },
races: [],
sponsors: [],
});
// Define tab configuration
const baseTabs = [

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { useParams, useRouter } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
import {
@@ -10,9 +10,13 @@ import {
useLeagueAdminSchedule
} from "@/hooks/league/useLeagueScheduleAdminPageData";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import {
publishScheduleAction,
unpublishScheduleAction,
createRaceAction,
updateRaceAction,
deleteRaceAction
} from './actions';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
@@ -23,10 +27,8 @@ export function LeagueAdminSchedulePageClient() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
const queryClient = useQueryClient();
const router = useRouter();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
// Form state
const [seasonId, setSeasonId] = useState<string>('');
const [track, setTrack] = useState('');
@@ -34,6 +36,11 @@ export function LeagueAdminSchedulePageClient() {
const [scheduledAtIso, setScheduledAtIso] = useState('');
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
// Action state
const [isPublishing, setIsPublishing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deletingRaceId, setDeletingRaceId] = useState<string | null>(null);
// Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
@@ -48,62 +55,6 @@ export function LeagueAdminSchedulePageClient() {
// Load schedule using domain hook
const { data: schedule, isLoading: scheduleLoading } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
// Mutations
const publishMutation = useMutation({
mutationFn: async () => {
if (!schedule || !selectedSeasonId) return null;
return schedule.published
? await leagueService.unpublishAdminSchedule(leagueId, selectedSeasonId)
: await leagueService.publishAdminSchedule(leagueId, selectedSeasonId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedSeasonId || !scheduledAtIso) return null;
if (!editingRaceId) {
return await leagueService.createAdminScheduleRace(leagueId, selectedSeasonId, {
track,
car,
scheduledAtIso,
});
} else {
return await leagueService.updateAdminScheduleRace(leagueId, selectedSeasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
// Reset form
setTrack('');
setCar('');
setScheduledAtIso('');
setEditingRaceId(null);
},
});
const deleteMutation = useMutation({
mutationFn: async (raceId: string) => {
return await leagueService.deleteAdminScheduleRace(leagueId, selectedSeasonId, raceId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
// Derived states
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
const isPublishing = publishMutation.isPending;
const isSaving = saveMutation.isPending;
const isDeleting = deleteMutation.variables || null;
// Handlers
const handleSeasonChange = (newSeasonId: string) => {
setSeasonId(newSeasonId);
@@ -113,13 +64,51 @@ export function LeagueAdminSchedulePageClient() {
setScheduledAtIso('');
};
const handlePublishToggle = () => {
publishMutation.mutate();
const handlePublishToggle = async () => {
if (!schedule || !selectedSeasonId) return;
setIsPublishing(true);
try {
const result = schedule.published
? await unpublishScheduleAction(leagueId, selectedSeasonId)
: await publishScheduleAction(leagueId, selectedSeasonId);
if (result.isOk()) {
router.refresh();
} else {
alert(result.getError());
}
} finally {
setIsPublishing(false);
}
};
const handleAddOrSave = () => {
if (!scheduledAtIso) return;
saveMutation.mutate();
const handleAddOrSave = async () => {
if (!selectedSeasonId || !scheduledAtIso) return;
setIsSaving(true);
try {
const result = !editingRaceId
? await createRaceAction(leagueId, selectedSeasonId, { track, car, scheduledAtIso })
: await updateRaceAction(leagueId, selectedSeasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
if (result.isOk()) {
// Reset form
setTrack('');
setCar('');
setScheduledAtIso('');
setEditingRaceId(null);
router.refresh();
} else {
alert(result.getError());
}
} finally {
setIsSaving(false);
}
};
const handleEdit = (raceId: string) => {
@@ -128,15 +117,27 @@ export function LeagueAdminSchedulePageClient() {
if (!race) return;
setEditingRaceId(raceId);
setTrack('');
setCar('');
setTrack(race.track || '');
setCar(race.car || '');
setScheduledAtIso(race.scheduledAt.toISOString());
};
const handleDelete = (raceId: string) => {
const handleDelete = async (raceId: string) => {
if (!selectedSeasonId) return;
const confirmed = window.confirm('Delete this race?');
if (!confirmed) return;
deleteMutation.mutate(raceId);
setDeletingRaceId(raceId);
try {
const result = await deleteRaceAction(leagueId, selectedSeasonId, raceId);
if (result.isOk()) {
router.refresh();
} else {
alert(result.getError());
}
} finally {
setDeletingRaceId(null);
}
};
const handleCancelEdit = () => {
@@ -146,6 +147,9 @@ export function LeagueAdminSchedulePageClient() {
setScheduledAtIso('');
};
// Derived states
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
// Prepare template data
const templateData = schedule && seasonsData && selectedSeasonId
? {
@@ -200,7 +204,7 @@ export function LeagueAdminSchedulePageClient() {
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={isDeleting}
isDeleting={deletingRaceId}
setTrack={setTrack}
setCar={setCar}
setScheduledAtIso={setScheduledAtIso}
@@ -221,4 +225,4 @@ export function LeagueAdminSchedulePageClient() {
}}
/>
);
}
}

View File

@@ -0,0 +1,70 @@
'use server';
import { revalidatePath } from 'next/cache';
import { Result } from '@/lib/contracts/Result';
import { ScheduleAdminMutation } from '@/lib/mutations/leagues/ScheduleAdminMutation';
import { routes } from '@/lib/routing/RouteConfig';
export async function publishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
const mutation = new ScheduleAdminMutation();
const result = await mutation.publishSchedule(leagueId, seasonId);
if (result.isOk()) {
revalidatePath(routes.league.schedule(leagueId));
}
return result;
}
export async function unpublishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
const mutation = new ScheduleAdminMutation();
const result = await mutation.unpublishSchedule(leagueId, seasonId);
if (result.isOk()) {
revalidatePath(routes.league.schedule(leagueId));
}
return result;
}
export async function createRaceAction(
leagueId: string,
seasonId: string,
input: { track: string; car: string; scheduledAtIso: string }
): Promise<Result<void, string>> {
const mutation = new ScheduleAdminMutation();
const result = await mutation.createRace(leagueId, seasonId, input);
if (result.isOk()) {
revalidatePath(routes.league.schedule(leagueId));
}
return result;
}
export async function updateRaceAction(
leagueId: string,
seasonId: string,
raceId: string,
input: Partial<{ track: string; car: string; scheduledAtIso: string }>
): Promise<Result<void, string>> {
const mutation = new ScheduleAdminMutation();
const result = await mutation.updateRace(leagueId, seasonId, raceId, input);
if (result.isOk()) {
revalidatePath(routes.league.schedule(leagueId));
}
return result;
}
export async function deleteRaceAction(leagueId: string, seasonId: string, raceId: string): Promise<Result<void, string>> {
const mutation = new ScheduleAdminMutation();
const result = await mutation.deleteRace(leagueId, seasonId, raceId);
if (result.isOk()) {
revalidatePath(routes.league.schedule(leagueId));
}
return result;
}

View File

@@ -169,6 +169,7 @@ module.exports = {
// Architecture Rules
'no-index-files': require('./no-index-files'),
'no-use-mutation-in-client': require('./no-use-mutation-in-client'),
},
// Configurations for different use cases
@@ -271,6 +272,7 @@ module.exports = {
// Route Configuration Rules
'gridpilot-rules/no-hardcoded-routes': 'error',
'gridpilot-rules/no-use-mutation-in-client': 'error',
},
},

View File

@@ -0,0 +1,50 @@
/**
* ESLint Rule: No useMutation in Client Components
*
* Forbids the use of useMutation from @tanstack/react-query in client components.
* All write operations must go through Next.js Server Actions.
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Forbid useMutation usage in client components',
category: 'Architecture',
recommended: true,
},
messages: {
noUseMutation: 'useMutation from @tanstack/react-query is forbidden. Use Next.js Server Actions for all write operations. See docs/architecture/website/FORM_SUBMISSION.md',
},
schema: [],
},
create(context) {
return {
ImportDeclaration(node) {
if (node.source.value === '@tanstack/react-query') {
const useMutationSpecifier = node.specifiers.find(
(specifier) =>
specifier.type === 'ImportSpecifier' &&
specifier.imported.name === 'useMutation'
);
if (useMutationSpecifier) {
context.report({
node: useMutationSpecifier,
messageId: 'noUseMutation',
});
}
}
},
CallExpression(node) {
if (node.callee.type === 'Identifier' && node.callee.name === 'useMutation') {
context.report({
node,
messageId: 'noUseMutation',
});
}
},
};
},
};

View File

@@ -1,19 +1,16 @@
'use client';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
import { completeOnboardingAction } from '@/app/onboarding/completeOnboardingAction';
import { Result } from '@/lib/contracts/Result';
import { DomainError } from '@/lib/contracts/services/Service';
import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
import { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
export function useCompleteOnboarding(
options?: Omit<UseMutationOptions<Result<CompleteOnboardingOutputDTO, DomainError>, Error, CompleteOnboardingInputDTO>, 'mutationFn'>
options?: Omit<UseMutationOptions<Result<{ success: boolean }, string>, Error, CompleteOnboardingInputDTO>, 'mutationFn'>
) {
return useMutation<Result<CompleteOnboardingOutputDTO, DomainError>, Error, CompleteOnboardingInputDTO>({
return useMutation<Result<{ success: boolean }, string>, Error, CompleteOnboardingInputDTO>({
mutationFn: async (input) => {
const service = new OnboardingService();
return await service.completeOnboarding(input);
return await completeOnboardingAction(input);
},
...options,
});

View File

@@ -1,8 +1,8 @@
'use client';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { generateAvatarsAction } from '@/app/onboarding/generateAvatarsAction';
import { Result } from '@/lib/contracts/Result';
import { DomainError } from '@/lib/contracts/services/Service';
interface GenerateAvatarsParams {
userId: string;
@@ -13,17 +13,14 @@ interface GenerateAvatarsParams {
interface GenerateAvatarsResult {
success: boolean;
avatarUrls?: string[];
errorMessage?: string;
}
export function useGenerateAvatars(
options?: Omit<UseMutationOptions<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>, 'mutationFn'>
options?: Omit<UseMutationOptions<Result<GenerateAvatarsResult, string>, Error, GenerateAvatarsParams>, 'mutationFn'>
) {
return useMutation<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>({
mutationFn: async (_params) => {
// This method doesn't exist in the service yet, but the hook is now created
// The service will need to implement this or we need to adjust the architecture
return Result.ok({ success: false, errorMessage: 'Not implemented' });
return useMutation<Result<GenerateAvatarsResult, string>, Error, GenerateAvatarsParams>({
mutationFn: async (params) => {
return await generateAvatarsAction(params);
},
...options,
});

View File

@@ -0,0 +1,26 @@
import { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
export class LeagueSummaryViewModelBuilder {
static build(league: LeaguesViewData['leagues'][number]): LeagueSummaryViewModel {
return {
id: league.id,
name: league.name,
description: league.description ?? '',
logoUrl: league.logoUrl,
ownerId: league.ownerId,
createdAt: league.createdAt,
maxDrivers: league.maxDrivers,
usedDriverSlots: league.usedDriverSlots,
maxTeams: league.maxTeams ?? 0,
usedTeamSlots: league.usedTeamSlots ?? 0,
structureSummary: league.structureSummary,
timingSummary: league.timingSummary,
category: league.category ?? undefined,
scoring: league.scoring ? {
...league.scoring,
primaryChampionshipType: league.scoring.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy',
} : undefined,
};
}
}

View File

@@ -0,0 +1,47 @@
/**
* DateDisplay
*
* Deterministic date formatting for display.
* Avoids Intl and toLocaleString to prevent SSR/hydration mismatches.
*/
export class DateDisplay {
private static readonly MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
private static readonly FULL_MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
private static readonly DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
private static readonly FULL_DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
/**
* Formats a date as "MMM D, YYYY" (e.g., "Jan 1, 2024")
*/
static formatShort(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return `${this.MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
/**
* Formats a date as "YYYY-MM-DD"
*/
static formatIso(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const day = d.getDate().toString().padStart(2, '0');
return `${d.getFullYear()}-${month}-${day}`;
}
/**
* Formats a date as "Weekday, Month Day, Year"
*/
static formatFull(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return `${this.FULL_DAYS[d.getDay()]}, ${this.FULL_MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
/**
* Formats a date as "MMM YYYY" (e.g., "Jan 2024")
*/
static formatMonthYear(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return `${this.MONTHS[d.getMonth()]} ${d.getFullYear()}`;
}
}

View File

@@ -0,0 +1,18 @@
/**
* NumberDisplay
*
* Deterministic number formatting for display.
* Avoids Intl and toLocaleString to prevent SSR/hydration mismatches.
*/
export class NumberDisplay {
/**
* Formats a number with thousands separators (commas).
* Example: 1234567 -> "1,234,567"
*/
static format(value: number): string {
const parts = value.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
}
}

View File

@@ -1,6 +1,6 @@
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { Result } from '@/lib/contracts/Result';
import { LeagueService } from '@/lib/services/leagues/LeagueService';
import { LeagueService, type LeagueDetailData } from '@/lib/services/leagues/LeagueService';
import { type PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError';
/**
@@ -8,8 +8,8 @@ import { type PresentationError, mapToPresentationError } from '@/lib/contracts/
* Returns the raw API DTO for the league detail page
* No DI container usage - constructs dependencies explicitly
*/
export class LeagueDetailPageQuery implements PageQuery<unknown, string, PresentationError> {
async execute(leagueId: string): Promise<Result<unknown, PresentationError>> {
export class LeagueDetailPageQuery implements PageQuery<LeagueDetailData, string, PresentationError> {
async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
const service = new LeagueService();
const result = await service.getLeagueDetailData(leagueId);
@@ -21,7 +21,7 @@ export class LeagueDetailPageQuery implements PageQuery<unknown, string, Present
}
// Static method to avoid object construction in server code
static async execute(leagueId: string): Promise<Result<unknown, PresentationError>> {
static async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
const query = new LeagueDetailPageQuery();
return query.execute(leagueId);
}

View File

@@ -105,6 +105,9 @@ export function usePageDataMultiple<T extends Record<string, unknown>>(
* Mutation hook wrapper - STANDARDIZED PATTERN
* Use for: All mutation operations
*
* @deprecated Use Next.js Server Actions instead for all write operations.
* See docs/architecture/website/FORM_SUBMISSION.md
*
* @example
* const mutation = usePageMutation(
* (variables) => service.mutateData(variables),

View File

@@ -12,6 +12,7 @@ import { Container } from '@/ui/Container';
import { Icon } from '@/ui/Icon';
import { StatusBadge } from '@/ui/StatusBadge';
import { InfoBox } from '@/ui/InfoBox';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import {
RefreshCw,
Shield,
@@ -193,7 +194,7 @@ export function AdminUsersTemplate({
</TableCell>
<TableCell>
<Text size="sm" color="text-gray-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
</Text>
</TableCell>
<TableCell>

View File

@@ -24,6 +24,7 @@ import { RecentActivity } from '@/components/feed/RecentActivity';
import { PageHero } from '@/ui/PageHero';
import { DriversSearch } from '@/ui/DriversSearch';
import { EmptyState } from '@/components/shared/state/EmptyState';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
interface DriversTemplateProps {
@@ -62,8 +63,8 @@ export function DriversTemplate({
stats={[
{ label: 'drivers', value: drivers.length, color: 'text-primary-blue' },
{ label: 'active', value: activeCount, color: 'text-performance-green', animate: true },
{ label: 'total wins', value: totalWins.toLocaleString(), color: 'text-warning-amber' },
{ label: 'races', value: totalRaces.toLocaleString(), color: 'text-neon-aqua' },
{ label: 'total wins', value: NumberDisplay.format(totalWins), color: 'text-warning-amber' },
{ label: 'races', value: NumberDisplay.format(totalRaces), color: 'text-neon-aqua' },
]}
actions={[
{

View File

@@ -11,6 +11,7 @@ import { GridItem } from '@/ui/GridItem';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import { Settings, Users, Trophy, Shield, Clock, LucideIcon } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import type { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData';
interface LeagueSettingsTemplateProps {
@@ -47,7 +48,7 @@ export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps
<GridItem colSpan={2}>
<InfoItem label="Description" value={viewData.league.description} />
</GridItem>
<InfoItem label="Created" value={new Date(viewData.league.createdAt).toLocaleDateString()} />
<InfoItem label="Created" value={DateDisplay.formatShort(viewData.league.createdAt)} />
<InfoItem label="Owner ID" value={viewData.league.ownerId} />
</Grid>
</Stack>

View File

@@ -13,6 +13,7 @@ import { Grid } from '@/ui/Grid';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import { ArrowLeft, Trophy, Zap, type LucideIcon } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import type { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData';
import { RaceResultRow } from '@/components/races/RaceResultRow';
import { RacePenaltyRow } from '@/ui/RacePenaltyRowWrapper';
@@ -45,12 +46,7 @@ export function RaceResultsTemplate({
importError,
}: RaceResultsTemplateProps) {
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
return DateDisplay.formatFull(date);
};
const formatTime = (ms: number) => {

View File

@@ -23,6 +23,7 @@ import {
Gavel,
Scale,
} from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import type { RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
export type StewardingTab = 'pending' | 'resolved' | 'penalties';
@@ -51,11 +52,7 @@ export function RaceStewardingTemplate({
setActiveTab,
}: RaceStewardingTemplateProps) {
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return DateDisplay.formatShort(date);
};
if (isLoading) {

View File

@@ -30,6 +30,7 @@ import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
import { SponsorTierCard } from '@/components/sponsors/SponsorTierCard';
import { NumberDisplay } from '@/lib/display-objects/NumberDisplay';
import { siteConfig } from '@/lib/siteConfig';
import { routes } from '@/lib/routing/RouteConfig';
@@ -320,7 +321,7 @@ export function SponsorLeagueDetailTemplate({
<Box>
{race.status === 'completed' ? (
<Box textAlign="right">
<Text weight="semibold" color="text-white" block>{race.views.toLocaleString()}</Text>
<Text weight="semibold" color="text-white" block>{NumberDisplay.format(race.views)}</Text>
<Text size="xs" color="text-gray-500">views</Text>
</Box>
) : (

View File

@@ -9,6 +9,7 @@ import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
export interface SponsorshipRequestsTemplateProps {
@@ -61,7 +62,7 @@ export function SponsorshipRequestsTemplate({
<Text size="xs" color="text-gray-400" block mt={1}>{request.message}</Text>
)}
<Text size="xs" color="text-gray-500" block mt={2}>
{new Date(request.createdAtIso).toLocaleDateString()}
{DateDisplay.formatShort(request.createdAtIso)}
</Text>
</Box>
<Stack direction="row" gap={2}>

View File

@@ -15,6 +15,7 @@ import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { HorizontalStatItem } from '@/ui/HorizontalStatItem';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { TeamAdmin } from '@/components/teams/TeamAdmin';
import { TeamHero } from '@/components/teams/TeamHero';
@@ -169,10 +170,7 @@ export function TeamDetailTemplate({
{team.createdAt && (
<HorizontalStatItem
label="Founded"
value={new Date(team.createdAt).toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
})}
value={DateDisplay.formatMonthYear(team.createdAt)}
color="text-gray-300"
/>
)}

View File

@@ -0,0 +1,310 @@
# Website architecture audit: Template vs Client, Server Actions, Mutations, React Query
Contract baseline: [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1)
## 0) Executive summary
The codebase is **not uniformly wrong**; it is **inconsistent**.
Two competing architectures coexist:
1) **Contract-first RSC** (good):
- `app/**/page.tsx` calls PageQuery → builds ViewData → renders Template (examples below).
- Writes go through Server Actions → Mutations (examples below).
2) **React Query client app** (problematic when used for writes):
- Client modules execute writes via React Query `useMutation()` directly against services (or placeholders).
- This **violates the strict write boundary** in [`docs/architecture/website/FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:20).
Additionally, some Templates violate determinism rules (notably `toLocaleString()` usage) which can cause SSR/hydration mismatches.
My recommendation to get to a clean codebase:
- Keep React Query **only for client-side reads** (and optional cache invalidation after server actions), but **ban React Query mutations for writes**.
- Enforce a single write path everywhere: **Client intent → Server Action → Mutation → Service → API Client**.
- Align Templates with determinism and responsibility rules: Templates render; they do not format using runtime-locale APIs.
---
## 1) What the code is doing today (evidence)
### 1.1 A good reference read flow (RSC → PageQuery → Template)
- Server page calls PageQuery and renders Template: [`DashboardPage()`](apps/website/app/dashboard/page.tsx:5)
- Template is a pure renderer receiving ViewData: [`DashboardTemplate()`](apps/website/templates/DashboardTemplate.tsx:21)
This matches the contract read flow in [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:181).
### 1.2 A good reference write flow (Server Action → Mutation)
- Server action calls a mutation and revalidates: [`updateUserStatus()`](apps/website/app/admin/actions.ts:25)
- Server action calls a mutation and revalidates: [`updateProfileAction()`](apps/website/app/profile/actions.ts:8)
- Server action calls a mutation and revalidates: [`completeOnboardingAction()`](apps/website/app/onboarding/completeOnboardingAction.ts:18)
This matches the contract write flow in [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:236) and the mutation guidance in [`docs/architecture/website/MUTATIONS.md`](docs/architecture/website/MUTATIONS.md:15).
### 1.3 React Query is definitely used, and it is used for writes
React Query is a dependency: [`apps/website/package.json`](apps/website/package.json:16)
React Query provider exists: [`QueryClientProvider()`](apps/website/lib/providers/QueryClientProvider.tsx:16)
There is a standardized abstraction that promotes React Query usage for both reads and writes:
- [`usePageData()`](apps/website/lib/page/usePageData.ts:25)
- [`usePageMutation()`](apps/website/lib/page/usePageData.ts:114)
Concrete contract violations:
- Client-side writes via React Query mutation calling a service:
- [`useMutation()`](apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx:52) calling [`leagueService.createAdminScheduleRace()`](apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx:69) and similar write methods.
- This is a direct breach of the strict rule: “All writes MUST enter through Next.js Server Actions” in [`docs/architecture/website/FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:20).
- Duplicate write paths exist for onboarding:
- Server action exists: [`completeOnboardingAction()`](apps/website/app/onboarding/completeOnboardingAction.ts:18)
- But a React Query mutation hook also calls a service directly: [`useCompleteOnboarding()`](apps/website/hooks/onboarding/useCompleteOnboarding.ts:10)
- Some “mutation hooks” are placeholders (dangerous because they normalize the wrong path):
- [`useJoinTeam()`](apps/website/hooks/team/useJoinTeam.ts:10)
- [`useLeaveTeam()`](apps/website/hooks/team/useLeaveTeam.ts:9)
- These currently do no server action and no API call, but they establish the pattern that client-side “writes” live in React Query.
### 1.4 Template vs Client responsibilities are inconsistent
A clean example of the intended split exists:
- Server page acts as data orchestrator: [`Page()`](apps/website/app/drivers/page.tsx:6)
- Client entry-point owns state and navigation: [`DriversPageClient()`](apps/website/app/drivers/DriversPageClient.tsx:30)
- Template is basically composition/rendering: [`DriversTemplate()`](apps/website/templates/DriversTemplate.tsx:38)
But the actual boundaries are blurry in at least two ways:
1) Templates contain non-deterministic formatting:
- [`toLocaleString()`](apps/website/templates/DriversTemplate.tsx:65)
- This violates strict determinism constraints in [`docs/architecture/website/VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:58) and [`docs/architecture/website/DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:71).
2) `app/**/*PageClient.tsx` sometimes becomes “everything”: state, rendering, presentation model transformation, and workaround ViewModel creation.
- Example: “TODO wtf we have builders for this” comment while doing ViewData → ViewModel ad-hoc mapping in [`LeaguesPageClient()`](apps/website/app/leagues/LeaguesPageClient.tsx:418) and specifically the conversion at [`LeagueSummaryViewModel`](apps/website/app/leagues/LeaguesPageClient.tsx:380).
### 1.5 There are plain bugs/anti-patterns that amplify the mess
- `useState()` used as an effect runner (should be `useEffect()`):
- [`useState()`](apps/website/app/leagues/LeaguesPageClient.tsx:297)
- This is not an architecture issue per se, but it is “noise” that makes architecture discussions harder.
---
## 2) The root causes (why this became a mess)
1) **Write boundary not enforced strongly enough**
- There are good server actions + mutation examples, but nothing stops new client code from doing `useMutation()``service.write()`.
2) **Templates and ViewData rules are not consistently applied**
- Determinism rules (no `Intl.*`, no `toLocale*`) are strict in docs, but not enforced in Templates.
3) **Two read strategies are both used**
- RSC PageQueries exist.
- React Query hooks exist.
- Without a clear “when to use which,” teams will mix-and-match.
---
## 3) Recommended target architecture (single coherent system)
### 3.1 Writes: one true path
Hard rule (enforced):
- Client code MUST NOT perform write HTTP requests.
- Client code MUST NOT call write methods on services that hit the API.
- Client code MUST NOT use React Query `useMutation()` for writes.
Canonical write flow (matches docs):
```mermaid
flowchart TD
UI[Client component intent] --> SA[Server Action]
SA --> M[Mutation]
M --> S[Service]
S --> AC[API Client]
AC --> API[Backend API]
SA --> RV[Revalidate Path]
```
Docs reference: [`docs/architecture/website/FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:20)
### 3.2 Reads: choose a default, allow an exception
Default:
- If the route can be rendered on the server: `page.tsx` → PageQuery → ViewDataBuilder → Template.
Exception:
- Use React Query for client-only interactive islands that truly need client-driven data refresh.
- React Query should be treated as **a client state/cache** of authoritative API truth, not the truth itself.
Canonical read flow option A (RSC):
```mermaid
flowchart TD
Page[page.tsx] --> PQ[PageQuery]
PQ --> S[Service]
S --> AC[API Client]
PQ --> B[ViewDataBuilder]
B --> T[Template]
```
Docs reference: [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:181)
Canonical read flow option B (client-only island):
```mermaid
flowchart TD
C[Client component] --> Q[React Query useQuery]
Q --> S[Service or API Client read]
C --> T[Template]
```
Important: This is only acceptable for reads. Writes still go through server actions.
---
## 4) Concrete refactor plan (incremental, low-risk)
### Step 1: Establish explicit conventions
1) `app/**/page.tsx` MUST be server by default and must only orchestrate:
- call PageQuery
- map Result → `notFound()`/`redirect()`/error UI
- build ViewData using Builder
- render Template
2) `app/**/*PageClient.tsx` is the ONLY place allowed to:
- use `useRouter()`
- hold UI state
- call server actions
3) `templates/**/*Template.tsx` MUST:
- accept ViewData (or client-only “presentation props” like handler functions)
- contain no `Intl.*` or `toLocale*` formatting
- contain no data fetching
### Step 2: Fix strict correctness violations first (determinism)
- Remove runtime-locale formatting from templates:
- Replace [`toLocaleString()`](apps/website/templates/DriversTemplate.tsx:65) and [`toLocaleString()`](apps/website/templates/DriversTemplate.tsx:66) with deterministic formatting.
- Preferred: ViewData already contains display-ready strings (built via ViewDataBuilder), or deterministic Display Objects.
- Docs reference: [`docs/architecture/website/VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:58).
### Step 3: Move client-side writes behind server actions
Primary offender to migrate:
- Replace React Query write mutations in [`LeagueAdminSchedulePageClient()`](apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx:22)
- Replace [`useMutation()`](apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx:52) with server actions (publish/save/delete) that call Mutations.
- Then the client component calls those server actions and refreshes view (either `router.refresh()` or route revalidation).
Secondary offenders:
- Consolidate onboarding writes to one path:
- Keep server actions: [`completeOnboardingAction()`](apps/website/app/onboarding/completeOnboardingAction.ts:18) and [`generateAvatarsAction()`](apps/website/app/onboarding/generateAvatarsAction.ts:12)
- Delete or rewrite client hook [`useCompleteOnboarding()`](apps/website/hooks/onboarding/useCompleteOnboarding.ts:10) to call server action instead of a service.
### Step 4: Clean up presentation model transforms
- Eliminate ad-hoc “ViewData → ViewModel” conversions in client code:
- Example to refactor: the conversion at [`LeagueSummaryViewModel`](apps/website/app/leagues/LeaguesPageClient.tsx:380).
- Replace with either:
- a proper ViewModelBuilder (if the component truly needs ViewModel semantics), or
- adjust the component (`LeagueCard`) to accept ViewData shape directly.
### Step 5: Reduce React Query surface area (optional, but recommended)
- Deprecate the abstraction that encourages client-side writes:
- Keep [`usePageData()`](apps/website/lib/page/usePageData.ts:25) for reads if desired.
- Remove or forbid [`usePageMutation()`](apps/website/lib/page/usePageData.ts:114).
### Step 6: Add guardrails to prevent regression
Minimal guardrails that pay off:
1) ESLint rule or convention: forbid `useMutation` imports except in `__tests__` or an allowlist.
2) ESLint rule: forbid service write methods from being imported into client modules (harder, but possible with path rules).
3) ESLint rule: forbid `toLocaleString()` and `Intl.*` in Templates.
---
## 5) Answers to your specific concerns
### 5.1 `*Template.tsx` vs `*Client.tsx`
- This split is **correct** and already present in multiple routes.
- The inconsistency is that some “Client” files are doing too much (transform, render, and mutate).
- The fix is to treat `*PageClient.tsx` as wiring + state + calling server actions, and keep templates deterministic.
### 5.2 Server actions vs mutations
- The codebase already shows the correct approach:
- [`updateUserStatus()`](apps/website/app/admin/actions.ts:25)
- [`updateProfileAction()`](apps/website/app/profile/actions.ts:8)
- The “mess” is client-side writes bypassing this.
### 5.3 React Query usage: is it even used?
Yes.
- React Query exists in dependencies: [`apps/website/package.json`](apps/website/package.json:16)
- There are dozens of hooks using it (search results in workspace).
The problem is not React Query for reads. The problem is React Query being used as the write mechanism.
---
## 6) Opinion: ViewData vs ViewModel (what actually makes sense)
Your original intuition is sound:
- **ViewData = server output** (JSON, deterministic, template-ready)
- **ViewModel = client-only** (state, derived UI helpers, never serialized)
The awkward part is: the repo currently contains **conflicting written contracts**.
- Strict statement: “Templates accept ViewData only” in [`docs/architecture/website/WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:372).
- But [`docs/architecture/website/VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:34) shows an example where a Template receives a ViewModel in client code.
### My recommendation: keep Templates ViewData-only, keep ViewModels client-only
I would enforce this as the single, clean rule:
1) **Templates accept ViewData only** (one prop-shape; server and client use the same templates).
2) **Client components may use ViewModels internally** for state and derived values.
3) **The boundary is explicit**: client code converts `ViewModel -> ViewData` right before rendering a Template.
Why this is the least-bad option:
- It prevents a “two template APIs” world (one for ViewData, one for ViewModel) which tends to explode complexity.
- It aligns with RSC constraints (serializable data across the server/client boundary).
- It keeps ViewModels useful: they can own derived UI state and display logic, but the render surface is still a plain object.
Concrete example of why you feel “ViewModels are pushed away by ViewData”:
- In [`LeaguesPageClient()`](apps/website/app/leagues/LeaguesPageClient.tsx:418) youre holding ViewData and then doing a hand-rolled mapping to a “ViewModel-like” shape at [`LeagueSummaryViewModel`](apps/website/app/leagues/LeaguesPageClient.tsx:380). Thats a symptom of **missing builders and an unclear boundary**, not a reason to abandon ViewModels.
## 7) Implementation Status (Audit Results)
The following cleanups have been implemented:
1. **Deterministic Formatting**: Removed `toLocaleString()` and `toLocaleDateString()` from all templates. Introduced `NumberDisplay` and `DateDisplay` display objects for deterministic formatting.
2. **Server Action Migration**: Migrated `LeagueAdminSchedulePageClient.tsx` from React Query `useMutation` to Next.js Server Actions.
3. **Onboarding Consolidation**: Updated onboarding hooks to use server actions instead of direct service calls.
4. **ViewModel Builders**: Introduced `LeagueSummaryViewModelBuilder` to eliminate ad-hoc ViewData -> ViewModel mapping in `LeaguesPageClient.tsx`.
5. **React Query Deprecation**: Deprecated `usePageMutation` in `usePageData.ts`.
6. **Guardrails**: Added `gridpilot-rules/no-use-mutation-in-client` ESLint rule to prevent future React Query write usage.
## 8) Next Steps for the Team
- Continue migrating remaining `useMutation` usages to Server Actions.
- Ensure all new templates follow the ViewData-only rule.
- Use `NumberDisplay` and `DateDisplay` for all UI formatting.