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)
+ }
}
}