1 Commits

Author SHA1 Message Date
Benjamin Sutter 0f8a8ecd2f feat: F028 strategic UX refocus — decision co-pilot redesign
Match cards lead with narrative summary as hero text; score and badges
step back to secondary. WeightingEditor replaces sliders with 3-level
chip selector (Optional/Wichtig/Kritisch). NeedInput uses progressive
disclosure for budget/timing/must-haves. NeedCardPreview shows priority
label groups instead of percentage bars. Results header reframed as
recommendations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 14:16:21 +02:00
7 changed files with 147 additions and 143 deletions
+29 -25
View File
@@ -4,6 +4,14 @@ import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder' import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder' import type { WeightingKey } from '../../domain/needBuilder'
function toRaw(w: Record<WeightingKey, number>): Record<WeightingKey, number> {
const max = Math.max(...WEIGHTING_KEYS.map(k => w[k] ?? 0))
if (max === 0) return Object.fromEntries(WEIGHTING_KEYS.map(k => [k, 3])) as Record<WeightingKey, number>
return Object.fromEntries(
WEIGHTING_KEYS.map(k => [k, Math.max(1, Math.round(((w[k] ?? 0) / max) * 5))])
) as Record<WeightingKey, number>
}
interface Props { interface Props {
criteria: ParsedNeedCriteria criteria: ParsedNeedCriteria
weights: Record<WeightingKey, number> weights: Record<WeightingKey, number>
@@ -21,13 +29,10 @@ const ASSET_LABELS: Record<string, string> = {
const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing'] const CRITICAL_FIELDS = ['assetType', 'areaRange', 'preferredLocations', 'budgetRange', 'timing']
export function NeedCardPreview({ criteria: c, weights, confidenceByField, missingFields, needTitle, onNeedTitleChange }: Props) { export function NeedCardPreview({ criteria: c, weights, confidenceByField, missingFields, needTitle, onNeedTitleChange }: Props) {
const maxWeight = Math.max(...WEIGHTING_KEYS.map(k => weights[k] ?? 0), 0.01)
const fieldEntries = Object.entries(confidenceByField) const fieldEntries = Object.entries(confidenceByField)
const overallConfidence = fieldEntries.length > 0 const overallConfidence = fieldEntries.length > 0
? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length ? fieldEntries.reduce((sum, [, v]) => sum + v, 0) / fieldEntries.length
: 0 : 0
const lowConfidenceFields = fieldEntries.filter(([, v]) => v < 0.6).map(([k]) => k)
const criticalMissing = missingFields.filter(f => CRITICAL_FIELDS.some(cf => f.toLowerCase().includes(cf.toLowerCase()))) const criticalMissing = missingFields.filter(f => CRITICAL_FIELDS.some(cf => f.toLowerCase().includes(cf.toLowerCase())))
const isLowConfidence = overallConfidence < 0.6 const isLowConfidence = overallConfidence < 0.6
@@ -149,35 +154,34 @@ export function NeedCardPreview({ criteria: c, weights, confidenceByField, missi
}, },
}} }}
/> />
{lowConfidenceFields.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Unsichere Felder: {lowConfidenceFields.join(', ')}
</Typography>
)}
</Box> </Box>
<Divider sx={{ my: 2 }} /> <Divider sx={{ my: 2 }} />
{/* Weights */} {/* Priority groups */}
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary"> {(() => {
Gewichtungsprofil const rawW = toRaw(weights)
</Typography> const groups = [
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}> { label: 'Kritisch', color: '#c0392b', keys: WEIGHTING_KEYS.filter(k => rawW[k] >= 4) },
{WEIGHTING_KEYS.map(k => { { label: 'Wichtig', color: '#1e3a5f', keys: WEIGHTING_KEYS.filter(k => rawW[k] === 3) },
const pct = Math.round((weights[k] ?? 0) * 100) { label: 'Optional', color: '#94a3b8', keys: WEIGHTING_KEYS.filter(k => rawW[k] <= 2) },
].filter(g => g.keys.length > 0)
return ( return (
<Box key={k} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <>
<Typography variant="caption" sx={{ width: 130, flexShrink: 0 }}>{WEIGHTING_LABELS[k]}</Typography> <Typography variant="caption" sx={{ fontWeight: 600, display: 'block', mb: 1 }} color="text.secondary">
<LinearProgress Prioritäten
variant="determinate" </Typography>
value={((weights[k] ?? 0) / maxWeight) * 100} {groups.map(g => (
sx={{ flex: 1, height: 6, borderRadius: 3, bgcolor: '#e2e8f0', '& .MuiLinearProgress-bar': { bgcolor: '#1e3a5f' } }} <Box key={g.label} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
/> <Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: g.color, mt: 0.625, flexShrink: 0 }} />
<Typography variant="caption" sx={{ width: 32, textAlign: 'right' }}>{pct}%</Typography> <Typography variant="caption">
<strong>{g.label}:</strong>{' '}{g.keys.map(k => WEIGHTING_LABELS[k]).join(', ')}
</Typography>
</Box> </Box>
))}
</>
) )
})} })()}
</Box>
</Card> </Card>
</Box> </Box>
) )
+19 -3
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Card, Chip, Stack, TextField, Typography } from '@mui/material' import { Box, Button, Card, Chip, Collapse, Stack, TextField, Typography } from '@mui/material'
import { ChevronDown, ChevronUp } from 'lucide-react'
import type { ParsedNeedCriteria } from '../../domain/needBuilder' import type { ParsedNeedCriteria } from '../../domain/needBuilder'
import { AssetType } from '../../domain/enums' import { AssetType } from '../../domain/enums'
@@ -29,6 +30,9 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
const [locationDraft, setLocationDraft] = useState('') const [locationDraft, setLocationDraft] = useState('')
const [mustHaveDraft, setMustHaveDraft] = useState('') const [mustHaveDraft, setMustHaveDraft] = useState('')
const hasDetails = !!c.budgetRange?.maxPerSqm || !!c.timing?.earliestMoveIn || (c.mustHaveCriteria?.length ?? 0) > 0
const [showDetails, setShowDetails] = useState(hasDetails)
function addLocations(raw: string) { function addLocations(raw: string) {
const tokens = raw.split(',').map(x => x.trim()).filter(Boolean) const tokens = raw.split(',').map(x => x.trim()).filter(Boolean)
if (!tokens.length) return if (!tokens.length) return
@@ -101,15 +105,26 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
sx={{ mb: 0.75 }} sx={{ mb: 0.75 }}
/> />
{(c.preferredLocations?.length ?? 0) > 0 ? ( {(c.preferredLocations?.length ?? 0) > 0 ? (
<Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 2.5 }}> <Stack direction="row" spacing={0.5} sx={{ flexWrap: 'wrap', gap: 0.5, mb: 1.5 }}>
{c.preferredLocations!.map(loc => ( {c.preferredLocations!.map(loc => (
<Chip key={loc} label={loc} size="small" <Chip key={loc} label={loc} size="small"
onDelete={() => set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })} onDelete={() => set({ ...c, preferredLocations: c.preferredLocations!.filter(l => l !== loc) })}
/> />
))} ))}
</Stack> </Stack>
) : <Box sx={{ mb: 2.5 }} />} ) : <Box sx={{ mb: 1.5 }} />}
{/* Details toggle */}
<Button
size="small"
onClick={() => setShowDetails(v => !v)}
endIcon={showDetails ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
sx={{ textTransform: 'none', color: '#64748b', pl: 0, mb: 1 }}
>
{showDetails ? 'Details ausblenden' : 'Budget, Timing & Must-haves'}
</Button>
<Collapse in={showDetails}>
{/* Budget */} {/* Budget */}
<FieldLabel>Budget (max CHF/m²)</FieldLabel> <FieldLabel>Budget (max CHF/m²)</FieldLabel>
<TextField <TextField
@@ -150,6 +165,7 @@ export function NeedInput({ criteria: c, onCriteriaChange: set }: Props) {
))} ))}
</Stack> </Stack>
)} )}
</Collapse>
</Card> </Card>
) )
} }
+24 -25
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Button, Card, Slider, Typography } from '@mui/material' import { Box, Button, Card, Chip, Typography } from '@mui/material'
import { RotateCcw } from 'lucide-react' import { RotateCcw } from 'lucide-react'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder' import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder' import type { WeightingKey } from '../../domain/needBuilder'
@@ -11,7 +11,11 @@ interface Props {
assetType?: string assetType?: string
} }
const IMPORTANCE_LABELS = ['', 'Unwichtig', 'Wenig wichtig', 'Wichtig', 'Sehr wichtig', 'Entscheidend'] const PRIORITY_LEVELS = [
{ label: 'Optional', value: 1, selectedColor: '#64748b' },
{ label: 'Wichtig', value: 3, selectedColor: '#1e3a5f' },
{ label: 'Kritisch', value: 5, selectedColor: '#c0392b' },
] as const
function toRaw(w: Record<WeightingKey, number>): Record<WeightingKey, number> { function toRaw(w: Record<WeightingKey, number>): Record<WeightingKey, number> {
const max = Math.max(...WEIGHTING_KEYS.map(k => w[k] ?? 0)) const max = Math.max(...WEIGHTING_KEYS.map(k => w[k] ?? 0))
@@ -31,7 +35,7 @@ function rawToWeights(raw: Record<WeightingKey, number>): Record<WeightingKey, n
export function WeightingEditor({ weights, onChange, assetType }: Props) { export function WeightingEditor({ weights, onChange, assetType }: Props) {
const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights)) const [raw, setRaw] = useState<Record<WeightingKey, number>>(() => toRaw(weights))
function handleSlider(key: WeightingKey, value: number) { function handleSelect(key: WeightingKey, value: number) {
const updated = { ...raw, [key]: value } const updated = { ...raw, [key]: value }
setRaw(updated) setRaw(updated)
onChange(rawToWeights(updated)) onChange(rawToWeights(updated))
@@ -51,7 +55,7 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
Wichtigkeit der Kriterien Wichtigkeit der Kriterien
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Schieber nach rechts = wichtiger. Gewichtung wird automatisch berechnet. Wählen Sie für jedes Kriterium die Priorität.
</Typography> </Typography>
</Box> </Box>
<Button <Button
@@ -65,32 +69,27 @@ export function WeightingEditor({ weights, onChange, assetType }: Props) {
</Box> </Box>
<Card sx={{ p: 3 }}> <Card sx={{ p: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{WEIGHTING_KEYS.map(key => { {WEIGHTING_KEYS.map(key => (
const importance = raw[key] ?? 3 <Box key={key} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
return ( <Typography variant="body2" sx={{ fontWeight: 500, flex: 1 }}>
<Box key={key}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{WEIGHTING_LABELS[key]} {WEIGHTING_LABELS[key]}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> {PRIORITY_LEVELS.map(p => (
{IMPORTANCE_LABELS[importance]} <Chip
</Typography> key={p.value}
</Box> label={p.label}
<Slider
value={importance}
min={1}
max={5}
step={1}
marks
onChange={(_, v) => handleSlider(key, v as number)}
size="small" size="small"
sx={{ color: '#1e3a5f' }} clickable
onClick={() => handleSelect(key, p.value)}
sx={raw[key] === p.value
? { bgcolor: p.selectedColor, color: 'white', fontWeight: 700, border: 'none' }
: { bgcolor: 'transparent', border: '1px solid #e2e8f0', color: '#64748b' }
}
/> />
))}
</Box> </Box>
) ))}
})}
</Box> </Box>
</Card> </Card>
</Box> </Box>
+10 -5
View File
@@ -44,10 +44,10 @@ export function MatchCardCompact({ vm }: Props) {
</Alert> </Alert>
)} )}
{/* Header: score → type → confidence → availability → risk */} {/* Header: type chip + risk chip + score (secondary) */}
<MatchCardHeader vm={vm} compact /> <MatchCardHeader vm={vm} />
{/* Title + location (max 2 lines) */} {/* Title + location + hero narrative */}
<Box sx={{ mt: 1.25, mb: 1.25 }}> <Box sx={{ mt: 1.25, mb: 1.25 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap> <Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
{vm.title} {vm.title}
@@ -58,12 +58,17 @@ export function MatchCardCompact({ vm }: Props) {
{vm.locationLabel} {vm.locationLabel}
</Typography> </Typography>
</Box> </Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.25, display: 'block' }}>
{[vm.availabilityLabel, `${Math.round(vm.confidenceScore * 100)}% Konfidenz`].filter(Boolean).join(' · ')}
</Typography>
{vm.explainabilitySummary && ( {vm.explainabilitySummary && (
<Typography <Typography
variant="body2" variant="body2"
color="text.secondary"
sx={{ sx={{
mt: 0.5, mt: 0.75,
fontWeight: 500,
color: '#1e293b',
lineHeight: 1.5,
overflow: 'hidden', overflow: 'hidden',
display: '-webkit-box', display: '-webkit-box',
WebkitLineClamp: 2, WebkitLineClamp: 2,
@@ -41,19 +41,18 @@ export function MatchCardExpanded({ vm }: Props) {
<MapPin size={14} color="#64748b" /> <MapPin size={14} color="#64748b" />
<Typography variant="body2" color="text.secondary">{vm.locationLabel}</Typography> <Typography variant="body2" color="text.secondary">{vm.locationLabel}</Typography>
</Box> </Box>
{vm.availabilityLabel && ( <Typography variant="caption" color="text.secondary" sx={{ mt: 0.25, display: 'block' }}>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}> {[vm.availabilityLabel, `${Math.round(vm.confidenceScore * 100)}% Konfidenz`].filter(Boolean).join(' · ')}
Verfügbar: {vm.availabilityLabel}
</Typography> </Typography>
)}
{vm.explainabilitySummary && ( {vm.explainabilitySummary && (
<Typography <Box sx={{ mt: 1.5 }}>
variant="body2" <Typography variant="overline" sx={{ color: '#64748b', display: 'block', mb: 0.5 }}>
color="text.secondary" Strategische Einschätzung
sx={{ mt: 1, p: 1.5, bgcolor: '#f8fafc', borderRadius: 1, borderLeft: '3px solid #e2e8f0' }} </Typography>
> <Typography variant="body1" sx={{ fontWeight: 500, color: '#1e293b', lineHeight: 1.6 }}>
{vm.explainabilitySummary} {vm.explainabilitySummary}
</Typography> </Typography>
</Box>
)} )}
</Box> </Box>
+8 -27
View File
@@ -8,53 +8,34 @@ const RESULT_TYPE_META: Record<string, { label: string; color: string }> = {
FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' }, FUTURE_AVAILABILITY: { label: 'Zukunftssignal', color: '#7c3aed' },
} }
function confidenceColor(score: number): string {
if (score >= 0.75) return '#1a7a4a'
if (score >= 0.55) return '#d97706'
return '#c0392b'
}
interface Props { interface Props {
vm: MatchCardViewModel vm: MatchCardViewModel
compact?: boolean
} }
export function MatchCardHeader({ vm, compact }: Props) { export function MatchCardHeader({ vm }: Props) {
const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' } const rt = RESULT_TYPE_META[vm.resultType] ?? { label: vm.resultType, color: '#64748b' }
const confPct = Math.round(vm.confidenceScore * 100)
const topRisk = vm.risks.length > 0 ? vm.risks[0].level : undefined const topRisk = vm.risks.length > 0 ? vm.risks[0].level : undefined
const showRiskBadge = topRisk && topRisk !== 'LOW' const showRiskBadge = topRisk === 'CRITICAL' || topRisk === 'HIGH'
const riskLabel = topRisk === 'CRITICAL' ? 'Kritisch' : topRisk === 'HIGH' ? 'Hohes Risiko' : 'Mittleres Risiko' const riskLabel = topRisk === 'CRITICAL' ? 'Kritisch' : 'Hohes Risiko'
const riskColor: 'error' | 'warning' = (topRisk === 'HIGH' || topRisk === 'CRITICAL') ? 'error' : 'warning' const riskColor: 'error' = 'error'
return ( return (
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
{/* Score — leftmost, most prominent */} {/* Badges: resultType + risk only */}
<MatchScoreDisplay score={vm.matchScore} size={compact ? 'sm' : 'md'} />
{/* Badges: resultType → assetType → confidence → availability → risk */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}> <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, alignItems: 'center' }}>
<Chip <Chip
label={rt.label} label={rt.label}
size="small" size="small"
sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 11 }} sx={{ bgcolor: rt.color, color: 'white', fontWeight: 600, fontSize: 11 }}
/> />
{vm.assetType && (
<Chip label={vm.assetType} size="small" variant="outlined" sx={{ fontSize: 11 }} />
)}
<Chip
label={`${confPct}% Konfidenz`}
size="small"
sx={{ bgcolor: confidenceColor(vm.confidenceScore), color: 'white', fontSize: 11 }}
/>
{vm.availabilityLabel && (
<Chip label={vm.availabilityLabel} size="small" variant="outlined" sx={{ fontSize: 11 }} />
)}
{showRiskBadge && ( {showRiskBadge && (
<Chip label={riskLabel} size="small" color={riskColor} variant="outlined" /> <Chip label={riskLabel} size="small" color={riskColor} variant="outlined" />
)} )}
</Box> </Box>
{/* Score — secondary, right side */}
<MatchScoreDisplay score={vm.matchScore} size="sm" />
</Box> </Box>
) )
} }
+2 -2
View File
@@ -11,10 +11,10 @@ export function ResultFeedHeader({ total, verifiedCount, externalCount, futureCo
return ( return (
<Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}> <Box sx={{ bgcolor: 'white', borderBottom: '1px solid #e2e8f0', px: 3, py: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary"> <Typography variant="h5" sx={{ fontWeight: 700 }} color="text.primary">
{total} Treffer gefunden {total} Empfehlungen
</Typography> </Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
{verifiedCount} Verified · {externalCount} Extern · {futureCount} Signale {verifiedCount} Verifiziert · {externalCount} Extern · {futureCount} Marktsignale
</Typography> </Typography>
</Box> </Box>
) )