feat: score transparency on all cards + budget parser fix

- Single scoring system: MockupNeedProvider rewrites matches via calculateScore
  exclusively — allFactors always populated, no more dual-system (computeScore
  removed), weights from weightingProfile reflected in every breakdown
- ScoreInlineBreakdown: new component shows hard/soft criteria with importance
  labels (Entscheidend/Sehr wichtig/…) and formula on compact + expanded cards
- MatchCardAdapter: passes scoreBreakdown + allFactors to ViewModel
- MatchDetail: 'Zukunftssignal' label replaced with 'Future Availability'
- aiService budget parser: values < 100 treated as monthly and multiplied by 12
  to produce correct annual CHF/m²/year value — fixes 0-result searches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-21 20:41:46 +02:00
parent 34a4dcfb29
commit 27c53f3af1
13 changed files with 1126 additions and 299 deletions
@@ -0,0 +1,134 @@
import { Box, Divider, LinearProgress, Typography } from '@mui/material'
import type { ScoreFactor } from '../../domain/match'
interface ScoreBreakdownData {
hardMatchScore: number
softFactorScore: number
totalScore: number
}
interface Props {
scoreBreakdown: ScoreBreakdownData
allFactors?: ScoreFactor[]
compact?: boolean
}
const CRITERION_LABEL: Record<string, string> = {
area: 'Fläche', location: 'Standort', budget: 'Budget', timing: 'Verfügbarkeit',
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
talentAccess: 'Talent-Zugang', esg: 'ESG', taxEnvironment: 'Steuerumfeld',
}
const HARD_KEYS = new Set(['area', 'location', 'budget', 'timing'])
function scoreColor(v: number): 'success' | 'warning' | 'error' {
return v >= 70 ? 'success' : v >= 50 ? 'warning' : 'error'
}
function scoreTextColor(v: number): string {
return v >= 70 ? '#1a7a4a' : v >= 50 ? '#d97706' : '#c0392b'
}
function importanceLabel(weight: number, maxWeight: number): { label: string; color: string } {
const ratio = maxWeight > 0 ? weight / maxWeight : 0
if (ratio >= 0.85) return { label: 'Entscheidend', color: '#1e3a5f' }
if (ratio >= 0.65) return { label: 'Sehr wichtig', color: '#1d4ed8' }
if (ratio >= 0.40) return { label: 'Wichtig', color: '#475569' }
if (ratio >= 0.20) return { label: 'Wenig wichtig',color: '#94a3b8' }
return { label: 'Unwichtig', color: '#cbd5e1' }
}
function FactorRow({ factor, maxWeight }: { factor: ScoreFactor; maxWeight: number }) {
const label = CRITERION_LABEL[factor.criterion] ?? factor.criterion
const pct = Math.round(factor.weight * 100)
const imp = importanceLabel(factor.weight, maxWeight)
const color = scoreColor(factor.score)
return (
<Box sx={{ mb: 1.25 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0.35 }}>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e293b', minWidth: 85 }}>{label}</Typography>
<Typography variant="caption" sx={{ color: imp.color, fontSize: '0.68rem', fontWeight: 500 }}>{imp.label}</Typography>
<Typography variant="caption" sx={{ color: '#cbd5e1', fontSize: '0.65rem' }}>{pct}%</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography variant="caption" sx={{ fontWeight: 700, color: scoreTextColor(factor.score) }}>
{factor.score}/100
</Typography>
<Typography variant="caption" sx={{ color: '#94a3b8', fontSize: '0.68rem', minWidth: 40, textAlign: 'right' }}>
{factor.contribution.toFixed(1)} Pkt
</Typography>
</Box>
</Box>
<LinearProgress variant="determinate" value={factor.score} color={color} sx={{ height: 4, borderRadius: 3, mb: 0.3 }} />
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', lineHeight: 1.3, fontSize: '0.7rem' }}>
{factor.explanation}
</Typography>
</Box>
)
}
export function ScoreInlineBreakdown({ scoreBreakdown: sb, allFactors, compact = false }: Props) {
const hardContrib = Math.round(sb.hardMatchScore * 0.60 * 10) / 10
const softContrib = Math.round(sb.softFactorScore * 0.40 * 10) / 10
const formulaBox = (
<Box sx={{ bgcolor: '#f8fafc', px: 1.25, py: 0.6, borderRadius: 1 }}>
<Typography variant="caption" sx={{ color: '#64748b', display: 'block', mb: 0.25, fontWeight: 500, fontSize: '0.68rem' }}>
Berechnung
</Typography>
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: '#1e293b', display: 'block', lineHeight: 1.5 }}>
Hart {sb.hardMatchScore} × 60% + Soft {sb.softFactorScore} × 40%
{' = '}{hardContrib} + {softContrib}{' = '}
<span style={{ fontWeight: 800, color: scoreTextColor(sb.totalScore) }}>{sb.totalScore}</span>
</Typography>
</Box>
)
// Compact: only the formula line
if (compact || !allFactors || allFactors.length === 0) {
return formulaBox
}
// Full: all criteria + formula
const hardFactors = allFactors.filter(f => HARD_KEYS.has(f.criterion))
const softFactors = allFactors.filter(f => !HARD_KEYS.has(f.criterion))
const maxHardWeight = Math.max(...hardFactors.map(f => f.weight), 0.001)
const maxSoftWeight = Math.max(...softFactors.map(f => f.weight), 0.001)
return (
<Box>
<Typography variant="caption" sx={{ fontWeight: 700, color: '#1e3a5f', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
Hart-Kriterien
</Typography>
{hardFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxHardWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#eef2ff', px: 1.25, py: 0.6, borderRadius: 1, mb: softFactors.length > 0 ? 2 : 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#1e3a5f' }}>
{sb.hardMatchScore}/100 × 60%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#1e3a5f' }}>{hardContrib} Pkt</Typography>
</Box>
{softFactors.length > 0 && (
<>
<Divider sx={{ mb: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 700, color: '#475569', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', mb: 1 }}>
Soft-Faktoren
</Typography>
{softFactors.map((f, i) => <FactorRow key={i} factor={f} maxWeight={maxSoftWeight} />)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', bgcolor: '#f8fafc', px: 1.25, py: 0.6, borderRadius: 1, mb: 1.5 }}>
<Typography variant="caption" sx={{ fontWeight: 600, color: '#475569' }}>
{sb.softFactorScore}/100 × 40%
</Typography>
<Typography variant="caption" sx={{ fontWeight: 800, color: '#475569' }}>{softContrib} Pkt</Typography>
</Box>
</>
)}
<Divider sx={{ mb: 1 }} />
{formulaBox}
</Box>
)
}