V1 : session detail redesign, computed success logic, UI improvements

- Redesign session header: title on top, badge + date/map/scenario below, participants aligned
- Add 3-column layout for standard sessions: KPIs | Global+Personal Stats | BarChart
- FireRange/LongRange: per-variant target sizing (human=320px, longRange=480px)
- Challenge: hide target and objectives, show reaction time chart
- Add TargetVisualization component with SVG hit markers
- Compute success/failed from debrief data (civilKilled, policeKilled, hitsReceived)
- Apply computed success across Dashboard, Sessions list, UserDetail, SessionDetail
- Add useComputedSuccess hook for batch debrief loading
- Unified participant combo box in session header with auto-select for single player
- Dark theme: lighter dropdown arrows, brighter muted text, larger ScoreBadge
- Add i18n keys for new stats labels (FR/EN)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 10:52:43 +01:00
parent bfc63e4847
commit 2b9d532627
16 changed files with 644 additions and 223 deletions

View File

@@ -3,20 +3,22 @@ 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 Table from 'react-bootstrap/Table';
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 { REACT_EVENT_TYPE_LABELS, SessionType } 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 { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
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);
@@ -30,37 +32,6 @@ function formatDate(dateStr: string, lang: string): string {
return d.toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-US', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
const HIT_TYPE_COLORS: Record<string, string> = {
'enemy': '#27ae60',
'civilian': '#e74c3c',
'police': '#f39c12',
'object': '#6c757d',
'target': '#4a90d9',
'paperTarget': '#2980b9',
'deadBody': '#95a5a6',
};
/** Maps French REACT_EVENT_TYPE_LABELS values to internal hit-type keys */
const FRENCH_LABEL_TO_HIT_KEY: Record<string, string> = {
'Ennemi': 'enemy',
'Civil': 'civilian',
'Police': 'police',
'Objet': 'object',
'Cible': 'target',
'Cible Papier': 'paperTarget',
'Corps': 'deadBody',
};
/** Maps internal hit-type keys to TranslationKey */
const HIT_KEY_TO_TRANSLATION: Record<string, TranslationKey> = {
'enemy': 'hitType.enemy',
'civilian': 'hitType.civilian',
'police': 'hitType.police',
'object': 'hitType.object',
'target': 'hitType.target',
'paperTarget': 'hitType.paperTarget',
'deadBody': 'hitType.deadBody',
};
function SessionDetail() {
const { id } = useParams<{ id: string }>();
@@ -73,6 +44,7 @@ function SessionDetail() {
const [objectives, setObjectives] = useState<Participation | null>(null);
const [shotDetails, setShotDetails] = useState<SessionDebriefRow[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUserId, setSelectedUserId] = useState<number>(-1);
useEffect(() => {
if (!sessionId) return;
@@ -85,16 +57,15 @@ function SessionDetail() {
])
.then(([sessionData, usersData, debriefData, objectivesData]) => {
setSession(sessionData as Session | null);
setUsers(usersData as User[]);
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 firerange/challenge types
// Load shot details for all session types
if (sessionData) {
const st = sessionData.sessionTypeAsInt;
if (st === SessionType.FireRange || st === SessionType.Challenge || st === SessionType.LongRange) {
getSessionStats(sessionId, -1, st).then(setShotDetails);
}
getSessionStats(sessionId, -1, sessionData.sessionTypeAsInt).then(setShotDetails);
}
})
.finally(() => setLoading(false));
@@ -113,23 +84,16 @@ function SessionDetail() {
}
}
// Build hit distribution data from debrief
const hitDistribution: Record<string, number> = {};
debrief.forEach((row) => {
if (row.nbEnemyHitsByUser > 0) hitDistribution['enemy'] = (hitDistribution['enemy'] || 0) + row.nbEnemyHitsByUser;
if (row.nbCivilHitsByUser > 0) hitDistribution['civilian'] = (hitDistribution['civilian'] || 0) + row.nbCivilHitsByUser;
if (row.nbPoliceHitsByUser > 0) hitDistribution['police'] = (hitDistribution['police'] || 0) + row.nbPoliceHitsByUser;
if (row.nbObjectHitsByUser > 0) hitDistribution['object'] = (hitDistribution['object'] || 0) + row.nbObjectHitsByUser;
});
const hitChartData = Object.entries(hitDistribution).map(([key, value]) => ({
name: t(HIT_KEY_TO_TRANSLATION[key]),
value,
color: HIT_TYPE_COLORS[key] || '#6c757d',
}));
// 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 (
<>
<PrintHeader subtitle={session.sessionName || session.scenarioName || `Session #${session.id}`} />
@@ -144,101 +108,315 @@ function SessionDetail() {
{/* Session Header */}
<Card className="data-card mb-4">
<Card.Body>
<Row className="align-items-center">
<Row className="align-items-start">
<Col>
<h3 className="mb-1">
<h3 className="mb-2">
{session.sessionName || session.scenarioName || `Session #${session.id}`}
</h3>
<div className="d-flex gap-3 align-items-center flex-wrap">
<div className="d-flex align-items-center gap-3">
<SessionTypeBadge typeId={session.sessionTypeAsInt} />
<span className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)}</span>
<span className="text-muted-custom">Map: {session.mapName || '-'}</span>
<span className="text-muted-custom">Scenario: {session.scenarioName || '-'}</span>
<div>
<div className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)} Map: {session.mapName || '-'}</div>
<div className="text-muted-custom">Scenario: {session.scenarioName || '-'}</div>
</div>
</div>
</Col>
<Col xs="auto">
<ScoreBadge success={session.success} score={session.score} />
{users.length > 0 && (
<Col xs="auto" style={{ minWidth: 200 }}>
<h3 className="mb-2">{t('session.participants')}</h3>
<Form.Select
size="sm"
value={selectedUserId}
onChange={e => setSelectedUserId(Number(e.target.value))}
style={{ backgroundColor: '#1a1a2e', color: '#e0e0e0', borderColor: '#333' }}
>
<option value={-1}>{t('session.global')}</option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.firstName && u.lastName ? `${u.firstName} ${u.lastName}` : u.username}
</option>
))}
</Form.Select>
</Col>
)}
<Col className="flex-grow-1" />
<Col xs="auto" className="align-self-center">
<ScoreBadge success={computedSuccess} />
</Col>
</Row>
</Card.Body>
</Card>
{/* KPI Cards */}
<Row className="mb-4 g-3">
<Col xs={6} md={2}>
<StatCard title={t('session.score')} value={session.score} color="#4a90d9" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.duration')} value={formatDuration(session.timeToFinish)} color="#9b59b6" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.enemiesHit')} value={session.nbEnemyHit} color="#27ae60" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.civiliansHit')} value={session.nbCivilsHit} color={session.nbCivilsHit > 0 ? '#e74c3c' : '#27ae60'} />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.damageTaken')} value={Math.round(session.damageTaken)} color="#f39c12" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.participants')} value={users.length} color="#1abc9c" />
</Col>
</Row>
{/* 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;
{/* Participants */}
<Card className="data-card mb-4">
<Card.Body>
<Card.Title>{t('session.participants')}</Card.Title>
<Table hover responsive className="data-table mb-0">
<thead>
<tr>
<th>{t('table.user')}</th>
<th>{t('table.shotsFired')}</th>
<th>{t('table.shotsMissed')}</th>
<th>{t('table.enemiesHit')}</th>
<th>{t('table.civiliansHit')}</th>
<th>{t('table.avgPrecision')}</th>
<th>{t('table.reactionTime')}</th>
<th>{t('table.hitsReceivedIA')}</th>
<th>{t('table.enemiesKilled')}</th>
<th>{t('table.civiliansKilled')}</th>
</tr>
</thead>
<tbody>
{debrief.map((row, i) => {
const user = userMap.get(row.userId);
return (
<tr key={i}>
<td>
<Link to={`/users/${row.userId}`} className="table-link">
{user ? (user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.username) : `User #${row.userId}`}
</Link>
</td>
<td>{row.nbFiredShotsByUser}</td>
<td>{row.nbMissedShotsByUser}</td>
<td>{row.nbEnemyHitsByUser}</td>
<td className={row.nbCivilHitsByUser > 0 ? 'text-danger' : ''}>{row.nbCivilHitsByUser}</td>
<td>{row.averagePrecision != null ? `${(row.averagePrecision * 100).toFixed(1)}%` : '-'}</td>
<td>{row.averageReactionTime != null && row.averageReactionTime > 0 ? `${row.averageReactionTime.toFixed(0)} ms` : '-'}</td>
<td>{row.nbReceivedHitsFromEnemyIA ?? 0}</td>
<td>{row.totalEnemyKilled ?? 0}</td>
<td className={(row.totalCivilKilled ?? 0) > 0 ? 'text-danger' : ''}>{row.totalCivilKilled ?? 0}</td>
</tr>
);
})}
{debrief.length === 0 && (
<tr><td colSpan={10} className="text-center text-muted-custom">{t('noData')}</td></tr>
)}
</tbody>
</Table>
</Card.Body>
</Card>
// 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,
};
});
<Row className="mb-4 g-3">
{/* Objectives */}
{parsedObjectives && (
<Col md={6}>
<Card className="data-card h-100">
return (
<Row className="mb-4 g-3 align-items-stretch">
{/* Col 1: KPIs */}
<Col lg={2} className="d-flex">
<div className="d-flex flex-column justify-content-between flex-grow-1" style={{ gap: '0.5rem' }}>
<Card className="data-card flex-grow-1">
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('session.duration')}</div>
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{formatDuration(session.timeToFinish)}</div>
</Card.Body>
</Card>
<Card className="data-card flex-grow-1">
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('firerange.shotsFired')}</div>
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{sumField('nbFiredShotsByUser')}</div>
</Card.Body>
</Card>
<Card className="data-card flex-grow-1">
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('firerange.shotsMissed')}</div>
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{sumField('nbMissedShotsByUser')}</div>
</Card.Body>
</Card>
<Card className="data-card flex-grow-1">
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('firerange.avgPrecision')}</div>
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{avgPrecision > 0 ? `${(avgPrecision * 100).toFixed(2)}%` : '-'}</div>
</Card.Body>
</Card>
</div>
</Col>
{/* Col 2: Global Stats + Personal Stats */}
<Col lg={3} className="d-flex">
<div className="d-flex flex-column justify-content-between flex-grow-1">
<Card className="data-card mb-2 flex-grow-1">
<Card.Body className="py-2 px-3">
<h6 className="mb-2" style={{ fontSize: '0.95rem' }}>{t('session.globalStats')}</h6>
<table className="w-100" style={{ fontSize: '0.9rem' }}>
<tbody>
<tr><td className="py-1" style={{ color: '#27ae60' }}>{t('stats.enemiesKilled')}</td><td className="text-end fw-bold">{sumField('totalEnemyKilled')}</td></tr>
<tr><td className="py-1" style={{ color: '#e74c3c' }}>{t('stats.civiliansKilled')}</td><td className="text-end fw-bold">{sumField('totalCivilKilled')}</td></tr>
<tr><td className="py-1" style={{ color: '#1abc9c' }}>{t('stats.policeKilled')}</td><td className="text-end fw-bold">{sumField('totalPoliceKilled')}</td></tr>
<tr><td className="py-1" style={{ color: '#f39c12' }}>{t('session.hitsReceived')}</td><td className="text-end fw-bold">{sumField('nbReceivedHitsFromEnemyIA') + sumField('nbReceivedHitsFromEnemyUser') + sumField('nbReceivedHitsFromPoliceUser')}</td></tr>
</tbody>
</table>
</Card.Body>
</Card>
<Card className="data-card flex-grow-1">
<Card.Body className="py-2 px-3">
<h6 className="mb-2" style={{ fontSize: '0.95rem' }}>{t('session.personalStats')}</h6>
<table className="w-100" style={{ fontSize: '0.9rem' }}>
<tbody>
<tr><td className="py-1" style={{ color: '#27ae60' }}>{t('session.enemiesHit')}</td><td className="text-end fw-bold">{sumField('nbEnemyHitsByUser')}</td></tr>
<tr><td className="py-1" style={{ color: '#e74c3c' }}>{t('session.civiliansHit')}</td><td className="text-end fw-bold">{sumField('nbCivilHitsByUser')}</td></tr>
<tr><td className="py-1" style={{ color: '#1abc9c' }}>{t('session.friendlyFire')}</td><td className="text-end fw-bold">{sumField('nbPoliceHitsByUser')}</td></tr>
<tr><td className="py-1" style={{ color: '#f39c12' }}>{t('session.hitsReceived')}</td><td className="text-end fw-bold">{sumField('nbReceivedHitsFromEnemyIA') + sumField('nbReceivedHitsFromEnemyUser') + sumField('nbReceivedHitsFromPoliceUser')}</td></tr>
</tbody>
</table>
</Card.Body>
</Card>
</div>
</Col>
{/* Col 3: Bar chart */}
<Col lg={7} className="d-flex">
<Card className="data-card flex-grow-1">
<Card.Body className="d-flex flex-column">
<Card.Title className="text-center">{t('session.shotsInSession')}</Card.Title>
<div className="d-flex flex-grow-1 align-items-center">
<ResponsiveContainer width="100%" height={350}>
<BarChart data={barData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="name" stroke="#999" tick={false} />
<YAxis stroke="#999" />
<Tooltip contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }} />
<Legend />
<Bar dataKey={t('hitType.enemy')} fill="#ff6b8a" />
<Bar dataKey={t('hitType.civilian')} fill="#f39c12" />
<Bar dataKey={t('hitType.police')} fill="#1abc9c" />
<Bar dataKey={t('session.missed')} fill="#ff9ec4" />
</BarChart>
</ResponsiveContainer>
</div>
</Card.Body>
</Card>
</Col>
</Row>
);
})()}
{/* 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 (
<Row className="mb-4 g-3">
{/* Left: KPI cards vertical */}
<Col lg={3}>
<Row className="g-3">
<Col xs={6} lg={isChallenge ? 6 : 12}>
<StatCard title={t('session.duration')} value={formatDuration(session.timeToFinish)} color="#9b59b6" />
</Col>
<Col xs={6} lg={isChallenge ? 6 : 12}>
<StatCard title={t('firerange.shotsFired')} value={shotDetails.length} color="#1abc9c" />
</Col>
{isChallenge && (
<Col xs={6} lg={6}>
<StatCard title={t('firerange.targetsHit')} value={hitsOnly.length} color="#4a90d9" />
</Col>
)}
<Col xs={6} lg={isChallenge ? 6 : 12}>
<StatCard title={t('firerange.shotsMissed')} value={missedCount} color="#e74c3c" />
</Col>
<Col xs={6} lg={isChallenge ? 6 : 12}>
<StatCard title={t('firerange.avgPrecision')} value={avgPrecisionStr} color="#27ae60" />
</Col>
{isChallenge && (
<Col xs={6} lg={6}>
<StatCard title={t('firerange.avgReaction')} value={avgReactionStr} color="#f39c12" />
</Col>
)}
</Row>
</Col>
{/* Middle: Target (not for Challenge) */}
{!isChallenge && (
<Col lg={4}>
<Card className="data-card h-100">
<Card.Body className="d-flex flex-column">
<Card.Title className="text-center">{t('firerange.targetView')}</Card.Title>
<div className="d-flex flex-grow-1 align-items-center justify-content-center">
<TargetVisualization shots={targetShots} variant={session.sessionTypeAsInt === SessionType.LongRange ? 'longRange' : 'human'} />
</div>
</Card.Body>
</Card>
</Col>
)}
{/* Right: Chart */}
<Col lg={isChallenge ? 9 : 5}>
<Card className="data-card h-100">
<Card.Body className="d-flex flex-column">
<Card.Title className="text-center">
{isChallenge ? t('firerange.reactionChart') : t('firerange.precisionChart')}
</Card.Title>
<div className="d-flex flex-grow-1 align-items-center">
{isChallenge ? (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={reactionData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="shot" stroke="#999" />
<YAxis stroke="#999" width={50} tickFormatter={v => `${v}`} />
<Tooltip
contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }}
formatter={(value: number) => [`${value} ms`, t('chart.reactionTime')]}
/>
<Line
type="linear"
dataKey="reaction"
stroke="#f39c12"
strokeWidth={2}
dot={{ fill: '#f39c12', r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={precisionData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="shot" stroke="#999" />
<YAxis domain={[0, 100]} stroke="#999" width={50} tickFormatter={v => `${v}`} />
<Tooltip
contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }}
formatter={(value: number) => [`${value}%`, t('chart.precision')]}
/>
<Line
type="linear"
dataKey="precision"
stroke="#ff6b8a"
strokeWidth={2}
dot={{ fill: '#ff6b8a', r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</Card.Body>
</Card>
</Col>
</Row>
);
})()}
{/* Objectives (hidden for FireRange/Challenge) */}
{parsedObjectives && !isFireRange && (
<Row className="mb-4 g-3">
<Col md={12}>
<Card className="data-card">
<Card.Body>
<Card.Title>{t('session.objectives')}</Card.Title>
{Object.entries(parsedObjectives).map(([key, obj]) => {
@@ -268,82 +446,7 @@ function SessionDetail() {
</Card.Body>
</Card>
</Col>
)}
{/* Hit Distribution Chart */}
{hitChartData.length > 0 && (
<Col md={parsedObjectives ? 6 : 12}>
<Card className="chart-card h-100">
<Card.Body>
<Card.Title>{t('session.hitDistribution')}</Card.Title>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={hitChartData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
{hitChartData.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }} />
<Legend />
</PieChart>
</ResponsiveContainer>
</Card.Body>
</Card>
</Col>
)}
</Row>
{/* Shot Details for FireRange/Challenge */}
{shotDetails.length > 0 && (
<Card className="data-card mb-4">
<Card.Body>
<Card.Title>{t('session.shotDetails')}</Card.Title>
<Table hover responsive className="data-table mb-0" size="sm">
<thead>
<tr>
<th>{t('shot.index')}</th>
<th>{t('shot.shooter')}</th>
<th>{t('shot.impactType')}</th>
<th>{t('shot.target')}</th>
<th>{t('shot.boneZone')}</th>
<th>{t('shot.precision')}</th>
<th>{t('shot.distance')}</th>
<th>{t('shot.reaction')}</th>
<th>{t('shot.killed')}</th>
<th>{t('shot.time')}</th>
</tr>
</thead>
<tbody>
{shotDetails.map((shot, i) => {
const frenchLabel = REACT_EVENT_TYPE_LABELS[shot.reactTypeId] || '';
const hitKey = FRENCH_LABEL_TO_HIT_KEY[frenchLabel] || '';
const translatedLabel = hitKey && HIT_KEY_TO_TRANSLATION[hitKey]
? t(HIT_KEY_TO_TRANSLATION[hitKey])
: shot.reactType || '-';
const badgeColor = HIT_TYPE_COLORS[hitKey] || '#6c757d';
return (
<tr key={i}>
<td>{shot.shotIndex}</td>
<td>{shot.shooterName || `#${shot.shooterId}`}</td>
<td>
<Badge bg="secondary" style={{ backgroundColor: badgeColor }}>
{translatedLabel}
</Badge>
</td>
<td>{shot.targetName || shot.targetUserName || '-'}</td>
<td>{shot.targetBoneName || shot.hitLocationTag || '-'}</td>
<td>{shot.hitPrecision != null ? `${(shot.hitPrecision * 100).toFixed(1)}%` : '-'}</td>
<td>{shot.hitTargetDistance != null && shot.hitTargetDistance > 0 ? `${shot.hitTargetDistance.toFixed(1)}m` : '-'}</td>
<td>{shot.reactionTime != null && shot.reactionTime > 0 ? `${shot.reactionTime.toFixed(0)}ms` : '-'}</td>
<td>{shot.targetKilled ? <Badge bg="danger">{t('badge.killed')}</Badge> : '-'}</td>
<td>{shot.timeStamp != null ? `${shot.timeStamp.toFixed(1)}s` : '-'}</td>
</tr>
);
})}
</tbody>
</Table>
</Card.Body>
</Card>
</Row>
)}
</>
);