Files
gridpilot.gg/plans/website-architecture-violations.md
2026-01-12 01:01:49 +01:00

1060 lines
63 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Website Architecture Violations Report
Scope: `apps/website/**` aligned against `docs/architecture/website/**`, with the authoritative contract at [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:1).
This report lists violations as: rule ⇒ evidence ⇒ impact ⇒ fix direction.
This version also includes a concrete remediation plan with file-by-file actions.
---
## 0) Target architecture (what good looks like)
Per-route structure required by [`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50):
```text
app/<route>/page.tsx server: composition only
lib/page-queries/<Route>... server: fetch + assemble Page DTO + return PageQueryResult
app/<route>/<Route>PageClient.tsx client: ViewData creation + client state
templates/<Route>Template.tsx client: pure UI, ViewData-only, no computation
lib/view-models/** client-only: ViewModels + Presenters (pure)
lib/display-objects/** deterministic formatting helpers (no locale APIs)
```
Hard boundaries to enforce:
- No server code imports from `lib/view-models/**` (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)).
- Templates import neither `lib/view-models/**` nor `lib/display-objects/**` (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)).
- No locale APIs (`Intl.*`, `toLocale*`) in any formatting path (guardrails: [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:13)).
- All writes enter via Server Actions (contract: [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)).
### 0.1 Naming: stop calling everything “dto” (clarify the model taxonomy)
This repo currently uses `dto` as a generic variable name, which collapses three different concepts.
We will use the contracts model names and enforce naming conventions to prevent category errors.
Authoritative types:
- **API Transport DTO**: returned by `apps/api` over HTTP. In this repo, often generated from OpenAPI.
- Canonical placement: [`apps/website/lib/types/**`](apps/website/lib/types/League.ts:1) per [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:41).
- Naming rule:
- Type names end with `ApiDto` (preferred) or `DTO` (existing generated types).
- Variable names: `apiDto`, `apiResponse`.
- **Page DTO**: website-owned server-to-client payload assembled by PageQueries.
- Canonical placement: `apps/website/lib/page-queries/**` per [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:54).
- Naming rule:
- Type names end with `PageDto`.
- Variable names: `pageDto`.
- **ViewData**: the only allowed input to Templates.
- Canonical placement: `apps/website/templates/**` per [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:86).
- Naming rule:
- Type names end with `ViewData`.
- Variable names: `viewData`.
Other model names we should keep explicit:
- **ViewModel**: client-only class instance.
- Naming rule: `*ViewModel` types/classes, variables `viewModel`.
- **Command DTO** (write intent): the payload sent to Server Actions / API for mutations.
- Naming rule: `*CommandDto`, variables `commandDto`.
Non-negotiable rule:
- Never use a bare `dto` variable name. Use `apiDto`, `pageDto`, `viewData`, or `commandDto`.
This is a Clean Architecture guardrail: when names are precise, wrong-layer dependencies become obvious during review.
### 0.2 Enforce the taxonomy with required abstractions (not lint)
You asked for “proper abstractions that must be implemented” (interfaces and/or abstract classes), rather than naming-only or ESLint enforcement.
The goal is that the *compiler* (and the module boundaries) makes it hard to mix:
- API Transport DTO vs Page DTO vs ViewData
- server-only vs client-only
- orchestration vs presentation mapping
Proposed required abstractions (website-owned):
1. **API Client contracts** (transport boundary)
- Interface per API area, returns **ApiDto/DTO only**.
- Example: `LeaguesApiClient` already exists; enforce that it returns `*DTO` types only.
- Prohibit returning ViewModels.
2. **Service contracts** (orchestration boundary, server-safe)
- Interface per feature: `*Service` returns **ApiDto** or **PageDto** only.
- Must be stateless.
- Must not import `lib/view-models/**`.
3. **PageQuery contract** (server composition boundary)
- A generic interface:
- `execute(params) -> PageQueryResult<PageDto>`
- PageQueries must only depend on API clients + services (manual wiring).
- Must not import `lib/view-models/**`.
4. **Presenter contract** (pure mapping boundary, client-only)
- Presenter is a pure mapper:
- `PageDto -> ViewData` OR `PageDto -> ViewModel` OR `ViewModel -> ViewData`
- No HTTP, no side effects.
5. **ViewModel base** (client-only)
- Optional but useful to enforce non-serializability:
- e.g. `abstract class ViewModel { abstract toViewData(): ViewData }`
- No `toDTO()` method that resembles server DTOs (it tempts server usage).
6. **DisplayObject base** (deterministic formatting)
- Abstract base or interface to enforce “no locale APIs” policy by convention and review.
- Encourages `new MoneyDisplay(amount).formatted()` style, but only used from Presenters/ViewModels.
Implementation note:
- These abstractions live in `apps/website/lib/contracts/**` so every feature must conform.
- This is complementary to guardrails: guardrails prevent forbidden imports; contracts ensure the correct “shape” of each layer.
---
## 1) Remediation plan (explicit actions)
This is the **minimum actionable work** to align the codebase. It is ordered so earlier steps unblock later ones.
### 1.1 Block server-side container usage (DI contract)
**Goal:** zero usages of [`ContainerManager.getInstance().getContainer()`](apps/website/lib/di/container.ts:67) in server execution paths.
Actions (final standard):
1. Ban DI from server execution paths entirely.
- `apps/website/app/**/page.tsx` and `apps/website/lib/page-queries/**` MUST NOT use DI.
- PageQueries MUST do manual wiring (construct API clients + services explicitly).
2. Delete or rewrite any helper that makes server DI easy.
- Replace any server usage of [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) with manual wiring.
- Stop calling [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page/PageDataFetcher.ts:21) from server.
3. Enforcement:
- Add a guardrail test/ESLint rule that forbids importing [`ContainerManager`](apps/website/lib/di/container.ts:61) from:
- `apps/website/app/**/page.tsx`
- `apps/website/lib/page-queries/**`
Deliverable for this section:
- A CI-failing rule that prevents future regressions.
- A grep for `getContainer()` in server modules returns zero hits.
Rationale: why DI is banned on the server side
This is a deliberate safety simplification.
1. Next.js server execution is concurrent; DI containers make it easy to accidentally share state across requests.
- The contract explicitly warns about this risk ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:227), [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:9)).
- The singleton container is explicitly called out as unsafe ([`ContainerManager`](apps/website/lib/di/container.ts:61), [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:33)).
2. The codebase already contains stateful “service” patterns (e.g. blockers stored as instance fields), which are harmless in a per-request graph but dangerous in a shared container.
- Example: [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95) stores [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96).
3. Clean Architecture intent: server modules are composition roots; manual wiring makes dependencies explicit and reviewable.
- This reduces hidden coupling and prevents accidental imports of client-only types like `lib/view-models/**`.
Note: the strict contract technically allows request-scoped server DI ([`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:25)), but we choose the stronger rule (manual wiring only) to eliminate an entire class of cross-request bugs and enforcement ambiguity.
### 1.2 Standardize PageQuery contract (one discriminated union)
**Goal:** all PageQueries return the exact contract described in [`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:17).
Actions:
1. Define a single `PageQueryResult` type in a shared place under `apps/website/lib/page-queries/**`.
2. Update all PageQueries to return:
- `ok` with `{ dto }`
- `notFound`
- `redirect` with `{ to }`
- `error` with `{ errorId }` (do not return raw Error objects)
Example violation:
- Current local type uses `data`/`destination`: [`PageQueryResult<TData>`](apps/website/lib/page-queries/DashboardPageQuery.ts:11)
Deliverable:
- All pages use `switch(result.status)` and call `notFound()` / `redirect()` only from `page.tsx`.
### 1.3 Fix Template purity (ViewData-only, no imports, no compute)
**Goal:** Templates become “dumb” renderers: no `useMemo`, no filtering/sorting, no ViewModel or DisplayObject imports.
Actions:
1. Remove ViewModel props from Templates.
- Example: replace [`DriverRankingsTemplateProps.drivers: DriverLeaderboardItemViewModel[]`](apps/website/templates/DriverRankingsTemplate.tsx:19)
with `drivers: DriverRankingsViewData[]` (a ViewData type).
2. Remove compute from Templates.
- Example: move [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81)
into:
- a ViewModel (client) OR
- a Presenter (client) OR
- the PageClient container (client) if it is driven by UI state.
3. Remove DisplayObject usage from Templates.
- Example violation: [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13)
- Replace with primitive values in ViewData (badge label, badge classes).
Deliverable:
- Guardrail tests (or ESLint) that fail if any `apps/website/templates/**` imports from:
- `apps/website/lib/view-models/**`
- `apps/website/lib/display-objects/**`
### 1.4 Eliminate locale APIs from all formatting paths
**Goal:** zero usage of `Intl.*` and `toLocale*` in:
- `apps/website/templates/**`
- `apps/website/app/**/page.tsx`
- `apps/website/lib/view-models/**`
- `apps/website/lib/display-objects/**`
- shared formatting helpers like [`time.ts`](apps/website/lib/utilities/time.ts:1)
Actions:
1. Replace locale formatting in Templates.
- Example: [`toLocaleDateString()`](apps/website/templates/RacesTemplate.tsx:148)
- Replace with: (a) deterministic formatter in a Display Object, or (b) API-provided display labels.
2. Replace locale formatting in ViewModels.
- Example: [`BillingViewModel.InvoiceViewModel.formattedTotalAmount`](apps/website/lib/view-models/BillingViewModel.ts:85)
- Replace with deterministic formatting helpers (no runtime locale).
3. Remove locale formatting helpers in `lib/utilities`.
- Example: [`formatDate()`](apps/website/lib/utilities/time.ts:48)
- Replace with deterministic formatters.
Deliverable:
- The search pattern `\\bIntl\\.|toLocale` returns zero results for production code.
### 1.5 Enforce write boundary: Server Actions only
**Goal:** no client-initiated writes (no `fetch` POST/PUT/PATCH/DELETE from client components).
Actions:
1. Replace client logout POST with Server Action.
- Example violation: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212)
- Replace with a Server Action and use `<form action={logoutAction}>`.
- Ensure the action does the mutation and then triggers navigation and/or revalidation.
Deliverable:
- Search for `fetch(` with write methods in client components returns zero hits.
### 1.6 Remove UX Blockers from services (state leakage risk)
**Goal:** services remain stateless and safe regardless of DI scope.
Actions:
1. Remove or relocate blockers from [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95).
- Evidence: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97)
2. Re-introduce blockers at the client boundary (component/hook) where they belong.
Deliverable:
- No stateful blockers stored on service instances.
### 1.7 Consolidate hooks into the canonical folder
**Goal:** one place for React-only helpers: `apps/website/lib/hooks/**`.
Contract:
- Canonical placement is `apps/website/lib/hooks/**` ([`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:32), [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:217)).
Actions:
1. Move `apps/website/hooks/**``apps/website/lib/hooks/**`.
2. Update imports across `apps/website/**`.
3. Remove `hooks/` from TS include list in [`tsconfig.json`](apps/website/tsconfig.json:70).
4. Add a guardrail test to fail if `apps/website/hooks/**` reappears.
### 1.8 Tighten the Service boundary (server-safe only)
**Goal:** services become safe to call from server modules (Page Queries) with zero ambiguity.
New non-negotiable rule:
- `apps/website/lib/services/**` returns **API DTOs or Page DTOs only** (JSON-serializable). It MUST NOT import or instantiate anything from `apps/website/lib/view-models/**`.
Why (Clean Architecture + SOLID):
- SRP: services orchestrate IO and composition; they do not prepare UI.
- DIP: server modules depend on service interfaces/DTO contracts, not client-only classes.
- Eliminates the current “maybe server, maybe client” ambiguity.
Actions:
1. Refactor any service returning ViewModels to return DTOs/Page DTOs instead.
2. Move mapping/formatting responsibilities into Presenters colocated with ViewModels.
3. Add a boundary test: forbid `lib/services/**` importing `lib/view-models/**`.
### 1.9 Remove `AdminViewModelService` (Presenter misclassified as Service)
**Goal:** all mapping lives in Presenters, not in services.
Action:
- Replace [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:10) with a Presenter colocated with its ViewModel(s), then delete the service + test.
### 1.10 Fix the confirmed “PageQuery constructs ViewModel” violation
Evidence:
- [`ProfilePageQuery.execute()`](apps/website/lib/page-queries/ProfilePageQuery.ts:34) resolves [`DriverService`](apps/website/lib/page-queries/ProfilePageQuery.ts:3) and then calls `viewModel.toDTO()` ([`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:54)).
Rule violated:
- Page Queries MUST NOT instantiate ViewModels ([`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:31)).
Action:
- Refactor any service method used by a PageQuery that currently returns a ViewModel to return a Page DTO instead.
---
## 12) Generic integrity rules for untrusted transport data (no case studies)
This is the durable architectural rule behind the “`as` looks vulnerable” concern.
### 12.1 Rule: treat API Transport DTO values as untrusted input
Even with OpenAPI generation, runtime values can drift (backend bug, contract mismatch, migrations, older clients).
Therefore:
- Never use `as SomeClosedUnion` on fields coming from an API response.
- Never assume string enums are safe.
### 12.2 Where validation/coercion belongs
- **API Transport DTO** remains raw (what the API sent).
- **Page DTO** can remain raw but should be structurally stable.
- **Presenter/ViewModel** is the correct place to normalize/coerce *for UI resilience*.
This keeps the website as a delivery layer: were not enforcing business rules; were preventing UI crashes.
### 12.3 Required pattern: parsers for string unions
Define small pure parsers (in a Presenter-adjacent module) for every “closed set” field:
- `parseSocialPlatform(value: unknown): SocialPlatform | 'unknown'`
- `parseAchievementIcon(value: unknown): AchievementIcon | 'unknown'`
- `parseAchievementRarity(value: unknown): AchievementRarity | 'unknown'`
Policy (agreed):
- ViewModel keeps `'unknown'` for debugging/telemetry.
- ViewData omits unknown items (UI stays clean).
This keeps code safe without turning the website into a second source of truth (the API still owns validation).
---
## 2) Route-by-route refactor recipes (concrete file actions)
This section tells you **exactly what to change** for the already-identified hot spots.
### 2.1 `profile/leagues` route
Current violations:
- DI container used on server: [`ContainerManager.getInstance().getContainer()`](apps/website/app/profile/leagues/page.tsx:45)
- Inline server “template” inside `page.tsx`: [`ProfileLeaguesTemplate()`](apps/website/app/profile/leagues/page.tsx:82)
- Server code manipulates ViewModels: [`LeagueSummaryViewModel`](apps/website/app/profile/leagues/page.tsx:13)
Required changes:
1. Create a PageQuery: `apps/website/lib/page-queries/ProfileLeaguesPageQuery.ts`.
- It should:
- Read session (server-side) and determine `currentDriverId`.
- Call API clients directly (manual wiring).
- Assemble a **Page DTO** like:
- `ownedLeagues: Array<{ leagueId; name; description; membershipRole }>`
- `memberLeagues: Array<{ leagueId; name; description; membershipRole }>`
- Return `PageQueryResult`.
2. Replace `apps/website/app/profile/leagues/page.tsx` with composition:
- Call PageQuery.
- Switch on result.
- Render `<ProfileLeaguesPageClient dto={dto} />`.
3. Create `apps/website/app/profile/leagues/ProfileLeaguesPageClient.tsx` (`'use client'`).
- Convert Page DTO into ViewData (via Presenter function in `lib/view-models/**`).
- Render a pure Template.
4. Create `apps/website/templates/ProfileLeaguesTemplate.tsx`.
- Props: ViewData only.
- No API calls. No `useMemo`. No filtering/sorting.
### 2.2 `teams` routes
Current violations:
- Server route does sorting/filtering: [`computeDerivedData()`](apps/website/app/teams/page.tsx:12)
- Server route imports ViewModels: [`TeamSummaryViewModel`](apps/website/app/teams/page.tsx:10)
- Team detail server route passes ViewModels into Template: [`TeamDetailTemplateWrapper()`](apps/website/app/teams/[id]/page.tsx:20)
- Placeholder “currentDriverId” on server: [`const currentDriverId = ''`](apps/website/app/teams/[id]/page.tsx:63)
Required changes:
1. Introduce PageQueries:
- `TeamsPageQuery` returns Page DTO `{ teams: Array<{ id; name; rating; ...raw fields }> }`.
- `TeamDetailPageQuery` returns Page DTO `{ team; memberships; currentDriverId; }` (raw serializable).
2. Move derived computations out of server route.
- `teamsByLevel`, `topTeams`, etc. belong in client ViewModel or PageClient.
3. Add `TeamsPageClient.tsx` and `TeamDetailPageClient.tsx`.
- Instantiate ViewModels client-side.
- Produce ViewData for Templates.
4. Change Templates to take ViewData.
- Stop passing ViewModels into [`TeamDetailTemplate`](apps/website/app/teams/[id]/page.tsx:22).
### 2.3 `dashboard` PageQuery
Current violations:
- Server PageQuery uses singleton DI container: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110)
- PageQuery imports from `lib/view-models/**`: [`DashboardOverviewViewModelData`](apps/website/lib/page-queries/DashboardPageQuery.ts:6)
Required changes:
1. Move the DTO-like shape out of view-models and into `lib/page-queries/**`.
2. Replace DI with manual wiring.
3. Return `PageQueryResult` as spec.
4. Ensure the result payload is a Page DTO (raw values only).
### 2.4 Logout write boundary
Current violation:
- Client-side POST request: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212)
Required changes:
1. Create a server action in an appropriate server-only module.
2. Replace the demo-user logout handler with a `<form action={logoutAction}>` submission.
3. Ensure post-logout navigation uses server-side redirect.
---
## 3) What to add to make this enforceable (guardrails)
Minimum guardrails to add:
1. ESLint or test guard: fail on `toLocale*` / `Intl.*` under `apps/website/**` formatting paths.
2. ESLint or test guard: fail if `apps/website/templates/**` imports ViewModels or Display Objects.
3. ESLint or test guard: fail if `apps/website/app/**/page.tsx` imports from `lib/view-models/**`.
4. ESLint or test guard: fail if server code imports [`ContainerManager`](apps/website/lib/di/container.ts:61).
---
## 4) Missing pieces found in the second scan (add to plan)
This section is the “coverage pass”: it enumerates additional concrete hotspots that must be refactored using the recipes above.
### 4.1 Remaining server-side singleton container usage (must be removed)
1. Home SSR data fetch uses container directly
- Evidence: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/services/home/getHomeData.ts:9)
- Action: rewrite [`getHomeData()`](apps/website/lib/services/home/getHomeData.ts:8) to use explicit wiring (manual construction) or move the logic into a PageQuery that does manual wiring.
2. Profile page query dynamically imports container and uses singleton
- Evidence: `(await import('@/lib/di/container')).ContainerManager.getInstance().getContainer()` in [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:43)
- Action: ban all container usage in [`apps/website/lib/page-queries/**`](apps/website/lib/page-queries/DashboardPageQuery.ts:1); refactor to manual wiring.
Deliverable:
- Add these files to the “no server DI” guardrail allowlist/banlist:
- [`getHomeData.ts`](apps/website/lib/services/home/getHomeData.ts:1)
- [`ProfilePageQuery.ts`](apps/website/lib/page-queries/ProfilePageQuery.ts:1)
### 4.2 Routes still importing ViewModels from server modules (must be converted to Page DTO + PageClient)
The following are **server route modules** (App Router) that import ViewModels and therefore violate the server/client boundary.
Actions for each: create a PageQuery returning Page DTO, and a `*PageClient.tsx` that builds ViewData and renders the Template.
1. Leaderboards entry route
- Evidence: ViewModel types imported in [`leaderboards/page.tsx`](apps/website/app/leaderboards/page.tsx:8)
- Action:
- Replace `*PageWrapper` patterns with a `*PageClient` container.
- Ensure the template receives ViewData only (no ViewModels).
- Remove ViewModel imports from:
- [`leaderboards/page.tsx`](apps/website/app/leaderboards/page.tsx:8)
- [`LeaderboardsPageWrapper.tsx`](apps/website/app/leaderboards/LeaderboardsPageWrapper.tsx:4)
2. Driver leaderboards route
- Evidence: ViewModel import in [`leaderboards/drivers/page.tsx`](apps/website/app/leaderboards/drivers/page.tsx:7)
- Action: same as above; rename wrapper to `DriverRankingsPageClient.tsx` and pass ViewData to Templates.
3. Team leaderboard route
- Evidence: ViewModel import in [`teams/leaderboard/page.tsx`](apps/website/app/teams/leaderboard/page.tsx:7) and wrapper import in [`TeamLeaderboardPageWrapper.tsx`](apps/website/app/teams/leaderboard/TeamLeaderboardPageWrapper.tsx:5)
- Action: same as above.
4. Leagues routes
- Evidence:
- [`LeagueDetailPageViewModel`](apps/website/app/leagues/[id]/page.tsx:13)
- [`LeagueDetailPageViewModel`](apps/website/app/leagues/[id]/rulebook/page.tsx:13)
- [`LeagueScheduleViewModel`](apps/website/app/leagues/[id]/schedule/page.tsx:13)
- [`LeagueStandingsViewModel`](apps/website/app/leagues/[id]/standings/page.tsx:13) and [`DriverViewModel`](apps/website/app/leagues/[id]/standings/page.tsx:14)
- Action:
- Introduce PageQueries per route: `LeagueDetailPageQuery`, `LeagueRulebookPageQuery`, `LeagueSchedulePageQuery`, `LeagueStandingsPageQuery`.
- PageQueries return Page DTO only.
- Client creates ViewModels + ViewData.
5. Sponsor routes
- Evidence: server route imports ViewModel and performs reduce/filter on ViewModel instance: [`AvailableLeaguesViewModel`](apps/website/app/sponsor/leagues/page.tsx:6)
- Action:
- Convert to PageQuery returning Page DTO.
- Move summary computations (counts/averages) to Presenter/ViewModel in client.
6. Races + stewarding routes
- Evidence:
- ViewModel imports in [`races/[id]/page.tsx`](apps/website/app/races/[id]/page.tsx:7)
- Non-canonical hooks usage and ViewModel transformer import in [`races/[id]/results/page.tsx`](apps/website/app/races/[id]/results/page.tsx:5)
- ViewModel import in [`races/[id]/stewarding/page.tsx`](apps/website/app/races/[id]/stewarding/page.tsx:10)
- Action:
- Convert each to PageQuery + PageClient.
- Move hooks from top-level `apps/website/hooks/**` into `apps/website/lib/hooks/**` (canonical per [`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:47)).
- Rename the “transformer” pattern to a Presenter under `lib/view-models/**` and ensure it stays deterministic.
7. Profile routes (beyond `profile/leagues`)
- Evidence: ViewModel import and locale formatting in [`profile/page.tsx`](apps/website/app/profile/page.tsx:16) and `toLocaleDateString` usage (e.g. [`profile/page.tsx`](apps/website/app/profile/page.tsx:430))
- Action: convert to PageQuery + ProfilePageClient + ViewData.
8. Races listing route doing server-side filtering
- Evidence: [`races/all/page.tsx`](apps/website/app/races/all/page.tsx:39) uses `.filter` in route module
- Action: same route split; if filtering is canonical (not UX-only), push it into API; if UX-only, do it client-side in a ViewModel.
### 4.3 “Templates” living under `app/**` (structural and guardrail gap)
The architecture assumes Templates are under `apps/website/templates/**`.
- Evidence: wallet UI is a “Template” in a route folder: [`WalletTemplate.tsx`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:1) and it imports a ViewModel: [`LeagueWalletViewModel`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:7)
Actions:
1. Move this file to `apps/website/templates/LeagueWalletTemplate.tsx`.
2. Ensure it takes ViewData only.
3. Add a guardrail: forbid `*Template.tsx` under `apps/website/app/**`.
### 4.4 Remaining write-boundary violations (client-side POST)
Additional client-side logout write:
- Evidence: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/app/sponsor/settings/page.tsx:178)
Actions:
1. Replace with Server Action flow.
2. Ensure any other write HTTP calls from client code are removed (search pattern already exists).
### 4.5 Locale formatting still present outside Templates (components + lib)
The strict contract forbids locale APIs in formatting paths; the codebase currently uses `toLocale*` heavily in:
- Components (examples):
- [`DriverTopThreePodium.tsx`](apps/website/components/DriverTopThreePodium.tsx:75)
- [`TeamAdmin.tsx`](apps/website/components/teams/TeamAdmin.tsx:192)
- [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212)
- Infrastructure (example):
- [`ErrorReplay.ts`](apps/website/lib/infrastructure/ErrorReplay.ts:214)
- ViewModels (examples):
- [`BillingViewModel`](apps/website/lib/view-models/BillingViewModel.ts:86)
- [`SponsorshipViewModel`](apps/website/lib/view-models/SponsorshipViewModel.ts:48)
- [`LeagueMemberViewModel`](apps/website/lib/view-models/LeagueMemberViewModel.ts:22)
- [`PaymentViewModel`](apps/website/lib/view-models/PaymentViewModel.ts:44)
- [`utilities/time.ts`](apps/website/lib/utilities/time.ts:41)
Actions (make it explicit):
1. Introduce deterministic formatting primitives under `apps/website/lib/display-objects/**` (or a dedicated deterministic formatter module) and route **all** formatting through them.
2. Replace locale formatting calls in ViewModels first (largest fan-out).
3. Replace locale formatting in components by ensuring components consume ViewData strings/numbers that are already formatted deterministically.
4. Add guardrails for `apps/website/components/**` too (not only Templates), otherwise the same issue migrates.
---
## 5) Third scan: contract checks against the questions
This section explicitly answers:
- do we have any filtering, sorting, formatting or any business logic in pages or ui components?
- do we have clear separation of concerns?
- did you consider correct DI usage?
- did you consider server actions?
### 5.1 Filtering, sorting, formatting, business logic in `app/**`
Contract expectations:
- `app/**/page.tsx` should be server composition only ([`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)).
- Formatting must be deterministic and must not use `Intl.*` or `toLocale*` ([`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41)).
Observed violations (representative, not exhaustive):
| Category | Evidence in `app/**` | Why this violates the contract | Required remediation |
|---|---|---|---|
| Filtering/sorting in `page.tsx` | `reduce/filter/sort` in [`computeDerivedData()`](apps/website/app/teams/page.tsx:14) | Server route does presentation shaping (guardrail violation). | Convert to PageQuery + `TeamsPageClient.tsx`; keep `page.tsx` orchestration only. |
| Filtering inside route module | Filtering races in [`races/all/page.tsx`](apps/website/app/races/all/page.tsx:40) | Filtering belongs in API (canonical) or in client ViewModel/PageClient (UX-only), not in route module. | Split route into PageQuery + PageClient. |
| “Authorization-like” decisions in route modules | `isAdmin` derived from role in [`races/[id]/results/page.tsx`](apps/website/app/races/[id]/results/page.tsx:80) and [`races/[id]/stewarding/page.tsx`](apps/website/app/races/[id]/stewarding/page.tsx:65) | Website may hide UI for UX but must not drift into enforcing security truth ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:186)). These checks are mixed with data/load/render responsibilities. | Ensure these are strictly UI flags derived from API truth and produced in client ViewModels/Presenters. |
| Locale formatting in route modules | `toLocaleDateString/toLocaleString` in [`profile/page.tsx`](apps/website/app/profile/page.tsx:430) and [`sponsor/campaigns/page.tsx`](apps/website/app/sponsor/campaigns/page.tsx:210) | Forbidden locale APIs in formatting paths. | Replace with deterministic formatting, passed via ViewData. |
| ViewModel instantiation in routes | `new DriverViewModel(...)` in [`leagues/[id]/standings/page.tsx`](apps/website/app/leagues/[id]/standings/page.tsx:66) | ViewModels are client-only ([`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:71)). | Replace with Page DTO; create ViewModels client-side only. |
### 5.2 Filtering, sorting, formatting, business logic in `components/**`
Observed violations (high signal):
| Category | Evidence in `components/**` | Why this violates the contract | Required remediation |
|---|---|---|---|
| Locale formatting in components | `toLocale*` in [`TeamAdmin.tsx`](apps/website/components/teams/TeamAdmin.tsx:192), [`DriverTopThreePodium.tsx`](apps/website/components/DriverTopThreePodium.tsx:75), [`NotificationCenter.tsx`](apps/website/components/notifications/NotificationCenter.tsx:84) | Determinism/hydration risk; forbidden formatting path. | Ensure components consume deterministic ViewData strings/numbers produced by Presenters/Display Objects. |
| Client-initiated write calls | `fetch('/api/auth/logout', { method: 'POST' })` in [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) and [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67) | Violates Server Action-only write boundary ([`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)). | Replace with Server Action invoked via `<form action={...}>`. |
### 5.3 Separation of concerns
Answer: **No**; boundaries are blurred.
Concrete evidence:
1. Templates import ViewModels and compute.
- Import example: [`DriverLeaderboardItemViewModel`](apps/website/templates/DriverRankingsTemplate.tsx:7)
- Compute example: [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81)
2. Templates exist under `app/**`.
- Example: [`WalletTemplate.tsx`](apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx:1)
3. Services construct ViewModels.
- Example: `return new RaceDetailViewModel(...)` in [`RaceService`](apps/website/lib/services/races/RaceService.ts:24)
- Example: `return ...map(... => new TeamSummaryViewModel(...))` in [`TeamService`](apps/website/lib/services/teams/TeamService.ts:35)
Remediation:
- Server: PageQueries return Page DTO.
- Client: Presenters/ViewModels produce ViewData.
- Templates: ViewData-only, no compute.
### 5.4 DI usage
Answer: **Not correct today**; server uses the singleton container.
Confirmed violations:
- Server route DI: [`ContainerManager.getInstance().getContainer()`](apps/website/app/profile/leagues/page.tsx:45)
- Server PageQuery DI: [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110)
- Server helper enabling DI: [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:21)
- Server service function DI: [`getHomeData()`](apps/website/lib/services/home/getHomeData.ts:9)
- Dynamic import DI in PageQuery: [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:43)
Remediation:
1. Add guardrail: forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61).
2. Refactor these call sites to manual wiring or request-scoped container.
### 5.5 Server Actions
Answer: **Not fully enforced**; client-side POSTs exist.
Confirmed violations:
- Client POST logout in [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212)
- Client POST logout in [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67)
- Client POST logout in [`sponsor/settings/page.tsx`](apps/website/app/sponsor/settings/page.tsx:178)
Remediation:
1. Implement a Server Action for logout.
2. Replace all client-side POSTs with `<form action={logoutAction}>`.
3. Add guardrail to forbid `fetch` write methods in client code.
---
## A) DI contract violations (server-side singleton container usage)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
| Server `app/**/page.tsx` MUST NOT access the DI container ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:231), [`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:15)) | [`ContainerManager.getInstance().getContainer()`](apps/website/app/profile/leagues/page.tsx:45) | Server route is directly resolving from a singleton container; violates the client-first DI contract and request-safety. | Replace with a PageQuery + manual per-request wiring, or a request-scoped container factory. |
| `ContainerManager.getContainer()` is client-only ([`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:35)) | [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) calls [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page/PageDataFetcher.ts:21) | This helper enables forbidden server access to the singleton container. | Ban server use of [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15). Use explicit construction in PageQueries. |
| PageQueries may use DI only if request-scoped and stateless ([`WEBSITE_DI_RULES.md`](docs/architecture/website/WEBSITE_DI_RULES.md:20)) | [`DashboardPageQuery.execute()`](apps/website/lib/page-queries/DashboardPageQuery.ts:108) uses [`ContainerManager.getInstance().getContainer()`](apps/website/lib/page-queries/DashboardPageQuery.ts:110) | PageQuery runs on the server; the singleton container is explicitly unsafe for concurrent server requests. | Convert to manual wiring (construct API client + service per call), or create a fresh container per request. |
---
## B) RSC route structure violations (page.tsx doing more than composition)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
| `page.tsx` does composition only ([`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:26), [`WEBSITE_RSC_PRESENTATION.md`](docs/architecture/website/WEBSITE_RSC_PRESENTATION.md:50)) | Data fetch + categorization + inline template in a server route module: [`fetchProfileLeaguesData()`](apps/website/app/profile/leagues/page.tsx:22) and [`ProfileLeaguesTemplate()`](apps/website/app/profile/leagues/page.tsx:82) | Responsibilities are mixed: server data fetching, decision logic, and rendering composition in one module. | Split into per-route shape: `page.tsx` calls a PageQuery and passes Page DTO; a `*PageClient.tsx` builds ViewData; Template renders ViewData only. |
| PageQueries MUST return the documented discriminated union ([`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:17)) | Local type differs from spec: [`PageQueryResult<TData>`](apps/website/lib/page-queries/DashboardPageQuery.ts:11) uses `data` and `destination` | Breaks the documented contract (`ok` with `dto`, `redirect` with `to`). | Standardize a single `PageQueryResult` type per [`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:17) and enforce across queries. |
---
## C) ViewModel boundary violations (ViewModels used/created on server and/or passed into Templates)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
| ViewModels are client-only and never serialized ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:70), [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:71)) | Server route data types include ViewModels: [`TeamDetailData.team: TeamDetailsViewModel`](apps/website/app/teams/[id]/page.tsx:15) and are passed into Template wrapper: [`TeamDetailTemplateWrapper()`](apps/website/app/teams/[id]/page.tsx:20) | Implies ViewModels cross the server→client boundary, contradicting the “client-only class” rule and risking serialization/hydration issues. | Use Page DTOs for server-to-client, and instantiate ViewModels only in `'use client'` modules. |
| Templates accept ViewData only ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:86), [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:3)) | Template prop uses ViewModel instances: [`DriverRankingsTemplateProps.drivers: DriverLeaderboardItemViewModel[]`](apps/website/templates/DriverRankingsTemplate.tsx:19) | Template is no longer receiving ViewData (JSON-serializable primitive shapes). | Introduce Presenters that map Page DTO → ViewData and pass ViewData into Templates. |
| PageQueries must not instantiate client-only types / ViewModels ([`WEBSITE_PAGE_QUERIES.md`](docs/architecture/website/WEBSITE_PAGE_QUERIES.md:31)) | PageQuery imports from `lib/view-models/**`: [`DashboardOverviewViewModelData`](apps/website/lib/page-queries/DashboardPageQuery.ts:6) | `lib/view-models/**` is the client-only boundary (also where Presenters live per [`PRESENTERS.md`](docs/architecture/website/PRESENTERS.md:50)). | Move Page DTO types under `lib/page-queries/**` and keep view-model types under `lib/view-models/**`. |
---
## D) Template purity violations (Templates importing forbidden layers and doing computation)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
| Templates MUST NOT import ViewModels ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)) | Example: [`DriverLeaderboardItemViewModel`](apps/website/templates/DriverRankingsTemplate.tsx:7) | Template depends on client-only classes, drifting into Presenter/ViewModel responsibilities. | Replace ViewModel props with ViewData props. |
| Templates MUST NOT import Display Objects ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)) | Example: [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13) | Display Objects are forbidden inside Templates. | Use Presenters/ViewModels to emit primitive strings into ViewData. |
| Templates MUST NOT compute derived values ([`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:33)) | Filtering/sorting occurs inside Template: [`drivers.filter()`](apps/website/templates/DriverRankingsTemplate.tsx:73) and [`sort(...)`](apps/website/templates/DriverRankingsTemplate.tsx:81) | Template performs orchestration and non-trivial computation rather than rendering prepared ViewData. | Move to ViewModels (client) and/or Presenters; Templates render only. |
| “Templates are pure” ([`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:27)) | Hooks in templates: [`useMemo()`](apps/website/templates/RacesTemplate.tsx:3) used for filtering/grouping | Pure-template contract is violated by stateful/reactive computations. | Create a `*PageClient.tsx` container for state/computation; keep template as pure function over ViewData. |
---
## E) Determinism violations: forbidden locale/time formatting paths (`Intl.*`, `toLocale*`)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
| Templates must not call locale APIs ([`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:41), [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)) | Example: [`toLocaleDateString()`](apps/website/templates/RacesTemplate.tsx:148) | Locale/timezone-dependent formatting can differ between SSR and browser, risking hydration mismatches and non-determinism. | Replace with deterministic formatting via Display Objects, or API-provided labels, passed through Presenters into ViewData. |
| Formatting code paths must not use locale APIs ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:63), [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:37)) | Example: [`toLocaleString()`](apps/website/lib/view-models/BillingViewModel.ts:86) | Even though ViewModels may format, this repos strict determinism rules forbid runtime-locale APIs. | Replace with deterministic numeric/date formatting helpers (no `Intl.*`, no `toLocale*`). |
| Utility helpers used in presentation must not use locale APIs ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:7)) | [`formatDate()`](apps/website/lib/utilities/time.ts:48) calls [`toLocaleDateString()`](apps/website/lib/utilities/time.ts:50) | Utilities become hidden sources of non-determinism across the app. | Replace these helpers with deterministic formatters and enforce via guardrail tests/ESLint. |
---
## F) Page.tsx guardrail violations (sorting/filtering in server routes and forbidden imports)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
| RSC `page.tsx` must not perform sorting/filtering beyond trivial checks ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)) | Server route computes derived values via reduce/filter/sort: [`computeDerivedData()`](apps/website/app/teams/page.tsx:12) | Server route contains presentation shaping. | Move shaping into PageQuery output (as Page DTO only) and/or into client ViewModel/Presenter. |
| RSC `page.tsx` must not import ViewModels ([`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:9)) | Example: [`TeamSummaryViewModel`](apps/website/app/teams/page.tsx:10) | ViewModels are client-only. | Replace with Page DTO types; construct ViewModels client-side. |
---
## G) Write-flow violations (writes not entering through Server Actions)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
| All writes MUST enter through Server Actions ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:171), [`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:18)) | Client component performs POST: [`fetch('/api/auth/logout', { method: 'POST' })`](apps/website/components/profile/UserPill.tsx:212) | Client-initiated writes are explicitly forbidden. | Replace with a Server Action invoked via `<form action={...}>` or button action, then revalidate/navigate. |
---
## H) UX-only blockers embedded in services (state leakage risk)
| Rule | Evidence | Why this is a violation | Fix direction |
|---|---|---|---|
| Blockers are UX-only and should be local + reversible ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:7), [`CLIENT_STATE.md`](docs/architecture/website/CLIENT_STATE.md:7)) | Stateful blockers inside a service: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97) | If the service is DI-singleton or reused across requests, this state can leak across users/requests; also mixes UI concerns into a service boundary. | Move blockers into client UI boundary (hook/component) or ensure strict client-only, per-instance usage. Services should remain stateless. |
---
## 6) Display Objects, Command Models, Blockers (fourth scan)
This section answers:
1) Are Display Objects used as intended?
2) Where should we introduce Display Objects instead of ad-hoc formatting/mapping?
3) Are Command Models used as intended?
4) Where should we introduce Command Models instead of ad-hoc form state/validation?
5) Are Blockers used as intended?
6) Where should we introduce Blockers instead of ad-hoc UX prevention?
### 6.1 Display Objects: current state vs contract
Contract excerpts:
- Display Objects are deterministic UI-only formatting/mapping helpers ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:1)).
- Placement rule: `apps/website/lib/display-objects/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:141)).
- Must be class-based, immutable, deterministic ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:46)).
- Must NOT call `Intl.*` or `toLocale*` ([`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:63)).
- Templates must not import Display Objects (guardrail: [`WEBSITE_GUARDRAILS.md`](docs/architecture/website/WEBSITE_GUARDRAILS.md:16)).
#### 6.1.1 Violations: Display Objects imported from Templates
| Violation | Evidence | Why it matters | Required remediation |
|---|---|---|---|
| Template imports a Display Object | [`LeagueRoleDisplay`](apps/website/templates/LeagueDetailTemplate.tsx:13) | Templates are required to be ViewData-only and must not import display objects. | Move role badge derivation into Presenter/ViewModel and pass `{ text, badgeClasses }` as ViewData. |
#### 6.1.2 Structural drift: “Display Objects” implemented as plain functions/records
The following files live under `lib/display-objects/**`, but they are not class-based value objects (they are exported maps/functions):
- [`DashboardDisplay.ts`](apps/website/lib/display-objects/DashboardDisplay.ts:1)
- [`ProfileDisplay.ts`](apps/website/lib/display-objects/ProfileDisplay.ts:1)
- [`LeagueRoleDisplay.ts`](apps/website/lib/display-objects/LeagueRoleDisplay.ts:1) (partly mitigated via wrapper class, but still map-backed)
This conflicts with the strict “class-based” rule in [`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:46). If we keep them as functions, we should either:
Clean-code direction (no exceptions):
- Refactor these modules into small, immutable classes with explicit APIs.
- Keep them deterministic and free of locale APIs.
#### 6.1.3 Where Display Objects should be introduced (replace scattered formatting/mapping)
The repo currently performs formatting/mapping directly in Templates, route modules, components, and ViewModels. Those are prime candidates for Display Objects.
High-signal candidates found:
| Pattern | Evidence | Display Object to introduce | What the Display Object should output |
|---|---|---|---|
| Date formatting (`toLocaleDateString`, `toLocaleString`) | Many templates, e.g. [`RaceDetailTemplate.tsx`](apps/website/templates/RaceDetailTemplate.tsx:172), and route code e.g. [`profile/page.tsx`](apps/website/app/profile/page.tsx:430) | `DeterministicDateDisplay` (class) | `{ shortDate, longDate, shortTime, dateTime }` as strings based on ISO inputs, deterministic timezone policy.
| Number formatting (`toLocaleString`) | Templates like [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:206) and components like [`MetricCard.tsx`](apps/website/components/sponsors/MetricCard.tsx:49) | `DeterministicNumberDisplay` (class) | `{ compact, integer, fixed2 }` or explicit formatting helpers; avoid runtime locale.
| Currency formatting | ViewModels like [`BillingViewModel.ts`](apps/website/lib/view-models/BillingViewModel.ts:85) | `MoneyDisplay` (class) | `formatted` string(s) given `{ currencyCode, minorUnits }` or `{ currency, amount }`.
| Role badge mapping duplicated outside display objects | Switch in [`MembershipStatus.tsx`](apps/website/components/leagues/MembershipStatus.tsx:33) and also display object usage elsewhere ([`StandingsTable.tsx`](apps/website/components/leagues/StandingsTable.tsx:9)) | Consolidate as `LeagueRoleBadgeDisplay` | `{ text, badgeClasses }` (or `{ text, bg, border, textColor }`), deterministic.
| Sponsorship status + time remaining UI mapping | Derived UI flags in [`sponsor/campaigns/page.tsx`](apps/website/app/sponsor/campaigns/page.tsx:138) | `SponsorshipStatusDisplay` | `{ statusText, statusClasses, isExpiringSoon, daysRemainingLabel }` (pure).
**Important boundary rule:** Display Objects must be used by Presenters/ViewModels, not by Templates. Templates should only see the primitive outputs.
### 6.2 Command Models: current state vs intended use
Contract excerpts:
- Command Models are for transient form state; UX validation only; never derived from ViewModels; never reused from read models ([`FORM_SUBMISSION.md`](docs/architecture/website/FORM_SUBMISSION.md:61)).
- Canonical placement: `apps/website/lib/command-models/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:219)).
#### 6.2.1 Good usage already present
- `League wizard` uses a command model: [`LeagueWizardCommandModel`](apps/website/lib/command-models/leagues/LeagueWizardCommandModel.ts:79) (consumed in [`CreateLeagueWizard.tsx`](apps/website/components/leagues/CreateLeagueWizard.tsx:27)).
- `Auth` command models exist: [`LoginCommandModel`](apps/website/lib/command-models/auth/LoginCommandModel.ts:17) and [`SignupCommandModel`](apps/website/lib/command-models/auth/SignupCommandModel.ts:21).
- `Protest` write intent uses a command model: [`ProtestDecisionCommandModel`](apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts:10) (constructed in [`protests/[protestId]/page.tsx`](apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx:171)).
#### 6.2.2 Violations / drift: Command-model-like logic living in components
Example: onboarding wizard duplicates field validation inline:
- Validation logic in [`OnboardingWizard.tsx`](apps/website/components/onboarding/OnboardingWizard.tsx:202)
Plan action:
1. Create `apps/website/lib/command-models/onboarding/OnboardingCommandModel.ts`.
2. Move field-level validation + errors to the command model.
3. Keep the component responsible only for UI state (step navigation, show/hide) and invoking the server action.
Similarly, any other form pages that implement repeated validation strings/constraints should be moved into command models.
#### 6.2.3 Server Actions integration: command models should be consumed at the boundary
Contract requires writes to enter via Server Actions.
Plan action (repeatable pattern):
1. UI collects primitives → CommandModel instance (client-only) validates.
2. Submit creates **Command DTO** (plain object) and calls a Server Action.
3. Server Action performs UX validation (not business rules), calls API, redirects/revalidates.
### 6.3 Blockers: current state vs intended use
Contract excerpts:
- Blockers are UX-only, reversible helpers ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:7)).
- Client state is UI-only; blockers are not security ([`CLIENT_STATE.md`](docs/architecture/website/CLIENT_STATE.md:45)).
- Canonical placement: `apps/website/lib/blockers/**` ([`BLOCKERS.md`](docs/architecture/website/BLOCKERS.md:48)).
#### 6.3.1 Violations: Blockers embedded in services (state leakage risk)
- [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95) stores blockers as instance fields: [`submitBlocker`](apps/website/lib/services/leagues/LeagueService.ts:96) and [`throttle`](apps/website/lib/services/leagues/LeagueService.ts:97).
- [`LeagueWalletService`](apps/website/lib/services/leagues/LeagueWalletService.ts:12) does the same: [`submitBlocker`](apps/website/lib/services/leagues/LeagueWalletService.ts:13) and [`throttle`](apps/website/lib/services/leagues/LeagueWalletService.ts:14).
Why this matters:
- If any service instance is shared (DI singleton, caching, module singletons), blockers become cross-user/cross-request state.
Plan action:
1. Remove blockers from service instance state.
2. Reintroduce blockers at the client boundary:
- component local state
- a `useSubmitBlocker()` hook under `apps/website/lib/hooks/**`
- or a per-interaction blocker instance created inside a client function.
#### 6.3.2 Where blockers should be used instead of ad-hoc UX prevention
Candidates:
| UX need | Evidence | Recommended blocker |
|---|---|---|
| Prevent multiple logout clicks | Logout actions exist and currently do client POSTs: [`UserPill.tsx`](apps/website/components/profile/UserPill.tsx:212) and [`AdminLayout.tsx`](apps/website/components/admin/AdminLayout.tsx:67) | `SubmitBlocker` at the UI boundary (button/form) |
| Throttle rapid filter/search updates | Multiple pages render lists with client filtering/sorting, e.g. [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:72) | `ThrottleBlocker` inside a client container or hook (not in service) |
---
## 7) Hooks folder split (`apps/website/hooks` vs `apps/website/lib/hooks`)
Contract:
- React-only utilities MUST live under `apps/website/lib/hooks/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:217), [`WEBSITE_FILE_STRUCTURE.md`](docs/architecture/website/WEBSITE_FILE_STRUCTURE.md:32)).
Observation:
- Repo contains **two** hooks locations:
- Top-level: [`apps/website/hooks/useCapability.ts`](apps/website/hooks/useCapability.ts:1) (and many more)
- Canonical: [`apps/website/lib/hooks/useEnhancedForm.ts`](apps/website/lib/hooks/useEnhancedForm.ts:1) (and others)
Violation:
| Violation | Evidence | Why it matters | Required remediation |
|---|---|---|---|
| Hooks implemented outside canonical `lib/hooks` | Many hooks under [`apps/website/hooks/**`](apps/website/hooks/useEffectiveDriverId.ts:1) despite contract naming `apps/website/lib/hooks/**` | Confuses imports/boundaries; increases chance hooks depend on route-level concerns or mix server/client incorrectly. Also violates the strict file-structure contract. | Move `apps/website/hooks/**` into `apps/website/lib/hooks/**`, update all imports, remove the top-level folder, and add a guardrail to prevent regression. |
Notes:
- The current `tsconfig` explicitly includes top-level `hooks/` ([`tsconfig.json`](apps/website/tsconfig.json:70)), which suggests this drift is intentional-but-undocumented.
---
## 8) `AdminViewModelService` violates ViewModel instantiation + Presenter placement rules
Contract:
- ViewModels are client-only classes; they are instantiated only in `'use client'` modules ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:76)).
- Presenters (pure mappings) are colocated with ViewModels in `apps/website/lib/view-models/**` ([`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:124)).
- Services are for orchestration, not mapping API DTOs into ViewModels (implied by data-flow contract: [`WEBSITE_CONTRACT.md`](docs/architecture/website/WEBSITE_CONTRACT.md:145)).
Evidence:
- [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:5) is a `lib/services/**` module that performs mapping of API DTOs to ViewModels:
- imports API DTO types from [`AdminApiClient`](apps/website/lib/services/AdminViewModelService.ts:1)
- instantiates `AdminUserViewModel` et al. ([`AdminViewModelService.mapUser()`](apps/website/lib/services/AdminViewModelService.ts:14))
Violations:
| Violation | Evidence | Why it matters | Required remediation |
|---|---|---|---|
| ViewModels instantiated in a non-client module | [`new AdminUserViewModel(dto)`](apps/website/lib/services/AdminViewModelService.ts:15) in a service file with no `'use client'` boundary | If imported by server modules (Page Queries, RSC pages), it breaks the rule that ViewModels are client-only, and risks serializing or executing client-only assumptions on the server. | Move mapping into a Presenter colocated with the ViewModel (e.g. [`AdminUserViewModelPresenter`](apps/website/lib/view-models/AdminUserViewModel.ts:1) or a sibling file) and ensure ViewModel creation happens only in client modules. |
| Presenter logic misclassified as a Service | All methods are pure mapping functions ([`AdminViewModelService.mapUsers()`](apps/website/lib/services/AdminViewModelService.ts:21)) | Encourages importing “services” in server paths, causing architecture erosion (services become dumping grounds). | Rename/rehome as Presenter. Services should call API clients + return Page DTOs, not construct ViewModels. |
---
## 9) Alignment plan (approved direction: align code to contract)
## 9) Alignment plan (Clean Architecture / SOLID, no gaps)
Non-negotiable target:
- `app/**/page.tsx`: server composition only (no business logic, no formatting, no filtering).
- `lib/page-queries/**`: server composition + IO only; returns Page DTO; **no ViewModels**; **no DI singleton**.
- Server DI policy: **no DI at all** in PageQueries or `page.tsx` (manual wiring only).
- `lib/services/**`: server-safe orchestration only; returns API DTO or Page DTO; **no ViewModels**; **no blockers**.
- `lib/view-models/**` + Presenters: client-only; pure mapping to ViewData.
- `templates/**`: dumb renderer; ViewData only.
Naming enforcement (applies across all refactors):
- API response shapes are `ApiDto`/`DTO` and stored/handled as `apiDto`.
- PageQuery output is always named `pageDto` and typed `*PageDto`.
- Template props are always `*ViewData` and passed as `viewData`.
Abstractions enforcement (applies across all refactors):
- Every PageQuery implements a common `PageQuery<PageDto>` contract (in `lib/contracts/page-queries/**`).
- Every service implements a `Service` interface that returns `ApiDto`/`PageDto` only (in `lib/contracts/services/**`).
- Every Presenter implements `Presenter<Input, Output>` with `Output` being `ViewData` or `ViewModel` (in `lib/contracts/presenters/**`).
- ViewModels extend a shared base (in `lib/contracts/view-models/**`) to discourage accidental server serialization patterns.
### 9.1 Guardrails first (prevent regression)
Add CI-failing checks (tests or ESLint rules). These come first to enforce Clean Architecture boundaries while refactors are in flight:
1. `app/**/page.tsx` MUST NOT import from `apps/website/lib/view-models/**`.
2. `templates/**` MUST NOT import from `apps/website/lib/view-models/**` nor `apps/website/lib/display-objects/**`.
3. `lib/page-queries/**` MUST NOT import from `apps/website/lib/view-models/**`.
4. `lib/services/**` MUST NOT import from `apps/website/lib/view-models/**`.
5. Forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61).
6. Forbid `Intl.*` and `toLocale*` in all presentation paths.
7. Forbid `*Template.tsx` under `apps/website/app/**`.
Order of implementation (guardrails):
1. Server DI ban: forbid server imports of [`ContainerManager`](apps/website/lib/di/container.ts:61) and forbid use of [`PageDataFetcher.fetch()`](apps/website/lib/page/PageDataFetcher.ts:15) from server modules.
2. Server/client boundary: forbid any imports of `apps/website/lib/view-models/**` from `apps/website/app/**/page.tsx` and `apps/website/lib/page-queries/**`.
3. Template boundary: forbid imports of view-models and display-objects from `apps/website/templates/**`.
4. Service boundary: forbid imports of view-models from `apps/website/lib/services/**`.
5. Determinism: forbid `Intl.*` and `toLocale*` in all presentation paths.
6. Write boundary: forbid client-side `fetch` with write methods in client modules.
These align cleanly with SRP (single reason to change per layer) and remove ambiguity.
### 9.2 Structural cleanups
1. Move all hooks to `apps/website/lib/hooks/**` (see §7 and §1.7).
2. Convert Display Objects to class-based, immutable modules (see §6.1.2).
3. Delete [`AdminViewModelService`](apps/website/lib/services/AdminViewModelService.ts:10) and replace with a Presenter.
### 9.3 Service boundary refactor (DTO-only)
1. For each service currently returning ViewModels (examples: [`LeagueService`](apps/website/lib/services/leagues/LeagueService.ts:95), [`DashboardService`](apps/website/lib/services/dashboard/DashboardService.ts:12), [`MediaService`](apps/website/lib/services/media/MediaService.ts:15)):
- Change outputs to API DTOs or Page DTOs.
- Move any ViewModel instantiation into client Presenters.
- Remove blockers/state from services.
2. Fix the confirmed server/ViewModel leak:
- [`ProfilePageQuery`](apps/website/lib/page-queries/ProfilePageQuery.ts:34) must not call `viewModel.toDTO()`.
- It must call a service returning Page DTO and return that directly.
### 9.4 Route refactor recipe (repeatable)
For every route currently doing logic in `page.tsx`:
1. `page.tsx`: fetches via PageQuery, switches on result, renders a `*PageClient`.
2. PageQuery: calls services + assembles Page DTO only.
3. `*PageClient.tsx`: instantiates ViewModels (client-only) and produces ViewData via Presenters.
4. Template: renders ViewData only.
Write path (per-route):
1. `app/<route>/actions.ts`: Server Actions for that route only (mutations, UX validation, redirect/revalidate).
2. `*PageClient.tsx` (or a client component) uses `<form action={...}>` to invoke the action.
3. Services called by actions remain DTO-only and do not contain UI logic.
This is the mechanism that keeps pages free from business logic and leaves no seams for drift.
### 9.5 ViewModels: allowed, but only behind the client boundary
You chose: keep ViewModels, enforce client-only.
Non-negotiable rules (to avoid gaps):
1. No server module imports from `apps/website/lib/view-models/**`.
2. No `apps/website/lib/services/**` imports from `apps/website/lib/view-models/**`.
3. A ViewModel instance is never passed into a Template.
4. A ViewModel instance is never serialized.
Allowed flows:
- Page DTO → Presenter → ViewData → Template
- Page DTO → Presenter → ViewModel → Presenter → ViewData → Template
This keeps presentation state cohesive (ViewModels) while preserving a clean dependency direction (server depends on DTOs, client depends on ViewModels).
---
## High-signal file sets (pattern-based indicators)
### Templates importing ViewModels and or Display Objects (forbidden)
- [`DriverRankingsTemplate.tsx`](apps/website/templates/DriverRankingsTemplate.tsx:7)
- [`LeagueDetailTemplate.tsx`](apps/website/templates/LeagueDetailTemplate.tsx:13)
- [`LeagueStandingsTemplate.tsx`](apps/website/templates/LeagueStandingsTemplate.tsx:6)
- [`TeamDetailTemplate.tsx`](apps/website/templates/TeamDetailTemplate.tsx:14)
### Server routes importing ViewModels and or doing formatting/filtering (forbidden/discouraged)
- [`teams/page.tsx`](apps/website/app/teams/page.tsx:12)
- [`teams/[id]/page.tsx`](apps/website/app/teams/[id]/page.tsx:9)
- [`profile/leagues/page.tsx`](apps/website/app/profile/leagues/page.tsx:7)
### Structural drift vs canonical `lib/*` layout
- API client exists at root `lib/` instead of `lib/api/`: [`apiClient.ts`](apps/website/lib/apiClient.ts:1)
- Non-canonical `lib/page/` exists: [`PageDataFetcher.ts`](apps/website/lib/page/PageDataFetcher.ts:1)