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>
21 KiB
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:
-
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.
-
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.
-
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
asyncand returns aPromise— 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>orItemResponse<T>fromservices/types.ts - Errors are thrown via
throwServiceError()fromservices/errors.ts - Services never import React hooks — they are plain TypeScript
- Services access Zustand stores via
getState(), never viauseStore()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. useQueryfor reads,useMutationfor 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.invalidateQuerieson 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 | 150–200 lines |
| Feature component | 250 lines | 100–150 lines |
| Atom/shared component | 150 lines | 50–100 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 (uses => 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) |
0–100 | hex string | Overall match score color |
confidenceHex(score) |
0–1 | hex string | Confidence score color |
dataQualityColor(score) |
0–1 | hex string | Data quality color |
criterionScoreColor(score) |
0–100 | MUI color token | Individual criterion badge |
criterionScoreTextColor(score) |
0–100 | 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:
aiMonitoringServicelogs 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 inlinerole === 'PROPERTY_MANAGER' - Workspace access is enforced by
<ProtectedRoute workspace={...} />— never guard withifinside 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:
- Which workspace does this belong to? (SUPPLY / DEMAND / OPS)
- What decision does this help the user make? (If you can't answer this, reconsider the feature)
- Does a provider already exist for this entity? If not, create
I[Entity]Provider+Mockup[Entity]Providerfirst - Does a service already exist? If not, create
[entity]Servicethat wraps the provider - Does a hook already exist? If not, create
use[Entity]insrc/hooks/ - Is this server state or UI state? (see STATE_MANAGEMENT.md)
- Does this show a score or confidence value? Use
DS_COLORS+ helper functions fromlib/utils.ts - Does this involve AI? Go through
IAIService— never call LLM APIs directly - 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: trueis enforced — do not disable strict mode or individual checks- Prefer
interfacefor object shapes (can be extended); usetypefor unions and aliases - Props interfaces are named
Props(local) or[ComponentName]Props(exported) - Never use
@ts-ignore— fix the type instead as unknown as Tis acceptable only when bridging genuinely untyped third-party code; wrap it in a named adapter function- Enums from
src/domain/enums.tsare the source of truth — never redefine values locally