refactor: split AppShell, match-detail panels, extract useCompareData

AppShell.tsx: 627→116 lines
- appShellConfig.ts: NavItem/WorkspaceConfig types, WORKSPACE_CONFIG, nav helpers
- AppShellSidebar.tsx: Sidebar component with visual constants
- AppShellTopBar.tsx: TopBar component

Match-detail panels:
- ScoreBreakdownPanel: 377→303 lines (scoreBreakdownConstants.ts + CriterionRow.tsx extracted)
- LocationIntelligencePanel: 383→333 lines (SoftFactorBar.tsx extracted)
- FutureAvailabilityContextPanel: 386→351 lines (futureAvailabilityConstants.tsx extracted)

Compare.tsx: 485→441 lines
- useCompareData hook: all queries and derived state extracted to hooks/useCompareData.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-24 01:00:38 +02:00
parent ae82d0e6a0
commit 0bacd188d6
13 changed files with 792 additions and 741 deletions
+14 -58
View File
@@ -15,12 +15,7 @@ import {
} from '@mui/material'
import { Trophy, AlertTriangle, AlertOctagon, Zap, CheckCircle2, XCircle } from 'lucide-react'
import { useNavigate } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { useCompareStore } from '../../stores/compareStore'
import { aiService } from '../../services/aiService'
import { needService } from '../../services/needService'
import { WEIGHTING_KEYS, WEIGHTING_LABELS } from '../../domain/needBuilder'
import type { WeightingKey } from '../../domain/needBuilder'
import {
CompareEmptyState,
CompareColumnHeader,
@@ -35,13 +30,13 @@ import {
SCORE_COLOR,
TYPE_META,
RISK_LEVEL_ORDER,
CRITERION_ALIASES,
getProp,
getSig,
LABEL_SX,
DATA_SX,
scoreBar,
} from '../../components/compare/compareUtils'
import { useCompareData } from '../../hooks/useCompareData'
// ── Module-level helpers ──────────────────────────────────────────────────────
@@ -62,42 +57,19 @@ export default function Compare() {
const navigate = useNavigate()
const { compareItems, removeFromCompare, clearCompare } = useCompareStore()
const { data: aiSummary, isLoading: aiLoading } = useQuery({
queryKey: ['ai-compare', compareItems.map(i => i.matchId)],
queryFn: () => aiService.summarizeComparison(compareItems),
enabled: compareItems.length >= 2,
select: r => r.data,
staleTime: Infinity,
})
const { data: needsData } = useQuery({
queryKey: ['needs'],
queryFn: () => needService.getAll(),
select: r => r.data,
})
const activeNeed = needsData
? [...needsData].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]
: undefined
const relevantCriteria = activeNeed
? WEIGHTING_KEYS
.map(key => ({ key, label: WEIGHTING_LABELS[key], weight: activeNeed.weightingProfile[key] ?? 0 }))
.filter(c => c.weight > 0)
.sort((a, b) => b.weight - a.weight)
: []
const weightedTotals = compareItems.map(item =>
relevantCriteria.reduce((sum, { key, weight }) => {
const factor = [...item.match.positiveFactors, ...item.match.negativeFactors]
.find(f => CRITERION_ALIASES[key as WeightingKey].some(a => a.toLowerCase() === f.criterion.toLowerCase()))
return sum + (factor?.score ?? 50) * weight
}, 0)
)
const maxWeightedTotal = Math.max(...weightedTotals)
const overallWinnerIdx = compareItems.length > 1 && weightedTotals.filter(t => t === maxWeightedTotal).length === 1
? weightedTotals.indexOf(maxWeightedTotal)
: -1
const {
aiSummary,
aiLoading,
activeNeed,
relevantCriteria,
overallWinnerIdx,
bestScoreIdx,
worstConfIdx,
dqScores,
worstDQIdx,
missingCriticalCounts,
maxMissingCritical,
} = useCompareData(compareItems)
if (compareItems.length === 0) {
return (
@@ -110,22 +82,6 @@ export default function Compare() {
)
}
// ── Highlight indices ──────────────────────────────────────────────────────
const bestScoreIdx = compareItems.reduce(
(best, item, i) => item.matchScore > compareItems[best].matchScore ? i : best, 0
)
const worstConfIdx = compareItems.reduce(
(worst, item, i) => item.match.confidenceLevel < compareItems[worst].match.confidenceLevel ? i : worst, 0
)
const dqScores = compareItems.map(item => getProp(item)?.dataQuality.score ?? 1)
const worstDQIdx = dqScores.indexOf(Math.min(...dqScores))
const missingCriticalCounts = compareItems.map(
item => item.match.missingData?.filter(m => m.importance === 'CRITICAL').length ?? 0
)
const maxMissingCritical = Math.max(...missingCriticalCounts)
return (
<Box>
<AddToPipelineDialog />