Skip to content

Commit cf85b94

Browse files
authored
Merge pull request #34 from ankurrera/copilot/implement-live-data-mapping
Implement live data mapping system for Core Metrics radar chart
2 parents 0ecc084 + 6dfaf5c commit cf85b94

5 files changed

Lines changed: 1048 additions & 56 deletions

File tree

src/components/system/RadarChart.tsx

Lines changed: 24 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,28 @@
1-
import { useEffect, useRef, useMemo } from "react";
2-
import { useStats } from "@/hooks/useStats";
3-
1+
import { useEffect, useRef } from "react";
2+
import { useCoreMetrics } from "@/hooks/useCoreMetrics";
3+
import { MAX_METRIC_XP } from "@/lib/coreMetrics";
4+
5+
/**
6+
* Physical Balance Radar Chart Component
7+
*
8+
* CORE PRINCIPLE (NON-NEGOTIABLE):
9+
* - Radar reads ONLY Core Metric XP
10+
* - Core Metric XP is COMPUTED from Skills and Characteristics
11+
* - No hardcoded radar values
12+
*
13+
* HARD OVERRIDE LINE:
14+
* "If the radar chart is not driven entirely by computed Core Metric XP derived from Skills,
15+
* the implementation is incorrect."
16+
*/
417
const RadarChart = () => {
518
const canvasRef = useRef<HTMLCanvasElement>(null);
6-
const { stats, isLoading } = useStats();
7-
8-
// 18 metrics for Life OS radar chart - clockwise order as specified
9-
const data = useMemo(() => {
10-
// Generate sample data based on stats (scale 0-2000)
11-
// TODO: Replace with actual metric data when backend is implemented
12-
// Currently using existing stats as a baseline to generate varied values
13-
const baseMultiplier = stats ? 10 : 5;
14-
15-
// Multipliers for variation across metrics
16-
const METRIC_MULTIPLIERS = {
17-
programming: 1.0,
18-
learning: 0.8,
19-
erudition: 1.0,
20-
discipline: 0.7,
21-
productivity: 1.7,
22-
foreignLanguage: 0.8,
23-
fitness: 1.2,
24-
drawing: 0.6,
25-
hygiene: 1.5,
26-
reading: 0.9,
27-
communication: 0.7,
28-
cooking: 1.1,
29-
meditation: 0.8,
30-
swimming: 1.3,
31-
running: 1.1,
32-
math: 0.9,
33-
music: 0.7,
34-
cleaning: 1.2
35-
};
36-
37-
return [
38-
{ label: "Programming", value: (stats?.strength || 30) * baseMultiplier * METRIC_MULTIPLIERS.programming },
39-
{ label: "Learning", value: (stats?.endurance || 25) * baseMultiplier * METRIC_MULTIPLIERS.learning },
40-
{ label: "Erudition", value: (stats?.mobility || 30) * baseMultiplier * METRIC_MULTIPLIERS.erudition },
41-
{ label: "Discipline", value: (stats?.consistency || 20) * baseMultiplier * METRIC_MULTIPLIERS.discipline },
42-
{ label: "Productivity", value: (stats?.recovery || 50) * baseMultiplier * METRIC_MULTIPLIERS.productivity },
43-
{ label: "Foreign Language", value: (stats?.strength || 30) * baseMultiplier * METRIC_MULTIPLIERS.foreignLanguage },
44-
{ label: "Fitness", value: (stats?.endurance || 25) * baseMultiplier * METRIC_MULTIPLIERS.fitness },
45-
{ label: "Drawing", value: (stats?.mobility || 30) * baseMultiplier * METRIC_MULTIPLIERS.drawing },
46-
{ label: "Hygiene", value: (stats?.consistency || 20) * baseMultiplier * METRIC_MULTIPLIERS.hygiene },
47-
{ label: "Reading", value: (stats?.recovery || 50) * baseMultiplier * METRIC_MULTIPLIERS.reading },
48-
{ label: "Communication", value: (stats?.strength || 30) * baseMultiplier * METRIC_MULTIPLIERS.communication },
49-
{ label: "Cooking", value: (stats?.endurance || 25) * baseMultiplier * METRIC_MULTIPLIERS.cooking },
50-
{ label: "Meditation", value: (stats?.mobility || 30) * baseMultiplier * METRIC_MULTIPLIERS.meditation },
51-
{ label: "Swimming", value: (stats?.consistency || 20) * baseMultiplier * METRIC_MULTIPLIERS.swimming },
52-
{ label: "Running", value: (stats?.recovery || 50) * baseMultiplier * METRIC_MULTIPLIERS.running },
53-
{ label: "Math", value: (stats?.strength || 30) * baseMultiplier * METRIC_MULTIPLIERS.math },
54-
{ label: "Music", value: (stats?.endurance || 25) * baseMultiplier * METRIC_MULTIPLIERS.music },
55-
{ label: "Cleaning", value: (stats?.mobility || 30) * baseMultiplier * METRIC_MULTIPLIERS.cleaning },
56-
];
57-
}, [stats]);
19+
20+
// Use computed Core Metrics - this is the ONLY data source for the radar
21+
const { radarData, isLoading, balanceScore } = useCoreMetrics();
22+
23+
// radarData is computed from Skills and Characteristics XP
24+
// It automatically updates when skill XP changes, attendance is marked, or time is edited
25+
const data = radarData;
5826

5927
useEffect(() => {
6028
const canvas = canvasRef.current;
@@ -67,7 +35,7 @@ const RadarChart = () => {
6735
const centerY = canvas.height / 2;
6836
const radius = Math.min(centerX, centerY) - 60; // More padding for labels
6937
const numAxes = data.length;
70-
const maxValue = 2000; // Scale from 0 to 2000
38+
const maxValue = MAX_METRIC_XP; // Max per metric: 2000 XP (from coreMetrics constants)
7139

7240
// Ensure data polygon occupies ~55-70% of chart radius
7341
// This prevents the "small spiky star" look by scaling up the visual impact

src/hooks/useCoreMetrics.ts

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
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

Comments
 (0)