website refactor
This commit is contained in:
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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() {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
70
apps/website/app/leagues/[id]/schedule/admin/actions.ts
Normal file
70
apps/website/app/leagues/[id]/schedule/admin/actions.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
50
apps/website/eslint-rules/no-use-mutation-in-client.js
Normal file
50
apps/website/eslint-rules/no-use-mutation-in-client.js
Normal 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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
47
apps/website/lib/display-objects/DateDisplay.ts
Normal file
47
apps/website/lib/display-objects/DateDisplay.ts
Normal 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()}`;
|
||||
}
|
||||
}
|
||||
18
apps/website/lib/display-objects/NumberDisplay.ts
Normal file
18
apps/website/lib/display-objects/NumberDisplay.ts
Normal 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('.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={[
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
310
plans/website-architecture-audit.md
Normal file
310
plans/website-architecture-audit.md
Normal 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) you’re holding ViewData and then doing a hand-rolled mapping to a “ViewModel-like” shape at [`LeagueSummaryViewModel`](apps/website/app/leagues/LeaguesPageClient.tsx:380). That’s 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.
|
||||
|
||||
Reference in New Issue
Block a user