Compare commits
3 Commits
bfc63e4847
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 68cea1eb4d | |||
| 97b980a05a | |||
| 2b9d532627 |
@@ -18,7 +18,25 @@
|
|||||||
"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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
FireRange.jpg
Normal file
BIN
FireRange.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
Nouveau dossier/other.png
Normal file
BIN
Nouveau dossier/other.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
BIN
PS_Report/dist/HumanTarget.PNG
vendored
Normal file
BIN
PS_Report/dist/HumanTarget.PNG
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
File diff suppressed because one or more lines are too long
143
PS_Report/dist/assets/index-CSeKVt42.js
vendored
143
PS_Report/dist/assets/index-CSeKVt42.js
vendored
File diff suppressed because one or more lines are too long
143
PS_Report/dist/assets/index-Hi0hQkPy.js
vendored
Normal file
143
PS_Report/dist/assets/index-Hi0hQkPy.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
PS_Report/dist/index.html
vendored
4
PS_Report/dist/index.html
vendored
@@ -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-CSeKVt42.js"></script>
|
<script type="module" crossorigin src="/ProserveReport/assets/index-Hi0hQkPy.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/ProserveReport/assets/index-DLRhV40F.css">
|
<link rel="stylesheet" crossorigin href="/ProserveReport/assets/index-BJVnQUvh.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
PS_Report/dist/longRangeTarget.PNG
vendored
Normal file
BIN
PS_Report/dist/longRangeTarget.PNG
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
BIN
PS_Report/public/HumanTarget.PNG
Normal file
BIN
PS_Report/public/HumanTarget.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
BIN
PS_Report/public/longRangeTarget.PNG
Normal file
BIN
PS_Report/public/longRangeTarget.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
@@ -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'}>
|
<Badge bg={success ? 'success' : 'danger'} style={{ fontSize: '1rem', padding: '0.5rem 1rem' }}>
|
||||||
{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>
|
||||||
|
|||||||
80
PS_Report/src/components/TargetVisualization.tsx
Normal file
80
PS_Report/src/components/TargetVisualization.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
60
PS_Report/src/hooks/useComputedSuccess.ts
Normal file
60
PS_Report/src/hooks/useComputedSuccess.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -40,6 +40,13 @@ 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' },
|
||||||
@@ -156,6 +163,17 @@ 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' },
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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';
|
||||||
@@ -42,12 +43,14 @@ 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) => s.success).length / totalSessions) * 100)
|
? Math.round((sessions.filter((s) => getComputedSuccess(successMap, s)).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
|
||||||
@@ -118,7 +121,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={session.success} /></td>
|
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -3,20 +3,22 @@ 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 { REACT_EVENT_TYPE_LABELS, SessionType } from '../types';
|
import { 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 { 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 { 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);
|
||||||
@@ -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' });
|
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 }>();
|
||||||
@@ -73,6 +44,7 @@ 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;
|
||||||
@@ -85,16 +57,15 @@ function SessionDetail() {
|
|||||||
])
|
])
|
||||||
.then(([sessionData, usersData, debriefData, objectivesData]) => {
|
.then(([sessionData, usersData, debriefData, objectivesData]) => {
|
||||||
setSession(sessionData as Session | null);
|
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[]);
|
setDebrief(debriefData as DebriefRow[]);
|
||||||
setObjectives(objectivesData as Participation | null);
|
setObjectives(objectivesData as Participation | null);
|
||||||
|
|
||||||
// Load shot details for firerange/challenge types
|
// Load shot details for all session types
|
||||||
if (sessionData) {
|
if (sessionData) {
|
||||||
const st = sessionData.sessionTypeAsInt;
|
getSessionStats(sessionId, -1, sessionData.sessionTypeAsInt).then(setShotDetails);
|
||||||
if (st === SessionType.FireRange || st === SessionType.Challenge || st === SessionType.LongRange) {
|
|
||||||
getSessionStats(sessionId, -1, st).then(setShotDetails);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.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
|
// 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}`} />
|
||||||
@@ -144,101 +108,315 @@ 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-center">
|
<Row className="align-items-start">
|
||||||
<Col>
|
<Col>
|
||||||
<h3 className="mb-1">
|
<h3 className="mb-2">
|
||||||
{session.sessionName || session.scenarioName || `Session #${session.id}`}
|
{session.sessionName || session.scenarioName || `Session #${session.id}`}
|
||||||
</h3>
|
</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} />
|
<SessionTypeBadge typeId={session.sessionTypeAsInt} />
|
||||||
<span className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)}</span>
|
<div>
|
||||||
<span className="text-muted-custom">Map: {session.mapName || '-'}</span>
|
<div className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)} Map: {session.mapName || '-'}</div>
|
||||||
<span className="text-muted-custom">Scenario: {session.scenarioName || '-'}</span>
|
<div className="text-muted-custom">Scenario: {session.scenarioName || '-'}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs="auto">
|
{users.length > 0 && (
|
||||||
<ScoreBadge success={session.success} score={session.score} />
|
<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>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* Standard sessions: stats left + bar chart right */}
|
||||||
<Row className="mb-4 g-3">
|
{!isFireRange && (() => {
|
||||||
<Col xs={6} md={2}>
|
// Compute filtered debrief stats
|
||||||
<StatCard title={t('session.score')} value={session.score} color="#4a90d9" />
|
const filteredDebrief = selectedUserId === -1
|
||||||
</Col>
|
? debrief
|
||||||
<Col xs={6} md={2}>
|
: debrief.filter(r => r.userId === selectedUserId);
|
||||||
<StatCard title={t('session.duration')} value={formatDuration(session.timeToFinish)} color="#9b59b6" />
|
const sumField = (field: keyof DebriefRow) =>
|
||||||
</Col>
|
filteredDebrief.reduce((sum, r) => sum + (Number(r[field]) || 0), 0);
|
||||||
<Col xs={6} md={2}>
|
const avgPrecision = filteredDebrief.length > 0
|
||||||
<StatCard title={t('session.enemiesHit')} value={session.nbEnemyHit} color="#27ae60" />
|
? filteredDebrief.reduce((sum, r) => sum + (r.averagePrecision || 0), 0) / filteredDebrief.length
|
||||||
</Col>
|
: 0;
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Participants */}
|
// Bar chart data: per participant
|
||||||
<Card className="data-card mb-4">
|
const barData = debrief.map(row => {
|
||||||
<Card.Body>
|
const user = userMap.get(row.userId);
|
||||||
<Card.Title>{t('session.participants')}</Card.Title>
|
const name = user ? (user.firstName || user.username) : `#${row.userId}`;
|
||||||
<Table hover responsive className="data-table mb-0">
|
return {
|
||||||
<thead>
|
name,
|
||||||
<tr>
|
[t('hitType.enemy')]: row.nbEnemyHitsByUser,
|
||||||
<th>{t('table.user')}</th>
|
[t('hitType.civilian')]: row.nbCivilHitsByUser,
|
||||||
<th>{t('table.shotsFired')}</th>
|
[t('hitType.police')]: row.nbPoliceHitsByUser,
|
||||||
<th>{t('table.shotsMissed')}</th>
|
[t('session.missed')]: row.nbMissedShotsByUser,
|
||||||
<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>
|
|
||||||
|
|
||||||
<Row className="mb-4 g-3">
|
return (
|
||||||
{/* Objectives */}
|
<Row className="mb-4 g-3 align-items-stretch">
|
||||||
{parsedObjectives && (
|
{/* Col 1: KPIs */}
|
||||||
<Col md={6}>
|
<Col lg={2} className="d-flex">
|
||||||
<Card className="data-card h-100">
|
<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.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]) => {
|
||||||
@@ -268,82 +446,7 @@ function SessionDetail() {
|
|||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
</Row>
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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';
|
||||||
@@ -50,11 +51,13 @@ 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) => s.success);
|
if (successFilter === 'success') result = result.filter((s) => getComputedSuccess(successMap, s));
|
||||||
else if (successFilter === 'failed') result = result.filter((s) => !s.success);
|
else if (successFilter === 'failed') result = result.filter((s) => !getComputedSuccess(successMap, s));
|
||||||
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));
|
||||||
@@ -64,7 +67,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, typeFilter, successFilter, searchQuery, sortKey, sortDir]);
|
}, [sessions, successMap, 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);
|
||||||
@@ -155,7 +158,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={session.success} /></td>
|
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{paginated.length === 0 && (
|
{paginated.length === 0 && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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';
|
||||||
@@ -38,6 +39,7 @@ 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;
|
||||||
@@ -229,7 +231,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={session.success} /></td>
|
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{sessions.length === 0 && (
|
{sessions.length === 0 && (
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
--bg-hover: #252545;
|
--bg-hover: #252545;
|
||||||
--text-primary: #e0e0e0;
|
--text-primary: #e0e0e0;
|
||||||
--text-secondary: #a0a0b0;
|
--text-secondary: #a0a0b0;
|
||||||
--text-muted: #6c6c80;
|
--text-muted: #9090a8;
|
||||||
--accent-blue: #4a90d9;
|
--accent-blue: #4a90d9;
|
||||||
--accent-orange: #f39c12;
|
--accent-orange: #f39c12;
|
||||||
--accent-green: #27ae60;
|
--accent-green: #27ae60;
|
||||||
@@ -233,6 +233,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/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"}
|
{"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"}
|
||||||
3
ProserveAPI/htdocs/proserve/cache/.gitignore
vendored
Normal file
3
ProserveAPI/htdocs/proserve/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
!.htaccess
|
||||||
4
ProserveAPI/htdocs/proserve/cache/.htaccess
vendored
Normal file
4
ProserveAPI/htdocs/proserve/cache/.htaccess
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Options -Indexes
|
||||||
|
<FilesMatch "\.json$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
109
ProserveAPI/htdocs/proserve/config/cache.php
Normal file
109
ProserveAPI/htdocs/proserve/config/cache.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
// Cache disque pour resultats lourds et immuables (debrief, userhistory, get session).
|
||||||
|
// Chaque session a son propre sous-dossier cache/sess_<id>/ : invalider une session
|
||||||
|
// se reduit a supprimer le dossier, sans risque de match partiel sur d'autres ids.
|
||||||
|
// Toutes les fonctions sont silencieuses en cas d'echec (best-effort, jamais bloquant).
|
||||||
|
|
||||||
|
if (!defined('PROSERVE_CACHE_DIR'))
|
||||||
|
define('PROSERVE_CACHE_DIR', __DIR__ . '/../cache');
|
||||||
|
|
||||||
|
function cache_session_dir($sessionId)
|
||||||
|
{
|
||||||
|
return PROSERVE_CACHE_DIR . '/sess_' . intval($sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cache_path($sessionId, $key)
|
||||||
|
{
|
||||||
|
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', $key);
|
||||||
|
return cache_session_dir($sessionId) . '/' . $safe . '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cache_get($sessionId, $key)
|
||||||
|
{
|
||||||
|
if (intval($sessionId) <= 0) return null;
|
||||||
|
$path = cache_path($sessionId, $key);
|
||||||
|
if (!file_exists($path)) return null;
|
||||||
|
$data = @file_get_contents($path);
|
||||||
|
if ($data === false || $data === '') return null;
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cache_put($sessionId, $key, $jsonString)
|
||||||
|
{
|
||||||
|
if (intval($sessionId) <= 0) return;
|
||||||
|
$dir = cache_session_dir($sessionId);
|
||||||
|
if (!is_dir($dir)) @mkdir($dir, 0755, true);
|
||||||
|
@file_put_contents(cache_path($sessionId, $key), $jsonString, LOCK_EX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cache_invalidate_session($sessionId)
|
||||||
|
{
|
||||||
|
$sid = intval($sessionId);
|
||||||
|
if ($sid <= 0) return;
|
||||||
|
$dir = cache_session_dir($sid);
|
||||||
|
if (!is_dir($dir)) return;
|
||||||
|
$files = glob($dir . '/*');
|
||||||
|
if (is_array($files)) {
|
||||||
|
foreach ($files as $f) @unlink($f);
|
||||||
|
}
|
||||||
|
@rmdir($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// DB-level cache for the materialized sessiondebriefs view (table session_debriefs_cache)
|
||||||
|
// Populated by stored procedure rebuild_debrief_cache(p_session_id) created in migration 1.7.0.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Marque le cache DB d'une session comme stale (delete des lignes).
|
||||||
|
// Appele depuis les endpoints d'evenements (storetriggerevent, storereactevent)
|
||||||
|
// pour amortir le cout : la reconstruction se fait paresseusement au prochain
|
||||||
|
// read via ensure_session_debriefs_cached_db, ou explicitement via rebuild...
|
||||||
|
function invalidate_session_debrief_cache_db($db, $sessionId)
|
||||||
|
{
|
||||||
|
$sid = intval($sessionId);
|
||||||
|
if ($sid <= 0 || $db === null) return;
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("DELETE FROM session_debriefs_cache WHERE SessionId = ?");
|
||||||
|
$stmt->execute([$sid]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// best-effort, ne jamais bloquer la requete metier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruit immediatement le cache DB d'une session (DELETE + INSERT via la proc).
|
||||||
|
// Appele depuis session/stop, session/userleave, session/updateobjectives.
|
||||||
|
function rebuild_session_debrief_cache_db($db, $sessionId)
|
||||||
|
{
|
||||||
|
$sid = intval($sessionId);
|
||||||
|
if ($sid <= 0 || $db === null) return;
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("CALL rebuild_debrief_cache(?)");
|
||||||
|
$stmt->execute([$sid]);
|
||||||
|
$stmt->closeCursor();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garantit que le cache DB contient au moins une ligne pour la session.
|
||||||
|
// Appele avant tout SELECT sur sessiondebriefs cote stats_object.php.
|
||||||
|
// Si le cache a ete invalide par un evenement et n'a pas encore ete reconstruit,
|
||||||
|
// cette fonction le reconstruit a la demande.
|
||||||
|
function ensure_session_debriefs_cached_db($db, $sessionId)
|
||||||
|
{
|
||||||
|
$sid = intval($sessionId);
|
||||||
|
if ($sid <= 0 || $db === null) return;
|
||||||
|
try {
|
||||||
|
$stmt = $db->prepare("SELECT 1 FROM session_debriefs_cache WHERE SessionId = ? LIMIT 1");
|
||||||
|
$stmt->execute([$sid]);
|
||||||
|
$exists = $stmt->fetchColumn();
|
||||||
|
$stmt->closeCursor();
|
||||||
|
if ($exists === false) {
|
||||||
|
rebuild_session_debrief_cache_db($db, $sid);
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
// si la table n'existe pas (migration 1.7.0 pas encore appliquee),
|
||||||
|
// on ne fait rien : le code legacy continue de marcher via la vue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -24,11 +24,11 @@ define("SESSIONDEBRIEFS_VIEW_NAME", "sessiondebriefs");
|
|||||||
define("SESSIONDEBRIEFS_VIEW_QUERY_SELECT",
|
define("SESSIONDEBRIEFS_VIEW_QUERY_SELECT",
|
||||||
"SELECT S.id AS SessionId, S.sessionType AS SessionTypeId, ST.displayName AS SessionType, S.sessionName AS SessionName, S.sessionDate AS SessionDate, " .
|
"SELECT S.id AS SessionId, S.sessionType AS SessionTypeId, ST.displayName AS SessionType, S.sessionName AS SessionName, S.sessionDate AS SessionDate, " .
|
||||||
"S.mapName AS MapName, S.scenarioName AS ScenarioName, S.success AS SessionSuccessful, S.timeToFinish AS SessionDuration, " .
|
"S.mapName AS MapName, S.scenarioName AS ScenarioName, S.success AS SessionSuccessful, S.timeToFinish AS SessionDuration, " .
|
||||||
"COALESCE(T.type, -1) AS TriggerTypeId, COALESCE(TT.displayName, '') AS TriggerType, (SELECT IFNULL(T.srcUserId, PS.userId)) AS ShooterId, US.username AS ShooterName, " .
|
"COALESCE(T.type, -1) AS TriggerTypeId, COALESCE(TT.displayName, '') AS TriggerType, IFNULL(T.srcUserId, PS.userId) AS ShooterId, US.username AS ShooterName, " .
|
||||||
"(SELECT IFNULL(PS.role, 3)) AS ShooterRoleId, (SELECT R.displayName FROM userroles R WHERE R.id = ShooterRoleId) AS ShooterRole, " .
|
"IFNULL(PS.role, 3) AS ShooterRoleId, URS.displayName AS ShooterRole, " .
|
||||||
"COALESCE(T.indexCount, -1) AS ShotIndex, COALESCE(R.id, -1) AS ReactId, COALESCE(R.reactMode, 2) AS ReactModeId, COALESCE(RM.displayName, '') AS ReactMode, " .
|
"COALESCE(T.indexCount, -1) AS ShotIndex, COALESCE(R.id, -1) AS ReactId, COALESCE(R.reactMode, 2) AS ReactModeId, COALESCE(RM.displayName, '') AS ReactMode, " .
|
||||||
"COALESCE(R.reactType, -1) AS ReactTypeId, COALESCE(RT.displayName, '') AS ReactType, COALESCE(R.hitUserId, -1) AS TargetUserId, " .
|
"COALESCE(R.reactType, -1) AS ReactTypeId, COALESCE(RT.displayName, '') AS ReactType, COALESCE(R.hitUserId, -1) AS TargetUserId, " .
|
||||||
"COALESCE(UH.username, '') AS TargetUserName, (SELECT IFNULL(PH.role, 3)) AS TargetRoleId, (SELECT R.displayName FROM userroles R WHERE R.id = TargetRoleId) AS TargetRole, " .
|
"COALESCE(UH.username, '') AS TargetUserName, IFNULL(PH.role, 3) AS TargetRoleId, URT.displayName AS TargetRole, " .
|
||||||
"COALESCE(R.hitTargetName, '') AS TargetName, COALESCE(R.hitBoneName, '') AS TargetBoneName, COALESCE(R.targetKilled, 0) AS TargetKilled, " .
|
"COALESCE(R.hitTargetName, '') AS TargetName, COALESCE(R.hitBoneName, '') AS TargetBoneName, COALESCE(R.targetKilled, 0) AS TargetKilled, " .
|
||||||
"COALESCE(R.objectHitLocationX, 0) AS HitLocationX, COALESCE(R.objectHitLocationY, 0) AS HitLocationY, COALESCE(R.objectHitTagLocation, '') AS HitLocationTag, " .
|
"COALESCE(R.objectHitLocationX, 0) AS HitLocationX, COALESCE(R.objectHitLocationY, 0) AS HitLocationY, COALESCE(R.objectHitTagLocation, '') AS HitLocationTag, " .
|
||||||
"COALESCE(R.hitPrecision, 0) AS HitPrecision, COALESCE(R.distance, 0) AS HitTargetDistance, COALESCE(R.reactTime, 0) AS ReactionTime, " .
|
"COALESCE(R.hitPrecision, 0) AS HitPrecision, COALESCE(R.distance, 0) AS HitTargetDistance, COALESCE(R.reactTime, 0) AS ReactionTime, " .
|
||||||
@@ -42,7 +42,9 @@ define ("SESSIONDEBRIEFS_VIEW_QUERY_FROM",
|
|||||||
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " R ON ( T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId ) " .
|
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " R ON ( T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId ) " .
|
||||||
"LEFT JOIN " . USERS_TABLE_NAME . " UH ON (UH.id = R.hitUserId) LEFT JOIN " . USERS_TABLE_NAME . " US ON (US.id = T.srcUserId OR US.id = PS.userId ) " .
|
"LEFT JOIN " . USERS_TABLE_NAME . " UH ON (UH.id = R.hitUserId) LEFT JOIN " . USERS_TABLE_NAME . " US ON (US.id = T.srcUserId OR US.id = PS.userId ) " .
|
||||||
"LEFT JOIN " . REACTEVENTTYPES_TABLE_NAME . " RT ON (RT.id = R.reactType) LEFT JOIN " . REACTEVENTMODES_TABLE_NAME . " RM ON (RM.id = COALESCE(R.reactMode, 2)) " .
|
"LEFT JOIN " . REACTEVENTTYPES_TABLE_NAME . " RT ON (RT.id = R.reactType) LEFT JOIN " . REACTEVENTMODES_TABLE_NAME . " RM ON (RM.id = COALESCE(R.reactMode, 2)) " .
|
||||||
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RK ON (R.id = RK.id AND RK.targetKilled = 1)");
|
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RK ON (R.id = RK.id AND RK.targetKilled = 1) " .
|
||||||
|
"LEFT JOIN " . USERROLES_TABLE_NAME . " URS ON (URS.id = IFNULL(PS.role, 3)) " .
|
||||||
|
"LEFT JOIN " . USERROLES_TABLE_NAME . " URT ON (URT.id = IFNULL(PH.role, 3))");
|
||||||
|
|
||||||
// GROUP BY clause
|
// GROUP BY clause
|
||||||
define("SESSIONDEBRIEFS_VIEW_QUERY_GROUPBY", " GROUP BY SessionId,ShooterId,ShotIndex,TargetName");
|
define("SESSIONDEBRIEFS_VIEW_QUERY_GROUPBY", " GROUP BY SessionId,ShooterId,ShotIndex,TargetName");
|
||||||
|
|||||||
@@ -8,21 +8,33 @@ class Database
|
|||||||
private $password = ""; //Password associated with username
|
private $password = ""; //Password associated with username
|
||||||
public $conn;
|
public $conn;
|
||||||
|
|
||||||
|
// shared connection re-used across endpoint includes within a single request,
|
||||||
|
// and (with PDO::ATTR_PERSISTENT) across requests within an Apache child process.
|
||||||
|
private static $sharedConn = null;
|
||||||
|
|
||||||
// get the database connection
|
// get the database connection
|
||||||
public function getConnection()
|
public function getConnection()
|
||||||
{
|
{
|
||||||
$this->conn = null;
|
if (self::$sharedConn === null)
|
||||||
try
|
{
|
||||||
{
|
try
|
||||||
$this->conn = new PDO("mysql:host=" . $this->host . ";dbname=" . $this->db_name, $this->username, $this->password);
|
{
|
||||||
$this->conn->exec("set names utf8");
|
self::$sharedConn = new PDO(
|
||||||
|
"mysql:host=" . $this->host . ";dbname=" . $this->db_name,
|
||||||
|
$this->username,
|
||||||
|
$this->password,
|
||||||
|
[PDO::ATTR_PERSISTENT => true]
|
||||||
|
);
|
||||||
|
self::$sharedConn->exec("set names utf8");
|
||||||
|
}
|
||||||
|
catch(PDOException $exception)
|
||||||
|
{
|
||||||
|
echo "Connection error: " . $exception->getMessage();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch(PDOException $exception)
|
$this->conn = self::$sharedConn;
|
||||||
{
|
return self::$sharedConn;
|
||||||
echo "Connection error: " . $exception->getMessage();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->conn;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -13,6 +13,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|||||||
|
|
||||||
// include database and object files
|
// include database and object files
|
||||||
include_once '../config/database.php';
|
include_once '../config/database.php';
|
||||||
|
include_once '../config/cache.php';
|
||||||
|
|
||||||
// get database connection
|
// get database connection
|
||||||
$database = new Database();
|
$database = new Database();
|
||||||
|
|||||||
106
ProserveAPI/htdocs/proserve/debug/benchmark.php
Normal file
106
ProserveAPI/htdocs/proserve/debug/benchmark.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
// Mesure de latence des endpoints lourds de l'API ProserveAPI.
|
||||||
|
// A lancer AVANT et APRES chaque palier d'optimisation pour avoir des chiffres comparatifs.
|
||||||
|
// Usage : POST sur ce fichier avec optionnellement userId et sessionId.
|
||||||
|
// curl -X POST -d "userId=1&sessionId=42" http://localhost/proserve/debug/benchmark.php
|
||||||
|
// ou ouvrir directement dans le navigateur (lance avec userId=-1 et sessionId=-1, certains endpoints retourneront vide mais la mesure du round-trip reste valide).
|
||||||
|
|
||||||
|
include_once '../config/init.php';
|
||||||
|
|
||||||
|
$userId = isset($_POST['userId']) ? intval($_POST['userId']) : (isset($_GET['userId']) ? intval($_GET['userId']) : -1);
|
||||||
|
$sessionId = isset($_POST['sessionId']) ? intval($_POST['sessionId']) : (isset($_GET['sessionId']) ? intval($_GET['sessionId']) : -1);
|
||||||
|
$iterations = isset($_POST['iterations']) ? max(1, intval($_POST['iterations'])) : (isset($_GET['iterations']) ? max(1, intval($_GET['iterations'])) : 5);
|
||||||
|
|
||||||
|
// si pas de sessionId fourni, prendre la plus recente pour avoir des donnees realistes
|
||||||
|
if ($sessionId <= 0) {
|
||||||
|
$stmt = $db->query("SELECT id FROM sessions ORDER BY id DESC LIMIT 1");
|
||||||
|
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
|
||||||
|
if ($row) $sessionId = intval($row['id']);
|
||||||
|
}
|
||||||
|
if ($userId <= 0) {
|
||||||
|
$stmt = $db->query("SELECT userId FROM participates WHERE sessionId=" . intval($sessionId) . " LIMIT 1");
|
||||||
|
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
|
||||||
|
if ($row) $userId = intval($row['userId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolution de l'URL de base (meme host et meme path)
|
||||||
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$base = $scheme . '://' . $host . rtrim(dirname(dirname($_SERVER['SCRIPT_NAME'])), '/\\');
|
||||||
|
|
||||||
|
$endpoints = [
|
||||||
|
'session/debrief' => ['sessionId' => $sessionId, 'userId' => $userId],
|
||||||
|
'session/get' => ['sessionId' => $sessionId],
|
||||||
|
'stats/userhistory' => ['userId' => $userId, 'sessionId' => $sessionId, 'quickMode' => 'false'],
|
||||||
|
'stats/get' => ['userId' => $userId, 'sessionId' => $sessionId],
|
||||||
|
'lists/sessions_for_user' => ['userId' => $userId],
|
||||||
|
'lists/users_in_session' => ['sessionId' => $sessionId],
|
||||||
|
];
|
||||||
|
|
||||||
|
function call_endpoint($url, $params)
|
||||||
|
{
|
||||||
|
// ext-curl est dispo dans XAMPP par defaut et bcp plus rapide que
|
||||||
|
// file_get_contents+stream_context_create sur Windows (qui ajoute ~10ms par appel).
|
||||||
|
static $ch = null;
|
||||||
|
if ($ch === null) {
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_TIMEOUT => 60,
|
||||||
|
CURLOPT_FORBID_REUSE => false,
|
||||||
|
CURLOPT_FRESH_CONNECT => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
|
||||||
|
|
||||||
|
$start = microtime(true);
|
||||||
|
$resp = curl_exec($ch);
|
||||||
|
$elapsed_ms = (microtime(true) - $start) * 1000;
|
||||||
|
$err = curl_errno($ch);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ms' => round($elapsed_ms, 2),
|
||||||
|
'bytes' => $resp === false ? 0 : strlen($resp),
|
||||||
|
'ok' => $err === 0 && $resp !== false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize($samples)
|
||||||
|
{
|
||||||
|
$times = array_column($samples, 'ms');
|
||||||
|
sort($times);
|
||||||
|
$n = count($times);
|
||||||
|
return [
|
||||||
|
'min' => $times[0],
|
||||||
|
'max' => $times[$n - 1],
|
||||||
|
'median' => $times[intval($n / 2)],
|
||||||
|
'avg' => round(array_sum($times) / $n, 2),
|
||||||
|
'bytes' => $samples[0]['bytes'],
|
||||||
|
'ok' => count(array_filter(array_column($samples, 'ok'))) === $n,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [
|
||||||
|
'context' => [
|
||||||
|
'userId' => $userId,
|
||||||
|
'sessionId' => $sessionId,
|
||||||
|
'iterations' => $iterations,
|
||||||
|
'base_url' => $base,
|
||||||
|
'timestamp' => date('c'),
|
||||||
|
],
|
||||||
|
'endpoints' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($endpoints as $path => $params) {
|
||||||
|
$url = $base . '/' . $path . '.php';
|
||||||
|
$samples = [];
|
||||||
|
for ($i = 0; $i < $iterations; $i++) {
|
||||||
|
$samples[] = call_endpoint($url, $params);
|
||||||
|
}
|
||||||
|
$results['endpoints'][$path] = summarize($samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
print_r(json_encode($results, JSON_PRETTY_PRINT));
|
||||||
|
?>
|
||||||
@@ -40,6 +40,8 @@ $event->timeStamp = $_POST['timestamp'];
|
|||||||
// create reactevent
|
// create reactevent
|
||||||
if ($event->record())
|
if ($event->record())
|
||||||
{
|
{
|
||||||
|
cache_invalidate_session($event->srcEventSessionId);
|
||||||
|
invalidate_session_debrief_cache_db($db, $event->srcEventSessionId);
|
||||||
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
|
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
|
||||||
print_r(json_encode($evt_arr)); // OK
|
print_r(json_encode($evt_arr)); // OK
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ $event->successful = $_POST['success'];
|
|||||||
// create trigger event
|
// create trigger event
|
||||||
if ($event->record())
|
if ($event->record())
|
||||||
{
|
{
|
||||||
|
cache_invalidate_session($event->sessionId);
|
||||||
|
invalidate_session_debrief_cache_db($db, $event->sessionId);
|
||||||
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
|
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
|
||||||
print_r(json_encode($evt_arr)); // OK
|
print_r(json_encode($evt_arr)); // OK
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ include_once '../objects/stats_object.php';
|
|||||||
|
|
||||||
$userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
|
$userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
|
||||||
$typeId = isset($_POST['typeId']) ? $_POST['typeId'] : -1;
|
$typeId = isset($_POST['typeId']) ? $_POST['typeId'] : -1;
|
||||||
|
// Optional pagination : Unreal n'envoie pas ces champs (comportement legacy preserve),
|
||||||
|
// le futur web UI peut les passer pour ne pas charger toutes les sessions d'un coup.
|
||||||
|
$limit = isset($_POST['limit']) ? intval($_POST['limit']) : -1;
|
||||||
|
$offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
|
||||||
|
|
||||||
$stats = new StatsObject($db);
|
$stats = new StatsObject($db);
|
||||||
$stats->getSessionsForUser($userId, $typeId);
|
$stats->getSessionsForUser($userId, $typeId, $limit, $offset);
|
||||||
|
|
||||||
$stats_arr = $stats->getResultArray(true, "User_Sessions_List_OK");
|
$stats_arr = $stats->getResultArray(true, "User_Sessions_List_OK");
|
||||||
print_r(json_encode($stats_arr)); // OK
|
print_r(json_encode($stats_arr)); // OK
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ class StatsObject extends DBTableObject
|
|||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// get sessions of given type that given user has taken part in
|
// get sessions of given type that given user has taken part in
|
||||||
public function getRawSessions ($userId=-1, $typeId=-1)
|
// $limit and $offset are optional : when $limit <= 0 the full list is returned (legacy behavior)
|
||||||
|
public function getRawSessions ($userId=-1, $typeId=-1, $limit=-1, $offset=0)
|
||||||
{
|
{
|
||||||
// Check if we are looking for a specific user and/or type of session
|
// Check if we are looking for a specific user and/or type of session
|
||||||
$userConstraint = $userId > 0 ? " AND P.userId=" . $userId : "";
|
$userConstraint = $userId > 0 ? " AND P.userId=" . $userId : "";
|
||||||
@@ -127,9 +128,15 @@ class StatsObject extends DBTableObject
|
|||||||
// Order results from newest to oldest session
|
// Order results from newest to oldest session
|
||||||
$orderConstraint = " ORDER BY SD.id DESC";
|
$orderConstraint = " ORDER BY SD.id DESC";
|
||||||
|
|
||||||
|
// Optional pagination : SQL-level LIMIT/OFFSET when caller provided a positive limit
|
||||||
|
$limitConstraint = "";
|
||||||
|
if (intval($limit) > 0) {
|
||||||
|
$limitConstraint = " LIMIT " . intval($limit) . " OFFSET " . max(0, intval($offset));
|
||||||
|
}
|
||||||
|
|
||||||
//$query = "SELECT DISTINCT S.* FROM " . SESSIONS_TABLE_NAME . " S LEFT JOIN " . PARTICIPATES_TABLE_NAME . " P ON S.id = P.sessionId WHERE 1 " .
|
//$query = "SELECT DISTINCT S.* FROM " . SESSIONS_TABLE_NAME . " S LEFT JOIN " . PARTICIPATES_TABLE_NAME . " P ON S.id = P.sessionId WHERE 1 " .
|
||||||
$query = "SELECT DISTINCT SD.* FROM " . SESSIONS_TABLE_NAME . " SD, " . PARTICIPATES_TABLE_NAME . " P WHERE SD.id = P.sessionId " .
|
$query = "SELECT DISTINCT SD.* FROM " . SESSIONS_TABLE_NAME . " SD, " . PARTICIPATES_TABLE_NAME . " P WHERE SD.id = P.sessionId " .
|
||||||
$this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userConstraint . $typeConstraint . $orderConstraint;
|
$this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userConstraint . $typeConstraint . $orderConstraint . $limitConstraint;
|
||||||
|
|
||||||
// prepare and execute query
|
// prepare and execute query
|
||||||
$stmt = $this->conn->prepare($query);
|
$stmt = $this->conn->prepare($query);
|
||||||
@@ -141,10 +148,10 @@ class StatsObject extends DBTableObject
|
|||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
// get sessions that given user has taken part in
|
// get sessions that given user has taken part in
|
||||||
public function getSessionsForUser ($userId, $typeId=-1)
|
public function getSessionsForUser ($userId, $typeId=-1, $limit=-1, $offset=0)
|
||||||
{
|
{
|
||||||
// Check if we are looking for a specific user and/or session
|
// Check if we are looking for a specific user and/or session
|
||||||
$stmt = $this->getRawSessions($userId, $typeId);
|
$stmt = $this->getRawSessions($userId, $typeId, $limit, $offset);
|
||||||
|
|
||||||
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
|
||||||
{
|
{
|
||||||
@@ -469,6 +476,11 @@ class StatsObject extends DBTableObject
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// Lazy rebuild du cache materialise pour cette session avant le SELECT FROM sessiondebriefs
|
||||||
|
if ($sessionId > 0 && function_exists('ensure_session_debriefs_cached_db')) {
|
||||||
|
ensure_session_debriefs_cached_db($this->conn, $sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// create SQL constraints for query
|
// create SQL constraints for query
|
||||||
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId = " . $sessionId : "";
|
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId = " . $sessionId : "";
|
||||||
$userConstraint = $userId >= 0 ? " AND SD.ShooterId = " . $userId : "";
|
$userConstraint = $userId >= 0 ? " AND SD.ShooterId = " . $userId : "";
|
||||||
@@ -692,6 +704,12 @@ class StatsObject extends DBTableObject
|
|||||||
// debrief mission : get given users results in given session
|
// debrief mission : get given users results in given session
|
||||||
public function getResultsForSession ($sessionId, $userId, $fromUserId=-1)
|
public function getResultsForSession ($sessionId, $userId, $fromUserId=-1)
|
||||||
{
|
{
|
||||||
|
// Lazy rebuild du cache materialise (table session_debriefs_cache)
|
||||||
|
// si la session a ete invalidee par un evenement et pas encore reconstruite
|
||||||
|
if ($sessionId > 0 && function_exists('ensure_session_debriefs_cached_db')) {
|
||||||
|
ensure_session_debriefs_cached_db($this->conn, $sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
$userConstraint = $userId > 0 ? " AND SD.ShooterId=" . $userId : "";
|
$userConstraint = $userId > 0 ? " AND SD.ShooterId=" . $userId : "";
|
||||||
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId=" . $sessionId : "";
|
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId=" . $sessionId : "";
|
||||||
|
|
||||||
|
|||||||
@@ -325,7 +325,61 @@ INSERT INTO `userstatus` (`id`, `displayName`, `description`) VALUES
|
|||||||
--
|
--
|
||||||
DROP TABLE IF EXISTS `sessiondebriefs`;
|
DROP TABLE IF EXISTS `sessiondebriefs`;
|
||||||
|
|
||||||
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`localhost` SQL SECURITY DEFINER VIEW `sessiondebriefs` AS SELECT `s`.`id` AS `SessionId`, `s`.`sessionType` AS `SessionTypeId`, `st`.`displayName` AS `SessionType`, `s`.`sessionName` AS `SessionName`, `s`.`sessionDate` AS `SessionDate`, `s`.`mapName` AS `MapName`, `s`.`scenarioName` AS `ScenarioName`, `s`.`success` AS `SessionSuccessful`, `s`.`timeToFinish` AS `SessionDuration`, coalesce(`t`.`type`,-1) AS `TriggerTypeId`, coalesce(`tt`.`displayName`,'') AS `TriggerType`, (select ifnull(`t`.`srcUserId`,`ps`.`userId`)) AS `ShooterId`, `us`.`username` AS `ShooterName`, (select ifnull(`ps`.`role`,3)) AS `ShooterRoleId`, (select `r`.`displayName` from `userroles` `r` where `r`.`id` = `ShooterRoleId`) AS `ShooterRole`, coalesce(`t`.`indexCount`,-1) AS `ShotIndex`, coalesce(`r`.`id`,-1) AS `ReactId`, coalesce(`r`.`reactMode`,2) AS `ReactModeId`, coalesce(`rm`.`displayName`,'') AS `ReactMode`, coalesce(`r`.`reactType`,-1) AS `ReactTypeId`, coalesce(`rt`.`displayName`,'') AS `ReactType`, coalesce(`r`.`hitUserId`,-1) AS `TargetUserId`, coalesce(`uh`.`username`,'') AS `TargetUserName`, (select ifnull(`ph`.`role`,3)) AS `TargetRoleId`, (select `r`.`displayName` from `userroles` `r` where `r`.`id` = `TargetRoleId`) AS `TargetRole`, coalesce(`r`.`hitTargetName`,'') AS `TargetName`, coalesce(`r`.`hitBoneName`,'') AS `TargetBoneName`, coalesce(`r`.`targetKilled`,0) AS `TargetKilled`, coalesce(`r`.`objectHitLocationX`,0) AS `HitLocationX`, coalesce(`r`.`objectHitLocationY`,0) AS `HitLocationY`, coalesce(`r`.`objectHitTagLocation`,'') AS `HitLocationTag`, coalesce(`r`.`hitPrecision`,0) AS `HitPrecision`, coalesce(`r`.`distance`,0) AS `HitTargetDistance`, coalesce(`r`.`reactTime`,0) AS `ReactionTime`, coalesce(`r`.`timeStamp`,0) AS `TimeStamp`, count(distinct `r`.`id`) AS `NbHit`, count(distinct `rk`.`srcEventIndex`,`rk`.`hitTargetName`) AS `NbKilled` FROM (((((((((((`sessions` `s` left join `participates` `ps` on(`s`.`id` = `ps`.`sessionId`)) left join `participates` `ph` on(`s`.`id` = `ph`.`sessionId`)) left join `triggerevents` `t` on(`s`.`id` = `t`.`sessionId`)) left join `triggereventtypes` `tt` on(`tt`.`id` = `t`.`type`)) left join `sessiontypes` `st` on(`st`.`id` = `s`.`sessionType`)) left join `reactevents` `r` on(`t`.`indexCount` = `r`.`srcEventIndex` and `t`.`sessionId` = `r`.`srcEventSessionId`)) left join `users` `uh` on(`uh`.`id` = `r`.`hitUserId`)) left join `users` `us` on(`us`.`id` = `t`.`srcUserId` or `us`.`id` = `ps`.`userId`)) left join `reacteventtypes` `rt` on(`rt`.`id` = `r`.`reactType`)) left join `reacteventmodes` `rm` on(`rm`.`id` = coalesce(`r`.`reactMode`,2))) left join `reactevents` `rk` on(`r`.`id` = `rk`.`id` and `rk`.`targetKilled` = 1)) GROUP BY `s`.`id`, (select ifnull(`t`.`srcUserId`,`ps`.`userId`)), `t`.`indexCount`, `r`.`hitTargetName` ORDER BY `s`.`id` ASC, (select ifnull(`t`.`srcUserId`,`ps`.`userId`)) ASC, `t`.`indexCount` ASC, `r`.`id` ASC ;
|
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`localhost` SQL SECURITY DEFINER VIEW `sessiondebriefs` AS
|
||||||
|
SELECT
|
||||||
|
`s`.`id` AS `SessionId`,
|
||||||
|
`s`.`sessionType` AS `SessionTypeId`,
|
||||||
|
`st`.`displayName` AS `SessionType`,
|
||||||
|
`s`.`sessionName` AS `SessionName`,
|
||||||
|
`s`.`sessionDate` AS `SessionDate`,
|
||||||
|
`s`.`mapName` AS `MapName`,
|
||||||
|
`s`.`scenarioName` AS `ScenarioName`,
|
||||||
|
`s`.`success` AS `SessionSuccessful`,
|
||||||
|
`s`.`timeToFinish` AS `SessionDuration`,
|
||||||
|
COALESCE(`t`.`type`, -1) AS `TriggerTypeId`,
|
||||||
|
COALESCE(`tt`.`displayName`, '') AS `TriggerType`,
|
||||||
|
IFNULL(`t`.`srcUserId`, `ps`.`userId`) AS `ShooterId`,
|
||||||
|
`us`.`username` AS `ShooterName`,
|
||||||
|
IFNULL(`ps`.`role`, 3) AS `ShooterRoleId`,
|
||||||
|
`urs`.`displayName` AS `ShooterRole`,
|
||||||
|
COALESCE(`t`.`indexCount`, -1) AS `ShotIndex`,
|
||||||
|
COALESCE(`r`.`id`, -1) AS `ReactId`,
|
||||||
|
COALESCE(`r`.`reactMode`, 2) AS `ReactModeId`,
|
||||||
|
COALESCE(`rm`.`displayName`, '') AS `ReactMode`,
|
||||||
|
COALESCE(`r`.`reactType`, -1) AS `ReactTypeId`,
|
||||||
|
COALESCE(`rt`.`displayName`, '') AS `ReactType`,
|
||||||
|
COALESCE(`r`.`hitUserId`, -1) AS `TargetUserId`,
|
||||||
|
COALESCE(`uh`.`username`, '') AS `TargetUserName`,
|
||||||
|
IFNULL(`ph`.`role`, 3) AS `TargetRoleId`,
|
||||||
|
`urt`.`displayName` AS `TargetRole`,
|
||||||
|
COALESCE(`r`.`hitTargetName`, '') AS `TargetName`,
|
||||||
|
COALESCE(`r`.`hitBoneName`, '') AS `TargetBoneName`,
|
||||||
|
COALESCE(`r`.`targetKilled`, 0) AS `TargetKilled`,
|
||||||
|
COALESCE(`r`.`objectHitLocationX`, 0) AS `HitLocationX`,
|
||||||
|
COALESCE(`r`.`objectHitLocationY`, 0) AS `HitLocationY`,
|
||||||
|
COALESCE(`r`.`objectHitTagLocation`, '') AS `HitLocationTag`,
|
||||||
|
COALESCE(`r`.`hitPrecision`, 0) AS `HitPrecision`,
|
||||||
|
COALESCE(`r`.`distance`, 0) AS `HitTargetDistance`,
|
||||||
|
COALESCE(`r`.`reactTime`, 0) AS `ReactionTime`,
|
||||||
|
COALESCE(`r`.`timeStamp`, 0) AS `TimeStamp`,
|
||||||
|
COUNT(DISTINCT `r`.`id`) AS `NbHit`,
|
||||||
|
COUNT(DISTINCT `rk`.`srcEventIndex`, `rk`.`hitTargetName`) AS `NbKilled`
|
||||||
|
FROM `sessions` `s`
|
||||||
|
LEFT JOIN `participates` `ps` ON (`s`.`id` = `ps`.`sessionId`)
|
||||||
|
LEFT JOIN `participates` `ph` ON (`s`.`id` = `ph`.`sessionId`)
|
||||||
|
LEFT JOIN `triggerevents` `t` ON (`s`.`id` = `t`.`sessionId`)
|
||||||
|
LEFT JOIN `triggereventtypes` `tt` ON (`tt`.`id` = `t`.`type`)
|
||||||
|
LEFT JOIN `sessiontypes` `st` ON (`st`.`id` = `s`.`sessionType`)
|
||||||
|
LEFT JOIN `reactevents` `r` ON (`t`.`indexCount` = `r`.`srcEventIndex` AND `t`.`sessionId` = `r`.`srcEventSessionId`)
|
||||||
|
LEFT JOIN `users` `uh` ON (`uh`.`id` = `r`.`hitUserId`)
|
||||||
|
LEFT JOIN `users` `us` ON (`us`.`id` = `t`.`srcUserId` OR `us`.`id` = `ps`.`userId`)
|
||||||
|
LEFT JOIN `reacteventtypes` `rt` ON (`rt`.`id` = `r`.`reactType`)
|
||||||
|
LEFT JOIN `reacteventmodes` `rm` ON (`rm`.`id` = COALESCE(`r`.`reactMode`, 2))
|
||||||
|
LEFT JOIN `reactevents` `rk` ON (`r`.`id` = `rk`.`id` AND `rk`.`targetKilled` = 1)
|
||||||
|
LEFT JOIN `userroles` `urs` ON (`urs`.`id` = IFNULL(`ps`.`role`, 3))
|
||||||
|
LEFT JOIN `userroles` `urt` ON (`urt`.`id` = IFNULL(`ph`.`role`, 3))
|
||||||
|
GROUP BY `SessionId`, `ShooterId`, `ShotIndex`, `TargetName`
|
||||||
|
ORDER BY `SessionId`, `ShooterId`, `ShotIndex`, `ReactId`;
|
||||||
|
|
||||||
--
|
--
|
||||||
-- Index pour les tables déchargées
|
-- Index pour les tables déchargées
|
||||||
|
|||||||
@@ -16,12 +16,22 @@ $userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
|
|||||||
// check sessionId is > 0
|
// check sessionId is > 0
|
||||||
//if ($sessionId > 0)
|
//if ($sessionId > 0)
|
||||||
//{
|
//{
|
||||||
// prepare user object
|
$cacheKey = "debrief_" . intval($userId);
|
||||||
$stats = new StatsObject($db);
|
$cached = cache_get($sessionId, $cacheKey);
|
||||||
$stats->getStatsForSession($sessionId, $userId);
|
if ($cached !== null)
|
||||||
|
{
|
||||||
|
print_r($cached);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$stats = new StatsObject($db);
|
||||||
|
$stats->getStatsForSession($sessionId, $userId);
|
||||||
|
|
||||||
$stats_arr = $stats->getResultArray(true, "Stats_Collected_OK");
|
$stats_arr = $stats->getResultArray(true, "Stats_Collected_OK");
|
||||||
print_r(json_encode($stats_arr)); // OK
|
$json = json_encode($stats_arr);
|
||||||
|
cache_put($sessionId, $cacheKey, $json);
|
||||||
|
print_r($json); // OK
|
||||||
|
}
|
||||||
//}
|
//}
|
||||||
//else trigger_error("Invalid session id");
|
//else trigger_error("Invalid session id");
|
||||||
?>
|
?>
|
||||||
@@ -12,11 +12,17 @@ if (isset($_POST['sessionId']))
|
|||||||
$gameSession->id = $_POST['sessionId'];
|
$gameSession->id = $_POST['sessionId'];
|
||||||
else trigger_error( missing_parameter_error("sessionId") );
|
else trigger_error( missing_parameter_error("sessionId") );
|
||||||
|
|
||||||
// create the game session
|
$cached = cache_get($gameSession->id, "session");
|
||||||
if ($gameSession->load())
|
if ($cached !== null)
|
||||||
|
{
|
||||||
|
print_r($cached);
|
||||||
|
}
|
||||||
|
else if ($gameSession->load())
|
||||||
{
|
{
|
||||||
$session_arr = $gameSession->getResultArray(true, "Session_Found");
|
$session_arr = $gameSession->getResultArray(true, "Session_Found");
|
||||||
print_r(json_encode($session_arr)); // OK
|
$json = json_encode($session_arr);
|
||||||
|
cache_put($gameSession->id, "session", $json);
|
||||||
|
print_r($json); // OK
|
||||||
}
|
}
|
||||||
else trigger_error("Error_Occured");
|
else trigger_error("Error_Occured");
|
||||||
?>
|
?>
|
||||||
@@ -20,6 +20,8 @@ $gameSession->score = $_POST['score'];
|
|||||||
// close the game session
|
// close the game session
|
||||||
if ($gameSession->stop())
|
if ($gameSession->stop())
|
||||||
{
|
{
|
||||||
|
cache_invalidate_session($gameSession->id);
|
||||||
|
rebuild_session_debrief_cache_db($db, $gameSession->id);
|
||||||
$session_arr = $gameSession->getResultArray(true, "Session_Stop_OK");
|
$session_arr = $gameSession->getResultArray(true, "Session_Stop_OK");
|
||||||
print_r(json_encode($session_arr)); // OK
|
print_r(json_encode($session_arr)); // OK
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ if ($participation->load())
|
|||||||
// update objectives of user in the game session
|
// update objectives of user in the game session
|
||||||
if ($participation->updateObjectives())
|
if ($participation->updateObjectives())
|
||||||
{
|
{
|
||||||
|
cache_invalidate_session($participation->sessionId);
|
||||||
|
rebuild_session_debrief_cache_db($db, $participation->sessionId);
|
||||||
$participation_arr = $participation->getResultArray(true, "Results_Saved_OK");
|
$participation_arr = $participation->getResultArray(true, "Results_Saved_OK");
|
||||||
print_r(json_encode($participation_arr)); // OK
|
print_r(json_encode($participation_arr)); // OK
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ if ($participation->userLeaves())
|
|||||||
// update score and averages
|
// update score and averages
|
||||||
if ($participation->updateUserScores())
|
if ($participation->updateUserScores())
|
||||||
{
|
{
|
||||||
|
cache_invalidate_session($participation->sessionId);
|
||||||
|
rebuild_session_debrief_cache_db($db, $participation->sessionId);
|
||||||
$participation_arr = $participation->getResultArray(true, "User_Left_OK");
|
$participation_arr = $participation->getResultArray(true, "User_Left_OK");
|
||||||
print_r(json_encode($participation_arr)); // OK
|
print_r(json_encode($participation_arr)); // OK
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,21 @@ if (isset($_POST['quickMode']))
|
|||||||
|
|
||||||
//if ($userId > 0 && $sessionId > 0)
|
//if ($userId > 0 && $sessionId > 0)
|
||||||
{
|
{
|
||||||
$stats = new StatsObject($db);
|
$cacheKey = "userhistory_" . intval($userId) . "_" . intval($quickMode);
|
||||||
$stats->getUserHistory($userId, $sessionId, $quickMode);
|
$cached = cache_get($sessionId, $cacheKey);
|
||||||
|
if ($cached !== null)
|
||||||
|
{
|
||||||
|
print_r($cached);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
$stats = new StatsObject($db);
|
||||||
|
$stats->getUserHistory($userId, $sessionId, $quickMode);
|
||||||
|
|
||||||
$stats_arr = $stats->getResultArray(true, "Results_Collected_OK");
|
$stats_arr = $stats->getResultArray(true, "Results_Collected_OK");
|
||||||
print_r(json_encode($stats_arr)); // OK
|
$json = json_encode($stats_arr);
|
||||||
|
cache_put($sessionId, $cacheKey, $json);
|
||||||
|
print_r($json); // OK
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
126
ProserveAPI/htdocs/proserve/update_proserve_db 1.6.0.sql
Normal file
126
ProserveAPI/htdocs/proserve/update_proserve_db 1.6.0.sql
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration 1.6.0 - Optimisation des temps de reponse
|
||||||
|
-- ============================================================
|
||||||
|
-- Cible : MariaDB 10.4+ (XAMPP local).
|
||||||
|
-- Idempotent : ce fichier peut etre rejoue sans erreur sur n'importe quel poste.
|
||||||
|
-- Aucun changement de structure : seuls des index sont ajoutes
|
||||||
|
-- (via une procedure stockee qui teste leur presence) et la vue
|
||||||
|
-- sessiondebriefs est redefinie via CREATE OR REPLACE VIEW.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
USE proserveapi;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Procedure helper : ajoute un index seulement s'il n'existe pas
|
||||||
|
-- (CREATE INDEX IF NOT EXISTS n'est dispo qu'a partir de MariaDB 10.5.4)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
DROP PROCEDURE IF EXISTS create_index_if_missing;
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE create_index_if_missing(
|
||||||
|
IN p_table VARCHAR(64),
|
||||||
|
IN p_index VARCHAR(64),
|
||||||
|
IN p_cols VARCHAR(255)
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE()
|
||||||
|
AND TABLE_NAME = p_table
|
||||||
|
AND INDEX_NAME = p_index
|
||||||
|
) THEN
|
||||||
|
SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD INDEX `', p_index, '` (', p_cols, ')');
|
||||||
|
PREPARE stmt FROM @sql;
|
||||||
|
EXECUTE stmt;
|
||||||
|
DEALLOCATE PREPARE stmt;
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Index manquants identifies a l'analyse des requetes lourdes
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
-- JOIN reactevents <-> triggerevents : la cle composite la plus utilisee
|
||||||
|
CALL create_index_if_missing('reactevents', 'idx_react_session_event', '`srcEventSessionId`, `srcEventIndex`');
|
||||||
|
|
||||||
|
-- Filtres "tirs d'un user dans une session" (stats_object getShotsFiredByUser, getTargetKilledInSession)
|
||||||
|
CALL create_index_if_missing('triggerevents', 'idx_trigger_session_user', '`sessionId`, `srcUserId`');
|
||||||
|
|
||||||
|
-- Agregation NbKilled (auto-jointure RK dans la vue sessiondebriefs)
|
||||||
|
CALL create_index_if_missing('reactevents', 'idx_react_killed', '`targetKilled`, `srcEventSessionId`');
|
||||||
|
|
||||||
|
-- Tri/filtrage des sessions par date (listes recentes)
|
||||||
|
CALL create_index_if_missing('sessions', 'idx_sessions_date', '`sessionDate` DESC');
|
||||||
|
|
||||||
|
-- Nettoyage de la procedure helper
|
||||||
|
DROP PROCEDURE create_index_if_missing;
|
||||||
|
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Redefinition de la vue sessiondebriefs (idempotent via CREATE OR REPLACE)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- Modifications par rapport a la definition d'origine :
|
||||||
|
-- - Suppression des wraps "(SELECT IFNULL(...))" qui forcaient une evaluation par row
|
||||||
|
-- -> remplaces par IFNULL(...) directement (semantique identique).
|
||||||
|
-- - Suppression des sous-requetes correlees (SELECT R.displayName FROM userroles R WHERE R.id = ...)
|
||||||
|
-- -> remplacees par deux LEFT JOIN explicites sur userroles (URS et URT).
|
||||||
|
-- - Tous les autres JOIN, le GROUP BY et l'ORDER BY restent strictement identiques
|
||||||
|
-- pour preserver le contrat de sortie (memes colonnes, memes lignes, memes valeurs).
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW sessiondebriefs AS
|
||||||
|
SELECT
|
||||||
|
S.id AS SessionId,
|
||||||
|
S.sessionType AS SessionTypeId,
|
||||||
|
ST.displayName AS SessionType,
|
||||||
|
S.sessionName AS SessionName,
|
||||||
|
S.sessionDate AS SessionDate,
|
||||||
|
S.mapName AS MapName,
|
||||||
|
S.scenarioName AS ScenarioName,
|
||||||
|
S.success AS SessionSuccessful,
|
||||||
|
S.timeToFinish AS SessionDuration,
|
||||||
|
COALESCE(T.type, -1) AS TriggerTypeId,
|
||||||
|
COALESCE(TT.displayName, '') AS TriggerType,
|
||||||
|
IFNULL(T.srcUserId, PS.userId) AS ShooterId,
|
||||||
|
US.username AS ShooterName,
|
||||||
|
IFNULL(PS.role, 3) AS ShooterRoleId,
|
||||||
|
URS.displayName AS ShooterRole,
|
||||||
|
COALESCE(T.indexCount, -1) AS ShotIndex,
|
||||||
|
COALESCE(R.id, -1) AS ReactId,
|
||||||
|
COALESCE(R.reactMode, 2) AS ReactModeId,
|
||||||
|
COALESCE(RM.displayName, '') AS ReactMode,
|
||||||
|
COALESCE(R.reactType, -1) AS ReactTypeId,
|
||||||
|
COALESCE(RT.displayName, '') AS ReactType,
|
||||||
|
COALESCE(R.hitUserId, -1) AS TargetUserId,
|
||||||
|
COALESCE(UH.username, '') AS TargetUserName,
|
||||||
|
IFNULL(PH.role, 3) AS TargetRoleId,
|
||||||
|
URT.displayName AS TargetRole,
|
||||||
|
COALESCE(R.hitTargetName, '') AS TargetName,
|
||||||
|
COALESCE(R.hitBoneName, '') AS TargetBoneName,
|
||||||
|
COALESCE(R.targetKilled, 0) AS TargetKilled,
|
||||||
|
COALESCE(R.objectHitLocationX, 0) AS HitLocationX,
|
||||||
|
COALESCE(R.objectHitLocationY, 0) AS HitLocationY,
|
||||||
|
COALESCE(R.objectHitTagLocation, '') AS HitLocationTag,
|
||||||
|
COALESCE(R.hitPrecision, 0) AS HitPrecision,
|
||||||
|
COALESCE(R.distance, 0) AS HitTargetDistance,
|
||||||
|
COALESCE(R.reactTime, 0) AS ReactionTime,
|
||||||
|
COALESCE(R.timeStamp, 0) AS TimeStamp,
|
||||||
|
COUNT(DISTINCT R.id) AS NbHit,
|
||||||
|
COUNT(DISTINCT RK.srcEventIndex, RK.hitTargetName) AS NbKilled
|
||||||
|
FROM sessions S
|
||||||
|
LEFT JOIN participates PS ON (S.id = PS.sessionId)
|
||||||
|
LEFT JOIN participates PH ON (S.id = PH.sessionId)
|
||||||
|
LEFT JOIN triggerevents T ON (S.id = T.sessionId)
|
||||||
|
LEFT JOIN triggereventtypes TT ON (TT.id = T.type)
|
||||||
|
LEFT JOIN sessiontypes ST ON (ST.id = S.sessionType)
|
||||||
|
LEFT JOIN reactevents R ON (T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId)
|
||||||
|
LEFT JOIN users UH ON (UH.id = R.hitUserId)
|
||||||
|
LEFT JOIN users US ON (US.id = T.srcUserId OR US.id = PS.userId)
|
||||||
|
LEFT JOIN reacteventtypes RT ON (RT.id = R.reactType)
|
||||||
|
LEFT JOIN reacteventmodes RM ON (RM.id = COALESCE(R.reactMode, 2))
|
||||||
|
LEFT JOIN reactevents RK ON (R.id = RK.id AND RK.targetKilled = 1)
|
||||||
|
LEFT JOIN userroles URS ON (URS.id = IFNULL(PS.role, 3))
|
||||||
|
LEFT JOIN userroles URT ON (URT.id = IFNULL(PH.role, 3))
|
||||||
|
GROUP BY SessionId, ShooterId, ShotIndex, TargetName
|
||||||
|
ORDER BY SessionId, ShooterId, ShotIndex, ReactId;
|
||||||
236
ProserveAPI/htdocs/proserve/update_proserve_db 1.7.0.sql
Normal file
236
ProserveAPI/htdocs/proserve/update_proserve_db 1.7.0.sql
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration 1.7.0 - Materialisation de la vue sessiondebriefs
|
||||||
|
-- ============================================================
|
||||||
|
-- Cible : MariaDB 10.4+.
|
||||||
|
-- Idempotent : peut etre rejoue sans erreur. Le cache est entierement
|
||||||
|
-- reconstruit a chaque execution (TRUNCATE + backfill).
|
||||||
|
--
|
||||||
|
-- Changement principal : la vue sessiondebriefs etait recalculee a chaque
|
||||||
|
-- requete (12 LEFT JOIN + GROUP BY + sous-requetes correlees). Elle est
|
||||||
|
-- desormais materialisee dans une vraie table session_debriefs_cache,
|
||||||
|
-- peuplee :
|
||||||
|
-- - en bulk au moment de cette migration (backfill toutes sessions),
|
||||||
|
-- - puis par une procedure stockee rebuild_debrief_cache(p_session_id)
|
||||||
|
-- appelee depuis le code PHP a chaque modification de session
|
||||||
|
-- (session/stop, session/userleave, session/updateobjectives,
|
||||||
|
-- events/storetriggerevent, events/storereactevent).
|
||||||
|
--
|
||||||
|
-- La vue sessiondebriefs garde son nom et ses colonnes : le code consommateur
|
||||||
|
-- (stats_object.php : getResultsForSession, getUserHistory) ne change pas.
|
||||||
|
-- Elle devient un simple passe-plat sur la table cache.
|
||||||
|
--
|
||||||
|
-- Action 2 (fix partiel du double JOIN participates) : la jointure PH
|
||||||
|
-- (qui sert a determiner TargetRoleId/TargetRole) est desormais contrainte
|
||||||
|
-- sur PH.userId = R.hitUserId au lieu de matcher tous les participants.
|
||||||
|
-- Effet :
|
||||||
|
-- * Pour les hits user-on-user : meme resultat (correct par construction).
|
||||||
|
-- * Pour les hits NPC/objets : TargetRoleId vaut 3 (IA) au lieu d'un
|
||||||
|
-- role aleatoire d'un autre participant. C'est un fix de bug.
|
||||||
|
-- La jointure PS reste inchangee (preserve le fallback ShooterId quand T est NULL).
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
USE proserveapi;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 1) Table de cache (creation idempotente)
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS `session_debriefs_cache` (
|
||||||
|
`SessionId` int(11) NOT NULL,
|
||||||
|
`SessionTypeId` int(11) DEFAULT NULL,
|
||||||
|
`SessionType` varchar(255) DEFAULT NULL,
|
||||||
|
`SessionName` varchar(255) DEFAULT NULL,
|
||||||
|
`SessionDate` datetime DEFAULT NULL,
|
||||||
|
`MapName` varchar(255) DEFAULT NULL,
|
||||||
|
`ScenarioName` varchar(255) DEFAULT NULL,
|
||||||
|
`SessionSuccessful` tinyint(1) DEFAULT NULL,
|
||||||
|
`SessionDuration` float DEFAULT NULL,
|
||||||
|
`TriggerTypeId` int(11) DEFAULT NULL,
|
||||||
|
`TriggerType` varchar(255) DEFAULT NULL,
|
||||||
|
`ShooterId` int(11) DEFAULT NULL,
|
||||||
|
`ShooterName` varchar(255) DEFAULT NULL,
|
||||||
|
`ShooterRoleId` int(11) DEFAULT NULL,
|
||||||
|
`ShooterRole` varchar(255) DEFAULT NULL,
|
||||||
|
`ShotIndex` int(11) DEFAULT NULL,
|
||||||
|
`ReactId` int(11) DEFAULT NULL,
|
||||||
|
`ReactModeId` int(11) DEFAULT NULL,
|
||||||
|
`ReactMode` varchar(255) DEFAULT NULL,
|
||||||
|
`ReactTypeId` int(11) DEFAULT NULL,
|
||||||
|
`ReactType` varchar(255) DEFAULT NULL,
|
||||||
|
`TargetUserId` int(11) DEFAULT NULL,
|
||||||
|
`TargetUserName` varchar(255) DEFAULT NULL,
|
||||||
|
`TargetRoleId` int(11) DEFAULT NULL,
|
||||||
|
`TargetRole` varchar(255) DEFAULT NULL,
|
||||||
|
`TargetName` varchar(255) DEFAULT NULL,
|
||||||
|
`TargetBoneName` varchar(255) DEFAULT NULL,
|
||||||
|
`TargetKilled` int(4) DEFAULT NULL,
|
||||||
|
`HitLocationX` double DEFAULT NULL,
|
||||||
|
`HitLocationY` double DEFAULT NULL,
|
||||||
|
`HitLocationTag` varchar(255) DEFAULT NULL,
|
||||||
|
`HitPrecision` double DEFAULT NULL,
|
||||||
|
`HitTargetDistance` double DEFAULT NULL,
|
||||||
|
`ReactionTime` double DEFAULT NULL,
|
||||||
|
`TimeStamp` double DEFAULT NULL,
|
||||||
|
`NbHit` bigint(21) DEFAULT NULL,
|
||||||
|
`NbKilled` bigint(21) DEFAULT NULL,
|
||||||
|
KEY `idx_sdc_session` (`SessionId`),
|
||||||
|
KEY `idx_sdc_session_shooter` (`SessionId`, `ShooterId`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 2) Procedure stockee pour reconstruire le cache d'une session
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
DROP PROCEDURE IF EXISTS rebuild_debrief_cache;
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE rebuild_debrief_cache(IN p_session_id INT)
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM session_debriefs_cache WHERE SessionId = p_session_id;
|
||||||
|
|
||||||
|
INSERT INTO session_debriefs_cache (
|
||||||
|
SessionId, SessionTypeId, SessionType, SessionName, SessionDate,
|
||||||
|
MapName, ScenarioName, SessionSuccessful, SessionDuration,
|
||||||
|
TriggerTypeId, TriggerType, ShooterId, ShooterName,
|
||||||
|
ShooterRoleId, ShooterRole, ShotIndex, ReactId, ReactModeId,
|
||||||
|
ReactMode, ReactTypeId, ReactType, TargetUserId, TargetUserName,
|
||||||
|
TargetRoleId, TargetRole, TargetName, TargetBoneName, TargetKilled,
|
||||||
|
HitLocationX, HitLocationY, HitLocationTag, HitPrecision,
|
||||||
|
HitTargetDistance, ReactionTime, TimeStamp, NbHit, NbKilled
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
S.id,
|
||||||
|
S.sessionType,
|
||||||
|
ST.displayName,
|
||||||
|
S.sessionName,
|
||||||
|
S.sessionDate,
|
||||||
|
S.mapName,
|
||||||
|
S.scenarioName,
|
||||||
|
S.success,
|
||||||
|
S.timeToFinish,
|
||||||
|
COALESCE(T.type, -1),
|
||||||
|
COALESCE(TT.displayName, ''),
|
||||||
|
IFNULL(T.srcUserId, PS.userId),
|
||||||
|
US.username,
|
||||||
|
IFNULL(PS.role, 3),
|
||||||
|
URS.displayName,
|
||||||
|
COALESCE(T.indexCount, -1),
|
||||||
|
COALESCE(R.id, -1),
|
||||||
|
COALESCE(R.reactMode, 2),
|
||||||
|
COALESCE(RM.displayName, ''),
|
||||||
|
COALESCE(R.reactType, -1),
|
||||||
|
COALESCE(RT.displayName, ''),
|
||||||
|
COALESCE(R.hitUserId, -1),
|
||||||
|
COALESCE(UH.username, ''),
|
||||||
|
IFNULL(PH.role, 3),
|
||||||
|
URT.displayName,
|
||||||
|
COALESCE(R.hitTargetName, ''),
|
||||||
|
COALESCE(R.hitBoneName, ''),
|
||||||
|
COALESCE(R.targetKilled, 0),
|
||||||
|
COALESCE(R.objectHitLocationX, 0),
|
||||||
|
COALESCE(R.objectHitLocationY, 0),
|
||||||
|
COALESCE(R.objectHitTagLocation, ''),
|
||||||
|
COALESCE(R.hitPrecision, 0),
|
||||||
|
COALESCE(R.distance, 0),
|
||||||
|
COALESCE(R.reactTime, 0),
|
||||||
|
COALESCE(R.timeStamp, 0),
|
||||||
|
COUNT(DISTINCT R.id),
|
||||||
|
COUNT(DISTINCT RK.srcEventIndex, RK.hitTargetName)
|
||||||
|
FROM sessions S
|
||||||
|
LEFT JOIN participates PS ON (S.id = PS.sessionId)
|
||||||
|
LEFT JOIN triggerevents T ON (S.id = T.sessionId)
|
||||||
|
LEFT JOIN triggereventtypes TT ON (TT.id = T.type)
|
||||||
|
LEFT JOIN sessiontypes ST ON (ST.id = S.sessionType)
|
||||||
|
LEFT JOIN reactevents R ON (T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId)
|
||||||
|
LEFT JOIN users UH ON (UH.id = R.hitUserId)
|
||||||
|
LEFT JOIN users US ON (US.id = T.srcUserId OR US.id = PS.userId)
|
||||||
|
LEFT JOIN reacteventtypes RT ON (RT.id = R.reactType)
|
||||||
|
LEFT JOIN reacteventmodes RM ON (RM.id = COALESCE(R.reactMode, 2))
|
||||||
|
LEFT JOIN reactevents RK ON (R.id = RK.id AND RK.targetKilled = 1)
|
||||||
|
-- PH : Action 2 du fix - PH represente la participation de l'utilisateur cible
|
||||||
|
-- (au lieu de "n'importe quel participant") pour que TargetRole soit coherent.
|
||||||
|
LEFT JOIN participates PH ON (S.id = PH.sessionId AND PH.userId = R.hitUserId)
|
||||||
|
LEFT JOIN userroles URS ON (URS.id = IFNULL(PS.role, 3))
|
||||||
|
LEFT JOIN userroles URT ON (URT.id = IFNULL(PH.role, 3))
|
||||||
|
WHERE S.id = p_session_id
|
||||||
|
GROUP BY S.id, IFNULL(T.srcUserId, PS.userId), COALESCE(T.indexCount, -1), COALESCE(R.hitTargetName, '')
|
||||||
|
ORDER BY S.id, IFNULL(T.srcUserId, PS.userId), COALESCE(T.indexCount, -1), COALESCE(R.id, -1);
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 3) Backfill complet : recalcule le cache pour toutes les sessions existantes
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
TRUNCATE TABLE session_debriefs_cache;
|
||||||
|
|
||||||
|
INSERT INTO session_debriefs_cache (
|
||||||
|
SessionId, SessionTypeId, SessionType, SessionName, SessionDate,
|
||||||
|
MapName, ScenarioName, SessionSuccessful, SessionDuration,
|
||||||
|
TriggerTypeId, TriggerType, ShooterId, ShooterName,
|
||||||
|
ShooterRoleId, ShooterRole, ShotIndex, ReactId, ReactModeId,
|
||||||
|
ReactMode, ReactTypeId, ReactType, TargetUserId, TargetUserName,
|
||||||
|
TargetRoleId, TargetRole, TargetName, TargetBoneName, TargetKilled,
|
||||||
|
HitLocationX, HitLocationY, HitLocationTag, HitPrecision,
|
||||||
|
HitTargetDistance, ReactionTime, TimeStamp, NbHit, NbKilled
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
S.id,
|
||||||
|
S.sessionType,
|
||||||
|
ST.displayName,
|
||||||
|
S.sessionName,
|
||||||
|
S.sessionDate,
|
||||||
|
S.mapName,
|
||||||
|
S.scenarioName,
|
||||||
|
S.success,
|
||||||
|
S.timeToFinish,
|
||||||
|
COALESCE(T.type, -1),
|
||||||
|
COALESCE(TT.displayName, ''),
|
||||||
|
IFNULL(T.srcUserId, PS.userId),
|
||||||
|
US.username,
|
||||||
|
IFNULL(PS.role, 3),
|
||||||
|
URS.displayName,
|
||||||
|
COALESCE(T.indexCount, -1),
|
||||||
|
COALESCE(R.id, -1),
|
||||||
|
COALESCE(R.reactMode, 2),
|
||||||
|
COALESCE(RM.displayName, ''),
|
||||||
|
COALESCE(R.reactType, -1),
|
||||||
|
COALESCE(RT.displayName, ''),
|
||||||
|
COALESCE(R.hitUserId, -1),
|
||||||
|
COALESCE(UH.username, ''),
|
||||||
|
IFNULL(PH.role, 3),
|
||||||
|
URT.displayName,
|
||||||
|
COALESCE(R.hitTargetName, ''),
|
||||||
|
COALESCE(R.hitBoneName, ''),
|
||||||
|
COALESCE(R.targetKilled, 0),
|
||||||
|
COALESCE(R.objectHitLocationX, 0),
|
||||||
|
COALESCE(R.objectHitLocationY, 0),
|
||||||
|
COALESCE(R.objectHitTagLocation, ''),
|
||||||
|
COALESCE(R.hitPrecision, 0),
|
||||||
|
COALESCE(R.distance, 0),
|
||||||
|
COALESCE(R.reactTime, 0),
|
||||||
|
COALESCE(R.timeStamp, 0),
|
||||||
|
COUNT(DISTINCT R.id),
|
||||||
|
COUNT(DISTINCT RK.srcEventIndex, RK.hitTargetName)
|
||||||
|
FROM sessions S
|
||||||
|
LEFT JOIN participates PS ON (S.id = PS.sessionId)
|
||||||
|
LEFT JOIN triggerevents T ON (S.id = T.sessionId)
|
||||||
|
LEFT JOIN triggereventtypes TT ON (TT.id = T.type)
|
||||||
|
LEFT JOIN sessiontypes ST ON (ST.id = S.sessionType)
|
||||||
|
LEFT JOIN reactevents R ON (T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId)
|
||||||
|
LEFT JOIN users UH ON (UH.id = R.hitUserId)
|
||||||
|
LEFT JOIN users US ON (US.id = T.srcUserId OR US.id = PS.userId)
|
||||||
|
LEFT JOIN reacteventtypes RT ON (RT.id = R.reactType)
|
||||||
|
LEFT JOIN reacteventmodes RM ON (RM.id = COALESCE(R.reactMode, 2))
|
||||||
|
LEFT JOIN reactevents RK ON (R.id = RK.id AND RK.targetKilled = 1)
|
||||||
|
LEFT JOIN participates PH ON (S.id = PH.sessionId AND PH.userId = R.hitUserId)
|
||||||
|
LEFT JOIN userroles URS ON (URS.id = IFNULL(PS.role, 3))
|
||||||
|
LEFT JOIN userroles URT ON (URT.id = IFNULL(PH.role, 3))
|
||||||
|
GROUP BY S.id, IFNULL(T.srcUserId, PS.userId), COALESCE(T.indexCount, -1), COALESCE(R.hitTargetName, '')
|
||||||
|
ORDER BY S.id, IFNULL(T.srcUserId, PS.userId), COALESCE(T.indexCount, -1), COALESCE(R.id, -1);
|
||||||
|
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 4) La vue devient un passe-plat sur la table cache
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW sessiondebriefs AS
|
||||||
|
SELECT * FROM session_debriefs_cache;
|
||||||
Reference in New Issue
Block a user