From 99e99d66c5b824c5caf1ddd792c71a6f89be0c53 Mon Sep 17 00:00:00 2001 From: Benjamin Sutter Date: Wed, 20 May 2026 20:03:54 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20unit-level=20matching=20architecture=20?= =?UTF-8?q?=E2=80=94=20match=20against=20Einheiten,=20not=20Objekte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domain/property: add getEffectiveUnits() — returns explicit units or synthesises one from property-level data so all properties work uniformly at unit level - domain/match: add unitId? field — every Match references a specific unit - domain/unifiedResult: add unit? to VerifiedPortfolioResult + ExternalMarketResult - MockupNeedProvider: generateSyntheticMatches iterates getEffectiveUnits(prop); computeScore uses unit-level areaSqm + rentPricePerSqm; resultId for PRE-MARKET signals matches useSchattenmarktSignals signal ID format - useUnifiedResults: resolve unit from match.unitId for VERIFIED_PORTFOLIO/EXTERNAL_MARKET - matchCardAdapter: unit now extracted for all result types; unitId from match.unitId - IntelligenceMatchCard: regular cards show unit chip (floor + label + m²) when a named unit is known — only adds context, never shows for synthetic/whole-property units Co-Authored-By: Claude Sonnet 4.6 --- .../match-card/IntelligenceMatchCard.tsx | 14 ++ src/domain/match.ts | 1 + src/domain/property.ts | 25 ++++ src/domain/unifiedResult.ts | 2 + src/features/matching/matchCardAdapter.ts | 6 +- src/hooks/useUnifiedResults.ts | 7 + src/provider/MockupNeedProvider.ts | 133 ++++++++++-------- 7 files changed, 128 insertions(+), 60 deletions(-) diff --git a/src/components/match-card/IntelligenceMatchCard.tsx b/src/components/match-card/IntelligenceMatchCard.tsx index 1453e0a..3d5c437 100644 --- a/src/components/match-card/IntelligenceMatchCard.tsx +++ b/src/components/match-card/IntelligenceMatchCard.tsx @@ -399,6 +399,20 @@ export function IntelligenceMatchCard({ vm, imageUrl, lat, lng, cityLabel }: Pro {[vm.locationLabel, vm.availabilityLabel].filter(Boolean).join(' · ')} + {/* Unit context — only shown when a specific named unit is known */} + {vm.preMarketUnit?.unitLabel && ( + + + + {vm.preMarketUnit.areaSqm.toLocaleString('de-CH')} m² + + + )} + {/* Regular explainability summary */} {vm.explainabilitySummary && ( <> diff --git a/src/domain/match.ts b/src/domain/match.ts index 2500232..225ddf6 100644 --- a/src/domain/match.ts +++ b/src/domain/match.ts @@ -88,6 +88,7 @@ export interface Match { resultId?: string // preferred: generic result ID resultType?: ResultType propertyId: string // legacy alias for resultId (VERIFIED_PORTFOLIO) + unitId?: string // specific rentable unit this match refers to // Scoring matchScore: number // 0–100 diff --git a/src/domain/property.ts b/src/domain/property.ts index 242c9dd..e37bf64 100644 --- a/src/domain/property.ts +++ b/src/domain/property.ts @@ -2,6 +2,7 @@ import type { AssetType, ResultType, AvailabilityStatus, AvailabilityType, FreshnessStatus, RiskLevel, SourceType, DataQualityLevel, } from './enums' +import { AvailabilityStatus as AS } from './enums' // ── Location / Address ──────────────────────────────────────────────────────── @@ -196,3 +197,27 @@ export interface Property { export type CreatePropertyInput = Omit export type UpdatePropertyInput = Partial + +/** + * Returns the rentable units of a property. + * For properties without explicit units, synthesises one unit from property-level data + * so that all matching logic can operate uniformly at unit level. + */ +export function getEffectiveUnits(p: Property): PropertyUnit[] { + if (p.units && p.units.length > 0) return p.units + return [{ + id: p.id, + propertyId: p.id, + floorLevel: p.hardFacts?.floor ?? p.floorLevel ?? 0, + unitLabel: undefined, + areaSqm: p.areaSqm, + available: p.availabilityStatus === AS.AVAILABLE_NOW || p.availabilityStatus === AS.AVAILABLE_SOON, + rentPricePerSqm: p.rentPricePerSqm, + currentTenant: p.currentTenant, + leaseTerm: p.leaseTerm, + leaseEndDate: p.leaseEndDate, + schattenmarktRelease: p.schattenmarktRelease?.enabled + ? { enabled: true, availableFrom: p.leaseEndDate } + : undefined, + }] +} diff --git a/src/domain/unifiedResult.ts b/src/domain/unifiedResult.ts index c8fc8a5..5aabfee 100644 --- a/src/domain/unifiedResult.ts +++ b/src/domain/unifiedResult.ts @@ -18,12 +18,14 @@ export interface VerifiedPortfolioResult extends UnifiedResultBase { resultType: 'VERIFIED_PORTFOLIO' property: Property match: Match + unit?: PropertyUnit // specific unit this match refers to } export interface ExternalMarketResult extends UnifiedResultBase { resultType: 'EXTERNAL_MARKET' | 'MAISON_WORK' property: Property match: Match + unit?: PropertyUnit // specific unit this match refers to } export interface FutureAvailabilityResult extends UnifiedResultBase { diff --git a/src/features/matching/matchCardAdapter.ts b/src/features/matching/matchCardAdapter.ts index 34777a7..14e6b94 100644 --- a/src/features/matching/matchCardAdapter.ts +++ b/src/features/matching/matchCardAdapter.ts @@ -40,7 +40,9 @@ export function buildMatchCardViewModel( const signal = isFuture ? (result as FutureAvailabilityResult).signal : undefined - const unit = isFuture ? (result as FutureAvailabilityResult).unit : undefined + const unit = isFuture + ? (result as FutureAvailabilityResult).unit + : (result as VerifiedPortfolioResult | ExternalMarketResult).unit const city = property?.location?.city const district = property?.location?.district @@ -121,7 +123,7 @@ export function buildMatchCardViewModel( signalUnconfirmedFacts: signal?.unconfirmedFacts, signalAreaSqmEstimate: signal?.areaSqmEstimate, propertyId: property?.id ?? signal?.propertyId, - unitId: unit?.id ?? signal?.unitId, + unitId: match.unitId ?? unit?.id ?? signal?.unitId, preMarketUnit: unit, preMarketAllUnits: property?.units, } diff --git a/src/hooks/useUnifiedResults.ts b/src/hooks/useUnifiedResults.ts index db6e2b5..e4505fa 100644 --- a/src/hooks/useUnifiedResults.ts +++ b/src/hooks/useUnifiedResults.ts @@ -59,6 +59,11 @@ export function useUnifiedResults(needId?: string) { resultType: 'FUTURE_AVAILABILITY', signal, match, property, unit: backingUnit }] } + // Resolve specific unit if the match carries a unitId + const matchUnit = match.unitId + ? (property.units?.find(u => u.id === match.unitId) ?? undefined) + : undefined + if (rt === 'EXTERNAL_MARKET' || rt === 'MAISON_WORK') { const result: ExternalMarketResult = { matchId: match.id, @@ -67,6 +72,7 @@ export function useUnifiedResults(needId?: string) { resultType: rt as 'EXTERNAL_MARKET' | 'MAISON_WORK', property, match, + unit: matchUnit, } return [result] } @@ -78,6 +84,7 @@ export function useUnifiedResults(needId?: string) { resultType: 'VERIFIED_PORTFOLIO', property, match, + unit: matchUnit, } return [result] }) diff --git a/src/provider/MockupNeedProvider.ts b/src/provider/MockupNeedProvider.ts index 6b14cc5..10ff48b 100644 --- a/src/provider/MockupNeedProvider.ts +++ b/src/provider/MockupNeedProvider.ts @@ -5,6 +5,8 @@ import { matchStore } from './MockupMatchProvider' import { propertyStore } from './MockupPropertyProvider' import { MatchStrength, MatchStatus, RiskLevel } from '../domain/enums' import type { Match } from '../domain/match' +import { getEffectiveUnits } from '../domain/property' +import type { PropertyUnit } from '../domain/property' const store: Need[] = [...mockNeeds] @@ -37,39 +39,42 @@ function locationScore(propCity: string, preferredLocations: string[]): number { function computeScore( prop: { - assetType: string; areaSqm: number; rentPricePerSqm: number; - location: { city: string }; resultType?: string; - schattenmarktRelease?: { enabled?: boolean } + assetType: string + location: { city: string } + resultType?: string + rentPricePerSqm: number }, + unit: PropertyUnit, need: Need, ): number | null { if (need.assetType && prop.assetType !== need.assetType) return null const locScore = locationScore(prop.location.city, need.preferredLocations ?? []) - // Location dominates: same city → 50-90 base, different → 25-45 + // Location dominates: same city → 65 base, same canton → 42, other → 28 let score = locScore >= 0.9 ? 65 : locScore >= 0.5 ? 42 : 28 - // Area overlap (+0-20) - if (need.requiredArea && prop.areaSqm) { + // Area — use unit area (more precise than property aggregate) + if (need.requiredArea && unit.areaSqm) { const { min, max } = need.requiredArea - if (prop.areaSqm >= min && prop.areaSqm <= max) score += 20 - else if (prop.areaSqm >= min * 0.7 && prop.areaSqm <= max * 1.5) score += 10 - else if (prop.areaSqm < min * 0.5 || prop.areaSqm > max * 2) score -= 10 + if (unit.areaSqm >= min && unit.areaSqm <= max) score += 20 + else if (unit.areaSqm >= min * 0.7 && unit.areaSqm <= max * 1.5) score += 10 + else if (unit.areaSqm < min * 0.5 || unit.areaSqm > max * 2) score -= 10 } - // Budget fit (+0-10): property stores annual CHF/m², need budget is monthly CHF/m² - if (need.budgetRange?.maxPerSqm && prop.rentPricePerSqm) { - const monthlyRate = prop.rentPricePerSqm / 12 + // Budget — prefer unit-level rent, fall back to property rent; both stored as annual CHF/m² + const effectiveRent = unit.rentPricePerSqm ?? prop.rentPricePerSqm + if (need.budgetRange?.maxPerSqm && effectiveRent) { + const monthlyRate = effectiveRent / 12 if (monthlyRate <= need.budgetRange.maxPerSqm) score += 10 else if (monthlyRate <= need.budgetRange.maxPerSqm * 1.2) score += 3 else score -= 8 } - // Discount for probabilistic/future signals so they score below confirmed listings + // Discount: external future signals score lower (probabilistic), pre-market units slightly lower if (prop.resultType === 'FUTURE_AVAILABILITY') { score = Math.round(score * 0.82) - } else if (prop.schattenmarktRelease?.enabled) { + } else if (unit.schattenmarktRelease?.enabled) { score = Math.round(score * 0.92) } @@ -89,56 +94,68 @@ function generateSyntheticMatches(need: Need) { const now = new Date().toISOString() for (const prop of propertyStore) { - const score = computeScore(prop, need) - if (score === null || score < 25) continue + for (const unit of getEffectiveUnits(prop)) { + const score = computeScore(prop, unit, need) + if (score === null || score < 25) continue - const locS = locationScore(prop.location.city, need.preferredLocations ?? []) - const isGoodLoc = locS >= 0.9 + const locS = locationScore(prop.location.city, need.preferredLocations ?? []) + const isGoodLoc = locS >= 0.9 - // VERIFIED_PORTFOLIO properties with schattenmarktRelease appear as FUTURE_AVAILABILITY - // in demand search so demand users see the PRE-MARKET VERIFIED card - const effectiveResultType = - prop.resultType === 'VERIFIED_PORTFOLIO' && prop.schattenmarktRelease?.enabled + // Unit with pre-market release → FUTURE_AVAILABILITY so demand users see PRE-MARKET VERIFIED card + // External future signals (property-level) → FUTURE_AVAILABILITY + const isPreMarket = unit.schattenmarktRelease?.enabled === true + const isFutureProp = prop.resultType === 'FUTURE_AVAILABILITY' + const effectiveResultType = (isPreMarket || isFutureProp) ? 'FUTURE_AVAILABILITY' : (prop.resultType ?? 'VERIFIED_PORTFOLIO') - const match: Match = { - id: crypto.randomUUID(), - propertyId: prop.id, - needId: need.id, - resultId: prop.id, - resultType: effectiveResultType as Match['resultType'], - matchScore: score, - matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength], - status: score >= 75 ? MatchStatus.PENDING_REVIEW : MatchStatus.PENDING_REVIEW, - scoreBreakdown: { - hardMatchScore: score + 5, - softFactorScore: score - 5, - confidenceModifier: isGoodLoc ? 0.96 : 0.82, - dataQualityModifier: 0.92, - totalScore: score, - }, - positiveFactors: isGoodLoc - ? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} – bevorzugter Standort` }] - : [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${prop.areaSqm} m² verfügbar` }], - negativeFactors: !isGoodLoc - ? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }] - : [], - tradeoffs: !isGoodLoc - ? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }] - : [], - explainabilitySummary: isGoodLoc - ? `${prop.location.city} trifft den Standortwunsch. Objekt entspricht den Kernkriterien.` - : `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`, - confidenceLevel: isGoodLoc ? 0.88 : 0.60, - riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM, - uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'], - organizationId: 'org-wincasa', - createdAt: now, - updatedAt: now, - } + // Signal ID must match what useSchattenmarktSignals generates: + // unit-level → schattenmarkt-{propId}-{unitId} + // property-level fallback (unit.id === prop.id) → schattenmarkt-{propId} + const resultId = isPreMarket + ? (unit.id === prop.id ? `schattenmarkt-${prop.id}` : `schattenmarkt-${prop.id}-${unit.id}`) + : prop.id - matchStore.push(match) + const unitAreaLabel = `${unit.areaSqm.toLocaleString('de-CH')} m²` + const match: Match = { + id: crypto.randomUUID(), + propertyId: prop.id, + unitId: unit.id, + needId: need.id, + resultId, + resultType: effectiveResultType as Match['resultType'], + matchScore: score, + matchStrength: strengthFromScore(score) as typeof MatchStrength[keyof typeof MatchStrength], + status: MatchStatus.PENDING_REVIEW, + scoreBreakdown: { + hardMatchScore: score + 5, + softFactorScore: score - 5, + confidenceModifier: isGoodLoc ? 0.96 : 0.82, + dataQualityModifier: 0.92, + totalScore: score, + }, + positiveFactors: isGoodLoc + ? [{ criterion: 'Standort', weight: 0.25, score: 92, contribution: 23, explanation: `${prop.location.city} – bevorzugter Standort` }] + : [{ criterion: 'Fläche', weight: 0.25, score: 70, contribution: 17.5, explanation: `${unitAreaLabel} verfügbar` }], + negativeFactors: !isGoodLoc + ? [{ criterion: 'Standort', weight: 0.25, score: 35, contribution: 8.75, explanation: `${prop.location.city} liegt außerhalb der bevorzugten Region` }] + : [], + tradeoffs: !isGoodLoc + ? [{ criterion: 'Standort', concern: `${prop.location.city} ist nicht im Präferenzgebiet`, severity: 'MEDIUM' as const }] + : [], + explainabilitySummary: isGoodLoc + ? `${prop.location.city} trifft den Standortwunsch. Einheit (${unitAreaLabel}) entspricht den Kernkriterien.` + : `Abweichender Standort (${prop.location.city}). Nur bei Engpass im Zielgebiet empfohlen.`, + confidenceLevel: isGoodLoc ? 0.88 : 0.60, + riskLevel: isGoodLoc ? RiskLevel.LOW : RiskLevel.MEDIUM, + uncertaintyIndicators: isGoodLoc ? [] : ['Standort außerhalb Präferenz'], + organizationId: 'org-wincasa', + createdAt: now, + updatedAt: now, + } + + matchStore.push(match) + } } }