import { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; import Row from 'react-bootstrap/Row'; import Col from 'react-bootstrap/Col'; import Card from 'react-bootstrap/Card'; import Badge from 'react-bootstrap/Badge'; import Form from 'react-bootstrap/Form'; import ProgressBar from 'react-bootstrap/ProgressBar'; import { getSession, getUsersInSession, getDebrief, getObjectives, getSessionStats } from '../api/client'; import type { Session, User, DebriefRow, Participation, ObjectiveResults, SessionDebriefRow } from '../types'; import { SessionType } from '../types'; import SessionTypeBadge from '../components/SessionTypeBadge'; import ScoreBadge from '../components/ScoreBadge'; import StatCard from '../components/StatCard'; import LoadingSpinner from '../components/LoadingSpinner'; import PrintHeader from '../components/PrintHeader'; import { Tooltip, ResponsiveContainer, Legend, LineChart, Line, XAxis, YAxis, CartesianGrid, BarChart, Bar } from 'recharts'; import { useI18n } from '../i18n/context'; import type { TranslationKey } from '../i18n/translations'; import TargetVisualization from '../components/TargetVisualization'; import { computeSuccess } from '../hooks/useComputedSuccess'; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${m}m ${s}s`; } function formatDate(dateStr: string, lang: string): string { if (!dateStr) return '-'; const d = new Date(dateStr); return d.toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-US', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } function SessionDetail() { const { id } = useParams<{ id: string }>(); const sessionId = Number(id); const { t, lang } = useI18n(); const [session, setSession] = useState(null); const [users, setUsers] = useState([]); const [debrief, setDebrief] = useState([]); const [objectives, setObjectives] = useState(null); const [shotDetails, setShotDetails] = useState([]); const [loading, setLoading] = useState(true); const [selectedUserId, setSelectedUserId] = useState(-1); useEffect(() => { if (!sessionId) return; Promise.all([ getSession(sessionId).catch(() => null), getUsersInSession(sessionId).catch(() => []), getDebrief(sessionId).catch(() => []), getObjectives(sessionId).catch(() => null), ]) .then(([sessionData, usersData, debriefData, objectivesData]) => { setSession(sessionData as Session | null); const uList = usersData as User[]; setUsers(uList); if (uList.length === 1) setSelectedUserId(uList[0].id); setDebrief(debriefData as DebriefRow[]); setObjectives(objectivesData as Participation | null); // Load shot details for all session types if (sessionData) { getSessionStats(sessionId, -1, sessionData.sessionTypeAsInt).then(setShotDetails); } }) .finally(() => setLoading(false)); }, [sessionId]); if (loading) return ; if (!session) return

{t('session.notFound')}

; // Parse objectives let parsedObjectives: ObjectiveResults | null = null; if (objectives?.resultsAsString) { try { parsedObjectives = JSON.parse(objectives.resultsAsString); } catch { // ignore parse errors } } // Map userId to username const userMap = new Map(users.map((u) => [u.id, u])); const isFireRange = session.sessionTypeAsInt === SessionType.FireRange || session.sessionTypeAsInt === SessionType.Challenge || session.sessionTypeAsInt === SessionType.LongRange; const isChallenge = session.sessionTypeAsInt === SessionType.Challenge; const computedSuccess = computeSuccess(session.success, debrief); return ( <>
← {t('session.backToSessions')}
{/* Session Header */}

{session.sessionName || session.scenarioName || `Session #${session.id}`}

{formatDate(session.sessionDateAsString, lang)} Map: {session.mapName || '-'}
Scenario: {session.scenarioName || '-'}
{users.length > 0 && (

{t('session.participants')}

setSelectedUserId(Number(e.target.value))} style={{ backgroundColor: '#1a1a2e', color: '#e0e0e0', borderColor: '#333' }} > {users.map(u => ( ))} )}
{/* Standard sessions: stats left + bar chart right */} {!isFireRange && (() => { // Compute filtered debrief stats const filteredDebrief = selectedUserId === -1 ? debrief : debrief.filter(r => r.userId === selectedUserId); const sumField = (field: keyof DebriefRow) => filteredDebrief.reduce((sum, r) => sum + (Number(r[field]) || 0), 0); const avgPrecision = filteredDebrief.length > 0 ? filteredDebrief.reduce((sum, r) => sum + (r.averagePrecision || 0), 0) / filteredDebrief.length : 0; // Bar chart data: per participant const barData = debrief.map(row => { const user = userMap.get(row.userId); const name = user ? (user.firstName || user.username) : `#${row.userId}`; return { name, [t('hitType.enemy')]: row.nbEnemyHitsByUser, [t('hitType.civilian')]: row.nbCivilHitsByUser, [t('hitType.police')]: row.nbPoliceHitsByUser, [t('session.missed')]: row.nbMissedShotsByUser, }; }); return ( {/* Col 1: KPIs */}
{t('session.duration')}
{formatDuration(session.timeToFinish)}
{t('firerange.shotsFired')}
{sumField('nbFiredShotsByUser')}
{t('firerange.shotsMissed')}
{sumField('nbMissedShotsByUser')}
{t('firerange.avgPrecision')}
{avgPrecision > 0 ? `${(avgPrecision * 100).toFixed(2)}%` : '-'}
{/* Col 2: Global Stats + Personal Stats */}
{t('session.globalStats')}
{t('stats.enemiesKilled')}{sumField('totalEnemyKilled')}
{t('stats.civiliansKilled')}{sumField('totalCivilKilled')}
{t('stats.policeKilled')}{sumField('totalPoliceKilled')}
{t('session.hitsReceived')}{sumField('nbReceivedHitsFromEnemyIA') + sumField('nbReceivedHitsFromEnemyUser') + sumField('nbReceivedHitsFromPoliceUser')}
{t('session.personalStats')}
{t('session.enemiesHit')}{sumField('nbEnemyHitsByUser')}
{t('session.civiliansHit')}{sumField('nbCivilHitsByUser')}
{t('session.friendlyFire')}{sumField('nbPoliceHitsByUser')}
{t('session.hitsReceived')}{sumField('nbReceivedHitsFromEnemyIA') + sumField('nbReceivedHitsFromEnemyUser') + sumField('nbReceivedHitsFromPoliceUser')}
{/* Col 3: Bar chart */} {t('session.shotsInSession')}
); })()} {/* FireRange / Challenge / LongRange - KPIs left + Chart/Target right */} {isFireRange && shotDetails.length > 0 && (() => { // Handle both PascalCase (API) and camelCase (TS interface) property names // eslint-disable-next-line @typescript-eslint/no-explicit-any const getVal = (shot: any, ...keys: string[]): number => { for (const k of keys) { if (shot[k] != null) return shot[k] as number; } return 0; }; const hitsOnly = shotDetails.filter(s => getVal(s, 'ReactId', 'reactId') >= 0); const missedCount = shotDetails.filter(s => getVal(s, 'ReactId', 'reactId') < 0).length; const avgPrecisionStr = (() => { if (hitsOnly.length === 0) return '-'; const avg = hitsOnly.reduce((sum, s) => sum + getVal(s, 'HitPrecision', 'hitPrecision'), 0) / hitsOnly.length; return `${(avg * 100).toFixed(1)}%`; })(); const avgReactionStr = (() => { const withReaction = shotDetails.filter(s => getVal(s, 'ReactionTime', 'reactionTime') > 0); if (withReaction.length === 0) return '-'; const avg = withReaction.reduce((sum, s) => sum + getVal(s, 'ReactionTime', 'reactionTime'), 0) / withReaction.length; return `${(avg / 1000).toFixed(3)} s`; })(); // Precision chart data const precisionData = shotDetails.map(s => ({ shot: getVal(s, 'ShotIndex', 'shotIndex'), precision: getVal(s, 'ReactId', 'reactId') >= 0 ? Math.round(getVal(s, 'HitPrecision', 'hitPrecision') * 100) : 0, })); // Reaction time chart data (for Challenge) const reactionData = hitsOnly .filter(s => getVal(s, 'ReactionTime', 'reactionTime') > 0) .map(s => ({ shot: getVal(s, 'ShotIndex', 'shotIndex'), reaction: Math.round(getVal(s, 'ReactionTime', 'reactionTime')), })); // Target hit positions (only shots that hit) const targetShots = hitsOnly.map(s => ({ index: getVal(s, 'ShotIndex', 'shotIndex'), x: getVal(s, 'HitLocationX', 'hitLocationX'), y: getVal(s, 'HitLocationY', 'hitLocationY'), precision: getVal(s, 'HitPrecision', 'hitPrecision'), })); return ( {/* Left: KPI cards vertical */} {isChallenge && ( )} {isChallenge && ( )} {/* Middle: Target (not for Challenge) */} {!isChallenge && ( {t('firerange.targetView')}
)} {/* Right: Chart */} {isChallenge ? t('firerange.reactionChart') : t('firerange.precisionChart')}
{isChallenge ? ( `${v}`} /> [`${value} ms`, t('chart.reactionTime')]} /> ) : ( `${v}`} /> [`${value}%`, t('chart.precision')]} /> )}
); })()} {/* Objectives (hidden for FireRange/Challenge) */} {parsedObjectives && !isFireRange && ( {t('session.objectives')} {Object.entries(parsedObjectives).map(([key, obj]) => { if (!obj || typeof obj !== 'object') return null; const pct = obj.maxScore > 0 ? (obj.score / obj.maxScore) * 100 : 0; return (
{t(`obj.${key}` as TranslationKey)} {obj.score}/{obj.maxScore} {obj.success !== undefined && ( {obj.success ? 'OK' : 'X'} )}
); })}
)} ); } export default SessionDetail;