# 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](./ARCHITECTURE.md). > For the pre-commit review checklist see [CODE_REVIEW_CHECKLIST.md](./CODE_REVIEW_CHECKLIST.md). > For state management patterns see [STATE_MANAGEMENT.md](./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 ``. 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` | ```ts // src/provider/IPropertyProvider.ts export interface IPropertyProvider { getAll(): Promise getById(id: string): Promise create(data: CreatePropertyInput): Promise update(id: string, data: UpdatePropertyInput): Promise remove(id: string): Promise } ``` ```ts // 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` or `ItemResponse` 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 ```ts // ✅ 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 ```ts // ✅ 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. ```tsx // ❌ 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. ```ts // ❌ 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. ```ts // ❌ const result = response as any const { data } = result // ✅ const result: ItemResponse = 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. ```ts // ❌ 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](https://mui.com/material-ui/all-components/). 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](./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 ```ts // ❌ 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`: ```ts type ListResponse = { data: T[]; total: number } type ItemResponse = { 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`) ```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. ```ts // ❌ 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`) ```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. ```ts // ❌ 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. ```ts // ❌ Raw LLM output in JSX {llmResponse} // ✅ Parsed, validated, typed output const result: ParsedNeed = await aiService.parseNeed(text) ``` 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. ```tsx // ❌ Runs on every render — expensive if list is large return (
{properties .filter(p => p.confidenceScore >= 0.7) .sort((a, b) => b.areaSqm - a.areaSqm) .map(p => )}
) // ✅ Runs only when dependencies change const visible = useMemo( () => properties.filter(p => p.confidenceScore >= 0.7).sort((a, b) => b.areaSqm - a.areaSqm), [properties] ) return
{visible.map(p => )}
``` ### 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). ```ts // ✅ 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`. ```ts // ✅ 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 `` — never guard with `if` inside a component ### 11.2 Role-Based Rendering ```ts // ❌ 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