Compare commits

..

No commits in common. "97b980a05a62779a4c817e4a1024e2f603e0c510" and "bfc63e4847df3b69b1474ae8cdf57cb2dc1cc92a" have entirely different histories.

21 changed files with 369 additions and 660 deletions

View File

@ -18,25 +18,7 @@
"Bash(curl -s -X POST \"http://localhost/proserve/lists/all_sessions.php\" -d \"\")", "Bash(curl -s -X POST \"http://localhost/proserve/lists/all_sessions.php\" -d \"\")",
"Bash(curl -s -X POST \"http://localhost/proserve/user/get.php\" -d \"userId=3\")", "Bash(curl -s -X POST \"http://localhost/proserve/user/get.php\" -d \"userId=3\")",
"Bash(curl -s -X POST \"http://localhost/proserve/stats/userhistory.php\" -d \"userId=3&quickMode=true\")", "Bash(curl -s -X POST \"http://localhost/proserve/stats/userhistory.php\" -d \"userId=3&quickMode=true\")",
"Bash(npx vite build)", "Bash(npx vite build)"
"Bash(find C:ASTERIONGITPS_ProserveReport -type f \\\\\\(-name *.js -o -name *.jsx -o -name *.ts -o -name *.tsx \\\\\\) ! -path */node_modules/*)",
"Bash(find C:ASTERIONGITPS_ProserveReportPS_Report -type f -name *.jsx -o -name *.js -o -name *.tsx)",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''Status:'''', d.get\\(''''status''''\\)\\); stats=d.get\\(''''stats'''',[]\\); print\\(''''Nombre de sessions retournées:'''', len\\(stats\\)\\); [print\\(f'''' ID={s[\"\"id\"\"]} | {s[\"\"scenarioName\"\"]} | type={s[\"\"sessionType\"\"]} | durée={s[\"\"timeToFinish\"\"]}s''''\\) for s in stats]\")",
"Bash(mysql -u root ProserveAPI -e \"SELECT COUNT\\(*\\) as total FROM sessions; SELECT COUNT\\(*\\) as calibration FROM sessions WHERE ScenarioName LIKE ''%Calibration%''; SELECT COUNT\\(*\\) as courtes FROM sessions WHERE timeToFinish <= 10 AND timeToFinish != 0 AND ScenarioName NOT LIKE ''%Calibration%''; SELECT id, ScenarioName, timeToFinish FROM sessions ORDER BY id DESC;\")",
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT COUNT\\(*\\) as total FROM sessions; SELECT COUNT\\(*\\) as calibration FROM sessions WHERE ScenarioName LIKE ''%Calibration%''; SELECT COUNT\\(*\\) as courtes FROM sessions WHERE timeToFinish <= 10 AND timeToFinish != 0 AND ScenarioName NOT LIKE ''%Calibration%''; SELECT id, ScenarioName, timeToFinish FROM sessions ORDER BY id DESC;\")",
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT COUNT\\(*\\) as courtes FROM sessions WHERE timeToFinish <= 10 AND timeToFinish != 0 AND ScenarioName NOT LIKE ''%Calibration%''\")",
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT id, ScenarioName, timeToFinish FROM sessions ORDER BY id DESC\")",
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT SD.id, SD.ScenarioName, SD.timeToFinish FROM sessions SD WHERE SD.ScenarioName NOT LIKE ''%Calibration%'' AND \\(SD.timeToFinish > 10 OR SD.timeToFinish = 0\\) AND SD.id NOT IN \\(SELECT sessionId FROM participates\\) ORDER BY SD.id DESC\")",
"Bash(curl -s -X POST http://localhost/proserve/lists/all_sessions.php -H \"Content-Type: application/x-www-form-urlencoded\" -d \"typeId=-1\")",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); sessions=d.get\\(''''stats'''',[]\\); s=sessions[0] if sessions else {}; print\\(json.dumps\\(s, indent=2\\)\\)\")",
"Bash(curl -s -X POST http://localhost/proserve/stats/get.php -H \"Content-Type: application/x-www-form-urlencoded\" -d \"sessionId=70&sessionType=0\")",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")",
"Bash(curl -s -X POST \"http://localhost/proserve/lists/all_sessions.php\" -d \"typeId=-1\")",
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); print\\(json.dumps\\(data[''''stats''''][0], indent=2\\)\\)\")",
"Bash(curl -s -X POST http://localhost/proserve/lists/all_sessions.php -d typeId=-1)",
"Bash(curl -s -X POST \"http://localhost/proserve/stats/get.php\" -d \"sessionId=73&userId=-1\")",
"Bash(git add:*)",
"Bash(git commit:*)"
] ]
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

143
PS_Report/dist/assets/index-CSeKVt42.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Proserve Report</title> <title>Proserve Report</title>
<script type="module" crossorigin src="/ProserveReport/assets/index-Hi0hQkPy.js"></script> <script type="module" crossorigin src="/ProserveReport/assets/index-CSeKVt42.js"></script>
<link rel="stylesheet" crossorigin href="/ProserveReport/assets/index-BJVnQUvh.css"> <link rel="stylesheet" crossorigin href="/ProserveReport/assets/index-DLRhV40F.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -9,7 +9,7 @@ interface ScoreBadgeProps {
function ScoreBadge({ success, score }: ScoreBadgeProps) { function ScoreBadge({ success, score }: ScoreBadgeProps) {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<Badge bg={success ? 'success' : 'danger'} style={{ fontSize: '1rem', padding: '0.5rem 1rem' }}> <Badge bg={success ? 'success' : 'danger'}>
{score !== undefined && <span className="me-1">{score}</span>} {score !== undefined && <span className="me-1">{score}</span>}
{success ? t('badge.success') : t('badge.failed')} {success ? t('badge.success') : t('badge.failed')}
</Badge> </Badge>

View File

@ -1,80 +0,0 @@
interface Shot {
index: number;
x: number; // 0-1 normalized
y: number; // 0-1 normalized
precision: number;
}
interface TargetVisualizationProps {
shots: Shot[];
variant?: 'human' | 'longRange';
}
const TARGETS = {
human: { image: 'HumanTarget.png', ratio: 0.7183098591549296, maxW: 320 },
longRange: { image: 'longRangeTarget.PNG', ratio: 1, maxW: 480 },
};
function TargetVisualization({ shots, variant = 'human' }: TargetVisualizationProps) {
const { image, ratio, maxW } = TARGETS[variant];
const VB_H = 100;
const VB_W = VB_H * ratio;
return (
<div style={{
width: '100%',
maxWidth: maxW,
margin: '0 auto',
aspectRatio: `${ratio}`,
}}>
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
width="100%"
height="100%"
style={{ display: 'block' }}
>
{/* Background target image — fills the entire viewBox */}
<image
href={`${import.meta.env.BASE_URL}${image}`}
x="0"
y="0"
width={VB_W}
height={VB_H}
preserveAspectRatio="none"
/>
{/* Shot markers */}
{shots.map((shot) => {
const cx = shot.x * VB_W;
const cy = shot.y * VB_H;
return (
<g key={shot.index}>
<circle
cx={cx}
cy={cy}
r={1.2}
fill="#00e5ff"
stroke="#000"
strokeWidth={0.3}
opacity={0.9}
/>
<text
x={cx + 2}
y={cy + 1}
fill="#00e5ff"
fontSize={2.8}
fontWeight="bold"
stroke="#000"
strokeWidth={0.15}
paintOrder="stroke"
>
{shot.index}
</text>
</g>
);
})}
</svg>
</div>
);
}
export default TargetVisualization;

View File

@ -1,60 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { getDebrief } from '../api/client';
import type { Session, DebriefRow } from '../types';
/**
* Computes success/failure for a list of sessions based on debrief data.
* A session is failed if any: civilKilled > 0, policeKilled > 0, or hitsReceived > 0.
* Only fetches debriefs for the given sessions (use with paginated/visible sessions).
* Returns a Map<sessionId, computedSuccess>.
*/
export function useComputedSuccess(sessions: Session[]): Map<number, boolean> {
const [successMap, setSuccessMap] = useState<Map<number, boolean>>(new Map());
const fetchedRef = useRef<Set<number>>(new Set());
useEffect(() => {
if (sessions.length === 0) return;
// Only fetch sessions we haven't already fetched
const toFetch = sessions.filter(s => !fetchedRef.current.has(s.id));
if (toFetch.length === 0) return;
Promise.all(
toFetch.map(async (session) => {
try {
const debrief = await getDebrief(session.id);
return { id: session.id, success: computeSuccess(session.success, debrief) };
} catch {
return { id: session.id, success: session.success };
}
}),
).then((results) => {
setSuccessMap(prev => {
const next = new Map(prev);
for (const r of results) {
next.set(r.id, r.success);
fetchedRef.current.add(r.id);
}
return next;
});
});
}, [sessions]);
return successMap;
}
/** Compute success from debrief rows */
export function computeSuccess(originalSuccess: boolean, debrief: DebriefRow[]): boolean {
const totalCivilKilled = debrief.reduce((s, r) => s + (Number(r.totalCivilKilled) || 0), 0);
const totalPoliceKilled = debrief.reduce((s, r) => s + (Number(r.totalPoliceKilled) || 0), 0);
const totalHitsReceived = debrief.reduce(
(s, r) => s + (Number(r.nbReceivedHitsFromEnemyIA) || 0) + (Number(r.nbReceivedHitsFromEnemyUser) || 0) + (Number(r.nbReceivedHitsFromPoliceUser) || 0),
0,
);
return originalSuccess && totalCivilKilled === 0 && totalPoliceKilled === 0 && totalHitsReceived === 0;
}
/** Helper: get computed success for a single session from the map, fallback to session.success */
export function getComputedSuccess(successMap: Map<number, boolean>, session: Session): boolean {
return successMap.has(session.id) ? successMap.get(session.id)! : session.success;
}

View File

@ -40,13 +40,6 @@ export const translations = {
'session.objectives': { fr: 'Objectifs', en: 'Objectives' }, 'session.objectives': { fr: 'Objectifs', en: 'Objectives' },
'session.hitDistribution': { fr: 'Répartition des impacts', en: 'Hit Distribution' }, 'session.hitDistribution': { fr: 'Répartition des impacts', en: 'Hit Distribution' },
'session.shotDetails': { fr: 'Détail des tirs', en: 'Shot Details' }, 'session.shotDetails': { fr: 'Détail des tirs', en: 'Shot Details' },
'session.global': { fr: 'Global', en: 'Global' },
'session.personalStats': { fr: 'Statistiques personnelles', en: 'Personal Statistics' },
'session.globalStats': { fr: 'Statistiques globales', en: 'Global Statistics' },
'session.friendlyFire': { fr: 'Tirs amis', en: 'Friendly Fire' },
'session.hitsReceived': { fr: 'Tirs reçus', en: 'Hits Received' },
'session.shotsInSession': { fr: 'Tirs durant la session', en: 'Shots In Session' },
'session.missed': { fr: 'Manqués', en: 'Missed' },
// Table headers // Table headers
'table.date': { fr: 'Date', en: 'Date' }, 'table.date': { fr: 'Date', en: 'Date' },
@ -163,17 +156,6 @@ export const translations = {
'badge.failed': { fr: 'Échoué', en: 'Failed' }, 'badge.failed': { fr: 'Échoué', en: 'Failed' },
'badge.killed': { fr: 'Tué', en: 'Killed' }, 'badge.killed': { fr: 'Tué', en: 'Killed' },
// FireRange detail
'firerange.personalStats': { fr: 'Statistiques personnelles', en: 'Personal Statistics' },
'firerange.shotsFired': { fr: 'Tirs effectués', en: 'Shots Fired' },
'firerange.shotsMissed': { fr: 'Tirs manqués', en: 'Missed Shots' },
'firerange.avgPrecision': { fr: 'Précision moyenne', en: 'Average Precision' },
'firerange.precisionChart': { fr: 'Précision des tirs durant la session', en: 'Shots Precision During Session' },
'firerange.targetView': { fr: 'Impacts sur la cible', en: 'Target Hits' },
'firerange.targetsHit': { fr: 'Cibles touchées', en: 'Targets Hit' },
'firerange.avgReaction': { fr: 'Réaction moyenne', en: 'Avg. Reaction Time' },
'firerange.reactionChart': { fr: 'Temps de réaction durant la session', en: 'Reaction Time During Session' },
// Charts // Charts
'chart.sessions': { fr: 'Sessions', en: 'Sessions' }, 'chart.sessions': { fr: 'Sessions', en: 'Sessions' },
'chart.precision': { fr: 'Précision', en: 'Precision' }, 'chart.precision': { fr: 'Précision', en: 'Precision' },

View File

@ -6,7 +6,6 @@ import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table'; import Table from 'react-bootstrap/Table';
import { getAllSessions, getAllUsers } from '../api/client'; import { getAllSessions, getAllUsers } from '../api/client';
import type { Session, User } from '../types'; import type { Session, User } from '../types';
import { useComputedSuccess, getComputedSuccess } from '../hooks/useComputedSuccess';
import StatCard from '../components/StatCard'; import StatCard from '../components/StatCard';
import ScoreBadge from '../components/ScoreBadge'; import ScoreBadge from '../components/ScoreBadge';
import SessionTypeBadge from '../components/SessionTypeBadge'; import SessionTypeBadge from '../components/SessionTypeBadge';
@ -43,14 +42,12 @@ function Dashboard() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
const successMap = useComputedSuccess(sessions);
if (loading) return <LoadingSpinner />; if (loading) return <LoadingSpinner />;
const totalSessions = sessions.length; const totalSessions = sessions.length;
const totalUsers = users.length; const totalUsers = users.length;
const successRate = totalSessions > 0 const successRate = totalSessions > 0
? Math.round((sessions.filter((s) => getComputedSuccess(successMap, s)).length / totalSessions) * 100) ? Math.round((sessions.filter((s) => s.success).length / totalSessions) * 100)
: 0; : 0;
const avgPrecision = users.length > 0 const avgPrecision = users.length > 0
? Math.round(users.reduce((acc, u) => acc + u.avgPrecision, 0) / users.length * 100) / 100 ? Math.round(users.reduce((acc, u) => acc + u.avgPrecision, 0) / users.length * 100) / 100
@ -121,7 +118,7 @@ function Dashboard() {
<td>{session.scenarioName || '-'}</td> <td>{session.scenarioName || '-'}</td>
<td>{session.score}</td> <td>{session.score}</td>
<td>{formatDuration(session.timeToFinish)}</td> <td>{formatDuration(session.timeToFinish)}</td>
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td> <td><ScoreBadge success={session.success} /></td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -3,22 +3,20 @@ import { useParams, Link } from 'react-router-dom';
import Row from 'react-bootstrap/Row'; import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col'; import Col from 'react-bootstrap/Col';
import Card from 'react-bootstrap/Card'; import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table';
import Badge from 'react-bootstrap/Badge'; import Badge from 'react-bootstrap/Badge';
import Form from 'react-bootstrap/Form';
import ProgressBar from 'react-bootstrap/ProgressBar'; import ProgressBar from 'react-bootstrap/ProgressBar';
import { getSession, getUsersInSession, getDebrief, getObjectives, getSessionStats } from '../api/client'; import { getSession, getUsersInSession, getDebrief, getObjectives, getSessionStats } from '../api/client';
import type { Session, User, DebriefRow, Participation, ObjectiveResults, SessionDebriefRow } from '../types'; import type { Session, User, DebriefRow, Participation, ObjectiveResults, SessionDebriefRow } from '../types';
import { SessionType } from '../types'; import { REACT_EVENT_TYPE_LABELS, SessionType } from '../types';
import SessionTypeBadge from '../components/SessionTypeBadge'; import SessionTypeBadge from '../components/SessionTypeBadge';
import ScoreBadge from '../components/ScoreBadge'; import ScoreBadge from '../components/ScoreBadge';
import StatCard from '../components/StatCard'; import StatCard from '../components/StatCard';
import LoadingSpinner from '../components/LoadingSpinner'; import LoadingSpinner from '../components/LoadingSpinner';
import PrintHeader from '../components/PrintHeader'; import PrintHeader from '../components/PrintHeader';
import { Tooltip, ResponsiveContainer, Legend, LineChart, Line, XAxis, YAxis, CartesianGrid, BarChart, Bar } from 'recharts'; import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { useI18n } from '../i18n/context'; import { useI18n } from '../i18n/context';
import type { TranslationKey } from '../i18n/translations'; import type { TranslationKey } from '../i18n/translations';
import TargetVisualization from '../components/TargetVisualization';
import { computeSuccess } from '../hooks/useComputedSuccess';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
@ -32,6 +30,37 @@ 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' }); 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() { function SessionDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -44,7 +73,6 @@ function SessionDetail() {
const [objectives, setObjectives] = useState<Participation | null>(null); const [objectives, setObjectives] = useState<Participation | null>(null);
const [shotDetails, setShotDetails] = useState<SessionDebriefRow[]>([]); const [shotDetails, setShotDetails] = useState<SessionDebriefRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedUserId, setSelectedUserId] = useState<number>(-1);
useEffect(() => { useEffect(() => {
if (!sessionId) return; if (!sessionId) return;
@ -57,15 +85,16 @@ function SessionDetail() {
]) ])
.then(([sessionData, usersData, debriefData, objectivesData]) => { .then(([sessionData, usersData, debriefData, objectivesData]) => {
setSession(sessionData as Session | null); setSession(sessionData as Session | null);
const uList = usersData as User[]; setUsers(usersData as User[]);
setUsers(uList);
if (uList.length === 1) setSelectedUserId(uList[0].id);
setDebrief(debriefData as DebriefRow[]); setDebrief(debriefData as DebriefRow[]);
setObjectives(objectivesData as Participation | null); setObjectives(objectivesData as Participation | null);
// Load shot details for all session types // Load shot details for firerange/challenge types
if (sessionData) { if (sessionData) {
getSessionStats(sessionId, -1, sessionData.sessionTypeAsInt).then(setShotDetails); const st = sessionData.sessionTypeAsInt;
if (st === SessionType.FireRange || st === SessionType.Challenge || st === SessionType.LongRange) {
getSessionStats(sessionId, -1, st).then(setShotDetails);
}
} }
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@ -84,16 +113,23 @@ 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 // Map userId to username
const userMap = new Map(users.map((u) => [u.id, u])); 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 ( return (
<> <>
<PrintHeader subtitle={session.sessionName || session.scenarioName || `Session #${session.id}`} /> <PrintHeader subtitle={session.sessionName || session.scenarioName || `Session #${session.id}`} />
@ -108,315 +144,101 @@ function SessionDetail() {
{/* Session Header */} {/* Session Header */}
<Card className="data-card mb-4"> <Card className="data-card mb-4">
<Card.Body> <Card.Body>
<Row className="align-items-start"> <Row className="align-items-center">
<Col> <Col>
<h3 className="mb-2"> <h3 className="mb-1">
{session.sessionName || session.scenarioName || `Session #${session.id}`} {session.sessionName || session.scenarioName || `Session #${session.id}`}
</h3> </h3>
<div className="d-flex align-items-center gap-3"> <div className="d-flex gap-3 align-items-center flex-wrap">
<SessionTypeBadge typeId={session.sessionTypeAsInt} /> <SessionTypeBadge typeId={session.sessionTypeAsInt} />
<div> <span className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)}</span>
<div className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)} Map: {session.mapName || '-'}</div> <span className="text-muted-custom">Map: {session.mapName || '-'}</span>
<div className="text-muted-custom">Scenario: {session.scenarioName || '-'}</div> <span className="text-muted-custom">Scenario: {session.scenarioName || '-'}</span>
</div>
</div> </div>
</Col> </Col>
{users.length > 0 && ( <Col xs="auto">
<Col xs="auto" style={{ minWidth: 200 }}> <ScoreBadge success={session.success} score={session.score} />
<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> </Col>
</Row> </Row>
</Card.Body> </Card.Body>
</Card> </Card>
{/* Standard sessions: stats left + bar chart right */} {/* KPI Cards */}
{!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 (
<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"> <Row className="mb-4 g-3">
{/* Left: KPI cards vertical */} <Col xs={6} md={2}>
<Col lg={3}> <StatCard title={t('session.score')} value={session.score} color="#4a90d9" />
<Row className="g-3"> </Col>
<Col xs={6} lg={isChallenge ? 6 : 12}> <Col xs={6} md={2}>
<StatCard title={t('session.duration')} value={formatDuration(session.timeToFinish)} color="#9b59b6" /> <StatCard title={t('session.duration')} value={formatDuration(session.timeToFinish)} color="#9b59b6" />
</Col> </Col>
<Col xs={6} lg={isChallenge ? 6 : 12}> <Col xs={6} md={2}>
<StatCard title={t('firerange.shotsFired')} value={shotDetails.length} color="#1abc9c" /> <StatCard title={t('session.enemiesHit')} value={session.nbEnemyHit} color="#27ae60" />
</Col> </Col>
{isChallenge && ( <Col xs={6} md={2}>
<Col xs={6} lg={6}> <StatCard title={t('session.civiliansHit')} value={session.nbCivilsHit} color={session.nbCivilsHit > 0 ? '#e74c3c' : '#27ae60'} />
<StatCard title={t('firerange.targetsHit')} value={hitsOnly.length} color="#4a90d9" />
</Col> </Col>
)} <Col xs={6} md={2}>
<Col xs={6} lg={isChallenge ? 6 : 12}> <StatCard title={t('session.damageTaken')} value={Math.round(session.damageTaken)} color="#f39c12" />
<StatCard title={t('firerange.shotsMissed')} value={missedCount} color="#e74c3c" />
</Col> </Col>
<Col xs={6} lg={isChallenge ? 6 : 12}> <Col xs={6} md={2}>
<StatCard title={t('firerange.avgPrecision')} value={avgPrecisionStr} color="#27ae60" /> <StatCard title={t('session.participants')} value={users.length} color="#1abc9c" />
</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> </Col>
</Row> </Row>
{/* 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>
{/* Objectives (hidden for FireRange/Challenge) */}
{parsedObjectives && !isFireRange && (
<Row className="mb-4 g-3"> <Row className="mb-4 g-3">
<Col md={12}> {/* Objectives */}
<Card className="data-card"> {parsedObjectives && (
<Col md={6}>
<Card className="data-card h-100">
<Card.Body> <Card.Body>
<Card.Title>{t('session.objectives')}</Card.Title> <Card.Title>{t('session.objectives')}</Card.Title>
{Object.entries(parsedObjectives).map(([key, obj]) => { {Object.entries(parsedObjectives).map(([key, obj]) => {
@ -446,7 +268,82 @@ function SessionDetail() {
</Card.Body> </Card.Body>
</Card> </Card>
</Col> </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> </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>
)} )}
</> </>
); );

View File

@ -8,7 +8,6 @@ import Form from 'react-bootstrap/Form';
import Pagination from 'react-bootstrap/Pagination'; import Pagination from 'react-bootstrap/Pagination';
import { getAllSessions } from '../api/client'; import { getAllSessions } from '../api/client';
import type { Session } from '../types'; import type { Session } from '../types';
import { useComputedSuccess, getComputedSuccess } from '../hooks/useComputedSuccess';
import SessionTypeBadge from '../components/SessionTypeBadge'; import SessionTypeBadge from '../components/SessionTypeBadge';
import ScoreBadge from '../components/ScoreBadge'; import ScoreBadge from '../components/ScoreBadge';
import LoadingSpinner from '../components/LoadingSpinner'; import LoadingSpinner from '../components/LoadingSpinner';
@ -51,13 +50,11 @@ function Sessions() {
getAllSessions().then(setSessions).finally(() => setLoading(false)); getAllSessions().then(setSessions).finally(() => setLoading(false));
}, []); }, []);
const successMap = useComputedSuccess(sessions);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let result = sessions; let result = sessions;
if (typeFilter >= 0) result = result.filter((s) => s.sessionTypeAsInt === typeFilter); if (typeFilter >= 0) result = result.filter((s) => s.sessionTypeAsInt === typeFilter);
if (successFilter === 'success') result = result.filter((s) => getComputedSuccess(successMap, s)); if (successFilter === 'success') result = result.filter((s) => s.success);
else if (successFilter === 'failed') result = result.filter((s) => !getComputedSuccess(successMap, s)); else if (successFilter === 'failed') result = result.filter((s) => !s.success);
if (searchQuery.trim()) { if (searchQuery.trim()) {
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
result = result.filter((s) => s.scenarioName.toLowerCase().includes(q) || s.mapName.toLowerCase().includes(q) || s.sessionName.toLowerCase().includes(q)); result = result.filter((s) => s.scenarioName.toLowerCase().includes(q) || s.mapName.toLowerCase().includes(q) || s.sessionName.toLowerCase().includes(q));
@ -67,7 +64,7 @@ function Sessions() {
if (typeof aVal === 'string' && typeof bVal === 'string') return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); if (typeof aVal === 'string' && typeof bVal === 'string') return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
return sortDir === 'asc' ? Number(aVal) - Number(bVal) : Number(bVal) - Number(aVal); return sortDir === 'asc' ? Number(aVal) - Number(bVal) : Number(bVal) - Number(aVal);
}); });
}, [sessions, successMap, typeFilter, successFilter, searchQuery, sortKey, sortDir]); }, [sessions, typeFilter, successFilter, searchQuery, sortKey, sortDir]);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE); const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
@ -158,7 +155,7 @@ function Sessions() {
<td>{session.nbEnemyHit}</td> <td>{session.nbEnemyHit}</td>
<td className={session.nbCivilsHit > 0 ? 'text-danger' : ''}>{session.nbCivilsHit}</td> <td className={session.nbCivilsHit > 0 ? 'text-danger' : ''}>{session.nbCivilsHit}</td>
<td>{formatDuration(session.timeToFinish)}</td> <td>{formatDuration(session.timeToFinish)}</td>
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td> <td><ScoreBadge success={session.success} /></td>
</tr> </tr>
))} ))}
{paginated.length === 0 && ( {paginated.length === 0 && (

View File

@ -6,7 +6,6 @@ import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table'; import Table from 'react-bootstrap/Table';
import { getUser, getSessionsForUser, getUserHistory } from '../api/client'; import { getUser, getSessionsForUser, getUserHistory } from '../api/client';
import type { User, Session, UserGlobalStats } from '../types'; import type { User, Session, UserGlobalStats } from '../types';
import { useComputedSuccess, getComputedSuccess } from '../hooks/useComputedSuccess';
import StatCard from '../components/StatCard'; import StatCard from '../components/StatCard';
import SessionTypeBadge from '../components/SessionTypeBadge'; import SessionTypeBadge from '../components/SessionTypeBadge';
import ScoreBadge from '../components/ScoreBadge'; import ScoreBadge from '../components/ScoreBadge';
@ -39,7 +38,6 @@ function UserDetail() {
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const [history, setHistory] = useState<UserGlobalStats | null>(null); const [history, setHistory] = useState<UserGlobalStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const successMap = useComputedSuccess(sessions);
useEffect(() => { useEffect(() => {
if (!userId) return; if (!userId) return;
@ -231,7 +229,7 @@ function UserDetail() {
<td>{session.scenarioName || '-'}</td> <td>{session.scenarioName || '-'}</td>
<td className="fw-bold">{session.score}</td> <td className="fw-bold">{session.score}</td>
<td>{formatDuration(session.timeToFinish)}</td> <td>{formatDuration(session.timeToFinish)}</td>
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td> <td><ScoreBadge success={session.success} /></td>
</tr> </tr>
))} ))}
{sessions.length === 0 && ( {sessions.length === 0 && (

View File

@ -18,7 +18,7 @@
--bg-hover: #252545; --bg-hover: #252545;
--text-primary: #e0e0e0; --text-primary: #e0e0e0;
--text-secondary: #a0a0b0; --text-secondary: #a0a0b0;
--text-muted: #9090a8; --text-muted: #6c6c80;
--accent-blue: #4a90d9; --accent-blue: #4a90d9;
--accent-orange: #f39c12; --accent-orange: #f39c12;
--accent-green: #27ae60; --accent-green: #27ae60;
@ -233,10 +233,6 @@ h2, h3 {
color: var(--text-primary) !important; color: var(--text-primary) !important;
} }
.form-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23a0a0b0' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
}
.form-label { .form-label {
color: var(--text-secondary) !important; color: var(--text-secondary) !important;
} }

View File

@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/components/loadingspinner.tsx","./src/components/navbar.tsx","./src/components/printheader.tsx","./src/components/scorebadge.tsx","./src/components/sessiontypebadge.tsx","./src/components/statcard.tsx","./src/components/targetvisualization.tsx","./src/components/charts/activitychart.tsx","./src/components/charts/precisionchart.tsx","./src/components/charts/sessionsbytypechart.tsx","./src/hooks/usecomputedsuccess.ts","./src/i18n/context.tsx","./src/i18n/translations.ts","./src/pages/dashboard.tsx","./src/pages/sessiondetail.tsx","./src/pages/sessions.tsx","./src/pages/userdetail.tsx","./src/pages/users.tsx","./src/types/index.ts"],"version":"5.6.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/components/loadingspinner.tsx","./src/components/navbar.tsx","./src/components/printheader.tsx","./src/components/scorebadge.tsx","./src/components/sessiontypebadge.tsx","./src/components/statcard.tsx","./src/components/charts/activitychart.tsx","./src/components/charts/precisionchart.tsx","./src/components/charts/sessionsbytypechart.tsx","./src/i18n/context.tsx","./src/i18n/translations.ts","./src/pages/dashboard.tsx","./src/pages/sessiondetail.tsx","./src/pages/sessions.tsx","./src/pages/userdetail.tsx","./src/pages/users.tsx","./src/types/index.ts"],"version":"5.6.3"}