TKK_E32231242/frontend/src/app/(admin)/monitor/page.jsx

603 lines
33 KiB
JavaScript

"use client";
import React, { useState, useEffect, useRef } from 'react';
import { rtdb } from '@/firebase';
import { ref, onValue, set, off } from 'firebase/database';
import { Badge } from "@/components/ui/badge";
import {
Activity,
Play,
Square,
Target,
Waves,
Zap,
MessageSquare,
TrendingUp,
RotateCcw
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
ReferenceLine
} from 'recharts';
const GRAPH_BARS = 50; // Jarak diperlebar (dari sebelumnya 150 titik menjadi 50 titik)
const Monitor = () => {
const [athletes, setAthletes] = useState([]);
const [selectedAthlete, setSelectedAthlete] = useState('');
const [isTesting, setIsTesting] = useState(false);
const [livePeak, setLivePeak] = useState(0);
const [liveForce, setLiveForce] = useState(0);
const [attempts, setAttempts] = useState([]);
const [forceHistory, setForceHistory] = useState(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
const [currentAttemptIndex, setCurrentAttemptIndex] = useState(0);
const [animatedValue, setAnimatedValue] = useState(0);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [strikeType, setStrikeType] = useState('pukulan');
// Track live peak locally for immediate responsiveness
const localPeakRef = useRef(0);
const isTestingRef = useRef(false); // Ref untuk sinkronisasi dengan listener Firebase
// 1. Fetch Athletes
useEffect(() => {
const r = ref(rtdb, 'users');
const unsub = onValue(r, (snap) => {
const data = snap.val();
if (data) {
const arr = Object.keys(data).map(key => ({
id: String(key),
name: String(data[key]?.profile?.name || data[key]?.name || 'Unknown'),
}));
setAthletes(arr);
}
});
return () => off(r, 'value', unsub);
}, []);
// 2. Load History (Current Session)
useEffect(() => {
// Reset seketika saat atlet berubah agar tidak ada sisa data atlet sebelumnya
setLiveForce(0);
setLivePeak(0);
setAnimatedValue(0);
setForceHistory(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
if (!selectedAthlete) {
setAttempts([]);
setCurrentSessionId(null);
return;
}
const r = ref(rtdb, `test_history/${selectedAthlete}`);
const unsub = onValue(r, (snap) => {
const data = snap.val();
if (data && typeof data === 'object') {
// Find all sessions or legacy attempts
const sessionKeys = Object.keys(data).filter(k => k.startsWith('session_')).sort().reverse();
// If there are sessions, use the latest one by default (unless we just started a new one)
// If no sessions but there are legacy attempt_ keys, treat them as a "Legacy" session
let targetSessionData = null;
let activeSessionId = currentSessionId;
if (!activeSessionId) {
if (sessionKeys.length > 0) {
activeSessionId = sessionKeys[0];
} else if (Object.keys(data).some(k => k.startsWith('attempt_'))) {
activeSessionId = 'legacy';
}
}
if (activeSessionId === 'legacy') {
targetSessionData = data;
} else if (activeSessionId) {
targetSessionData = data[activeSessionId];
}
setCurrentSessionId(activeSessionId);
if (targetSessionData) {
const arr = [];
Object.keys(targetSessionData).forEach(k => {
if (k.startsWith('attempt_')) {
const attemptNum = parseInt(k.replace('attempt_', ''));
arr.push({
number: attemptNum,
peak: Number(targetSessionData[k]?.peak_newton || 0)
});
}
});
arr.sort((a, b) => a.number - b.number);
setAttempts(arr);
} else {
setAttempts([]);
}
} else {
setAttempts([]);
setCurrentSessionId(null);
}
});
return () => off(r, 'value', unsub);
}, [selectedAthlete, currentSessionId]);
// 3. Real-time Listeners
useEffect(() => {
const sRef = ref(rtdb, 'system/test_status');
const lRef = ref(rtdb, 'realtime/current_force');
const pRef = ref(rtdb, 'realtime/peak_force');
const unsubS = onValue(sRef, (s) => {
const status = s.val();
const testing = status === 'measuring' || status === 'waiting';
setIsTesting(testing);
isTestingRef.current = testing; // Update ref juga
if (!testing) {
// Test selesai, reset local peak
localPeakRef.current = 0;
}
});
const unsubL = onValue(lRef, (s) => {
const val = Number(s.val() || 0);
setLiveForce(val);
// Update local peak tracking
if (val > localPeakRef.current) {
localPeakRef.current = val;
}
// Rolling graph history (Muncul dari kiri ke kanan)
setForceHistory(prev => {
const newData = { time: 0, value: val };
// Masukkan data baru di awal [newData], buang yang terakhir di belakang
const updated = [newData, ...prev.slice(0, -1)];
// Maintain indices for re-rendering
return updated.map((d, idx) => ({ ...d, time: idx }));
});
});
const unsubP = onValue(pRef, (s) => setLivePeak(Number(s.val() || 0)));
return () => {
off(sRef, 'value', unsubS);
off(lRef, 'value', unsubL);
off(pRef, 'value', unsubP);
};
}, []);
// 4. Count-up Animation Logic
useEffect(() => {
if (!isTesting) {
// Jangan reset ke 0 agar angka tetap stay di layar setelah selesai
return;
}
let animationFrame;
const animate = () => {
setAnimatedValue(prev => {
if (prev < livePeak) {
// Cepat bertambah jika selisih besar, melambat jika dekat
const diff = livePeak - prev;
const step = Math.max(1, Math.ceil(diff / 5)); // "urut" atau sequential cepat
return Math.min(livePeak, prev + step);
}
return prev;
});
animationFrame = requestAnimationFrame(animate);
};
animationFrame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrame);
}, [livePeak, isTesting]);
const startTest = async () => {
if (!selectedAthlete) return;
// Cek apakah sesi saat ini sudah penuh (3 percobaan selesai)
const isSessionFull = attempts.length >= 3 && attempts.every(a => a.peak > 0);
let sessionId = currentSessionId;
// Mulai sesi baru jika sesi lama penuh atau belum ada sesi/Legacy
if (!sessionId || isSessionFull || sessionId === 'legacy') {
sessionId = `session_${new Date().getTime()}`;
setCurrentSessionId(sessionId);
setAttempts([]); // Reset UI lokal seketika
}
// Temukan slot pertama (1, 2, atau 3) yang masih kosong di sesi ini
const firstEmptySlot = [1, 2, 3].find(num => {
const found = attempts.find(a => a.number === num);
return !found || found.peak <= 0;
}) || 1;
const nextId = firstEmptySlot;
setCurrentAttemptIndex(nextId - 1);
console.log(`Starting test for ${selectedAthlete} in session ${sessionId} slot ${nextId}`);
// Reset semua nilai sebelum sesi baru
localPeakRef.current = 0;
await set(ref(rtdb, 'realtime/current_force'), 0);
await set(ref(rtdb, 'realtime/peak_force'), 0);
setLivePeak(0);
setLiveForce(0);
setAnimatedValue(0);
setForceHistory(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
// Trik: UID diisi path 'athleteId/sessionId' agar firmware menyimpan ke folder tersebut
await set(ref(rtdb, 'test_command'), `${selectedAthlete}/${sessionId}|${nextId}|${strikeType}`);
};
const stopTest = async () => {
await set(ref(rtdb, 'system/test_status'), 'idle');
await set(ref(rtdb, 'test_command'), '');
};
const resetAttempts = async () => {
if (!selectedAthlete || isTesting) return;
if (currentSessionId && currentSessionId !== 'legacy') {
// Hanya hapus sesi yang aktif saat ini, jangan hapus seluruh riwayat atlet
await set(ref(rtdb, `test_history/${selectedAthlete}/${currentSessionId}`), null);
} else {
await set(ref(rtdb, `test_history/${selectedAthlete}`), null);
}
setAttempts([]);
};
const currentAthlete = athletes.find(a => a.id === selectedAthlete);
// Display values
// PERBAIKAN: Jika atlet belum dipilih, paksa angka ke 0. Jika sudah, gunakan logika persistence.
const latestAttemptPeak = attempts.length > 0 ? attempts[attempts.length - 1].peak : 0;
const displayForce = !selectedAthlete ? 0 : (isTesting ? animatedValue : (liveForce > 0 ? liveForce : latestAttemptPeak));
const displayPeak = !selectedAthlete ? 0 : (isTesting ? livePeak : (livePeak > 0 ? livePeak : latestAttemptPeak));
// Dynamic scale for graph
const maxGraph = Math.max(...forceHistory.map(d => d.value), 100);
// Font size based on digit count
const gaugeFontSize = displayForce >= 10000
? 'text-[7rem]'
: displayForce >= 1000
? 'text-[10rem]'
: displayForce >= 100
? 'text-[13rem]'
: 'text-[16rem]';
return (
<div className="flex flex-col xl:flex-row gap-8 animate-in fade-in duration-500">
{/* ─── Sidebar Kiri: SESI LATIHAN ────────────────────────── */}
<div className="w-full xl:w-96 space-y-6">
<div className="glass-card rounded-[3rem] overflow-hidden border-none shadow-2xl relative">
<div className="h-2 w-full bg-slate-900 absolute top-0 left-0" />
<div className="p-8 pt-10">
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-2xl bg-slate-900 flex items-center justify-center text-primary shadow-lg">
<Activity size={24} />
</div>
<div>
<h2 className="text-xl font-black italic uppercase tracking-tighter">Sesi Latihan</h2>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest leading-none">Konfigurasi audit performa</p>
</div>
</div>
<div className="space-y-4">
{/* Pilih Atlet */}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Pilih Subjek</label>
<select
value={selectedAthlete}
onChange={(e) => setSelectedAthlete(e.target.value)}
disabled={isTesting}
className="w-full h-14 bg-slate-50 border border-black/5 rounded-2xl px-4 font-bold text-slate-900 outline-none focus:border-primary transition-all appearance-none cursor-pointer"
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\' /%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 1rem center', backgroundSize: '1rem' }}
>
<option value="">Pilih Atlet dari Daftar</option>
{athletes.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
{/* Pilih Jenis Serangan */}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Jenis Serangan</label>
<div className="flex p-1 bg-slate-100 rounded-2xl gap-1">
<button
onClick={() => setStrikeType('pukulan')}
disabled={isTesting}
className={cn(
"flex-1 h-12 rounded-xl font-bold text-xs transition-all uppercase tracking-widest",
strikeType === 'pukulan'
? "bg-white text-primary shadow-sm"
: "text-slate-400 hover:bg-white/50"
)}
>
Pukulan
</button>
<button
onClick={() => setStrikeType('tendangan')}
disabled={isTesting}
className={cn(
"flex-1 h-12 rounded-xl font-bold text-xs transition-all uppercase tracking-widest",
strikeType === 'tendangan'
? "bg-white text-primary shadow-sm"
: "text-slate-400 hover:bg-white/50"
)}
>
Tendangan
</button>
</div>
</div>
{/* Tombol Mulai / Hentikan */}
{!isTesting ? (
<button
onClick={startTest}
disabled={!selectedAthlete}
className="w-full h-20 bg-primary text-white rounded-[1.8rem] font-black text-lg italic shadow-2xl shadow-primary/30 active:scale-95 transition-all disabled:opacity-30 flex items-center justify-center gap-3"
>
<Play fill="currentColor" size={20} /> MULAI SESI
</button>
) : (
<button
onClick={stopTest}
className="w-full h-20 bg-red-500 text-white rounded-[1.8rem] font-black text-lg italic shadow-2xl shadow-red-500/30 animate-pulse active:scale-95 transition-all flex items-center justify-center gap-3"
>
<Square fill="currentColor" size={20} /> HENTIKAN
</button>
)}
{/* Logging Percobaan */}
<div className="pt-8 space-y-4">
<div className="flex items-center justify-between">
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Logging Percobaan</p>
{attempts.length > 0 && !isTesting && (
<button
onClick={resetAttempts}
className="flex items-center gap-1 text-[9px] font-black uppercase text-red-400 hover:text-red-600 tracking-widest transition-colors"
title="Reset semua percobaan"
>
<RotateCcw size={10} /> Reset
</button>
)}
</div>
{[0, 1, 2].map(i => {
const isActive = isTesting && currentAttemptIndex === i;
const attemptData = attempts[i];
const hasData = attemptData !== undefined;
// Nilai yang ditampilkan:
// - Jika sedang aktif (testing): tampilkan animatedValue (count-up)
// - Jika sudah selesai: tampilkan peak dari history
// - Jika belum: tampilkan 0
const displayVal = isActive
? animatedValue
: (hasData ? attemptData.peak : 0);
return (
<div key={i} className={cn(
"h-20 rounded-[1.8rem] flex items-center justify-between px-6 border border-black/5 bg-slate-50 transition-all",
isActive && "bg-white shadow-xl ring-2 ring-primary/20 scale-[1.02]"
)}>
<div className="flex items-center gap-4">
<div className={cn("w-10 h-10 rounded-full flex items-center justify-center font-black text-[10px]", isActive ? "bg-primary text-white" : "bg-white text-slate-300 shadow-inner")}>
{i + 1}
</div>
<div className="flex flex-col">
<span className="text-[8px] font-black uppercase text-slate-400 tracking-widest leading-none mb-1">Attempt {i + 1}</span>
<span className={cn("text-[10px] font-black uppercase italic", isActive ? "text-primary" : "text-slate-300")}>
{isActive ? "Recording..." : (hasData ? "Finished" : "Ready")}
</span>
</div>
</div>
<div className={cn(
"text-2xl font-black italic transition-all",
isActive ? "text-primary animate-pulse" : (hasData ? "text-slate-900" : "text-slate-200")
)}>
{displayVal > 0 ? Number(displayVal).toFixed(0) : "0"} <span className="text-[10px]">N</span>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
{/* ─── Main: Center + Right ──────────────────────────────── */}
<div className="flex-1 space-y-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* CENTER GAUGE */}
<div className="flex-1 glass-card rounded-[4rem] p-12 shadow-2xl flex flex-col items-center justify-center text-center relative overflow-hidden min-h-[500px] border-none">
{/* Decorative circles */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[450px] h-[450px] rounded-full border-[1.5px] border-black/[0.03] flex items-center justify-center pointer-events-none">
<div className="w-[300px] h-[300px] rounded-full border border-primary/5" />
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 w-40 h-[1.5px] bg-slate-100" />
<Badge className="mb-6 px-6 py-2 rounded-full font-black text-[9px] tracking-[0.3em] uppercase bg-primary/5 text-primary border-primary/10">
{isTesting ? "Sensor Active Tracking" : "Sensor Standby Mode"}
</Badge>
<h2 className="text-slate-400 text-[11px] font-black uppercase tracking-[0.4em] mb-2">Gaya Saat Ini</h2>
{/* ANGKA NEWTON */}
<div className="flex flex-col items-center justify-center gap-0 relative z-10">
<span className={cn(
"font-black leading-none italic select-none tracking-tighter",
"transition-colors duration-75",
displayForce > 0 ? "text-slate-700" : "text-slate-100",
gaugeFontSize
)}>
{displayForce.toFixed(0)}
</span>
<span className="text-5xl font-black text-primary italic tracking-tighter -mt-8">NEWTON</span>
</div>
{/* Badge Peak */}
<div className={cn(
"mt-5 flex items-center gap-2 px-5 py-2 rounded-full transition-all duration-300",
isTesting ? "bg-emerald-50 opacity-100" : "opacity-0 pointer-events-none"
)}>
<TrendingUp size={13} className="text-emerald-500" />
<span className="text-[11px] font-black uppercase text-emerald-600 tracking-widest">
Peak: {displayPeak.toFixed(0)} N
</span>
</div>
<div className="flex items-center justify-center gap-12 mt-8 w-full max-w-sm">
<div className="flex items-center gap-2 text-[10px] font-black uppercase text-emerald-500 tracking-widest">
<Target size={14} /> Target: {strikeType === 'pukulan' ? '>150N' : '>250N'}
</div>
<div className="flex items-center gap-2 text-[10px] font-black uppercase text-primary tracking-widest border-l border-slate-100 pl-12">
<Waves size={14} /> Athlete: {currentAthlete?.name || "Pilih Atlet"}
</div>
</div>
</div>
{/* RIGHT ANALYSIS CARD */}
<div className="w-full lg:w-[400px] space-y-6">
<div className="glass-card rounded-[3.5rem] p-8 border-none shadow-2xl h-full">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-2xl bg-emerald-50 text-emerald-500 flex items-center justify-center shadow-inner">
<Zap size={22} />
</div>
<h3 className="text-xs font-black uppercase tracking-widest italic">Analisis Dampak</h3>
</div>
<div className="space-y-8 mt-10">
{/* Progress bar - max dinamis sesuai jenis serangan */}
{(() => {
const maxN = strikeType === 'pukulan' ? 150 : 300;
const pct = Math.min(100, (displayPeak / maxN) * 100);
return (
<div>
<div className="flex justify-between items-end mb-2">
<span className="text-[10px] font-black uppercase text-slate-400 tracking-widest">Efisiensi Serangan</span>
<span className="text-lg font-black italic text-emerald-500">
{pct.toFixed(1)}%
</span>
</div>
<div className="w-full h-2 bg-slate-50 rounded-full p-[2px] shadow-inner overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-[9px] font-black uppercase text-slate-300 tracking-widest">
<span>0 N</span>
<span>{maxN} N</span>
</div>
</div>
);
})()}
<div className="bg-slate-900 rounded-[2.5rem] p-8 text-white relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:rotate-12 transition-transform">
<MessageSquare size={100} />
</div>
<span className="text-[9px] font-black uppercase text-primary tracking-widest border-b border-primary/20 pb-2 mb-4 block">
Saran Pelatih &mdash; {strikeType === 'pukulan' ? '🥊 Pukulan' : '🦵 Tendangan'}
</span>
<p className="text-sm font-bold italic leading-relaxed text-slate-300">
{strikeType === 'pukulan'
? displayPeak >= 130
? '"Pukulan sangat kuat! Rotasi bahu dan dorongan pinggul sudah optimal. Pertahankan posisi siku tetap terkunci saat impact dan jaga konsistensi kecepatan di setiap percobaan."'
: displayPeak >= 80
? '"Pukulan cukup baik. Fokus pada koordinasi antara dorongan bahu dan ekstensi siku. Kunci pergelangan tangan sesaat sebelum mengenai target untuk memaksimalkan transfer gaya."'
: displayPeak > 0
? '"Gaya pukulan masih perlu ditingkatkan. Latih kecepatan ekstensi siku dan perkuat otot bahu. Pastikan seluruh berat badan ikut mendorong ke depan saat memukul."'
: '"Pilih atlet dan mulai sesi untuk melihat analisis pukulan."'
: displayPeak >= 250
? '"Tendangan sangat powerful! Pivot kaki tumpu dan rotasi pinggul sudah sangat baik. Jaga keseimbangan setelah impact dan pertahankan snap pergelangan kaki untuk akurasi maksimal."'
: displayPeak >= 150
? '"Tendangan cukup baik. Tingkatkan kecepatan rotasi pinggul dan pastikan lutut terangkat cukup tinggi sebelum eksekusi. Snap pergelangan kaki saat impact akan menambah gaya secara signifikan."'
: displayPeak > 0
? '"Gaya tendangan perlu ditingkatkan. Fokus pada kecepatan putaran pinggang dan angkat lutut lebih tinggi sebelum menendang. Latih fleksibilitas pinggul untuk menambah jangkauan dan gaya ledak."'
: '"Pilih atlet dan mulai sesi untuk melihat analisis tendangan."'
}
</p>
</div>
</div>
</div>
</div>
</div>
{/* BOTTOM — Live Force Stream Graph */}
<div className="glass-card rounded-[3.5rem] p-10 border-none shadow-2xl min-h-[300px] relative overflow-hidden">
<div className="flex items-center justify-between mb-8 relative z-10">
<div>
<div className="flex items-center gap-3">
<div className="p-2 bg-red-50 text-red-500 rounded-lg">
<Waves size={20} />
</div>
<h3 className="text-2xl font-black italic uppercase tracking-tighter">Live Force Stream</h3>
</div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Osiloskop gaya real-time dari sensor.</p>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-red-50 text-red-500 rounded-full">
<div className={cn("w-2 h-2 rounded-full bg-red-500", isTesting && "animate-pulse")} />
<span className="text-[10px] font-black uppercase tracking-widest">
{isTesting ? "Live" : "Idle"}
</span>
</div>
</div>
{/* Real-time Oscilloscope Waveform */}
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={forceHistory} margin={{ top: 10, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorForce" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(0,0,0,0.03)" />
<XAxis hide dataKey="time" />
<YAxis
hide
domain={[0, maxGraph]}
/>
<Area
type="monotone"
dataKey="value"
stroke="#3b82f6"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorForce)"
isAnimationActive={false}
/>
{isTesting && (
<ReferenceLine y={livePeak} stroke="#ef4444" strokeDasharray="3 3" label={{ position: 'right', value: `Peak: ${livePeak}N`, fill: '#ef4444', fontSize: 10, fontWeight: 'bold' }} />
)}
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex justify-between mt-6 text-[9px] font-black uppercase text-slate-300 tracking-widest px-2">
<span>Timeline (5s)</span>
<span>{(maxGraph / 2).toFixed(0)} N</span>
<span>Max Scale: {maxGraph.toFixed(0)} N</span>
</div>
<div className="absolute bottom-0 left-0 w-full h-[1.5px] bg-slate-100" />
</div>
</div>
</div>
);
};
export default Monitor;