diff --git a/apps/website/eslint-rules/display-object-rules.js b/apps/website/eslint-rules/display-object-rules.js deleted file mode 100644 index fb025b225..000000000 --- a/apps/website/eslint-rules/display-object-rules.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * ESLint rules for Display Object Guardrails - * - * Enforces display object boundaries and purity - */ - -module.exports = { - // Rule 1: No IO in display objects - 'no-io-in-display-objects': { - meta: { - type: 'problem', - docs: { - description: 'Forbid IO imports in display objects', - category: 'Display Objects', - }, - messages: { - message: 'DisplayObjects cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/display-objects/DisplayObject.ts', - }, - }, - create(context) { - const forbiddenPaths = [ - '@/lib/api/', - '@/lib/services/', - '@/lib/page-queries/', - '@/lib/view-models/', - '@/lib/presenters/', - ]; - - return { - ImportDeclaration(node) { - const importPath = node.source.value; - if (forbiddenPaths.some(path => importPath.includes(path)) && - !isInComment(node)) { - context.report({ - node, - messageId: 'message', - }); - } - }, - }; - }, - }, - - // Rule 2: No non-class display exports - 'no-non-class-display-exports': { - meta: { - type: 'problem', - docs: { - description: 'Forbid non-class exports in display objects', - category: 'Display Objects', - }, - messages: { - message: 'Display Objects must be class-based and export only classes - see apps/website/lib/contracts/display-objects/DisplayObject.ts', - }, - }, - create(context) { - return { - ExportNamedDeclaration(node) { - if (node.declaration && - (node.declaration.type === 'FunctionDeclaration' || - (node.declaration.type === 'VariableDeclaration' && - !node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) { - context.report({ - node, - messageId: 'message', - }); - } - }, - ExportDefaultDeclaration(node) { - if (node.declaration && - node.declaration.type !== 'ClassDeclaration' && - node.declaration.type !== 'ClassExpression') { - context.report({ - node, - messageId: 'message', - }); - } - }, - }; - }, - }, -}; - -// Helper functions -function isInComment(node) { - return false; -} \ No newline at end of file diff --git a/apps/website/eslint-rules/formatter-rules.js b/apps/website/eslint-rules/formatter-rules.js new file mode 100644 index 000000000..dac9ccde9 --- /dev/null +++ b/apps/website/eslint-rules/formatter-rules.js @@ -0,0 +1,138 @@ +/** + * ESLint rules for Formatter/Display Guardrails + * + * Enforces boundaries and purity for Formatters and Display Objects + */ + +module.exports = { + // Rule 1: No IO in formatters/displays + 'no-io-in-display-objects': { + meta: { + type: 'problem', + docs: { + description: 'Forbid IO imports in formatters and displays', + category: 'Formatters', + }, + messages: { + message: 'Formatters/Displays cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/formatters/Formatter.ts', + }, + }, + create(context) { + const forbiddenPaths = [ + '@/lib/api/', + '@/lib/services/', + '@/lib/page-queries/', + '@/lib/view-models/', + '@/lib/presenters/', + ]; + + return { + ImportDeclaration(node) { + const importPath = node.source.value; + if (forbiddenPaths.some(path => importPath.includes(path)) && + !isInComment(node)) { + context.report({ + node, + messageId: 'message', + }); + } + }, + }; + }, + }, + + // Rule 2: No non-class display exports + 'no-non-class-display-exports': { + meta: { + type: 'problem', + docs: { + description: 'Forbid non-class exports in formatters and displays', + category: 'Formatters', + }, + messages: { + message: 'Formatters and Displays must be class-based and export only classes - see apps/website/lib/contracts/formatters/Formatter.ts', + }, + }, + create(context) { + return { + ExportNamedDeclaration(node) { + if (node.declaration && + (node.declaration.type === 'FunctionDeclaration' || + (node.declaration.type === 'VariableDeclaration' && + !node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) { + context.report({ + node, + messageId: 'message', + }); + } + }, + ExportDefaultDeclaration(node) { + if (node.declaration && + node.declaration.type !== 'ClassDeclaration' && + node.declaration.type !== 'ClassExpression') { + context.report({ + node, + messageId: 'message', + }); + } + }, + }; + }, + }, + + // Rule 3: Formatters must return primitives + 'formatters-must-return-primitives': { + meta: { + type: 'problem', + docs: { + description: 'Enforce that Formatters return primitive values for ViewData compatibility', + category: 'Formatters', + }, + messages: { + message: 'Formatters used in ViewDataBuilders must return primitive values (string, number, boolean, null) - see apps/website/lib/contracts/formatters/Formatter.ts', + }, + }, + create(context) { + const filename = context.getFilename(); + const isViewDataBuilder = filename.includes('/lib/builders/view-data/'); + + if (!isViewDataBuilder) return {}; + + return { + CallExpression(node) { + // Check if calling a Formatter/Display method + if (node.callee.type === 'MemberExpression' && + node.callee.object.name && + (node.callee.object.name.endsWith('Formatter') || node.callee.object.name.endsWith('Display'))) { + + // If it's inside a ViewData object literal, it must be a primitive return + let parent = node.parent; + while (parent) { + if (parent.type === 'Property' && parent.parent.type === 'ObjectExpression') { + // This is a property in an object literal (likely ViewData) + // We can't easily check the return type of the method at lint time without type info, + // but we can enforce that it's not the whole object being assigned. + if (node.callee.property.name === 'format' || node.callee.property.name.startsWith('format')) { + // Good: calling a format method + return; + } + + // If they are assigning the result of a non-format method, warn + context.report({ + node, + messageId: 'message', + }); + } + parent = parent.parent; + } + } + }, + }; + }, + }, +}; + +// Helper functions +function isInComment(node) { + return false; +} \ No newline at end of file diff --git a/apps/website/eslint-rules/index.js b/apps/website/eslint-rules/index.js index fbefd095b..21e105ccb 100644 --- a/apps/website/eslint-rules/index.js +++ b/apps/website/eslint-rules/index.js @@ -17,7 +17,7 @@ const presenterContract = require('./presenter-contract'); const rscBoundaryRules = require('./rsc-boundary-rules'); const templatePurityRules = require('./template-purity-rules'); -const displayObjectRules = require('./display-object-rules'); +const displayObjectRules = require('./formatter-rules'); const pageQueryRules = require('./page-query-rules'); const servicesRules = require('./services-rules'); const clientOnlyRules = require('./client-only-rules'); @@ -30,7 +30,6 @@ const mutationContract = require('./mutation-contract'); const serverActionsMustUseMutations = require('./server-actions-must-use-mutations'); const viewDataLocation = require('./view-data-location'); const viewDataBuilderContract = require('./view-data-builder-contract'); -const viewModelBuilderContract = require('./view-model-builder-contract'); const singleExportPerFile = require('./single-export-per-file'); const filenameMatchesExport = require('./filename-matches-export'); const pageQueryMustUseBuilders = require('./page-query-must-use-builders'); @@ -48,7 +47,6 @@ const serverActionsInterface = require('./server-actions-interface'); const noDisplayObjectsInUi = require('./no-display-objects-in-ui'); const viewDataBuilderImplements = require('./view-data-builder-implements'); const viewDataBuilderImports = require('./view-data-builder-imports'); -const viewModelBuilderImplements = require('./view-model-builder-implements'); const viewDataImplements = require('./view-data-implements'); const viewModelImplements = require('./view-model-implements'); const viewModelTaxonomy = require('./view-model-taxonomy'); @@ -85,6 +83,7 @@ module.exports = { // Display Object Rules 'display-no-domain-models': displayObjectRules['no-io-in-display-objects'], 'display-no-business-logic': displayObjectRules['no-non-class-display-exports'], + 'formatters-must-return-primitives': displayObjectRules['formatters-must-return-primitives'], 'no-display-objects-in-ui': noDisplayObjectsInUi, // Page Query Rules @@ -139,8 +138,6 @@ module.exports = { 'view-data-implements': viewDataImplements, // View Model Rules - 'view-model-builder-contract': viewModelBuilderContract, - 'view-model-builder-implements': viewModelBuilderImplements, 'view-model-implements': viewModelImplements, 'view-model-taxonomy': viewModelTaxonomy, @@ -222,6 +219,7 @@ module.exports = { // Display Objects 'gridpilot-rules/display-no-domain-models': 'error', 'gridpilot-rules/display-no-business-logic': 'error', + 'gridpilot-rules/formatters-must-return-primitives': 'error', 'gridpilot-rules/no-display-objects-in-ui': 'error', // Page Queries diff --git a/apps/website/eslint-rules/view-model-builder-contract.js b/apps/website/eslint-rules/view-model-builder-contract.js deleted file mode 100644 index 57af31e7f..000000000 --- a/apps/website/eslint-rules/view-model-builder-contract.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * ESLint rule to enforce View Model Builder contract - * - * View Model Builders must: - * 1. Be classes named *ViewModelBuilder - * 2. Have a static build() method - * 3. Use 'satisfies ViewModelBuilder<...>' for static enforcement - * 4. Accept View Data as parameter - * 5. Return View Model - */ - -module.exports = { - meta: { - type: 'problem', - docs: { - description: 'Enforce View Model Builder contract', - category: 'Builders', - recommended: true, - }, - fixable: null, - schema: [], - messages: { - notAClass: 'View Model Builders must be classes named *ViewModelBuilder', - missingStaticBuild: 'View Model Builders must have a static build() method', - missingSatisfies: 'View Model Builders must use "satisfies ViewModelBuilder<...>" for static type enforcement', - invalidBuildSignature: 'build() method must accept View Data and return View Model', - }, - }, - - create(context) { - const filename = context.getFilename(); - const isInViewModelBuilders = filename.includes('/lib/builders/view-models/'); - - if (!isInViewModelBuilders) return {}; - - let hasStaticBuild = false; - let hasSatisfies = false; - let hasCorrectSignature = false; - - return { - // Check class declaration - ClassDeclaration(node) { - const className = node.id?.name; - - if (!className || !className.endsWith('ViewModelBuilder')) { - context.report({ - node, - messageId: 'notAClass', - }); - } - - // Check for static build method - const staticBuild = node.body.body.find(member => - member.type === 'MethodDefinition' && - member.key.type === 'Identifier' && - member.key.name === 'build' && - member.static === true - ); - - if (staticBuild) { - hasStaticBuild = true; - - // Check signature - should have at least one parameter - if (staticBuild.value && - staticBuild.value.params && - staticBuild.value.params.length > 0) { - hasCorrectSignature = true; - } - } - }, - - // Check for satisfies expression - TSSatisfiesExpression(node) { - if (node.typeAnnotation && - node.typeAnnotation.type === 'TSTypeReference' && - node.typeAnnotation.typeName.name === 'ViewModelBuilder') { - hasSatisfies = true; - } - }, - - 'Program:exit'() { - if (!hasStaticBuild) { - context.report({ - node: context.getSourceCode().ast, - messageId: 'missingStaticBuild', - }); - } - - if (!hasSatisfies) { - context.report({ - node: context.getSourceCode().ast, - messageId: 'missingSatisfies', - }); - } - - if (hasStaticBuild && !hasCorrectSignature) { - context.report({ - node: context.getSourceCode().ast, - messageId: 'invalidBuildSignature', - }); - } - }, - }; - }, -}; diff --git a/apps/website/eslint-rules/view-model-builder-implements.js b/apps/website/eslint-rules/view-model-builder-implements.js deleted file mode 100644 index addaa7dc1..000000000 --- a/apps/website/eslint-rules/view-model-builder-implements.js +++ /dev/null @@ -1,96 +0,0 @@ -/** - * ESLint rule to enforce View Model Builder contract implementation - * - * View Model Builders in lib/builders/view-models/ must: - * 1. Be classes named *ViewModelBuilder - * 2. Implement the ViewModelBuilder interface - * 3. Have a static build() method - */ - -module.exports = { - meta: { - type: 'problem', - docs: { - description: 'Enforce View Model Builder contract implementation', - category: 'Builders', - recommended: true, - }, - fixable: null, - schema: [], - messages: { - notAClass: 'View Model Builders must be classes named *ViewModelBuilder', - missingImplements: 'View Model Builders must implement ViewModelBuilder interface', - missingBuildMethod: 'View Model Builders must have a static build() method', - }, - }, - - create(context) { - const filename = context.getFilename(); - const isInViewModelBuilders = filename.includes('/lib/builders/view-models/'); - - if (!isInViewModelBuilders) return {}; - - let hasImplements = false; - let hasBuildMethod = false; - - return { - // Check class declaration - ClassDeclaration(node) { - const className = node.id?.name; - - if (!className || !className.endsWith('ViewModelBuilder')) { - context.report({ - node, - messageId: 'notAClass', - }); - } - - // Check if class implements ViewModelBuilder interface - if (node.implements && node.implements.length > 0) { - for (const impl of node.implements) { - // Handle GenericTypeAnnotation for ViewModelBuilder - if (impl.expression.type === 'TSInstantiationExpression') { - const expr = impl.expression.expression; - if (expr.type === 'Identifier' && expr.name === 'ViewModelBuilder') { - hasImplements = true; - } - } else if (impl.expression.type === 'Identifier') { - // Handle simple ViewModelBuilder (without generics) - if (impl.expression.name === 'ViewModelBuilder') { - hasImplements = true; - } - } - } - } - - // Check for static build method - const buildMethod = node.body.body.find(member => - member.type === 'MethodDefinition' && - member.key.type === 'Identifier' && - member.key.name === 'build' && - member.static === true - ); - - if (buildMethod) { - hasBuildMethod = true; - } - }, - - 'Program:exit'() { - if (!hasImplements) { - context.report({ - node: context.getSourceCode().ast, - messageId: 'missingImplements', - }); - } - - if (!hasBuildMethod) { - context.report({ - node: context.getSourceCode().ast, - messageId: 'missingBuildMethod', - }); - } - }, - }; - }, -}; diff --git a/apps/website/eslint-rules/view-model-taxonomy.js b/apps/website/eslint-rules/view-model-taxonomy.js index 8425b42b8..b6cf63270 100644 --- a/apps/website/eslint-rules/view-model-taxonomy.js +++ b/apps/website/eslint-rules/view-model-taxonomy.js @@ -5,7 +5,7 @@ * 1. NOT contain the word "DTO" (DTOs are for API/Services) * 2. NOT define ViewData interfaces (ViewData must be in lib/view-data/) * 3. NOT import from DTO paths (DTOs belong to lib/types/generated/) - * 4. ONLY import from allowed paths: lib/contracts/, lib/view-models/, lib/view-data/, lib/display-objects/ + * 4. ONLY import from allowed paths: lib/contracts/, lib/view-models/, lib/view-data/, lib/formatters/ */ module.exports = { @@ -22,7 +22,7 @@ module.exports = { noDtoInViewModel: 'ViewModels must not use the word "DTO". DTOs belong to the API/Service layer. Use plain logic-rich properties or ViewData types.', noDtoImport: 'ViewModels must not import from DTO paths. DTOs belong to lib/types/generated/. Import from lib/view-data/ or use plain properties instead.', noViewDataDefinition: 'ViewData must not be defined within ViewModel files. Import them from lib/view-data/ instead.', - strictImport: 'ViewModels can only import from lib/contracts/, lib/view-models/, lib/view-data/, or lib/display-objects/. External imports are allowed. Found: {{importPath}}', + strictImport: 'ViewModels can only import from lib/contracts/, lib/view-models/, lib/view-data/, or lib/formatters/. External imports are allowed. Found: {{importPath}}', }, }, @@ -70,7 +70,7 @@ module.exports = { '@/lib/contracts/', '@/lib/view-models/', '@/lib/view-data/', - '@/lib/display-objects/', + '@/lib/formatters/', ]; const isAllowed = allowedPaths.some(path => importPath.startsWith(path)); @@ -83,12 +83,12 @@ module.exports = { importPath.includes('/lib/contracts/') || importPath.includes('/lib/view-models/') || importPath.includes('/lib/view-data/') || - importPath.includes('/lib/display-objects/') || + importPath.includes('/lib/formatters/') || // Also check for patterns like ../contracts/... importPath.includes('contracts') || importPath.includes('view-models') || importPath.includes('view-data') || - importPath.includes('display-objects') || + importPath.includes('formatters') || // Allow relative imports to view models (e.g., ./InvoiceViewModel, ../ViewModelName) // This matches patterns like ./ViewModelName or ../ViewModelName /^\.\/[A-Z][a-zA-Z0-9]*ViewModel$/.test(importPath) || diff --git a/apps/website/lib/contracts/builders/ViewModelBuilder.ts b/apps/website/lib/contracts/builders/ViewModelBuilder.ts deleted file mode 100644 index c6bb98254..000000000 --- a/apps/website/lib/contracts/builders/ViewModelBuilder.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * ViewModel Builder Contract - * - * Purpose: Transform ViewData into ViewModels for client-side state management - * - * Rules: - * - Deterministic and side-effect free - * - No HTTP/API calls - * - Input: ViewData (JSON-serializable template-ready data) - * - Output: ViewModel (client-only class) - * - Must be in lib/builders/view-models/ - * - Must be named *ViewModelBuilder - * - Must have 'use client' directive - * - Must implement static build() method - * - Must use 'satisfies' for static type enforcement - */ - -import { ViewData } from '../view-data/ViewData'; -import { ViewModel } from '../view-models/ViewModel'; - -/** - * ViewModel Builder Contract (Static) - * - * Usage: - * export class MyViewModelBuilder { - * static build(viewData: MyViewData): MyViewModel { ... } - * } - * MyViewModelBuilder satisfies ViewModelBuilder; - */ -export interface ViewModelBuilder { - build(viewData: TViewData): TViewModel; -} \ No newline at end of file diff --git a/apps/website/lib/contracts/formatters/Formatter.ts b/apps/website/lib/contracts/formatters/Formatter.ts index ab8011300..65ab1edc5 100644 --- a/apps/website/lib/contracts/formatters/Formatter.ts +++ b/apps/website/lib/contracts/formatters/Formatter.ts @@ -1,22 +1,40 @@ /** * Formatter contract - * + * * Deterministic, reusable, UI-only formatting/mapping logic. - * - * Based on DISPLAY_OBJECTS.md: - * - Class-based - * - Immutable + * + * Based on FORMATTERS.md: + * - Stateless (Formatters) or Immutable (Display Objects) * - Deterministic * - Side-effect free - * - No Intl.* or toLocale* * - No business rules + * + * Uncle Bob says: "Data structures should not have behavior." + * Formatters ensure ViewData remains a dumb container of primitives. */ export interface Formatter { /** - * Format or map the display object - * - * @returns Primitive values only (strings, numbers, booleans) + * Format or map the input to a primitive value + * + * @returns Primitive values only (strings, numbers, booleans, null) */ - format(): unknown; + format(): string | number | boolean | null; +} + +/** + * Rich Display Object contract (Client-only) + * + * Used by ViewModels to provide a rich API to the UI. + */ +export interface DisplayObject { + /** + * Primary primitive output + */ + format(): string | number | boolean | null; + + /** + * Multiple primitive variants + */ + variants?(): Record; } \ No newline at end of file diff --git a/apps/website/lib/contracts/view-data/ViewData.ts b/apps/website/lib/contracts/view-data/ViewData.ts index f165949b2..01ad13557 100644 --- a/apps/website/lib/contracts/view-data/ViewData.ts +++ b/apps/website/lib/contracts/view-data/ViewData.ts @@ -1,16 +1,14 @@ -import { JsonValue } from "../types/primitives"; - /** * Base interface for ViewData objects * * All ViewData must be JSON-serializable for SSR. * This type ensures no class instances or functions are included. * - * Note: We use 'any' here to allow complex DTO structures, but the - * architectural rule is that these must be plain JSON objects. + * Uncle Bob says: "Data structures should not have behavior." + * ViewData is a dumb container for primitives and nested JSON only. */ export interface ViewData { - [key: string]: JsonValue; + [key: string]: any; } /** * Helper type to ensure a type is ViewData-compatible diff --git a/apps/website/lib/view-data/CategoryIconViewData.ts b/apps/website/lib/view-data/CategoryIconViewData.ts index 192c4ddbf..dfc44d93d 100644 --- a/apps/website/lib/view-data/CategoryIconViewData.ts +++ b/apps/website/lib/view-data/CategoryIconViewData.ts @@ -5,7 +5,6 @@ import { ViewData } from '@/lib/contracts/view-data/ViewData'; * * ViewData for category icon media rendering. */ -import { ViewData } from "../contracts/view-data/ViewData"; export interface CategoryIconViewData extends ViewData { buffer: string; // base64 encoded diff --git a/docs/architecture/website/BUILDERS.md b/docs/architecture/website/BUILDERS.md index 38dab4840..6c7057ca0 100644 --- a/docs/architecture/website/BUILDERS.md +++ b/docs/architecture/website/BUILDERS.md @@ -2,41 +2,25 @@ This document defines the **Builder** pattern for `apps/website`. -Builders exist to transform data between presentation model types. +Builders exist to transform raw API data into flat, serializable **ViewData**. ## 1) Definition -A **Builder** is a deterministic, side-effect free transformation between website presentation models. +A **Builder** is a deterministic, side-effect free transformation that bridges the boundary between the API (DTOs) and the Template (ViewData). -There are two types of builders: - -### 1.1 ViewModel Builders -Transform API Transport DTOs into ViewModels. - -**Purpose**: Prepare raw API data for client-side state management. - -**Location**: `apps/website/lib/builders/view-models/**` - -**Pattern**: -```typescript -export class AdminViewModelBuilder { - static build(dto: UserDto): AdminUserViewModel { - return new AdminUserViewModel(dto); - } -} -``` - -### 1.2 ViewData Builders +### 1.1 ViewData Builders Transform API DTOs directly into ViewData for templates. -**Purpose**: Prepare API data for server-side rendering without ViewModels. +**Purpose**: Prepare API data for server-side rendering. They ensure that logic-rich behavior is stripped away, leaving only a "dumb" JSON structure safe for SSR and hydration. **Location**: `apps/website/lib/builders/view-data/**` **Pattern**: ```typescript +import { ViewDataBuilder } from '@/lib/contracts/builders/ViewDataBuilder'; + export class LeagueViewDataBuilder { - static build(apiDto: LeagueApiDto): LeagueDetailViewData extends ViewData { + static build(apiDto: LeagueApiDto): LeagueDetailViewData { return { leagueId: apiDto.id, name: apiDto.name, @@ -44,164 +28,90 @@ export class LeagueViewDataBuilder { }; } } + +// Enforce static compliance without dummy instances +LeagueViewDataBuilder satisfies ViewDataBuilder; ``` ## 2) Non-negotiable rules -### ViewModel Builders -1. MUST be deterministic -2. MUST be side-effect free -3. MUST NOT perform HTTP -4. MUST NOT call API clients -5. MUST NOT access cookies/headers -6. Input: API Transport DTO -7. Output: ViewModel -8. MUST live in `lib/builders/view-models/**` - ### ViewData Builders -1. MUST be deterministic -2. MUST be side-effect free -3. MUST NOT perform HTTP -4. MUST NOT call API clients -5. MUST NOT access cookies/headers -6. Input: API DTO -7. Output: ViewData -8. MUST live in `lib/builders/view-data/**` +1. MUST be deterministic. +2. MUST be side-effect free. +3. MUST NOT perform HTTP or call API clients. +4. Input: API DTO. +5. Output: ViewData (Plain JSON). +6. MUST live in `lib/builders/view-data/**`. +7. MUST use `static build()` and `satisfies ViewDataBuilder`. +8. MUST use **Formatters** for primitive output (strings/numbers). -## 3) Why two builder types? +## 3) Why no ViewModel Builders? -**ViewModel Builders** (API → Client State): -- Bridge the API boundary -- Convert transport types to client classes -- Add client-only fields if needed -- Run in client code +**ViewModels are self-building.** -**ViewData Builders** (API → Render Data): -- Bridge the presentation boundary -- Transform API data directly for templates -- Format values for display -- Run in server code (RSC) +A ViewModel is a class that wraps data to provide behavior. Instead of a separate builder class, ViewModels are instantiated directly from ViewData using their **Constructor**. This removes unnecessary "ceremony" and keeps the API unambiguous. + +**❌ Redundant Pattern (Forbidden):** +```typescript +// Why have this extra class? +export class TeamViewModelBuilder { + static build(data: TeamViewData): TeamViewModel { + return new TeamViewModel(data); + } +} +``` + +**✅ Clean Pattern (Required):** +```typescript +// Just use the class itself in the ClientWrapper +const vm = new TeamViewModel(viewData); +``` ## 4) Relationship to other patterns ``` -API Transport DTO - ↓ -ViewModel Builder (lib/builders/view-models/) - ↓ -ViewModel (lib/view-models/) - ↓ - (for client components) - API Transport DTO ↓ ViewData Builder (lib/builders/view-data/) ↓ -ViewData (lib/templates/) +Formatters (lib/display-objects/) -- [primitive output] ↓ -Template (lib/templates/) +ViewData (Plain JSON) + ↓ +Template (SSR) + ↓ +ViewModel (lib/view-models/) -- [new ViewModel(viewData)] + ↓ +Display Objects (lib/display-objects/) -- [rich API] ``` ## 5) Naming convention -**ViewModel Builders**: `*ViewModelBuilder` -- `AdminViewModelBuilder` -- `RaceViewModelBuilder` - **ViewData Builders**: `*ViewDataBuilder` - `LeagueViewDataBuilder` - `RaceViewDataBuilder` -## 6) File structure +## 6) Usage example (Server Component) -``` -lib/ - builders/ - view-models/ - AdminViewModelBuilder.ts - RaceViewModelBuilder.ts - index.ts - - view-data/ - LeagueViewDataBuilder.ts - RaceViewDataBuilder.ts - index.ts -``` - -## 7) Usage examples - -### ViewModel Builder (Client Component) -```typescript -'use client'; - -import { AdminViewModelBuilder } from '@/lib/builders/view-models/AdminViewModelBuilder'; -import { AdminApiClient } from '@/lib/api/admin/AdminApiClient'; - -export function AdminPage() { - const [users, setUsers] = useState([]); - - useEffect(() => { - const apiClient = new AdminApiClient(); - const dto = await apiClient.getUsers(); - const viewModels = dto.map(d => AdminViewModelBuilder.build(d)); - setUsers(viewModels); - }, []); - - // ... render with viewModels -} -``` - -### ViewData Builder (Server Component) ```typescript import { LeagueViewDataBuilder } from '@/lib/builders/view-data/LeagueViewDataBuilder'; import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery'; export default async function LeagueDetailPage({ params }) { const apiDto = await LeagueDetailPageQuery.execute(params.id); + + // Transform to flat JSON for SSR const viewData = LeagueViewDataBuilder.build(apiDto); return ; } ``` -## 8) Common mistakes - -❌ **Wrong**: Using "Presenter" for DTO → ViewModel -```typescript -// DON'T -export class AdminPresenter { - static createViewModel(dto: UserDto): AdminUserViewModel { ... } -} -``` - -✅ **Correct**: Use ViewModelBuilder -```typescript -export class AdminViewModelBuilder { - static build(dto: UserDto): AdminUserViewModel { ... } -} -``` - -❌ **Wrong**: Using "Transformer" for ViewModel → ViewData -```typescript -// DON'T -export class RaceResultsDataTransformer { - static transform(...): TransformedData { ... } -} -``` - -✅ **Correct**: Use ViewDataBuilder -```typescript -export class RaceResultsViewDataBuilder { - static build(...): RaceResultsViewData extends ViewData { ... } -} -``` - -## 9) Enforcement +## 7) Enforcement These rules are enforced by ESLint: -- `gridpilot-rules/view-model-builder-contract` - `gridpilot-rules/view-data-builder-contract` -- `gridpilot-rules/filename-view-model-builder-match` - `gridpilot-rules/filename-view-data-builder-match` +- `gridpilot-rules/formatters-must-return-primitives` -See [`docs/architecture/website/WEBSITE_GUARDRAILS.md`](WEBSITE_GUARDRAILS.md) for details. \ No newline at end of file +See [`docs/architecture/website/WEBSITE_CONTRACT.md`](WEBSITE_CONTRACT.md) for the authoritative contract. \ No newline at end of file diff --git a/docs/architecture/website/DISPLAY_OBJECTS.md b/docs/architecture/website/DISPLAY_OBJECTS.md deleted file mode 100644 index 8442f794a..000000000 --- a/docs/architecture/website/DISPLAY_OBJECTS.md +++ /dev/null @@ -1,230 +0,0 @@ -# Displays - -## Definition - -A **Display** encapsulates **reusable, UI-only display logic**. - -In this codebase, a Display is a **Frontend Value Object**: - -- class-based -- immutable -- deterministic -- side-effect free - -### Distinction from Domain Value Objects - -While both are "Value Objects", they serve different layers: - -1. **Domain Value Objects (Core):** Encapsulate business truth and invariants (e.g., `Money`, `EmailAddress`). They are pure and never contain formatting logic. -2. **Display Objects (Website):** Encapsulate presentation truth and formatting (e.g., `PriceDisplay`, `DateDisplay`). They are used to transform raw data or Domain Value Objects into user-ready strings. - -It answers the question: - -> “How should this specific piece of information be shown?” - -Displays are **not screen-specific**. -They exist to avoid duplicating presentation logic across View Models. - -**Naming Convention:** -- Displays MUST end with `Display` suffix -- Displays MUST be reusable across multiple screens -- Valid examples: `PriceDisplay`, `EmailDisplay`, `RatingDisplay` -- Invalid examples: `DashboardRatingDisplay`, `UserProfileDisplay` - ---- - -## Responsibilities - -A Display MAY: - -- format values (money, dates, durations) -- handle localization only when localization inputs are deterministic (for example: mapping stable codes to stable labels) -- map codes to labels -- encapsulate UI display conventions -- be reused across multiple View Models - -In addition, a Display MAY: - -- normalize presentation inputs (for example trimming/casing) -- expose multiple explicit display variants (for example `shortLabel`, `longLabel`) - -A Display MUST: - -- be deterministic -- be side-effect free -- operate only on presentation data - -A Display MUST: - -- be implemented as a **class** with a small, explicit API -- accept only primitives/plain data in its constructor (or static factory) -- expose only primitive outputs (strings/numbers/booleans) - ---- - -## Restrictions - -A Display MUST NOT: - -- contain business logic -- enforce domain invariants -- perform validation -- influence system behavior -- be sent back to the server -- depend on backend or infrastructure concerns -- **depend on environment-specific APIs** (e.g., `window`, `document`, `navigator`) -- **be serialized** (they are classes; only their primitive outputs are stored in `ViewData`) - -In this repository, a Display MUST NOT: - -- call `Intl.*` -- call `Date.toLocaleString()` / `Date.toLocaleDateString()` / `Date.toLocaleTimeString()` - -Reason: these are runtime-locale/timezone dependent and cause SSR/hydration mismatches. - -### Handling Client-Only Formatting - -If a formatting requirement **strictly requires** client-only APIs (e.g., browser-native relative time or local timezone detection): - -1. It MUST NOT live in a `Display Object`. -2. It SHOULD live in a **View Model** (which is client-only). -3. The Template should handle the transition from server-provided `ViewData` to client-updated `ViewData`. - -### Best Practices for Time/Date Formatting - -To avoid hydration mismatches while still providing good SEO and UX: - -1. **Use UTC methods for Determinism:** In `Display Objects`, prefer `getUTCDate()`, `getUTCMonth()`, etc., over their local counterparts. This ensures the server and client produce the exact same string regardless of their local timezones. -2. **Hardcoded Arrays:** Use hardcoded arrays for month/day names instead of `Intl` to ensure consistency across environments. -3. **Pass "Now" as an Argument:** For relative time (e.g., "time ago"), pass the reference "now" timestamp as an argument to the `Display Object` instead of calling `Date.now()` inside it. -4. **The "Upgrade" Pattern:** - - **Server:** `ViewData Builder` uses a `Display Object` to produce a deterministic UTC-based string (e.g., "2024-01-18 15:00 UTC"). - - **Client:** `View Model` uses client APIs (`Intl`, `toLocale*`) to produce a localized string (e.g., "3:00 PM") and updates the `ViewData`. - -## Localization rule (strict) - -Localization MUST NOT depend on runtime locale APIs. - -Allowed approaches: - -- API returns the exact labels/strings for the current user context. -- Website maps stable codes to stable labels using a deterministic table. - -Forbidden approaches: - -- any usage of `Intl.*` -- any usage of `toLocale*` - -If a rule affects system correctness or persistence, -it does not belong in a Display. - ---- - -## Ownership & Placement - -- Displays belong to the **presentation layer** -- They are frontend-only -- They are not shared with the backend or core - -Placement rule (strict): - -- Displays live under `apps/website/lib/display-objects/*`. -- Filenames MUST match the class name with `.tsx` extension (e.g., `RatingDisplay.tsx` contains `class RatingDisplay`) - ---- - -## Relationship to View Models and ViewData Builders - -Displays are the **shared source of truth** for formatting logic across the website: - -- **ViewData Builders (Server):** Use Displays to produce deterministic, formatted strings for SEO and initial SSR. -- **View Models (Client):** Use Displays to produce formatted strings for interactive UI and client-specific context. - -Additional strict rules: - -- View Models SHOULD compose Displays. -- ViewData Builders SHOULD use Displays for all formatting. -- **Templates and Components MUST NOT use Displays directly.** They must receive already-formatted primitive outputs (strings, numbers) via their props. - -Reason: This keeps the rendering layer "dumb" and ensures that the `ViewData` remains the single source of truth for what is displayed on the screen. - -- Displays MUST NOT be serialized or passed across boundaries. - ---- - -## Testing - -Displays SHOULD be tested because they often contain: - -- locale-specific behavior -- formatting rules -- edge cases visible to users - -Additionally: - -- test determinism by running the same inputs under Node and browser contexts (where applicable) -- test boundary rules (no `Intl.*`, no `toLocale*`) - ---- - -## Common Candidates (Found in Components) - -The following patterns were identified in `apps/website/components` and SHOULD be migrated to Display Objects: - -### 1. Date & Time -- **Month/Year:** `new Date().toLocaleDateString('en-US', { month: 'short', year: 'numeric' })` → `DateDisplay.formatMonthYear()` -- **Time only:** `new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })` → `DateDisplay.formatTime()` -- **Full Date:** `new Date().toLocaleDateString()` → `DateDisplay.formatShort()` (ensure UTC) -- **Relative Time:** `timeAgo(timestamp)` logic → `RelativeTimeDisplay.format(timestamp, now)` - -### 2. Currency & Prices -- **Price with Symbol:** `$` + `amount.toFixed(2)` → `CurrencyDisplay.format(amount, 'USD')` -- **Compact Price:** `$` + `amount.toLocaleString()` → `CurrencyDisplay.formatCompact(amount)` - -### 3. Numbers & Stats -- **Ratings:** `Math.round(rating).toLocaleString()` → `RatingDisplay.format(rating)` -- **Percentages:** `(val * 100).toFixed(1) + '%'` → `PercentDisplay.format(val)` -- **Consistency:** `${stats.consistency}%` → `ConsistencyDisplay.format(stats.consistency)` -- **Average Finish:** `avgFinish.toFixed(1)` → `FinishDisplay.format(avgFinish)` -- **Durations:** `duration.toFixed(2) + 'ms'` or `minutes:seconds` → `DurationDisplay.format(ms)` -- **Memory:** `(bytes / 1024 / 1024).toFixed(1) + 'MB'` → `MemoryDisplay.format(bytes)` - -### 4. Status & Labels -- **Race Status:** Mapping `scheduled | running | completed` to labels → `RaceStatusDisplay` -- **Protest Status:** Mapping `pending | under_review | resolved` to labels → `ProtestStatusDisplay` -- **Action Status:** Mapping `PENDING | COMPLETED | FAILED` to labels → `ActionStatusDisplay` - -### 5. Pluralization -- **Member Count:** `${count} ${count === 1 ? 'member' : 'members'}` → `MemberDisplay.formatCount(count)` -- **League Count:** `${count} ${count === 1 ? 'league' : 'leagues'}` → `LeagueDisplay.formatCount(count)` - ---- - -## Existing Display Objects - -- **[`DateDisplay`](apps/website/lib/display-objects/DateDisplay.ts)**: UTC-based date and time formatting. -- **[`CurrencyDisplay`](apps/website/lib/display-objects/CurrencyDisplay.ts)**: Deterministic currency formatting. -- **[`RaceStatusDisplay`](apps/website/lib/display-objects/RaceStatusDisplay.ts)**: Race status labels, variants, and icons. -- **[`RatingDisplay`](apps/website/lib/display-objects/RatingDisplay.ts)**: Rounded rating formatting with thousands separators. -- **[`RelativeTimeDisplay`](apps/website/lib/display-objects/RelativeTimeDisplay.ts)**: Deterministic relative time (requires "now" argument). -- **[`MemberDisplay`](apps/website/lib/display-objects/MemberDisplay.ts)**: Member count pluralization. -- **[`LeagueDisplay`](apps/website/lib/display-objects/LeagueDisplay.ts)**: League count pluralization. - ---- - -## Summary - -- Displays encapsulate **how something looks** (the single source of truth for formatting logic). -- View Models encapsulate **what a screen needs** (including client-specific "last mile" formatting). -- Both are presentation concerns. -- Neither contains business truth. - -In one sentence: **Displays are the shared source of truth for deterministic formatting logic, used by both the server and the client.** - ---- - -## Final Rule: Where does formatting go? - -1. **Is it deterministic?** (e.g., currency symbols, fixed date formats, labels) → **Display Object**. -2. **Is it client-only?** (e.g., `Intl.*`, `toLocale*`, browser timezone) → **View Model**. -3. **Is it for SEO?** → **ViewData Builder** (using a Display Object). \ No newline at end of file diff --git a/docs/architecture/website/FORMATTERS.md b/docs/architecture/website/FORMATTERS.md new file mode 100644 index 000000000..5c8591802 --- /dev/null +++ b/docs/architecture/website/FORMATTERS.md @@ -0,0 +1,87 @@ +# Displays & Formatters + +## Definition + +A **Display** encapsulates **reusable, UI-only display logic**. + +In this codebase, we distinguish between **Formatters** (Stateless Logic) and **Display Objects** (Rich Value Objects). + +### 1. Formatters (The "Mouth") +Formatters are pure, stateless utilities. They are the "Experts" on how to transform a raw value into a primitive string/number. +- **Usage:** Used by `ViewDataBuilders` (Server) and `ViewModels` (Client). +- **Output:** MUST return primitive values only (`string`, `number`, `boolean`, `null`). +- **Uncle Bob says:** "Data structures (ViewData) should not have behavior. Keep logic in stateless utilities." + +### 2. Display Objects (The "Rich API") +Display Objects are logic-rich **Value Objects** that live only on the client. They wrap data and provide multiple ways to look at it. +- **Usage:** Used by `ViewModels` (Client) to provide a rich API to the UI. +- **Output:** Can return complex objects or variants. +- **Uncle Bob says:** "Objects expose behavior, not data. Use them to hide the complexity of the UI." + +--- + +## Responsibilities + +A Display/Formatter MAY: +- format values (money, dates, durations) +- handle deterministic localization (mapping stable codes to labels) +- encapsulate UI display conventions + +A Display/Formatter MUST: +- be deterministic +- be side-effect free +- be implemented as a **class** with static methods (Formatters) or as immutable classes (Display Objects) + +--- + +## Restrictions + +A Display/Formatter MUST NOT: +- contain business logic (e.g., "Team is full if count > 10") +- enforce domain invariants +- perform validation +- **be serialized** (only their primitive outputs are stored in `ViewData`) +- call `Intl.*` or `toLocale*` (unless explicitly marked for client-only ViewModels) + +--- + +## Relationship to ViewData and ViewModels + +### The "Primitive Compact" (Server-Side) +`ViewDataBuilders` MUST use **Formatters** to produce flat, serializable `ViewData`. +- **Rule:** `ViewData` properties assigned from a Display/Formatter MUST be primitives. +- **Reason:** Ensures `ViewData` remains a "dumb" JSON structure for SSR. + +### The "Rich API" (Client-Side) +`ViewModels` MAY use **Display Objects** to provide interactive formatting. +- **Rule:** `ViewModels` can return `Display Object` instances to the UI. +- **Reason:** Allows the UI to access multiple variants (e.g., `date.short`, `date.relative`) without re-fetching data. + +--- + +## Summary of the Flow + +```mermaid +graph TD + DTO[Raw DTO] -->|ViewDataBuilder| VD[ViewData] + subgraph "Server: The Formatter Compact" + VD -->|Uses| F[Stateless Formatter] + F -->|Returns| S[Primitive string/number] + end + + VD -->|SSR Boundary| T[Template] + + subgraph "Client: The DisplayObject Richness" + T -->|Props| CW[ClientWrapper] + CW -->|new| VM[ViewModel] + VM -->|Wraps| DO[Rich Display Object] + DO -->|Provides| R[Rich API: .time, .relative, .date] + end +``` + +## Final Rule: Where does logic live? + +1. **Is it a business rule?** (e.g., "Can join?") → **ViewModel**. +2. **Is it a formatting rule?** (e.g., "How to show date?") → **Formatter/Display**. +3. **Is it for SEO/SSR?** → **ViewDataBuilder** (using a Formatter). +4. **Is it for interaction?** → **ViewModel** (using a Display Object). \ No newline at end of file diff --git a/docs/architecture/website/REACT_COMPONENT_ARCHITECTURE.md b/docs/architecture/website/REACT_COMPONENT_ARCHITECTURE.md index ea1f0dd6a..9ed5c20e7 100644 --- a/docs/architecture/website/REACT_COMPONENT_ARCHITECTURE.md +++ b/docs/architecture/website/REACT_COMPONENT_ARCHITECTURE.md @@ -85,9 +85,9 @@ export function TeamsTemplate({ teams, searchQuery, onSearchChange, onTeamClick - @@ -123,7 +123,7 @@ import { Card, CardHeader, TeamRow } from '@/ui'; export function TeamLeaderboardPreview({ teams, onTeamClick }: Props) { // App-specific logic: medal colors, ranking, etc. - const getMedalColor = (position: number) => { + const getMedalColor = (position: number) => { if (position === 0) return 'gold'; if (position === 1) return 'silver'; return 'none'; @@ -133,7 +133,7 @@ export function TeamLeaderboardPreview({ teams, onTeamClick }: Props) { {teams.map((team, index) => ( - @@ -224,7 +224,7 @@ Components must only expose props that describe **what** the component is or **h - **`components/` components**: **MUST NOT** use `className`, `style`, or any prop that accepts raw styling values. They must only use the semantic APIs provided by `ui/`. - **`templates/`**: Same as `components/`. -## The Display Object Layer +## The Formatter & Display Object Layer **Purpose**: Reusable formatting and presentation logic @@ -235,11 +235,13 @@ Components must only expose props that describe **what** the component is or **h - Value transformations for display **Rules**: +- **Formatters**: Stateless utilities for server-side primitive output. +- **Display Objects**: Rich Value Objects for client-side interactive APIs. - Class-based - Immutable - Deterministic - No side effects -- No `Intl.*` or `toLocale*` +- No `Intl.*` or `toLocale*` (unless client-only) **Usage**: ```typescript @@ -256,11 +258,15 @@ export class RatingDisplay { } } -// In ViewModel Builder -const viewModel = { - rating: RatingDisplay.format(dto.rating), - ratingColor: RatingDisplay.getColor(dto.rating) +// In ViewData Builder (Server) +const viewData = { + rating: RatingDisplay.format(dto.rating), // Primitive string }; + +// In ViewModel (Client) +get rating() { + return new RatingDisplay(this.data.rating); // Rich API +} ``` ## Dependency Flow @@ -292,7 +298,7 @@ ui/ (generic primitives) - **Component**: Understands app concepts (teams, races, leagues) - **UI**: Generic building blocks (button, card, input) -### When to use Display Objects? +### When to use Formatters/Display Objects? - When formatting is reusable across multiple ViewModels - When mapping codes to labels - When presentation logic needs to be deterministic diff --git a/docs/architecture/website/VIEW_DATA.md b/docs/architecture/website/VIEW_DATA.md index eadde1591..d40b96886 100644 --- a/docs/architecture/website/VIEW_DATA.md +++ b/docs/architecture/website/VIEW_DATA.md @@ -12,13 +12,15 @@ ViewData is a JSON-serializable, template-ready data structure: - arrays and plain objects - `null` for missing values +**Uncle Bob says**: "Data structures should not have behavior." ViewData is a dumb container. + ## 2) What ViewData is NOT ViewData is not: - an API Transport DTO (raw transport) - a ViewModel (client-only class) -- a Display Object instance +- a Display Object instance (rich API) ## 3) Construction rules @@ -58,7 +60,7 @@ ViewData Builders MUST NOT call the API. ViewData is responsible for providing **fully formatted strings** to Templates for Server-Side Rendering (SSR). -- **SEO Requirement:** All data required for search engines (prices, dates, counts, labels) MUST be formatted in the `ViewData Builder` on the server. +- **SEO Requirement:** All data required for search engines (prices, dates, counts, labels) MUST be formatted in the `ViewData Builder` on the server using **Formatters**. - **Template Simplicity:** Templates should simply render the strings provided in `ViewData` without further processing. ## 5) Determinism rules @@ -75,7 +77,7 @@ Reason: SSR and browser outputs can differ. Localization MUST NOT depend on runtime locale APIs. If localized strings are required, they MUST be provided as deterministic inputs (for example via API-provided labels or a deterministic code-to-label map) and passed through ViewData Builders into ViewData. -## 5) Relationship to ViewModels +## 6) Relationship to ViewModels ViewData serves as the stable, serializable contract between the server and client. It is: - The input for Templates (both SSR and Client) @@ -88,11 +90,11 @@ ViewModels are built from ViewData, not from DTOs. This ensures: **Important:** ViewData MUST NOT contain ViewModel instances. ViewModels are classes with logic; ViewData is plain JSON. -## 6) Relationship to Display Objects +## 7) Relationship to Formatters & Display Objects -Display Objects are used to implement formatting/mapping, but their instances MUST NOT be stored inside ViewData. +Formatters are used to implement formatting/mapping, but their instances MUST NOT be stored inside ViewData. -Only primitive outputs produced by Display Objects may appear in ViewData. +Only primitive outputs produced by Formatters may appear in ViewData. ### The "Redundancy" Question DTOs, ViewData, and ViewModels might share similar fields, but they are NOT redundant: diff --git a/docs/architecture/website/VIEW_MODELS.md b/docs/architecture/website/VIEW_MODELS.md index 42b3892dc..edd9c0b1c 100644 --- a/docs/architecture/website/VIEW_MODELS.md +++ b/docs/architecture/website/VIEW_MODELS.md @@ -11,6 +11,8 @@ It answers the question: View Models are **UI-owned** classes. They do not represent business truth and do not enforce domain rules. +**Uncle Bob says**: "Objects expose behavior, not data." ViewModels encapsulate the behavior of the UI. + --- ## Responsibilities @@ -22,14 +24,14 @@ A View Model MAY: - combine or reshape data for rendering - perform formatting (dates, numbers, labels), especially for **client-only context** (e.g., local timezone, relative "time ago") - handle localization and presentation logic -- use Display Objects for reusable UI concerns +- use **Display Objects** for reusable UI concerns and rich APIs ### Formatting Responsibility -While `ViewData Builders` handle formatting for SEO and initial render, `View Models` are responsible for: +While `ViewData Builders` handle formatting for SEO and initial render using **Formatters**, `View Models` are responsible for: - **Client-specific formatting:** Data that depends on the browser's locale, timezone, or precise location. - **Interactive formatting:** Updating display values in response to user input or state changes. -- **Consistency:** Using the same `Display Objects` as the server to ensure a consistent look and feel. +- **Rich APIs:** Providing **Display Objects** to the UI for multi-variant formatting (e.g., `date.short`, `date.relative`). In the website SSR/RSC architecture, View Models MAY compute view-only derived values, but MUST NOT be the type passed into Templates. @@ -78,7 +80,7 @@ Rules (website): 1) View Models are created in client code only. 2) View Models are created from **ViewData** (not from DTOs). 3) Templates MUST NOT accept View Models; Templates accept ViewData only. -4) View Models MUST compose Display Objects and produce ViewData (primitive outputs only). +4) View Models MUST compose Display Objects and produce rich UI APIs. Authoritative reference: [plans/nextjs-rsc-viewmodels-concept.md](plans/nextjs-rsc-viewmodels-concept.md:1). diff --git a/docs/architecture/website/WEBSITE_CONTRACT.md b/docs/architecture/website/WEBSITE_CONTRACT.md index 12b9825e6..895517551 100644 --- a/docs/architecture/website/WEBSITE_CONTRACT.md +++ b/docs/architecture/website/WEBSITE_CONTRACT.md @@ -51,20 +51,7 @@ Canonical placement in this repo: - `apps/website/lib/types/**` (transport DTOs consumed by services and page queries) -### 3.2 API Transport DTO - -Definition: the shape returned by the backend API over HTTP. - -Rules: - -- API Transport DTOs MUST be contained inside infrastructure. -- API Transport DTOs MUST NOT be imported by Templates. - -Canonical placement in this repo: - -- `apps/website/lib/types/**` (transport DTOs consumed by services and page queries) - -### 3.3 ViewData +### 3.2 ViewData Definition: the only allowed input type for Templates. @@ -73,6 +60,7 @@ Rules: - JSON-serializable only. - Contains only template-ready values (mostly strings/numbers/booleans). - MUST NOT contain class instances. +- **Uncle Bob says**: "Data structures should not have behavior." See [`VIEW_DATA.md`](docs/architecture/website/VIEW_DATA.md:1). @@ -89,6 +77,7 @@ Rules: - Instantiated only in `'use client'` modules. - Never serialized. - Used for client components that need state management. +- **Uncle Bob says**: "Objects expose behavior, not data." See [`VIEW_MODELS.md`](docs/architecture/website/VIEW_MODELS.md:1). @@ -112,6 +101,7 @@ Rules: - MUST NOT call the API. - Input: `Result` or `ApiDto` - Output: ViewModel +- MUST use `static build()` and `satisfies ViewModelBuilder`. See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1). @@ -133,6 +123,7 @@ Rules: - MUST NOT call the API. - Input: `Result` or `ApiDto` - Output: ViewData +- MUST use `static build()` and `satisfies ViewDataBuilder`. See [`BUILDERS.md`](docs/architecture/website/BUILDERS.md:1). @@ -161,18 +152,18 @@ Canonical placement in this repo: - `apps/website/lib/contracts/Result.ts` -### 4.3 Display Object +### 4.4 Formatter & Display Object Definition: deterministic, reusable, UI-only formatting/mapping logic. Rules: -- Class-based, immutable, deterministic. -- MUST NOT call `Intl.*`. -- MUST NOT call any `toLocale*`. +- **Formatters**: Stateless utilities for server-side primitive output. +- **Display Objects**: Rich Value Objects for client-side interactive APIs. +- MUST NOT call `Intl.*` or `toLocale*` (unless client-only). - MUST NOT implement business rules. -See [`DISPLAY_OBJECTS.md`](docs/architecture/website/DISPLAY_OBJECTS.md:1). +See [`FORMATTERS.md`](docs/architecture/website/FORMATTERS.md:1). Canonical placement in this repo: diff --git a/docs/architecture/website/WEBSITE_DATA_FLOW.md b/docs/architecture/website/WEBSITE_DATA_FLOW.md index e9d3a51b8..3f2ca1a10 100644 --- a/docs/architecture/website/WEBSITE_DATA_FLOW.md +++ b/docs/architecture/website/WEBSITE_DATA_FLOW.md @@ -28,6 +28,8 @@ API Transport DTO ↓ ViewData Builder (lib/builders/view-data/) ↓ +Formatters (lib/display-objects/) -- [primitive output] + ↓ ViewData (Plain JSON) ↓ Template @@ -41,7 +43,7 @@ ViewData (from Props) ↓ ViewModel (lib/view-models/) -- [new ViewModel(viewData)] ↓ -Display Objects (lib/display-objects/) -- [formatting] +Display Objects (lib/display-objects/) -- [rich API] ↓ Interactive UI State ```