Files
property-match/CLAUDE.md
T
Benjamin Sutter 713ef3ec08 docs: engineering governance — CLAUDE.md, ARCHITECTURE.md, CODE_REVIEW_CHECKLIST.md
Complete rewrite of CLAUDE.md with full binding rules (vision, architecture layers,
component limits, state management, design system, AI integration, performance,
security). New ARCHITECTURE.md covers folder structure, all data flows with ASCII
diagrams, scoring pipeline, AI flow, unified result feed, and workspace routing.
New CODE_REVIEW_CHECKLIST.md provides a 10-section review guide and pre-commit
quick checklist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:10:30 +02:00

21 KiB
Raw Blame History

property-match — Engineering Guidelines

This file is read by Claude Code on every session. All rules here are binding. For architecture diagrams and data flows see ARCHITECTURE.md. For the pre-commit review checklist see CODE_REVIEW_CHECKLIST.md. For state management patterns see STATE_MANAGEMENT.md.


1. Project Vision

Property Match is a Decision Intelligence Platform — not a real estate portal.

The distinction matters for every design decision:

Classical Portal Decision Intelligence
Show listings, let the user decide Surface scored matches with explainability
Generic SaaS listing UI Every screen is designed around one user decision
More filters = better Fewer, smarter signals = better
Trust by volume Trust by transparency

Three design principles that override everything else:

  1. Explainability-first. Every score, badge, and recommendation must be traceable. If the user cannot understand why a match is strong, the feature is not done. Score breakdowns, trade-off panels, risk indicators, and data provenance are first-class UI elements — not tooltips added at the end.

  2. Trust-first. Confidence levels, data freshness, and missing-data warnings are shown proactively. We never surface a result that looks more certain than the underlying data supports. When data is stale or incomplete, we say so.

  3. Better-than-Google. A property search result that just shows "here are things that match your query" fails the user. Property Match should rank, explain trade-offs, surface future signals, and give the user a clear recommended next action — like a trusted advisor, not a search engine.


2. Stack

Technology Version Role
Vite 8 Build tool & dev server
React 19 UI framework
TypeScript 6 Type safety
MUI v9 (@mui/material) Component library
Tailwind CSS v4 Utility classes (via @tailwindcss/vite)
React Router v7 Routing — import from react-router, not react-router-dom
TanStack Query v5 Server state & caching
Zustand v5 Global UI state
@dnd-kit current Drag-and-drop (Pipeline board)
Zod current Schema validation
Lucide React current Icon set (supplementary to MUI icons)

There is no tailwind.config.js. Tailwind v4 uses @tailwindcss/vite — config is inline.


3. Three-Workspace Architecture

The app is organized into three protected workspaces. Every route lives in exactly one workspace.

Workspace Route prefix Primary user Core job-to-be-done
SUPPLY /supply/* Property manager / owner Manage inventory, respond to demand, monitor market
DEMAND /demand/* Tenant / company Find, compare, and pipeline commercial space
OPERATIONS /ops/* Internal staff AI governance, market intelligence, audit logs

Route protection is enforced at the router level via <ProtectedRoute workspace={WorkspaceType.X} />. Never add workspace-specific logic inside shared components — pass isStaff, isOwner, etc. as props.


4. Layered Architecture

Data and logic flow through four explicit layers. Never skip a layer.

Provider  →  Service  →  Hook (React Query)  →  Component

4.1 Provider Layer (src/provider/)

Providers are the only code that touches data storage. Everything else goes through them.

Rules:

  • Every entity has an interface: I[Entity]Provider (file: I[Entity]Provider.ts)
  • Every interface is implemented by a mockup: Mockup[Entity]Provider (file: Mockup[Entity]Provider.ts)
  • Every method is async and returns a Promise — even in the mockup
  • Mockups hold in-memory seed data from src/mock-data/
  • Real implementations swap in without changing any call sites

Naming:

Pattern Example
Interface IPropertyProvider
Mock implementation MockupPropertyProvider
Real implementation (future) RestPropertyProvider, SupabasePropertyProvider
// src/provider/IPropertyProvider.ts
export interface IPropertyProvider {
  getAll(): Promise<Property[]>
  getById(id: string): Promise<Property | null>
  create(data: CreatePropertyInput): Promise<Property>
  update(id: string, data: UpdatePropertyInput): Promise<Property>
  remove(id: string): Promise<void>
}
// src/provider/MockupPropertyProvider.ts
export const MockupPropertyProvider: IPropertyProvider = {
  async getAll() { return [...properties] },
  async getById(id) { return properties.find(p => p.id === id) ?? null },
  // ...
}

Anti-patterns:

  • Never call a provider directly from a component — use a service
  • Never mutate provider state from outside the provider
  • Never share state across providers (cross-provider coupling creates hidden dependencies)

4.2 Service Layer (src/services/)

Services wrap providers with business logic, error handling, and response standardization.

Rules:

  • Named [entity]Service (e.g., matchService, needService)
  • Return standardized types: ListResponse<T> or ItemResponse<T> from services/types.ts
  • Errors are thrown via throwServiceError() from services/errors.ts
  • Services never import React hooks — they are plain TypeScript
  • Services access Zustand stores via getState(), never via useStore() hooks
// ✅ Service accessing auth — no hook
const user = useSessionStore.getState().currentUser

4.3 Hook Layer (src/hooks/)

Hooks wrap React Query around services.

Rules:

  • One hook file per entity: useProperties.ts, useMatches.ts, etc.
  • useQuery for reads, useMutation for writes
  • Query keys follow the convention in STATE_MANAGEMENT.md
  • Stale times are imported from src/lib/constants.ts — never defined locally in hooks
  • Mutations call queryClient.invalidateQueries on success
// ✅ Stale time from constants — never local
import { STALE_PROPERTIES } from '../lib/constants'
export function useProperties() {
  return useQuery({ queryKey: ['properties'], queryFn: () => propertyService.getAll(), staleTime: STALE_PROPERTIES })
}

4.4 Component Layer (src/components/ + src/pages/)

Components are pure UI. They render data, dispatch mutations, and show state. They do not contain business logic.


5. Component Rules

5.1 Size Limits

File type Hard limit Soft target
Page component (src/pages/) 300 lines 150200 lines
Feature component 250 lines 100150 lines
Atom/shared component 150 lines 50100 lines

When a component exceeds its soft target, split it. Extract sub-components, move constants to a *Constants.ts file, move pure helpers to a *Utils.ts file (.tsx if it returns JSX).

5.2 No Business Logic in JSX

The render body is for presentation only. All logic belongs upstream.

// ❌ Business logic in JSX
{matches.filter(m => m.score > 70 && m.status !== 'DISMISSED').map(...)}

// ✅ Logic in useMemo, computed before the return
const visibleMatches = useMemo(
  () => matches.filter(m => m.score > 70 && m.status !== 'DISMISSED'),
  [matches]
)
// then:
{visibleMatches.map(...)}

5.3 No Magic Strings

Every label, status, color, and route string has a named constant.

// ❌
if (user.role === 'PROPERTY_MANAGER') { ... }
navigate('/supply/properties')

// ✅
import { UserRole } from '../domain/enums'
import { ROUTES } from '../lib/constants'
if (user.role === UserRole.PROPERTY_MANAGER) { ... }
navigate(ROUTES.SUPPLY.PROPERTIES)

All German UI labels for enums live in src/lib/constants.ts (e.g., ASSET_TYPE_LABELS, AVAILABILITY_LABELS).

5.4 No any Casts

any disables TypeScript. Use unknown with type guards, or fix the type properly.

// ❌
const result = response as any
const { data } = result

// ✅
const result: ItemResponse<Property> = response
const { data } = result

The one allowed exception: third-party library types that don't ship proper types. Wrap the cast in a type-safe adapter so the any is isolated.

5.5 No Duplicated State

If a value can be computed from existing state, compute it. Do not store it.

// ❌ Duplicated — sync bugs guaranteed
const [overdueCount, setOverdueCount] = useState(0)
useEffect(() => setOverdueCount(reminders.filter(r => isPast(r.dueDate)).length), [reminders])

// ✅ Derived inline
const overdueCount = reminders.filter(r => isPast(r.dueDate)).length

See STATE_MANAGEMENT.md for the full decision tree.

5.6 Component Library First

Before writing a custom component, check the MUI component list. Only build custom when:

  • MUI has no equivalent, or
  • The required behavior diverges significantly from what MUI provides

5.7 Styling

Use Tailwind utility classes for all layout and spacing. Use MUI's sx prop for component-specific overrides and theme values. Never add styles to .css files. The only CSS file is src/index.css (Tailwind layer imports) — do not add project styles there.


6. State Management

Full rules in STATE_MANAGEMENT.md. Summary:

Data type Where it lives
Server data (fetched from provider) React Query (useQuery)
Write operations React Query (useMutation)
Global UI state (dialog open/close, compare tray, wizard steps) Zustand store
Auth session sessionStore (Zustand, also read by services via getState())
Component-local state (form inputs, hover, toggle) useState
Values derived from existing state Computed inline — never stored

Critical rules:

  • Never copy React Query data into a Zustand store
  • Never call useStore() without a selector (use s => s.field)
  • Services read Zustand via getState(), never via hooks
  • Side effects (cache invalidation, analytics) go in useEffect, never in the render body

7. Domain & Types

7.1 Single Source of Truth

All domain types live in src/domain/. Never duplicate a type or interface.

File Contents
enums.ts All enums: AssetType, ResultType, MatchStatus, UserRole, WorkspaceType, etc.
property.ts Property, Location, ContactPerson
match.ts Match, ScoreBreakdown, ScoreFactor, TradeOff, Risk
need.ts Need, AreaRange, BudgetRange, WeightingProfile
unifiedResult.ts UnifiedMatchResult (the discriminated union for result feeds)
scoring.ts ScoringWeightProfile, HardFilterResult, MatchEngineOutput
aiOutput.ts AI scoring, extraction, monitoring types
... See /domain/index.ts for full list

7.2 No Inline Type Duplication

// ❌ Redefining a type locally
interface LocalProperty { id: string; title: string; ... }

// ✅ Import from domain
import type { Property } from '../../domain/property'

7.3 Response Types

All service responses use the standardized wrapper from src/services/types.ts:

type ListResponse<T> = { data: T[]; total: number }
type ItemResponse<T> = { data: T }

8. Design System

All visual constants are centralized. Never hardcode a color, spacing multiplier, or border-radius in a component.

8.1 Color Tokens (src/lib/ds.ts)

DS_COLORS.confidence.high   // '#1a7a4a'
DS_COLORS.confidence.medium // '#d97706'
DS_COLORS.confidence.low    // '#c0392b'
DS_COLORS.risk.critical     // '#dc2626'
DS_COLORS.risk.warning      // '#d97706'
DS_COLORS.freshness.stale   // '#d97706'
DS_COLORS.freshness.outdated// '#dc2626'

8.2 Score → Color Helpers (src/lib/utils.ts)

Function Input Output Use for
matchScoreHex(score) 0100 hex string Overall match score color
confidenceHex(score) 01 hex string Confidence score color
dataQualityColor(score) 01 hex string Data quality color
criterionScoreColor(score) 0100 MUI color token Individual criterion badge
criterionScoreTextColor(score) 0100 hex string Criterion text color

8.3 Result Type Metadata (src/lib/ds.ts)

Use RESULT_TYPE_META[resultType] for label, color, and background. Never define these inline.

// ❌
label="Verified Portfolio"
bgcolor="#1e3a5f"

// ✅
import { RESULT_TYPE_META } from '../../lib/ds'
label={RESULT_TYPE_META[result.resultType]?.label}
bgcolor={RESULT_TYPE_META[result.resultType]?.color}

8.4 Thresholds (src/lib/constants.ts)

SCORE_STRONG = 80    // match score considered "strong"
SCORE_MODERATE = 60  // match score considered "moderate"
CONF_HIGH = 0.85     // confidence considered "high"
CONF_MEDIUM = 0.65   // confidence considered "medium"
DQ_HIGH = 0.8        // data quality considered "high"
DQ_MEDIUM = 0.6      // data quality considered "medium"

These thresholds are used by scoreToConfidenceLevel(), scoreToDataQualityLevel(), and all badge logic. Never re-define them locally.


9. AI Integration Rules

Property Match integrates AI as a service, not as a feature bolted onto the UI.

9.1 Model-Agnostic Interface

AI is accessed exclusively through IAIService (src/services/ai/IAIService.ts). The interface defines capabilities (parseNeed, compareProperties, generateDecisionBrief, etc.). No component imports an LLM client directly.

// ❌ Never in a component or hook
import OpenAI from 'openai'
const client = new OpenAI(...)

// ✅ Always via service
import { aiService } from '../services/aiService'
const result = await aiService.parseNeed(text)

9.2 Implementations

Implementation File Used when
MockAIService src/services/ai/MockAIService.ts Dev / CI — deterministic, no API calls
OpenRouterAIService src/services/ai/OpenRouterAIService.ts Production — OpenRouter API

Swap implementations by changing the export in src/services/aiService.ts. No other file changes required.

9.3 Structured Responses

AI calls must return typed, structured objects — never raw strings passed into JSX.

// ❌ Raw LLM output in JSX
<Typography>{llmResponse}</Typography>

// ✅ Parsed, validated, typed output
const result: ParsedNeed = await aiService.parseNeed(text)
<NeedCardPreview need={result.need} confidence={result.confidence} />

All AI response schemas are validated with Zod before use.

9.4 Explainable Outputs

Every AI output that influences a user decision must be accompanied by an explanation.

  • Match scores include ScoreFactor[] (positive & negative)
  • Trade-off analysis includes TradeOff[] with severity
  • Future availability signals include probabilityScore + signalBasis
  • AI-parsed needs include confidence + which fields were inferred vs. stated

9.5 Human-in-the-Loop

AI suggestions are always proposals — the user confirms or overrides.

  • Need parsing: user reviews extracted criteria before search runs
  • AI recommendations: presented with confidence, not as commands
  • Review queue (/ops): all AI-generated content can be reviewed and corrected
  • Governance: aiMonitoringService logs every AI output with model version, prompt hash, and response

9.6 Fallback Handling

Every AI call has a defined fallback. If the AI service fails or returns a low-confidence result:

  • The UI shows a manual entry path
  • Confidence badges reflect uncertainty
  • No AI failure should block a user workflow

10. Performance Rules

10.1 No Expensive Calculations in Render

Any computation over a list, aggregation, or filter runs in useMemo, not in the render body.

// ❌ Runs on every render — expensive if list is large
return (
  <div>
    {properties
      .filter(p => p.confidenceScore >= 0.7)
      .sort((a, b) => b.areaSqm - a.areaSqm)
      .map(p => <PropertyCard key={p.id} property={p} />)}
  </div>
)

// ✅ Runs only when dependencies change
const visible = useMemo(
  () => properties.filter(p => p.confidenceScore >= 0.7).sort((a, b) => b.areaSqm - a.areaSqm),
  [properties]
)
return <div>{visible.map(p => <PropertyCard key={p.id} property={p} />)}</div>

10.2 Memoize List Items

Components rendered in lists (feeds, tables, grids) must be wrapped in React.memo to prevent cascade re-renders when parent state changes (e.g., a filter toggle or selectedId update).

// ✅ List item — always memo
export const PropertyCard = memo(function PropertyCard({ property, onSelect }: Props) { ... })
export const ReminderListRow = memo(function ReminderListRow({ reminder }: Props) { ... })

10.3 Stable Callbacks for Memoized Children

When passing callbacks as props to memoized children, wrap them in useCallback. An unstable function reference breaks React.memo.

// ✅ Stable reference — memo children won't re-render
const handleSelect = useCallback((id: string) => setSelectedId(id), [])

10.4 Single-Pass Aggregations

When computing multiple aggregates over the same list (e.g., count of matchReady + criticalGaps + lowConfidence), use a single for loop in one useMemo — not three separate .filter() calls.

10.5 Virtualization

The current page size (DEFAULT_PAGE_SIZE = 25) makes React virtualization unnecessary today. If paginated lists are removed or page size exceeds ~200 items, evaluate @tanstack/virtual for the result feed and property table.

10.6 Bundle Splitting

All routes are already lazy-loaded via React.lazy() in App.tsx. Keep it that way — do not import page components directly.


11. Security & Governance

11.1 Auth & Session

  • Auth state lives in sessionStore (Zustand)
  • On logout, clear the React Query cache: queryClient.clear()
  • Role checks use src/lib/permissions.ts — never inline role === 'PROPERTY_MANAGER'
  • Workspace access is enforced by <ProtectedRoute workspace={...} /> — never guard with if inside a component

11.2 Role-Based Rendering

// ❌ Magic string role check
if (user.role === 'PROPERTY_MANAGER') { ... }

// ✅ Permission helper
import { canViewMatchCenter } from '../../lib/permissions'
if (canViewMatchCenter(user)) { ... }

11.3 No Sensitive Data in Client State

  • Do not store tokens, passwords, or PII in Zustand stores beyond what is strictly needed for session identity
  • Do not log sensitive fields in console statements
  • Do not pass sensitive data as URL params (use navigation state instead)

11.4 AI Audit Logging

Every AI output that influences a business decision is logged via aiMonitoringService. Logs include:

  • Timestamp
  • Model ID and version
  • Prompt hash (not the full prompt — for privacy)
  • Response summary
  • User ID and workspace

11.5 Data Lineage

Every property record carries DataSource metadata (origin, freshness, last verified). Surface this data proactively — especially when freshness === 'STALE' or freshness === 'OUTDATED'.


12. Adding a New Feature — Checklist

Before writing any code, answer these questions:

  1. Which workspace does this belong to? (SUPPLY / DEMAND / OPS)
  2. What decision does this help the user make? (If you can't answer this, reconsider the feature)
  3. Does a provider already exist for this entity? If not, create I[Entity]Provider + Mockup[Entity]Provider first
  4. Does a service already exist? If not, create [entity]Service that wraps the provider
  5. Does a hook already exist? If not, create use[Entity] in src/hooks/
  6. Is this server state or UI state? (see STATE_MANAGEMENT.md)
  7. Does this show a score or confidence value? Use DS_COLORS + helper functions from lib/utils.ts
  8. Does this involve AI? Go through IAIService — never call LLM APIs directly
  9. Does this need explainability? Add score factors, provenance, or reasoning before calling it done

Do not add business logic to components. Do not duplicate types from /domain/. Do not hardcode colors, labels, or thresholds.


13. File Naming & Organization

Location What goes there
src/pages/[workspace]/ Route-level page components
src/components/[feature]/ Feature-specific components
src/components/shared/ Components used across features
src/components/ui/ Atomic UI: ErrorBoundary, EmptyState, Toast, DecisionContextPanel
src/components/layout/ AppShell, PageHeader, RightContextPanel
src/hooks/ React Query hooks
src/services/ Business logic services
src/services/ai/ AI service interface + implementations + prompts
src/provider/ Data access interfaces + mock implementations
src/domain/ TypeScript types and enums
src/features/matching/ Scoring and ranking algorithms
src/stores/ Zustand stores
src/lib/ Shared utilities, theme, constants, design system
src/mock-data/ Seed data for mock providers

Each component folder has an index.ts barrel export. New components must be added to the barrel before use.

File extension rule: Any file that contains JSX must use .tsx. Plain TypeScript (no JSX) uses .ts. Violating this causes Vite parse errors.


14. TypeScript Rules

  • strict: true is enforced — do not disable strict mode or individual checks
  • Prefer interface for object shapes (can be extended); use type for unions and aliases
  • Props interfaces are named Props (local) or [ComponentName]Props (exported)
  • Never use @ts-ignore — fix the type instead
  • as unknown as T is acceptable only when bridging genuinely untyped third-party code; wrap it in a named adapter function
  • Enums from src/domain/enums.ts are the source of truth — never redefine values locally