This document explains the reactive data architecture that ensures the Skills page and the CORE METRICS → PHYSICAL BALANCE radar chart are always synchronized.
"Remove all hardcoded radar data and bind the radar exclusively to computed Core Metrics derived from the Skills store."
This is the fundamental principle of the system. The radar chart never defines its own data - it is purely a visual representation of computed Core Metrics.
The system has exactly one authoritative dataset:
Skills Database (Supabase 'skills' table)
↓
Skills Store (React Query cache with key ['skills'])
↓
Core Metrics (computed, not stored)
↓
Radar Chart (visualization only)
- ✅ Skills page reads from Skills Store
- ✅ Core Metrics calculations read from Skills Store
- ✅ Radar chart reads from derived Core Metrics only
- ❌ No duplicate skill arrays
- ❌ No static radar labels or values
- ❌ No mock data
Skill CRUD Operation (Create/Update/Delete)
↓
useSkills.ts mutation onSuccess
↓
queryClient.invalidateQueries({ queryKey: ['skills'] })
↓
useQuery refetches from Supabase
↓
Skills Store updates (React Query cache)
↓
useCoreMetrics.ts useMemo dependencies change
↓
computeAllCoreMetrics() executes
↓
Core Metrics array generated (18 metrics with XP values)
↓
getRadarChartData() transforms metrics
↓
radarData state updates
↓
RadarChart.tsx useEffect dependency (data) changes
↓
Canvas re-renders with new dataAll updates happen automatically without page reload:
- T+0ms: User performs CRUD operation (e.g., marks attendance)
- T+50ms: Database operation completes
- T+51ms: React Query invalidates cache
- T+100ms: Skills refetched from database
- T+101ms:
useMemotriggers recomputation - T+102ms: Core Metrics computed
- T+103ms: Radar data generated
- T+104ms: Canvas re-renders with new shape
Total latency: ~100ms ⚡
For each of the 18 Core Metrics:
Metric XP = Σ (Skill XP × Contribution Weight)
// Example:
// Skill: "Python" with 1000 XP
// Contributes to: { Programming: 0.8, Math: 0.2 }
//
// Result:
// Programming XP += 1000 * 0.8 = 800
// Math XP += 1000 * 0.2 = 200- Programming
- Learning
- Erudition
- Discipline
- Productivity
- Foreign Language
- Fitness
- Drawing
- Hygiene
- Reading
- Communication
- Cooking
- Meditation
- Swimming
- Running
- Math
- Music
- Cleaning
These metrics are never edited directly. They are always computed from Skills.
// User creates new skill "Guitar Practice"
createSkill.mutate({
name: "Guitar Practice",
xp: 0,
contributesTo: { Music: 0.8, Discipline: 0.2 }
});
// Result:
// 1. Skill added to database
// 2. Skills cache invalidated
// 3. Core Metrics recomputed
// 4. Music metric increases by 0
// 5. Radar updates immediately (new shape with Music axis potentially active)// User marks attendance, earning 50 XP
// Skill "Guitar Practice" now has 50 XP
// Result:
// 1. Attendance record created
// 2. Skill XP updated to 50
// 3. Skills cache invalidated
// 4. Core Metrics recomputed
// 5. Music XP increases by 40 (50 * 0.8)
// 6. Discipline XP increases by 10 (50 * 0.2)
// 7. Radar expands on Music and Discipline axes// User changes contribution mapping
updateSkill.mutate({
id: "guitar-skill",
contributesTo: { Music: 1.0 } // Changed from 0.8/0.2 split
});
// Result:
// 1. Skill mapping updated
// 2. Skills cache invalidated
// 3. Core Metrics recomputed
// 4. Music XP recalculated with new weight
// 5. Discipline XP loses this skill's contribution
// 6. Radar shape changes immediately// User deletes "Guitar Practice" skill
deleteSkill.mutate("guitar-skill");
// Result:
// 1. Skill removed from database
// 2. Skills cache invalidated
// 3. Core Metrics recomputed
// 4. Music XP decreases (removes this skill's contribution)
// 5. Discipline XP decreases (removes this skill's contribution)
// 6. Radar contracts on affected axes
// 7. NO ghost values remainPurpose: Manage Skills CRUD with database sync
Key Features:
- Uses
@tanstack/react-queryfor caching - Invalidates cache on every mutation
- Returns optimistic UI updates
export const useSkills = () => {
const { skills, isLoading } = useQuery({
queryKey: ['skills', user?.id],
queryFn: fetchSkillsFromSupabase,
});
const createSkill = useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['skills'] });
},
});
// ... updateSkill, deleteSkill with same pattern
};Purpose: Compute Core Metrics from Skills (reactive)
Key Features:
- Reads from
useSkills()hook - Uses
useMemoto cache computation - Automatically recomputes when skills change
- NO internal state for metric values
export function useCoreMetrics() {
const { skills } = useSkills();
const { characteristics } = useCharacteristics();
// Auto-recomputes when skills/characteristics change
const coreMetrics = useMemo(() => {
return computeAllCoreMetrics(skills, characteristics);
}, [skills, characteristics]);
const radarData = useMemo(() => {
return getRadarChartData(coreMetrics);
}, [coreMetrics]);
return { coreMetrics, radarData, ... };
}Purpose: Visualize Core Metrics (display only)
Key Features:
- Reads from
useCoreMetrics()hook - Uses
useEffectto redraw canvas when data changes - NO local state for metric values
- NO hardcoded labels or values
const RadarChart = () => {
const { radarData, coreMetrics } = useCoreMetrics();
// data = radarData is the ONLY data source
const data = radarData;
useEffect(() => {
// Redraw canvas whenever data changes
drawRadarChart(data);
}, [data]);
// ... canvas drawing logic
};When NODE_ENV === 'development', the system logs every stage:
- Skills CRUD:
[Skills CRUD] Skill created/updated/deleted - Core Metrics:
[Core Metrics] Recomputed from skills - Radar Data:
[Radar Data] Updated - Radar Render:
[Radar Chart] Re-rendering with data
The radar chart includes a visual debug panel showing:
🔍 Debug Info
Radar Points: 18
Core Metrics: 18
Total Contributing Skills: 5
Non-Zero Metrics: 7
This helps verify:
- Data structure integrity
- Contribution tracking
- Real-time updates
Click any radar axis to see:
- Total XP for that metric
- All contributing skills
- XP contribution per skill
- Contribution weight per skill
Example:
Programming - 860 XP
Contributing Skills (3)
- Python (500 XP × 80%) +400 XP
- JavaScript (400 XP × 70%) +280 XP
- Rust (200 XP × 90%) +180 XP
// useCoreMetrics.ts
if (radarData.length !== coreMetrics.length) {
console.error('[CRITICAL] Radar data length mismatch!');
}All data structures are fully typed:
interface ComputedCoreMetric {
id: CoreMetricName;
name: CoreMetricName;
xp: number;
level: number;
contributions: MetricContributionDetail[];
}- 139 tests covering the entire pipeline
- Integration tests simulate full CRUD flows
- Tests verify no ghost values after deletion
- Tests verify immediate updates without refresh
// WRONG - creates stale data
const [radarData, setRadarData] = useState(initialData);// CORRECT - always fresh
const { radarData } = useCoreMetrics();
const data = radarData;// WRONG - static data
const data = [
{ label: 'Programming', value: 500 },
{ label: 'Fitness', value: 300 },
];// CORRECT - derived data
const metrics = computeAllCoreMetrics(skills);
const data = getRadarChartData(metrics);// WRONG - manual sync prone to bugs
const handleSkillUpdate = () => {
updateSkill();
updateRadar(); // Easy to forget!
};// CORRECT - automatic sync
const handleSkillUpdate = () => {
updateSkill.mutate(data);
// React Query + useMemo handle the rest
};All computations use useMemo to prevent unnecessary recalculation:
// Only recomputes when skills change
const coreMetrics = useMemo(() => {
return computeAllCoreMetrics(skills, characteristics);
}, [skills, characteristics]);
// Only transforms when metrics change
const radarData = useMemo(() => {
return getRadarChartData(coreMetrics);
}, [coreMetrics]);Skills are cached in memory and only refetched when invalidated:
useQuery({
queryKey: ['skills', user?.id],
staleTime: Infinity, // Skills remain fresh until invalidated
});If you must add one:
- Add to
PHYSICAL_BALANCE_METRICSincoreMetrics.ts - Update characteristic mapping in
useCoreMetrics.ts - All radar rendering code auto-adjusts to new count
- Run full test suite to verify
If changing how XP is computed:
- Update
computeCoreMetricXP()incoreMetricCalculation.ts - Update tests in
coreMetricCalculation.test.ts - Verify integration tests still pass
- Document formula change in this file
To add more debug info:
- Add logging in
useCoreMetrics.ts(withprocess.env.NODE_ENVcheck) - Add to debug panel in
RadarChart.tsx(wrapped in dev check) - Keep production bundle size minimal
Diagnosis:
- Check browser console for
[Skills CRUD]log - Verify
queryClient.invalidateQueriesis called - Check
[Core Metrics] Recomputedlog appears - Verify
[Radar Chart] Re-renderinglog appears
Common Cause: React Query cache not invalidated
Fix: Ensure mutation's onSuccess calls invalidateQueries
Diagnosis:
- Check if skill still exists in database
- Verify skills array doesn't contain deleted skill
- Check Core Metrics contributions list
Common Cause: Skill not actually deleted from database
Fix: Check delete mutation error handling
Diagnosis:
- Open debug panel (dev mode)
- Click radar axes to see contributors
- Verify XP values match expectations
- Check contribution weights in database
Common Cause: Contribution mapping mismatch
Fix: Update skill's contributes_to field
Use this checklist to verify the system is working correctly:
- Create skill → Radar updates without refresh
- Update skill XP → Radar expands/contracts immediately
- Delete skill → Radar removes contribution immediately
- Mark attendance → Radar updates with new XP
- Edit time → Radar recalculates XP
- Change skill mapping → Radar reshapes to new metrics
- Zero XP skill → Radar shows no contribution
- Multiple skills to same metric → Radar sums correctly
- Console shows all 4 debug log stages
- Debug panel counts match reality
- Click-to-debug shows correct contributors
- All 149 tests pass
- Build succeeds without warnings
This architecture ensures that the Skills page and Radar chart are always synchronized because they share a single source of truth: the Skills Store. The radar chart is purely a visualization of computed Core Metrics, which are themselves derived from Skills.
No refresh required. No manual sync. No stale data.
The system is a true reactive RPG stat engine.