STRAPS_LOCALHOST/app/client/training/page.tsx

907 lines
49 KiB
TypeScript

'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 (
<AuthProvider>
<Suspense fallback={<div className="min-h-screen bg-zinc-900 flex items-center justify-center text-white">Loading...</div>}>
<TrainingPage />
</Suspense>
</AuthProvider>
);
}
function TrainingPage() {
const { user } = useAuth();
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [isStarted, setIsStarted] = useState(false);
// Workflow State
const [menu, setMenu] = useState<any>(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<string>("");
const [isWarning, setIsWarning] = useState<boolean>(false);
// UI State
const [expandedSet, setExpandedSet] = useState<number | null>(null);
// Recap State
const [results, setResults] = useState<any[]>([]);
const maeBuffer = useRef<number[]>([]);
// Per-Rep Tracking
const repBuffer = useRef<number[]>([]);
const repFeedbackBuffer = useRef<string[]>([]); // 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<HARCore | null>(null);
const landmarkerRef = useRef<PoseLandmarker | null>(null);
const requestRef = useRef<number | null>(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<string, number> = {};
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 (
<div className="min-h-screen bg-zinc-950 text-white flex flex-col items-center justify-center p-4 font-sans">
<div className="max-w-2xl w-full bg-zinc-900 rounded-3xl border border-zinc-800 p-8 shadow-2xl relative overflow-hidden">
{/* Cyberpunk Glow */}
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-500 via-primary to-purple-500"></div>
<div className="text-center mb-8">
<div className="inline-block px-4 py-1 rounded-full bg-green-500/10 text-green-400 text-xs font-bold tracking-widest uppercase mb-4 border border-green-500/20">
Session Complete
</div>
<h1 className="text-4xl font-black text-white tracking-tight mb-2">TRAINING RECAP</h1>
<p className="text-zinc-500 text-sm">Excellent work. Here is your performance breakdown.</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4 mb-8">
<div className="bg-zinc-950/50 p-6 rounded-2xl border border-zinc-800 text-center">
<div className="text-3xl font-black text-white">{results.length}</div>
<div className="text-xs font-bold text-zinc-500 uppercase tracking-widest mt-1">Sets Completed</div>
</div>
<div className="bg-zinc-950/50 p-6 rounded-2xl border border-zinc-800 text-center">
<div className="text-3xl font-black text-primary">
{results.reduce((a, b) => a + b.reps, 0)}
</div>
<div className="text-xs font-bold text-zinc-500 uppercase tracking-widest mt-1">Total Reps</div>
</div>
</div>
{/* Detailed Results Table */}
<div className="bg-zinc-950/30 rounded-2xl border border-zinc-800 overflow-hidden mb-8 max-h-[40vh] overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-zinc-900 border-b border-zinc-800">
<tr>
<th className="px-4 py-3 text-left font-bold text-zinc-500 uppercase tracking-wider text-[10px]">Exercise</th>
<th className="px-4 py-3 text-center font-bold text-zinc-500 uppercase tracking-wider text-[10px]">Set</th>
<th className="px-4 py-3 text-center font-bold text-zinc-500 uppercase tracking-wider text-[10px]">Load</th>
<th className="px-4 py-3 text-right font-bold text-zinc-500 uppercase tracking-wider text-[10px]">Form Score</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{results.map((res, i) => {
const grade = getGrade(res.score);
const isExpanded = expandedSet === i;
return (
<React.Fragment key={i}>
<tr
onClick={() => setExpandedSet(isExpanded ? null : i)}
className="hover:bg-zinc-800/50 transition-colors cursor-pointer group"
>
<td className="px-4 py-3 font-medium text-white flex items-center gap-2">
{isExpanded ? <ChevronUp size={14} className="text-zinc-500" /> : <ChevronDown size={14} className="text-zinc-500" />}
{res.name}
</td>
<td className="px-4 py-3 text-center text-zinc-400 font-mono">#{res.set}</td>
<td className="px-4 py-3 text-center text-zinc-400">
{res.reps}x <span className="text-zinc-600">@</span> {res.weight}kg
</td>
<td className="px-4 py-3 text-right">
<div className="flex flex-col items-end gap-1">
<div className="flex items-center gap-2">
<span className={`font-black ${grade.color}`}>{grade.label}</span>
<span className="text-[10px] text-zinc-600 font-mono">Avg: {res.score.toFixed(1)}°</span>
</div>
{/* Preview Chips */}
{!isExpanded && res.repDetails && res.repDetails.length > 0 && (
<div className="flex justify-end gap-1">
{res.repDetails.map((r: any, idx: number) => (
<div key={idx} className={`w-1.5 h-1.5 rounded-full ${getGrade(r.score).color.replace('text-','bg-')}`} />
))}
</div>
)}
</div>
</td>
</tr>
{/* Expanded Detail Row */}
{isExpanded && (
<tr className="bg-zinc-900/50">
<td colSpan={4} className="px-4 py-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{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 (
<div key={idx} className="bg-zinc-950 border border-zinc-800 rounded-lg p-3 flex flex-col gap-1">
<div className="flex justify-between items-center w-full">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-zinc-500">#{idx + 1}</span>
<span className={`text-xs font-bold ${rGrade.color}`}>{rGrade.label}</span>
</div>
<span className="text-[10px] text-zinc-600 font-mono">{r.score.toFixed(1)}°</span>
</div>
{/* Feedback Text */}
<div className={`text-[10px] uppercase font-bold tracking-wide ${hasFeedback ? 'text-zinc-400' : 'text-zinc-600/50'}`}>
{hasFeedback ? `"${r.feedback}"` : "NO ISSUES DETECTED"}
</div>
</div>
)
})}
{(!res.repDetails || res.repDetails.length === 0) && (
<div className="col-span-3 text-center text-zinc-500 text-xs italic py-2">No individual rep data available.</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
<div className="flex gap-4">
<Link href="/client" className="flex-1 px-6 py-4 bg-white text-black font-bold uppercase tracking-widest rounded-xl hover:bg-zinc-200 transition-colors text-center text-sm">
Back to Dashboard
</Link>
</div>
</div>
</div>
);
}
// ... Render same as before ...
const currentTarget = menu?.exercises?.[currentExerciseIndex];
return (
<div className="min-h-screen bg-background text-foreground p-6 font-sans selection:bg-primary/30">
<header className="flex justify-between items-center mb-8">
<div className="flex items-center gap-4">
<Link href="/client" className="p-2 bg-white rounded-full hover:bg-zinc-100 transition-colors border border-zinc-200">
<ArrowLeft className="w-5 h-5 text-zinc-600" />
</Link>
<h1 className="text-3xl font-light tracking-widest text-zinc-800">TRAINING<span className="font-bold text-primary">.MODE</span></h1>
</div>
<div className="flex items-center gap-4">
<div className="bg-white border border-zinc-200 px-6 py-2 rounded-full text-xs font-medium tracking-wider text-zinc-600 uppercase shadow-sm">
{menu ? menu.name : 'Loading...'}
</div>
<button
onClick={() => {
setMenu(null);
fetchMenu();
}}
className="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 rounded-full transition-colors shadow-sm"
title="Refresh Menu"
>
<RefreshCcw size={18} className="text-zinc-500" />
</button>
<button
onClick={() => {
// Reset Logic
// setCurrentSet(1);
setRepsOffset(0);
setStats(prev => ({ ...prev, reps: 0, status: 'Idle', feedback: 'Reset' }));
if (harRef.current) harRef.current.resetParams();
setIsResting(false);
}}
className="px-4 py-2 bg-red-50 text-red-600 rounded-full text-xs font-bold uppercase tracking-widest border border-red-100 hover:bg-red-100 transition-colors"
>
Reset
</button>
<div className="flex bg-zinc-100 p-1 rounded-full border border-zinc-200">
<Link
href="/client/training"
className={`px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest transition-all ${
!(new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '').get('mode') === 'free')
? 'bg-white text-primary shadow-sm'
: 'text-zinc-400 hover:text-zinc-600'
}`}
>
Assigned
</Link>
<Link
href="/client/training?mode=free"
className={`px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest transition-all ${
(new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '').get('mode') === 'free')
? 'bg-white text-primary shadow-sm'
: 'text-zinc-400 hover:text-zinc-600'
}`}
>
Personal
</Link>
</div>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 relative border-8 border-white rounded-[2rem] overflow-hidden bg-zinc-100 shadow-2xl">
{isLoading && <div className="absolute inset-0 flex items-center justify-center text-blue-400 font-mono animate-pulse">Loading AI Engine (WASM)...</div>}
<video ref={videoRef} className="hidden" width="640" height="480" autoPlay playsInline muted />
<canvas ref={canvasRef} width="640" height="480" className="w-full h-auto object-contain" />
{/* Status Display Removed as per user request (kept in Live Monitor) */}
{/* <div className="absolute top-6 left-6 flex gap-4">
<div className={`px-4 py-1.5 rounded-full text-xs font-bold tracking-widest backdrop-blur-md border ${stats.status === 'Fall Detected' ? 'bg-red-500/20 border-red-500 text-red-400' : 'bg-primary/10 border-primary/30 text-primary'}`}>
{stats.status.toUpperCase()}
</div>
</div> */}
{/* --- NEW: FEEDBACK OVERLAY WINDOW --- */}
{/* Rest Overlay */}
{isResting && (
<div className="absolute inset-0 bg-black/95 z-50 flex flex-col items-center justify-center animate-in fade-in duration-500">
<div className="text-secondary font-medium tracking-[0.2em] uppercase mb-6 text-sm">Recovery Break</div>
<div className="text-9xl font-light text-highlight mb-10 tabular-nums tracking-tighter">
{Math.floor(restTimer / 60)}:{(restTimer % 60).toString().padStart(2, '0')}
</div>
<button
onClick={() => setIsResting(false)}
className="px-10 py-3 bg-secondary/10 hover:bg-secondary/20 hover:scale-105 text-secondary rounded-full font-medium transition-all text-xs uppercase tracking-widest border border-secondary/30"
>
Resume Workout
</button>
</div>
)}
{/* Start Overlay */}
{!isStarted && !isLoading && (
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm z-40 flex flex-col items-center justify-center p-8 text-center">
<h2 className="text-3xl font-bold text-zinc-900 mb-2">Ready to Train?</h2>
<p className="text-zinc-600 mb-8 max-w-md">
{menu ? `Start your assigned program: ${menu.name}` : `No assigned program found.`}
</p>
<div className="flex flex-col gap-4">
{menu && (
<button
onClick={() => setIsStarted(true)}
className="px-12 py-4 bg-primary text-white text-lg font-bold rounded-full shadow-xl hover:shadow-2xl hover:scale-105 transition-all flex items-center justify-center gap-3 w-64"
>
<PlayCircle className="w-6 h-6" /> START {menu.name ? 'PROGRAM' : 'WORKOUT'}
</button>
)}
<div className="flex items-center gap-4 w-64">
<div className="h-px bg-zinc-300 flex-1"></div>
<span className="text-xs text-zinc-400 font-bold uppercase">OR</span>
<div className="h-px bg-zinc-300 flex-1"></div>
</div>
<Link
href="/client/free"
className="px-12 py-4 bg-white border-2 border-zinc-200 text-zinc-600 text-lg font-bold rounded-full hover:border-primary hover:text-primary transition-all flex items-center justify-center gap-3 w-64 text-center"
>
CREATE PERSONAL MENU
</Link>
{new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '').get('mode') === 'free' && (
<Link
href="/client/training"
className="mt-2 text-zinc-400 text-xs font-bold uppercase tracking-widest hover:text-primary transition-colors"
>
Return to Assigned Program
</Link>
)}
</div>
</div>
)}
</div>
<div className="flex flex-col gap-6">
{/* Workout Menu List */}
<div className="bg-white rounded-2xl border border-zinc-200 overflow-hidden flex flex-col max-h-[40vh] shadow-lg">
<div className="p-4 border-b border-zinc-100 bg-zinc-50">
<h2 className="text-zinc-500 text-xs font-bold uppercase tracking-widest">Workout Plan</h2>
</div>
<div className="overflow-y-auto p-2 space-y-2">
{menu?.exercises?.map((ex: any, idx: number) => {
const isActive = idx === currentExerciseIndex;
const isCompleted = idx < currentExerciseIndex;
return (
<React.Fragment key={idx}>
{(idx === 0 || ex.set_index > (menu.exercises[idx - 1]?.set_index || 0)) && (
<div className="py-4 flex items-center gap-4">
<div className="h-px bg-zinc-200 flex-1"></div>
<span className="text-xs font-black text-zinc-400 uppercase tracking-[0.2em] bg-white px-2 rounded-lg">
SET {ex.set_index || 1}
</span>
<div className="h-px bg-zinc-200 flex-1"></div>
</div>
)}
<div
className={`p-5 rounded-2xl transition-all border ${
isActive
? 'bg-blue-50 border-primary shadow-sm'
: isCompleted
? 'opacity-40 grayscale border-transparent'
: 'bg-white border-zinc-100'
}`}
>
<div className="flex justify-between items-center">
<div>
<div className={`text-lg tracking-wide ${isActive ? 'font-bold text-zinc-900' : 'text-zinc-500 font-medium'}`}>
{ex.name}
</div>
<div className="text-[10px] text-secondary/70 uppercase tracking-widest mt-1">
Target: {ex.reps} reps {ex.weight}kg
</div>
</div>
{isActive && (
<div className="text-right">
<div className="text-3xl font-light text-primary">
{Math.max(0, stats.reps - repsOffset)}<span className="text-sm text-secondary/50 font-normal ml-1">/ {ex.reps}</span>
</div>
<div className="text-[10px] text-blue-300 uppercase tracking-wider font-bold animate-pulse">
Set {ex.set_index || 1}/{ex.total_sets || 1}
</div>
{(ex as any).rest_time_seconds > 0 && (
<div className="text-[10px] text-zinc-500 mt-1">
Rest: {(ex as any).rest_time_seconds}s
</div>
)}
</div>
)}
{isCompleted && (
<div className="text-green-500">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</div>
</div>
</React.Fragment>
);
})}
{!menu && (
<div className="p-4 text-center text-zinc-500 italic">No menu loaded</div>
)}
</div>
</div>
<div className="bg-white p-4 rounded-2xl border border-zinc-200 flex flex-col justify-center items-center text-center shadow-lg h-48">
<h2 className="text-zinc-400 text-[10px] font-bold uppercase tracking-widest mb-2">Real-time Counter</h2>
<div className="relative">
<svg className="w-32 h-32 transform -rotate-90">
<circle cx="64" cy="64" r="56" stroke="currentColor" strokeWidth="6" fill="transparent" className="text-zinc-100" />
<circle
cx="64" cy="64" r="56"
stroke="currentColor" strokeWidth="6" fill="transparent"
className="text-primary transition-all duration-500 ease-out drop-shadow-md"
strokeDasharray={2 * Math.PI * 56}
strokeDashoffset={2 * Math.PI * 56 * (1 - (Math.max(0, stats.reps - repsOffset) / (currentTarget?.reps || 1)))}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-4xl font-black text-zinc-900">{Math.max(0, stats.reps - repsOffset)}</span>
<span className="text-zinc-400 text-[10px] font-medium">REPS</span>
</div>
</div>
</div>
{/* Redesigned Cyberpunk Feedback Card (Expanded) */}
<div className={`p-0.5 rounded-xl flex-1 bg-gradient-to-r ${
isWarning ? 'from-red-500 via-rose-500 to-red-500 animate-pulse' : 'from-cyan-400 via-blue-500 to-cyan-400'
}`}>
<div className="bg-zinc-50 rounded-[10px] p-6 h-full relative overflow-hidden flex flex-col justify-center">
{/* Scanline effect (Subtle Light Mode) */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,0,0,0)_50%,rgba(0,0,0,0.02)_50%),linear-gradient(90deg,rgba(0,0,0,0.03),rgba(0,0,0,0.01),rgba(0,0,0,0.03))] z-0 pointer-events-none bg-[length:100%_4px,6px_100%]"></div>
<div className="relative z-10 text-center md:text-left">
<div className="flex justify-between items-start mb-4">
<h3 className={`text-xs font-black uppercase tracking-[0.2em] ${
isWarning ? 'text-red-500 drop-shadow-sm' : 'text-cyan-600 drop-shadow-sm'
}`}>
{isWarning ? 'CRITICAL ERROR' : 'SYSTEM ADVICE'}
</h3>
{isWarning && <div className="w-3 h-3 bg-red-500 rounded-full animate-ping"></div>}
</div>
<p className={`text-2xl font-bold leading-tight uppercase font-mono break-words ${
isWarning ? 'text-red-600' : 'text-zinc-800'
}`}>
{(stats.feedback || "SYSTEM READY").replace(/⚠️|✅|❌/g, '').replace(" | ", "\n").trim() || "WAITING FOR INPUT..."}
</p>
</div>
</div>
</div>
{/* Form Quality (MAE) - Expanded */}
<div className={`rounded-xl border-l-8 overflow-hidden shadow-lg bg-white h-32 flex flex-col justify-center ${
stats.mae > 15
? 'border-red-500'
: stats.mae > 5
? 'border-yellow-400'
: 'border-emerald-500'
}`}>
<div className="px-6 py-2 flex justify-between items-center h-full">
<div className="flex flex-col text-left">
<span className="text-xs font-black uppercase tracking-widest text-zinc-400">Form Quality</span>
<span className={`text-2xl uppercase font-black mt-1 ${
stats.mae > 15 ? 'text-red-600' : stats.mae > 5 ? 'text-yellow-600' : 'text-emerald-600'
}`}>
{stats.mae > 15 ? 'Needs Improvement' : stats.mae > 5 ? 'Fair' : 'Excellent'}
</span>
</div>
<div className="text-right">
<div className="text-5xl font-black tabular-nums leading-none text-zinc-900 tracking-tighter">
{stats.mae.toFixed(1)}°
</div>
<span className="text-[10px] uppercase text-zinc-400 tracking-wider font-bold">Deviation</span>
</div>
</div>
{/* Mini Graph Bar */}
<div className="h-2 w-full bg-zinc-100 flex mt-auto">
<div
className={`h-full transition-all duration-500 ${stats.mae > 15 ? 'bg-red-500' : stats.mae > 5 ? 'bg-yellow-400' : 'bg-emerald-500'}`}
style={{ width: `${Math.min(100, (stats.mae / 30) * 100)}%` }}
></div>
</div>
</div>
</div>
</div>
</div>
);
}