do to formatters
This commit is contained in:
@@ -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<LeagueApiDto, LeagueDetailViewData>;
|
||||
```
|
||||
|
||||
## 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<AdminUserViewModel[]>([]);
|
||||
|
||||
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 <LeagueDetailTemplate viewData={viewData} />;
|
||||
}
|
||||
```
|
||||
|
||||
## 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.
|
||||
See [`docs/architecture/website/WEBSITE_CONTRACT.md`](WEBSITE_CONTRACT.md) for the authoritative contract.
|
||||
@@ -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).
|
||||
87
docs/architecture/website/FORMATTERS.md
Normal file
87
docs/architecture/website/FORMATTERS.md
Normal file
@@ -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).
|
||||
@@ -85,9 +85,9 @@ export function TeamsTemplate({ teams, searchQuery, onSearchChange, onTeamClick
|
||||
<SearchInput value={searchQuery} onChange={onSearchChange} />
|
||||
</Header>
|
||||
|
||||
<TeamLeaderboardPreview
|
||||
teams={teams}
|
||||
onTeamClick={onTeamClick}
|
||||
<TeamLeaderboardPreview
|
||||
teams={teams}
|
||||
onTeamClick={onTeamClick}
|
||||
/>
|
||||
|
||||
<TeamGrid teams={teams} onTeamClick={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) {
|
||||
<Card variant="elevated">
|
||||
<CardHeader title="Top Teams" />
|
||||
{teams.map((team, index) => (
|
||||
<TeamRow
|
||||
<TeamRow
|
||||
key={team.id}
|
||||
name={team.name}
|
||||
rank={index + 1}
|
||||
@@ -174,7 +174,7 @@ export function Button({ children, variant = 'primary', onClick }: ButtonProps)
|
||||
}[variant];
|
||||
|
||||
return (
|
||||
<button
|
||||
<button
|
||||
className={`px-4 py-2 rounded-md transition-colors ${classes}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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<ApiDto, string>` 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<ApiDto, string>` 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:
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user