feat: F011 match card system

- MatchCardViewModel: typed view model decoupling card from raw API data
- Sub-components: MatchCardHeader, MatchScoreDisplay, MatchReasonList, TradeoffList, MatchDataQualitySummary, MatchActionToolbar, MatchCardSkeleton, MatchCardRestrictedState
- 4 variants: MatchCardCompact, MatchCardExpanded, MatchCardReview, MatchCardCompareMini
- MatchCard: main entry point with variant prop
- matchCardAdapter: buildMatchCardViewModel() converts UnifiedMatchResult -> ViewModel
- UnifiedResultCard: now thin wrapper using MatchCardCompact via adapter
- FUTURE_AVAILABILITY always shows non-dismissable disclaimer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-16 13:39:35 +02:00
parent 307b450807
commit ea221def74
17 changed files with 972 additions and 147 deletions
+110
View File
@@ -0,0 +1,110 @@
import type {
UnifiedMatchResult,
VerifiedPortfolioResult,
ExternalMarketResult,
FutureAvailabilityResult,
} from '../../domain/unifiedResult'
import type { ScoreFactor } from '../../domain/match'
import type {
MatchCardViewModel,
MatchCardAction,
MatchCardReason,
} from '../../components/match-card/MatchCardViewModel'
const HARD_CRITERIA = new Set(['area', 'location', 'budget', 'timing'])
function buildReasons(positiveFactors: ScoreFactor[]): MatchCardReason[] {
const reasons: MatchCardReason[] = []
const hardFact = positiveFactors.find(f => HARD_CRITERIA.has(f.criterion))
const softFact = positiveFactors.find(f => !HARD_CRITERIA.has(f.criterion))
if (hardFact) {
reasons.push({
type: 'HARD_FACT',
label: capitalize(hardFact.criterion),
explanation: hardFact.explanation,
score: hardFact.score,
})
}
if (softFact) {
reasons.push({
type: 'SOFT_FACTOR',
label: capitalize(softFact.criterion),
explanation: softFact.explanation,
score: softFact.score,
})
}
return reasons
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1)
}
export function buildMatchCardViewModel(
result: UnifiedMatchResult,
actions: MatchCardAction[],
): MatchCardViewModel {
const { match, matchScore, resultType } = result
const isFuture = resultType === 'FUTURE_AVAILABILITY'
const property = !isFuture
? (result as VerifiedPortfolioResult | ExternalMarketResult).property
: undefined
const signal = isFuture
? (result as FutureAvailabilityResult).signal
: undefined
const city = property?.location?.city
const district = property?.location?.district
const locationLabel = city
? `${city}${district ? `, ${district}` : ''}`
: signal?.locationHint ?? ''
const availabilityLabel =
property?.availabilityDate ??
(signal?.timeHorizonMonths ? `~${signal.timeHorizonMonths} Monate` : undefined)
const sourceLabel =
property?.sourceLabel ??
property?.sourceMeta?.sourceLabel ??
property?.sourceMeta?.sourceType
const externalUrl = property?.sourceUrl ?? property?.sourceMeta?.sourceUrl
const reasons = buildReasons(match.positiveFactors)
const dataQualityScore =
property?.dataQuality?.score ?? signal?.confidenceScore ?? 0.5
return {
id: result.matchId,
title:
property?.title ??
signal?.companyName ??
signal?.locationHint ??
'',
resultType,
assetType: property?.assetType,
matchScore,
confidenceScore: match.confidenceLevel,
dataQualityScore,
locationLabel,
availabilityLabel,
sourceLabel,
externalUrl,
reasons,
tradeoffs: match.tradeoffs ?? [],
risks: match.risks ?? [],
missingData: match.missingData ?? [],
actions,
disclaimer: isFuture
? (signal?.disclaimer ?? 'Probabilistisches Signal kein bestätigtes Objekt')
: undefined,
explainabilitySummary: match.explainabilitySummary,
isReviewRequired: match.status === 'PENDING_REVIEW',
isStaleData: false,
}
}