|
| 1 | +/** |
| 2 | + * useCoreMetrics Hook |
| 3 | + * |
| 4 | + * This hook provides reactive Core Metric XP computation from Skills and Characteristics. |
| 5 | + * It recalculates automatically when: |
| 6 | + * - Skill XP changes |
| 7 | + * - Attendance is marked |
| 8 | + * - Time spent is edited |
| 9 | + * |
| 10 | + * CORE PRINCIPLE (NON-NEGOTIABLE): |
| 11 | + * - Core Metric XP is COMPUTED, never stored manually |
| 12 | + * - Radar chart reads ONLY Core Metric XP |
| 13 | + * - No hardcoded radar values |
| 14 | + * |
| 15 | + * HARD OVERRIDE LINE: |
| 16 | + * "If the radar chart is not driven entirely by computed Core Metric XP derived from Skills, |
| 17 | + * the implementation is incorrect." |
| 18 | + */ |
| 19 | + |
| 20 | +import { useMemo } from 'react'; |
| 21 | +import { useSkills } from './useSkills'; |
| 22 | +import { useCharacteristics } from './useCharacteristics'; |
| 23 | +import { |
| 24 | + computeAllCoreMetrics, |
| 25 | + getRadarChartData, |
| 26 | + calculateBalanceScore, |
| 27 | + getTotalMetricXP, |
| 28 | + getAverageMetricLevel, |
| 29 | + getSkillMetricContributions, |
| 30 | + ComputedCoreMetric, |
| 31 | + SkillContributionData, |
| 32 | + CharacteristicContributionData, |
| 33 | +} from '@/lib/coreMetricCalculation'; |
| 34 | +import { CoreMetricName, PHYSICAL_BALANCE_METRICS } from '@/lib/coreMetrics'; |
| 35 | + |
| 36 | +export interface UseCoreMetricsResult { |
| 37 | + // Computed Core Metrics with XP values |
| 38 | + coreMetrics: ComputedCoreMetric[]; |
| 39 | + |
| 40 | + // Radar chart ready data |
| 41 | + radarData: { label: string; value: number }[]; |
| 42 | + |
| 43 | + // Aggregate stats |
| 44 | + balanceScore: number; |
| 45 | + totalXP: number; |
| 46 | + averageLevel: number; |
| 47 | + |
| 48 | + // Loading state |
| 49 | + isLoading: boolean; |
| 50 | + |
| 51 | + // Helper functions for traceability |
| 52 | + getMetricContributors: (metricName: CoreMetricName) => ComputedCoreMetric['contributions']; |
| 53 | + getSkillContributions: (skillId: string) => { metricName: CoreMetricName; weight: number; contributedXp: number }[]; |
| 54 | +} |
| 55 | + |
| 56 | +/** |
| 57 | + * Map skill data to contribution format |
| 58 | + */ |
| 59 | +function mapSkillToContributionData(skill: { |
| 60 | + id: string; |
| 61 | + name: string; |
| 62 | + xp: number; |
| 63 | + area: string | null; |
| 64 | +}): SkillContributionData { |
| 65 | + return { |
| 66 | + id: skill.id, |
| 67 | + name: skill.name, |
| 68 | + xp: skill.xp, |
| 69 | + area: skill.area, |
| 70 | + // contributesTo could be extended if skills store their own mappings |
| 71 | + }; |
| 72 | +} |
| 73 | + |
| 74 | +/** |
| 75 | + * Normalized mapping of characteristic names to Core Metric names |
| 76 | + * Handles naming variations (e.g., 'Foreign Languages' -> 'Foreign Language') |
| 77 | + */ |
| 78 | +const CHARACTERISTIC_TO_METRIC_MAP: Record<string, CoreMetricName> = { |
| 79 | + 'programming': 'Programming', |
| 80 | + 'learning': 'Learning', |
| 81 | + 'erudition': 'Erudition', |
| 82 | + 'discipline': 'Discipline', |
| 83 | + 'productivity': 'Productivity', |
| 84 | + 'foreign language': 'Foreign Language', |
| 85 | + 'foreign languages': 'Foreign Language', |
| 86 | + 'language': 'Foreign Language', |
| 87 | + 'languages': 'Foreign Language', |
| 88 | + 'fitness': 'Fitness', |
| 89 | + 'drawing': 'Drawing', |
| 90 | + 'hygiene': 'Hygiene', |
| 91 | + 'reading': 'Reading', |
| 92 | + 'communication': 'Communication', |
| 93 | + 'cooking': 'Cooking', |
| 94 | + 'meditation': 'Meditation', |
| 95 | + 'swimming': 'Swimming', |
| 96 | + 'running': 'Running', |
| 97 | + 'math': 'Math', |
| 98 | + 'mathematics': 'Math', |
| 99 | + 'music': 'Music', |
| 100 | + 'cleaning': 'Cleaning', |
| 101 | +}; |
| 102 | + |
| 103 | +/** |
| 104 | + * Map characteristic data to contribution format |
| 105 | + * Uses a predefined mapping for reliable characteristic-to-metric matching |
| 106 | + */ |
| 107 | +function mapCharacteristicToContributionData(char: { |
| 108 | + id: string; |
| 109 | + name: string; |
| 110 | + xp: number; |
| 111 | +}): CharacteristicContributionData { |
| 112 | + // Use normalized lowercase name for lookup |
| 113 | + const normalizedName = char.name.toLowerCase().trim(); |
| 114 | + const metricName = CHARACTERISTIC_TO_METRIC_MAP[normalizedName]; |
| 115 | + |
| 116 | + return { |
| 117 | + id: char.id, |
| 118 | + name: char.name, |
| 119 | + xp: char.xp, |
| 120 | + // If characteristic matches a metric name, it contributes 100% to that metric |
| 121 | + contributesTo: metricName ? { [metricName]: 1.0 } : {}, |
| 122 | + }; |
| 123 | +} |
| 124 | + |
| 125 | +/** |
| 126 | + * Hook to compute and access Core Metrics XP |
| 127 | + * |
| 128 | + * This hook is the single source of truth for radar chart data. |
| 129 | + * It automatically recomputes when skills or characteristics change. |
| 130 | + */ |
| 131 | +export function useCoreMetrics(): UseCoreMetricsResult { |
| 132 | + const { skills, isLoading: skillsLoading } = useSkills(); |
| 133 | + const { characteristics, isLoading: characteristicsLoading } = useCharacteristics(); |
| 134 | + |
| 135 | + const isLoading = skillsLoading || characteristicsLoading; |
| 136 | + |
| 137 | + // Map skills to contribution data |
| 138 | + const skillContributions = useMemo(() => { |
| 139 | + return skills.map(mapSkillToContributionData); |
| 140 | + }, [skills]); |
| 141 | + |
| 142 | + // Map characteristics to contribution data |
| 143 | + const characteristicContributions = useMemo(() => { |
| 144 | + return characteristics.map(mapCharacteristicToContributionData); |
| 145 | + }, [characteristics]); |
| 146 | + |
| 147 | + // Compute all Core Metrics (this is the main calculation) |
| 148 | + const coreMetrics = useMemo(() => { |
| 149 | + return computeAllCoreMetrics(skillContributions, characteristicContributions); |
| 150 | + }, [skillContributions, characteristicContributions]); |
| 151 | + |
| 152 | + // Get radar chart data (clamped to MAX_METRIC_XP) |
| 153 | + const radarData = useMemo(() => { |
| 154 | + return getRadarChartData(coreMetrics); |
| 155 | + }, [coreMetrics]); |
| 156 | + |
| 157 | + // Calculate aggregate stats |
| 158 | + const balanceScore = useMemo(() => calculateBalanceScore(coreMetrics), [coreMetrics]); |
| 159 | + const totalXP = useMemo(() => getTotalMetricXP(coreMetrics), [coreMetrics]); |
| 160 | + const averageLevel = useMemo(() => getAverageMetricLevel(coreMetrics), [coreMetrics]); |
| 161 | + |
| 162 | + // Helper: Get which skills/characteristics contribute to a specific metric |
| 163 | + const getMetricContributors = useMemo(() => { |
| 164 | + return (metricName: CoreMetricName) => { |
| 165 | + const metric = coreMetrics.find(m => m.name === metricName); |
| 166 | + return metric?.contributions || []; |
| 167 | + }; |
| 168 | + }, [coreMetrics]); |
| 169 | + |
| 170 | + // Helper: Get which metrics a skill contributes to |
| 171 | + const getSkillContributions = useMemo(() => { |
| 172 | + return (skillId: string) => { |
| 173 | + const skill = skills.find(s => s.id === skillId); |
| 174 | + if (!skill) return []; |
| 175 | + |
| 176 | + const contributionData = mapSkillToContributionData(skill); |
| 177 | + return getSkillMetricContributions(contributionData); |
| 178 | + }; |
| 179 | + }, [skills]); |
| 180 | + |
| 181 | + return { |
| 182 | + coreMetrics, |
| 183 | + radarData, |
| 184 | + balanceScore, |
| 185 | + totalXP, |
| 186 | + averageLevel, |
| 187 | + isLoading, |
| 188 | + getMetricContributors, |
| 189 | + getSkillContributions, |
| 190 | + }; |
| 191 | +} |
0 commit comments