'use client'; import React, { useEffect, useRef, useState } from 'react'; import { HARCore } from '@/lib/pose/HARCore'; // Import from the official tasks-vision package import { PoseLandmarker, FilesetResolver, DrawingUtils, PoseLandmarkerResult } from '@mediapipe/tasks-vision'; import { RefreshCcw, ArrowLeft, PlayCircle, ChevronDown, ChevronUp } from 'lucide-react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { Suspense } from 'react'; import { AuthProvider, useAuth } from '@/lib/auth'; export default function TrainingPageWrap() { return ( Loading...}> ); } function TrainingPage() { const { user } = useAuth(); const searchParams = useSearchParams(); const mode = searchParams.get('mode'); const videoRef = useRef(null); const canvasRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [isStarted, setIsStarted] = useState(false); // Workflow State const [menu, setMenu] = useState(null); const [currentExerciseIndex, setCurrentExerciseIndex] = useState(0); // const [currentSet, setCurrentSet] = useState(1); // REMOVED: Linear Progression uses index only const [repsOffset, setRepsOffset] = useState(0); // Offset for accumulated reps const [stats, setStats] = useState({ exercise: '', reps: 0, status: 'Idle', feedback: '', mae: 0}); const [isWorkoutComplete, setIsWorkoutComplete] = useState(false); const [isSaving, setIsSaving] = useState(false); const [feedbackMsg, setFeedbackMsg] = useState(""); const [isWarning, setIsWarning] = useState(false); // UI State const [expandedSet, setExpandedSet] = useState(null); // Recap State const [results, setResults] = useState([]); const maeBuffer = useRef([]); // Per-Rep Tracking const repBuffer = useRef([]); const repFeedbackBuffer = useRef([]); // Buffer for feedback text const lastRepCount = useRef(0); const currentSetReps = useRef<{rep: number, score: number, feedback: string}[]>([]); // Rest Timer State const [isResting, setIsResting] = useState(false); const [restTimer, setRestTimer] = useState(0); // Refs for loop const harRef = useRef(null); const landmarkerRef = useRef(null); const requestRef = useRef(null); const isRestingRef = useRef(false); const isStartedRef = useRef(false); useEffect(() => { isRestingRef.current = isResting; }, [isResting]); useEffect(() => { isStartedRef.current = isStarted; }, [isStarted]); // API Base URL const API_BASE = process.env.NEXT_PUBLIC_API_URL || ''; // Fetch Latest Menu const fetchMenu = async () => { // Check for Free Mode if (mode === 'free') { const local = localStorage.getItem('straps_free_mode_menu'); if (local) { const menuData = JSON.parse(local); setMenu(menuData); setCurrentExerciseIndex(0); setRepsOffset(0); return; } } if (!user) return; const headers = { 'x-user-id': user.id.toString() }; try { const res = await fetch(`${API_BASE}/api/menus`, { headers }); const data = await res.json(); if (data && data.length > 0) { const latest = data[0]; if (typeof latest.exercises === 'string') latest.exercises = JSON.parse(latest.exercises); setMenu(latest); setCurrentExerciseIndex(0); setRepsOffset(0); } } catch (err) { console.error("Failed to fetch menu:", err); } }; // Init Logic and Load Models useEffect(() => { let isMounted = true; async function init() { try { // 1. Fetch Menu await fetchMenu(); // 2. Init Core const core = new HARCore(); harRef.current = core; // 3. Init Vision const vision = await FilesetResolver.forVisionTasks( "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm" ); if (!isMounted) return; const landmarker = await PoseLandmarker.createFromOptions(vision, { baseOptions: { modelAssetPath: `https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_full/float16/1/pose_landmarker_full.task`, delegate: "GPU" }, runningMode: "VIDEO", numPoses: 1 }); landmarkerRef.current = landmarker; // 4. Init Camera if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } }); if (videoRef.current) { videoRef.current.srcObject = stream; try { await videoRef.current.play(); } catch (err) { console.warn("Video play aborted (harmless):", err); } setIsLoading(false); requestRef.current = requestAnimationFrame(predictWebcam); } } } catch (e) { console.error("Init Error:", e); setIsLoading(false); } } init(); return () => { isMounted = false; if (requestRef.current) cancelAnimationFrame(requestRef.current); if (videoRef.current && videoRef.current.srcObject) { (videoRef.current.srcObject as MediaStream).getTracks().forEach(t => t.stop()); } }; }, [user, mode]); // Trigger init/fetch when mode changes // Rest Timer Countdown useEffect(() => { let interval: NodeJS.Timeout; if (isResting && restTimer > 0) { interval = setInterval(() => { setRestTimer((prev) => prev - 1); }, 1000); } else if (isResting && restTimer <= 0) { // Rest Finished setIsResting(false); // Re-sync offset to ignore any movements during rest setRepsOffset(stats.reps); } return () => clearInterval(interval); }, [isResting, restTimer, stats.reps]); // Effect: Update Active Exercise in HAR Core useEffect(() => { if (menu && harRef.current) { const range = menu.exercises?.[currentExerciseIndex]; if (range) { harRef.current.setExercise(range.name); } } }, [menu, currentExerciseIndex]); // Frame Loop Logic const lastVideoTimeRef = useRef(-1); const predictWebcam = async () => { const video = videoRef.current; const canvas = canvasRef.current; const landmarker = landmarkerRef.current; const har = harRef.current; if (video && canvas && landmarker && har) { let startTimeMs = performance.now(); if (lastVideoTimeRef.current !== video.currentTime && video.videoWidth > 0 && video.videoHeight > 0) { lastVideoTimeRef.current = video.currentTime; const result = landmarker.detectForVideo(video, startTimeMs); // Draw const ctx = canvas.getContext('2d'); if (ctx) { ctx.save(); ctx.clearRect(0, 0, canvas.width, canvas.height); // Mirror ctx.scale(-1, 1); ctx.translate(-canvas.width, 0); // Draw Video Frame ctx.drawImage(video, 0, 0, canvas.width, canvas.height); if (result.landmarks) { const drawingUtils = new DrawingUtils(ctx); for (const lm of result.landmarks) { // Config const connectors = PoseLandmarker.POSE_CONNECTIONS; // Draw Connections ctx.lineCap = 'round'; ctx.lineJoin = 'round'; for (const { start, end } of connectors) { const p1 = lm[start]; const p2 = lm[end]; if (!p1 || !p2 || (p1.visibility && p1.visibility < 0.5) || (p2.visibility && p2.visibility < 0.5)) continue; ctx.beginPath(); ctx.moveTo(p1.x * canvas.width, p1.y * canvas.height); ctx.lineTo(p2.x * canvas.width, p2.y * canvas.height); let color = '#00FFFF'; // Cyan let glow = '#00FFFF'; // Cyan Glow ctx.shadowColor = glow; ctx.shadowBlur = 15; ctx.strokeStyle = color; ctx.lineWidth = 4; ctx.stroke(); } // Draw Joints for (let i = 0; i < lm.length; i++) { const p = lm[i]; if (p.visibility && p.visibility < 0.5) continue; if (i < 11 && i !== 0) continue; // Keep nose (0) ctx.beginPath(); ctx.arc(p.x * canvas.width, p.y * canvas.height, 5, 0, 2 * Math.PI); ctx.fillStyle = '#FFFFFF'; // White core ctx.shadowColor = '#00FFFF'; // Cyan glow ctx.shadowBlur = 20; ctx.fill(); } ctx.shadowBlur = 0; } } ctx.restore(); } // Process Logic if (isStartedRef.current && !isRestingRef.current && result.landmarks && result.landmarks.length > 0) { // Pass normalized landmarks (x,y,z,visibility) AND world landmarks (meters) const res = await har.process( result.landmarks[0] as any, result.worldLandmarks[0] as any ); if (res) { // Accumulate Form Score (MAE) if (res.debug && (res.debug as any).scores && (res.debug as any).scores.deviation_mae) { const val = (res.debug as any).scores.deviation_mae; if (val > 0) { maeBuffer.current.push(val); repBuffer.current.push(val); // Push to current rep buffer } } // Capture Feedback Text if (res.feedback && res.feedback.trim() !== "" && !res.feedback.includes("null")) { // Only push meaningful feedback repFeedbackBuffer.current.push(res.feedback); } // --- Rep Completion Logic --- if (res.reps > lastRepCount.current) { // Rep Finished! const avgRepScore = repBuffer.current.length > 0 ? repBuffer.current.reduce((a, b) => a + b, 0) / repBuffer.current.length : 0; // Calculate Dominant Feedback let dominantFeedback = "Perfect"; if (repFeedbackBuffer.current.length > 0) { // Find most frequent string const counts: Record = {}; let maxCount = 0; let maxKey = ""; for (const fb of repFeedbackBuffer.current) { const cleanFb = fb.trim(); counts[cleanFb] = (counts[cleanFb] || 0) + 1; if (counts[cleanFb] > maxCount) { maxCount = counts[cleanFb]; maxKey = cleanFb; } } if (maxKey) dominantFeedback = maxKey; } currentSetReps.current.push({ rep: res.reps, score: avgRepScore, feedback: dominantFeedback }); // Reset for next rep repBuffer.current = []; repFeedbackBuffer.current = []; lastRepCount.current = res.reps; } setStats({ status: res.status, exercise: res.exercise || 'Unknown', reps: res.reps, // Reps from RehabCore feedback: res.feedback, mae: (res.debug as any)?.scores?.deviation_mae || 0 }); // Update Feedback UI State if (res.feedback) { setFeedbackMsg(res.feedback); setIsWarning(res.feedback.includes("⚠️")); } else { setFeedbackMsg(""); setIsWarning(false); } } } } } requestRef.current = requestAnimationFrame(predictWebcam); }; // Progression Logic useEffect(() => { if (!menu || isWorkoutComplete) return; const currentTarget = menu.exercises[currentExerciseIndex]; if (!currentTarget) { finishWorkout(); return; } // Calculate Reps in Current Set const currentRepsInSet = Math.max(0, stats.reps - repsOffset); const isMatchingExercise = stats.exercise && numberSafeMatch(stats.exercise, currentTarget.name); if (isMatchingExercise) { if (currentRepsInSet >= currentTarget.reps) { // --- SET COMPLETE LOGIC --- // 1. Calculate Average Form Score const avgMae = maeBuffer.current.length > 0 ? maeBuffer.current.reduce((a, b) => a + b, 0) / maeBuffer.current.length : 0; // 2. Save Result setResults(prev => [...prev, { name: currentTarget.name, set: currentTarget.set_index || 1, reps: currentRepsInSet, weight: currentTarget.weight, score: avgMae, repDetails: [...currentSetReps.current] // CAPTURE REP DETAILS }]); // 3. Reset Buffers maeBuffer.current = []; repBuffer.current = []; repFeedbackBuffer.current = []; // Reset feedback too currentSetReps.current = []; lastRepCount.current = 0; // Reset for next set // Linear Logic: Next Exercise in List const nextExIdx = currentExerciseIndex + 1; const restTime = (currentTarget as any).rest_time_seconds || 0; if (nextExIdx >= menu.exercises.length) { finishWorkout(); } else { setCurrentExerciseIndex(nextExIdx); setRepsOffset(stats.reps); // Important: Offset total reps if (restTime > 0) { setIsResting(true); setRestTimer(restTime); } } } } }, [stats.reps, stats.exercise, menu, currentExerciseIndex, repsOffset]); const numberSafeMatch = (a: string, b: string) => { return a.toLowerCase().includes(b.split(' ')[0].toLowerCase()); } const saveRecap = async (summary: any) => { if (!menu || !user) return; try { await fetch(`${API_BASE}/api/recap`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-user-id': user.id }, body: JSON.stringify({ menu_id: menu.id, user_id: user.id, summary: { ...summary, detailed_results: results // Send detailed results to backend } }) }); } catch (e) { console.error("Failed to save recap:", e); } }; const finishWorkout = async () => { if (isWorkoutComplete || !menu) return; setIsWorkoutComplete(true); setIsSaving(true); try { await saveRecap({ completed: true, exercises: menu.exercises, timestamp: new Date().toISOString() }); } catch (e) { console.error(e); } finally { setIsSaving(false); } }; // Helper for Form Grade const getGrade = (mae: number) => { if (mae < 8) return { letter: 'S', color: 'text-purple-400', label: 'Excellent' }; if (mae < 15) return { letter: 'A', color: 'text-green-400', label: 'Good' }; if (mae < 25) return { letter: 'B', color: 'text-yellow-400', label: 'Fair' }; return { letter: 'C', color: 'text-red-400', label: 'Needs Improvement' }; }; if (isWorkoutComplete) { return (
{/* Cyberpunk Glow */}
Session Complete

TRAINING RECAP

Excellent work. Here is your performance breakdown.

{/* Stats Grid */}
{results.length}
Sets Completed
{results.reduce((a, b) => a + b.reps, 0)}
Total Reps
{/* Detailed Results Table */}
{results.map((res, i) => { const grade = getGrade(res.score); const isExpanded = expandedSet === i; return ( setExpandedSet(isExpanded ? null : i)} className="hover:bg-zinc-800/50 transition-colors cursor-pointer group" > {/* Expanded Detail Row */} {isExpanded && ( )} ); })}
Exercise Set Load Form Score
{isExpanded ? : } {res.name} #{res.set} {res.reps}x @ {res.weight}kg
{grade.label} Avg: {res.score.toFixed(1)}°
{/* Preview Chips */} {!isExpanded && res.repDetails && res.repDetails.length > 0 && (
{res.repDetails.map((r: any, idx: number) => (
))}
)}
{res.repDetails?.map((r: any, idx: number) => { const rGrade = getGrade(r.score); const isPerfect = rGrade.label === 'Excellent'; const hasFeedback = r.feedback && r.feedback !== 'Perfect'; return (
#{idx + 1} {rGrade.label}
{r.score.toFixed(1)}°
{/* Feedback Text */}
{hasFeedback ? `"${r.feedback}"` : "NO ISSUES DETECTED"}
) })} {(!res.repDetails || res.repDetails.length === 0) && (
No individual rep data available.
)}
Back to Dashboard
); } // ... Render same as before ... const currentTarget = menu?.exercises?.[currentExerciseIndex]; return (

TRAINING.MODE

{menu ? menu.name : 'Loading...'}
Assigned Personal
{isLoading &&
Loading AI Engine (WASM)...
}
{/* Workout Menu List */}

Workout Plan

{menu?.exercises?.map((ex: any, idx: number) => { const isActive = idx === currentExerciseIndex; const isCompleted = idx < currentExerciseIndex; return ( {(idx === 0 || ex.set_index > (menu.exercises[idx - 1]?.set_index || 0)) && (
SET {ex.set_index || 1}
)}
{ex.name}
Target: {ex.reps} reps • {ex.weight}kg
{isActive && (
{Math.max(0, stats.reps - repsOffset)}/ {ex.reps}
Set {ex.set_index || 1}/{ex.total_sets || 1}
{(ex as any).rest_time_seconds > 0 && (
Rest: {(ex as any).rest_time_seconds}s
)}
)} {isCompleted && (
)}
); })} {!menu && (
No menu loaded
)}

Real-time Counter

{Math.max(0, stats.reps - repsOffset)} REPS
{/* Redesigned Cyberpunk Feedback Card (Expanded) */}
{/* Scanline effect (Subtle Light Mode) */}

{isWarning ? 'CRITICAL ERROR' : 'SYSTEM ADVICE'}

{isWarning &&
}

{(stats.feedback || "SYSTEM READY").replace(/⚠️|✅|❌/g, '').replace(" | ", "\n").trim() || "WAITING FOR INPUT..."}

{/* Form Quality (MAE) - Expanded */}
15 ? 'border-red-500' : stats.mae > 5 ? 'border-yellow-400' : 'border-emerald-500' }`}>
Form Quality 15 ? 'text-red-600' : stats.mae > 5 ? 'text-yellow-600' : 'text-emerald-600' }`}> {stats.mae > 15 ? 'Needs Improvement' : stats.mae > 5 ? 'Fair' : 'Excellent'}
{stats.mae.toFixed(1)}°
Deviation
{/* Mini Graph Bar */}
15 ? 'bg-red-500' : stats.mae > 5 ? 'bg-yellow-400' : 'bg-emerald-500'}`} style={{ width: `${Math.min(100, (stats.mae / 30) * 100)}%` }} >
); }