Skip to content

Commit ef440e0

Browse files
Copilotankurrera
andcommitted
Implement core metrics calculation engine for live data mapping
Co-authored-by: ankurrera <186232326+ankurrera@users.noreply.github.com>
1 parent 53e2244 commit ef440e0

5 files changed

Lines changed: 1006 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: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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+
* Map characteristic data to contribution format
76+
* Characteristics contribute to their namesake metric if it exists
77+
*/
78+
function mapCharacteristicToContributionData(char: {
79+
id: string;
80+
name: string;
81+
xp: number;
82+
}): CharacteristicContributionData {
83+
// Check if the characteristic name matches a Core Metric
84+
const metricName = PHYSICAL_BALANCE_METRICS.find(
85+
m => m.toLowerCase() === char.name.toLowerCase()
86+
);
87+
88+
return {
89+
id: char.id,
90+
name: char.name,
91+
xp: char.xp,
92+
// If characteristic matches a metric name, it contributes 100% to that metric
93+
contributesTo: metricName ? { [metricName]: 1.0 } : {},
94+
};
95+
}
96+
97+
/**
98+
* Hook to compute and access Core Metrics XP
99+
*
100+
* This hook is the single source of truth for radar chart data.
101+
* It automatically recomputes when skills or characteristics change.
102+
*/
103+
export function useCoreMetrics(): UseCoreMetricsResult {
104+
const { skills, isLoading: skillsLoading } = useSkills();
105+
const { characteristics, isLoading: characteristicsLoading } = useCharacteristics();
106+
107+
const isLoading = skillsLoading || characteristicsLoading;
108+
109+
// Map skills to contribution data
110+
const skillContributions = useMemo(() => {
111+
return skills.map(mapSkillToContributionData);
112+
}, [skills]);
113+
114+
// Map characteristics to contribution data
115+
const characteristicContributions = useMemo(() => {
116+
return characteristics.map(mapCharacteristicToContributionData);
117+
}, [characteristics]);
118+
119+
// Compute all Core Metrics (this is the main calculation)
120+
const coreMetrics = useMemo(() => {
121+
return computeAllCoreMetrics(skillContributions, characteristicContributions);
122+
}, [skillContributions, characteristicContributions]);
123+
124+
// Get radar chart data (clamped to MAX_METRIC_XP)
125+
const radarData = useMemo(() => {
126+
return getRadarChartData(coreMetrics);
127+
}, [coreMetrics]);
128+
129+
// Calculate aggregate stats
130+
const balanceScore = useMemo(() => calculateBalanceScore(coreMetrics), [coreMetrics]);
131+
const totalXP = useMemo(() => getTotalMetricXP(coreMetrics), [coreMetrics]);
132+
const averageLevel = useMemo(() => getAverageMetricLevel(coreMetrics), [coreMetrics]);
133+
134+
// Helper: Get which skills/characteristics contribute to a specific metric
135+
const getMetricContributors = useMemo(() => {
136+
return (metricName: CoreMetricName) => {
137+
const metric = coreMetrics.find(m => m.name === metricName);
138+
return metric?.contributions || [];
139+
};
140+
}, [coreMetrics]);
141+
142+
// Helper: Get which metrics a skill contributes to
143+
const getSkillContributions = useMemo(() => {
144+
return (skillId: string) => {
145+
const skill = skills.find(s => s.id === skillId);
146+
if (!skill) return [];
147+
148+
const contributionData = mapSkillToContributionData(skill);
149+
return getSkillMetricContributions(contributionData);
150+
};
151+
}, [skills]);
152+
153+
return {
154+
coreMetrics,
155+
radarData,
156+
balanceScore,
157+
totalXP,
158+
averageLevel,
159+
isLoading,
160+
getMetricContributors,
161+
getSkillContributions,
162+
};
163+
}

0 commit comments

Comments
 (0)