publika träningslogik är nu utbruten och testbar
This commit is contained in:
@@ -104,6 +104,7 @@ Scripts:
|
|||||||
- `npm run status`
|
- `npm run status`
|
||||||
- `npm restart`
|
- `npm restart`
|
||||||
- `npm run verify`
|
- `npm run verify`
|
||||||
|
- `npm test`
|
||||||
- `npm run setup:windows`
|
- `npm run setup:windows`
|
||||||
|
|
||||||
### `server.js`
|
### `server.js`
|
||||||
@@ -117,6 +118,7 @@ Uses:
|
|||||||
- `child_process.spawn`
|
- `child_process.spawn`
|
||||||
- `express`
|
- `express`
|
||||||
- `better-sqlite3`
|
- `better-sqlite3`
|
||||||
|
- `server/public_training_profile.js`
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
- HTTP server
|
- HTTP server
|
||||||
@@ -129,6 +131,8 @@ Owns:
|
|||||||
- tokenized public driver training profile API and standalone mobile shell
|
- tokenized public driver training profile API and standalone mobile shell
|
||||||
- PDF export endpoint
|
- PDF export endpoint
|
||||||
|
|
||||||
|
Delegates public training token validation and response shaping to `server/public_training_profile.js`; `server.js` does not duplicate those rules.
|
||||||
|
|
||||||
Main API groups:
|
Main API groups:
|
||||||
- `/api/health`
|
- `/api/health`
|
||||||
- `/api/app-version`
|
- `/api/app-version`
|
||||||
@@ -154,6 +158,22 @@ Runtime environment variables:
|
|||||||
- `RC_TIMING_PID_FILE`: optional PID-file path used by process control
|
- `RC_TIMING_PID_FILE`: optional PID-file path used by process control
|
||||||
- `PUBLIC_OVERLAY_TOKEN`: optional shared token for public overlay routes
|
- `PUBLIC_OVERLAY_TOKEN`: optional shared token for public overlay routes
|
||||||
|
|
||||||
|
### `server/public_training_profile.js`
|
||||||
|
Role:
|
||||||
|
- pure CommonJS backend helper for tokenized driver profiles
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
- enabled share-token lookup
|
||||||
|
- selected-driver lap filtering and aggregate statistics
|
||||||
|
- sanitized branding, class, goal, rank, and recommendation response
|
||||||
|
- clock-skew-safe personal timer start/window normalization
|
||||||
|
- bounded current-session lap, best-lap, and average calculations
|
||||||
|
|
||||||
|
Security boundary:
|
||||||
|
- returns no driver IDs, transponders, raw passings, decoder settings, or other driver names
|
||||||
|
- disabled, short, unknown, or driverless tokens return no profile
|
||||||
|
- logo data is restricted to bounded PNG/JPEG/WebP data URLs
|
||||||
|
|
||||||
### `scripts/serverctl.js`
|
### `scripts/serverctl.js`
|
||||||
Role:
|
Role:
|
||||||
- local process manager for `server.js`
|
- local process manager for `server.js`
|
||||||
@@ -511,7 +531,8 @@ Owns:
|
|||||||
- `src/event_views.js`
|
- `src/event_views.js`
|
||||||
|
|
||||||
### Backend/root imports
|
### Backend/root imports
|
||||||
- `server.js` imports Node core + `express` + `better-sqlite3`
|
- `server.js` imports Node core + `express` + `better-sqlite3` + `server/public_training_profile.js`
|
||||||
|
- `server/public_training_profile.js` imports no runtime modules
|
||||||
- `scripts/serverctl.js` imports Node core only
|
- `scripts/serverctl.js` imports Node core only
|
||||||
|
|
||||||
## 3.2 Injected dependency graph
|
## 3.2 Injected dependency graph
|
||||||
@@ -883,6 +904,7 @@ sequenceDiagram
|
|||||||
| `public-training.html` | standalone entry | `public_training.css`, `public_training.js` | `server.js` route | root only | no | no |
|
| `public-training.html` | standalone entry | `public_training.css`, `public_training.js` | `server.js` route | root only | no | no |
|
||||||
| `src/public_training.js` | standalone public view | none | `public-training.html` | yes | local timer only | no backend mutation |
|
| `src/public_training.js` | standalone public view | none | `public-training.html` | yes | local timer only | no backend mutation |
|
||||||
| `src/public_training.css` | standalone stylesheet | n/a | `public-training.html` | n/a | no | no |
|
| `src/public_training.css` | standalone stylesheet | n/a | `public-training.html` | n/a | no | no |
|
||||||
|
| `server/public_training_profile.js` | backend pure logic | none | `server.js`, tests | no | no | no |
|
||||||
| `server.js` | backend runtime | node core + deps | npm/node | n/a | backend process state | yes |
|
| `server.js` | backend runtime | node core + deps | npm/node | n/a | backend process state | yes |
|
||||||
| `scripts/serverctl.js` | process control | node core | npm scripts | n/a | no | pid/log files only |
|
| `scripts/serverctl.js` | process control | node core | npm scripts | n/a | no | pid/log files only |
|
||||||
|
|
||||||
@@ -2045,6 +2067,12 @@ npm test
|
|||||||
- recommendation branch selection
|
- recommendation branch selection
|
||||||
- custom goal and automatic milestone progress
|
- custom goal and automatic milestone progress
|
||||||
|
|
||||||
|
`tests/public_training_profile.test.js` covers:
|
||||||
|
- enabled/disabled/unknown share-token handling
|
||||||
|
- selected-driver isolation and response sanitization
|
||||||
|
- personal timer aggregate and frozen-window calculations
|
||||||
|
- clock-skew-safe query normalization and bounds
|
||||||
|
|
||||||
The suite uses Node's built-in `node:test` runner and does not add a test-framework dependency. Windows PowerShell/CMD can run the same suite with `npm.cmd test`.
|
The suite uses Node's built-in `node:test` runner and does not add a test-framework dependency. Windows PowerShell/CMD can run the same suite with `npm.cmd test`.
|
||||||
|
|
||||||
## 19. Recommended Future Additions
|
## 19. Recommended Future Additions
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"status": "node scripts/serverctl.js status",
|
"status": "node scripts/serverctl.js status",
|
||||||
"restart": "node scripts/serverctl.js restart",
|
"restart": "node scripts/serverctl.js restart",
|
||||||
"verify": "node scripts/verify-install.js",
|
"verify": "node scripts/verify-install.js",
|
||||||
"test": "node --experimental-default-type=module tests/training_logic.test.js",
|
"test": "node --experimental-default-type=module tests/run.js",
|
||||||
"setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1"
|
"setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
197
server.js
197
server.js
@@ -4,6 +4,7 @@ const os = require("os");
|
|||||||
const { spawn } = require("child_process");
|
const { spawn } = require("child_process");
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const Database = require("better-sqlite3");
|
const Database = require("better-sqlite3");
|
||||||
|
const publicTrainingProfile = require("./server/public_training_profile");
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 8081);
|
const PORT = Number(process.env.PORT || 8081);
|
||||||
const HOST = process.env.HOST || "0.0.0.0";
|
const HOST = process.env.HOST || "0.0.0.0";
|
||||||
@@ -284,9 +285,9 @@ app.get(["/public-overlay", "/public-overlay/:mode"], (req, res) => {
|
|||||||
app.get("/public-training/api/:shareToken", (req, res) => {
|
app.get("/public-training/api/:shareToken", (req, res) => {
|
||||||
try {
|
try {
|
||||||
const stored = readStoredAppState();
|
const stored = readStoredAppState();
|
||||||
const sinceTimestamp = normalizePublicTrainingSince(req.query.since, req.query.startedAgoMs);
|
const sinceTimestamp = publicTrainingProfile.normalizePublicTrainingSince(req.query.since, req.query.startedAgoMs);
|
||||||
const windowMs = normalizePublicTrainingWindow(req.query.windowMs);
|
const windowMs = publicTrainingProfile.normalizePublicTrainingWindow(req.query.windowMs);
|
||||||
const profile = stored ? buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs) : null;
|
const profile = stored ? publicTrainingProfile.buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs) : null;
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
res.status(404).json({ error: "Profile not found" });
|
res.status(404).json({ error: "Profile not found" });
|
||||||
return;
|
return;
|
||||||
@@ -301,7 +302,7 @@ app.get("/public-training/api/:shareToken", (req, res) => {
|
|||||||
app.get("/public-training/:shareToken", (req, res) => {
|
app.get("/public-training/:shareToken", (req, res) => {
|
||||||
try {
|
try {
|
||||||
const stored = readStoredAppState();
|
const stored = readStoredAppState();
|
||||||
if (!stored || !hasPublicTrainingShare(stored.state, String(req.params.shareToken || ""))) {
|
if (!stored || !publicTrainingProfile.hasPublicTrainingShare(stored.state, String(req.params.shareToken || ""))) {
|
||||||
res.status(404).type("text/plain").send("Profile not found");
|
res.status(404).type("text/plain").send("Profile not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -376,194 +377,6 @@ function readStoredAppState() {
|
|||||||
return { state: JSON.parse(row.state_json), updatedAt: row.updated_at };
|
return { state: JSON.parse(row.state_json), updatedAt: row.updated_at };
|
||||||
}
|
}
|
||||||
|
|
||||||
function findPublicTrainingShare(state, shareToken) {
|
|
||||||
if (!shareToken || shareToken.length < 24) return null;
|
|
||||||
const shares = Array.isArray(state?.training?.shares) ? state.training.shares : [];
|
|
||||||
return shares.find((share) => share?.enabled !== false && String(share?.token || "") === shareToken) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasPublicTrainingShare(state, shareToken) {
|
|
||||||
return Boolean(findPublicTrainingShare(state, shareToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) {
|
|
||||||
const share = findPublicTrainingShare(state, shareToken);
|
|
||||||
if (!share) return null;
|
|
||||||
const driverId = String(share.driverId || "");
|
|
||||||
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
|
|
||||||
const driver = drivers.find((item) => String(item?.id || "") === driverId);
|
|
||||||
if (!driver) return null;
|
|
||||||
|
|
||||||
const classes = Array.isArray(state?.classes) ? state.classes : [];
|
|
||||||
const driverClass = classes.find((item) => String(item?.id || "") === String(driver.classId || ""));
|
|
||||||
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
|
||||||
const driverLaps = ledger
|
|
||||||
.filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500)
|
|
||||||
.map((entry) => ({
|
|
||||||
timestamp: Math.max(0, Number(entry.timestamp || 0) || 0),
|
|
||||||
lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)),
|
|
||||||
sessionId: String(entry.sessionId || ""),
|
|
||||||
}))
|
|
||||||
.sort((left, right) => left.timestamp - right.timestamp);
|
|
||||||
const stats = buildPublicTrainingStats(driverLaps);
|
|
||||||
const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : [])
|
|
||||||
.filter((goal) => String(goal?.driverId || "") === driverId)
|
|
||||||
.map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps));
|
|
||||||
const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean)));
|
|
||||||
const rankIndex = rankedTotals.findIndex(([id]) => id === driverId);
|
|
||||||
const settings = state?.settings && typeof state.settings === "object" ? state.settings : {};
|
|
||||||
const currentSession = sinceTimestamp
|
|
||||||
? buildPublicTrainingWindowStats(driverLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs)))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
updatedAt: String(updatedAt || ""),
|
|
||||||
profile: {
|
|
||||||
name: String(driver.name || "Driver").trim(),
|
|
||||||
brand: String(driver.brand || "").trim(),
|
|
||||||
className: String(driverClass?.name || "").trim(),
|
|
||||||
clubRank: rankIndex >= 0 ? rankIndex + 1 : 0,
|
|
||||||
rankedDrivers: rankedTotals.length,
|
|
||||||
},
|
|
||||||
branding: {
|
|
||||||
clubName: String(settings.clubName || "JMK RB RaceController").trim(),
|
|
||||||
clubTagline: String(settings.clubTagline || "RC Timing System").trim(),
|
|
||||||
logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl),
|
|
||||||
},
|
|
||||||
stats,
|
|
||||||
nextMilestone: getPublicTrainingMilestone(stats.totalLaps),
|
|
||||||
recommendation: getPublicTrainingRecommendation(stats),
|
|
||||||
goals,
|
|
||||||
currentSession,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePublicTrainingSince(value, startedAgoValue) {
|
|
||||||
const now = Date.now();
|
|
||||||
if (startedAgoValue !== undefined) {
|
|
||||||
const startedAgoMs = Math.round(Number(startedAgoValue));
|
|
||||||
if (Number.isFinite(startedAgoMs) && startedAgoMs >= 0 && startedAgoMs <= 24 * 60 * 60 * 1000) {
|
|
||||||
return now - startedAgoMs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const timestamp = Math.round(Number(value || 0) || 0);
|
|
||||||
return timestamp >= now - 24 * 60 * 60 * 1000 && timestamp <= now + 60 * 1000 ? timestamp : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePublicTrainingWindow(value) {
|
|
||||||
const windowMs = Math.round(Number(value || 0) || 0);
|
|
||||||
return Math.min(24 * 60 * 60 * 1000, Math.max(0, windowMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPublicTrainingWindowStats(laps) {
|
|
||||||
const lapTimes = laps.map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500);
|
|
||||||
return {
|
|
||||||
laps: lapTimes.length,
|
|
||||||
bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 0,
|
|
||||||
averageLapMs: lapTimes.length ? Math.round(lapTimes.reduce((sum, lapMs) => sum + lapMs, 0) / lapTimes.length) : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPublicTrainingGoal(goal, currentLaps) {
|
|
||||||
const targetLaps = Math.max(1, Math.round(Number(goal?.targetLaps || 1) || 1));
|
|
||||||
return {
|
|
||||||
title: String(goal?.title || "").trim(),
|
|
||||||
reward: String(goal?.reward || "").trim(),
|
|
||||||
targetLaps,
|
|
||||||
currentLaps,
|
|
||||||
remainingLaps: Math.max(0, targetLaps - currentLaps),
|
|
||||||
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
|
|
||||||
reached: currentLaps >= targetLaps,
|
|
||||||
claimed: Boolean(goal?.claimedAt),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRankedTrainingTotals(ledger, registeredDriverIds) {
|
|
||||||
const totals = new Map();
|
|
||||||
ledger.forEach((entry) => {
|
|
||||||
const driverId = String(entry?.driverId || "");
|
|
||||||
if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return;
|
|
||||||
totals.set(driverId, (totals.get(driverId) || 0) + 1);
|
|
||||||
});
|
|
||||||
return [...totals.entries()].sort((left, right) => right[1] - left[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPublicTrainingStats(laps) {
|
|
||||||
const lapTimes = laps.map((lap) => lap.lapMs);
|
|
||||||
const recent = lapTimes.slice(-30);
|
|
||||||
const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0;
|
|
||||||
const sortedRecent = [...recent].sort((left, right) => left - right);
|
|
||||||
const percentile = (ratio) => sortedRecent.length
|
|
||||||
? sortedRecent[Math.min(sortedRecent.length - 1, Math.max(0, Math.round((sortedRecent.length - 1) * ratio)))]
|
|
||||||
: 0;
|
|
||||||
const recentAverageMs = average(recent);
|
|
||||||
const daily = new Map();
|
|
||||||
laps.forEach((lap) => {
|
|
||||||
const date = new Date(lap.timestamp);
|
|
||||||
const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
||||||
const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 };
|
|
||||||
day.laps += 1;
|
|
||||||
day.totalMs += lap.lapMs;
|
|
||||||
day.bestLapMs = day.bestLapMs ? Math.min(day.bestLapMs, lap.lapMs) : lap.lapMs;
|
|
||||||
day.latestTimestamp = Math.max(day.latestTimestamp, lap.timestamp);
|
|
||||||
daily.set(dateKey, day);
|
|
||||||
});
|
|
||||||
const dailySummaries = [...daily.values()]
|
|
||||||
.map((day) => ({
|
|
||||||
dateKey: day.dateKey,
|
|
||||||
laps: day.laps,
|
|
||||||
bestLapMs: day.bestLapMs,
|
|
||||||
latestTimestamp: day.latestTimestamp,
|
|
||||||
averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0,
|
|
||||||
}))
|
|
||||||
.sort((left, right) => right.latestTimestamp - left.latestTimestamp)
|
|
||||||
.slice(0, 30);
|
|
||||||
const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0;
|
|
||||||
const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
|
|
||||||
let controlledLapStreak = 0;
|
|
||||||
for (let index = lapTimes.length - 1; index >= 0; index -= 1) {
|
|
||||||
if (!controlTargetMs || lapTimes[index] > controlTargetMs) break;
|
|
||||||
controlledLapStreak += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalLaps: laps.length,
|
|
||||||
totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size,
|
|
||||||
trainingDays: daily.size,
|
|
||||||
bestLapMs,
|
|
||||||
averageLapMs: average(lapTimes),
|
|
||||||
recentAverageMs,
|
|
||||||
recentLapCount: recent.length,
|
|
||||||
consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0,
|
|
||||||
controlledLapStreak,
|
|
||||||
dailySummaries,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePublicLogoDataUrl(value) {
|
|
||||||
const logo = String(value || "").trim();
|
|
||||||
return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPublicTrainingMilestone(currentLaps) {
|
|
||||||
const presets = [100, 250, 500, 1000, 1500, 2500, 5000];
|
|
||||||
const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000;
|
|
||||||
return {
|
|
||||||
currentLaps,
|
|
||||||
targetLaps,
|
|
||||||
remainingLaps: Math.max(0, targetLaps - currentLaps),
|
|
||||||
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPublicTrainingRecommendation(stats) {
|
|
||||||
if (stats.totalLaps < 10) return { level: "baseline", targetLapMs: 0 };
|
|
||||||
if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) {
|
|
||||||
return { level: "consistency", targetLapMs: Math.round(stats.bestLapMs * 1.08) };
|
|
||||||
}
|
|
||||||
return { level: "progression", targetLapMs: Math.round(stats.bestLapMs * 1.05) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePdfExportPayload(input = {}) {
|
function normalizePdfExportPayload(input = {}) {
|
||||||
const title = String(input.title || "JMK RB Live Event").trim();
|
const title = String(input.title || "JMK RB Live Event").trim();
|
||||||
const subtitle = String(input.subtitle || "").trim();
|
const subtitle = String(input.subtitle || "").trim();
|
||||||
|
|||||||
195
server/public_training_profile.js
Normal file
195
server/public_training_profile.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
function findPublicTrainingShare(state, shareToken) {
|
||||||
|
if (!shareToken || shareToken.length < 24) return null;
|
||||||
|
const shares = Array.isArray(state?.training?.shares) ? state.training.shares : [];
|
||||||
|
return shares.find((share) => share?.enabled !== false && String(share?.token || "") === shareToken) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPublicTrainingShare(state, shareToken) {
|
||||||
|
return Boolean(findPublicTrainingShare(state, shareToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) {
|
||||||
|
const share = findPublicTrainingShare(state, shareToken);
|
||||||
|
if (!share) return null;
|
||||||
|
const driverId = String(share.driverId || "");
|
||||||
|
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
|
||||||
|
const driver = drivers.find((item) => String(item?.id || "") === driverId);
|
||||||
|
if (!driver) return null;
|
||||||
|
|
||||||
|
const classes = Array.isArray(state?.classes) ? state.classes : [];
|
||||||
|
const driverClass = classes.find((item) => String(item?.id || "") === String(driver.classId || ""));
|
||||||
|
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
||||||
|
const driverLaps = ledger
|
||||||
|
.filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500)
|
||||||
|
.map((entry) => ({
|
||||||
|
timestamp: Math.max(0, Number(entry.timestamp || 0) || 0),
|
||||||
|
lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)),
|
||||||
|
sessionId: String(entry.sessionId || ""),
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.timestamp - right.timestamp);
|
||||||
|
const stats = buildPublicTrainingStats(driverLaps);
|
||||||
|
const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : [])
|
||||||
|
.filter((goal) => String(goal?.driverId || "") === driverId)
|
||||||
|
.map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps));
|
||||||
|
const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean)));
|
||||||
|
const rankIndex = rankedTotals.findIndex(([id]) => id === driverId);
|
||||||
|
const settings = state?.settings && typeof state.settings === "object" ? state.settings : {};
|
||||||
|
const currentSession = sinceTimestamp
|
||||||
|
? buildPublicTrainingWindowStats(driverLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: String(updatedAt || ""),
|
||||||
|
profile: {
|
||||||
|
name: String(driver.name || "Driver").trim(),
|
||||||
|
brand: String(driver.brand || "").trim(),
|
||||||
|
className: String(driverClass?.name || "").trim(),
|
||||||
|
clubRank: rankIndex >= 0 ? rankIndex + 1 : 0,
|
||||||
|
rankedDrivers: rankedTotals.length,
|
||||||
|
},
|
||||||
|
branding: {
|
||||||
|
clubName: String(settings.clubName || "JMK RB RaceController").trim(),
|
||||||
|
clubTagline: String(settings.clubTagline || "RC Timing System").trim(),
|
||||||
|
logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl),
|
||||||
|
},
|
||||||
|
stats,
|
||||||
|
nextMilestone: getPublicTrainingMilestone(stats.totalLaps),
|
||||||
|
recommendation: getPublicTrainingRecommendation(stats),
|
||||||
|
goals,
|
||||||
|
currentSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublicTrainingSince(value, startedAgoValue) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (startedAgoValue !== undefined) {
|
||||||
|
const startedAgoMs = Math.round(Number(startedAgoValue));
|
||||||
|
if (Number.isFinite(startedAgoMs) && startedAgoMs >= 0 && startedAgoMs <= 24 * 60 * 60 * 1000) {
|
||||||
|
return now - startedAgoMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const timestamp = Math.round(Number(value || 0) || 0);
|
||||||
|
return timestamp >= now - 24 * 60 * 60 * 1000 && timestamp <= now + 60 * 1000 ? timestamp : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublicTrainingWindow(value) {
|
||||||
|
const windowMs = Math.round(Number(value || 0) || 0);
|
||||||
|
return Math.min(24 * 60 * 60 * 1000, Math.max(0, windowMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicTrainingWindowStats(laps) {
|
||||||
|
const lapTimes = laps.map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500);
|
||||||
|
return {
|
||||||
|
laps: lapTimes.length,
|
||||||
|
bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 0,
|
||||||
|
averageLapMs: lapTimes.length ? Math.round(lapTimes.reduce((sum, lapMs) => sum + lapMs, 0) / lapTimes.length) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicTrainingGoal(goal, currentLaps) {
|
||||||
|
const targetLaps = Math.max(1, Math.round(Number(goal?.targetLaps || 1) || 1));
|
||||||
|
return {
|
||||||
|
title: String(goal?.title || "").trim(),
|
||||||
|
reward: String(goal?.reward || "").trim(),
|
||||||
|
targetLaps,
|
||||||
|
currentLaps,
|
||||||
|
remainingLaps: Math.max(0, targetLaps - currentLaps),
|
||||||
|
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
|
||||||
|
reached: currentLaps >= targetLaps,
|
||||||
|
claimed: Boolean(goal?.claimedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRankedTrainingTotals(ledger, registeredDriverIds) {
|
||||||
|
const totals = new Map();
|
||||||
|
ledger.forEach((entry) => {
|
||||||
|
const driverId = String(entry?.driverId || "");
|
||||||
|
if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return;
|
||||||
|
totals.set(driverId, (totals.get(driverId) || 0) + 1);
|
||||||
|
});
|
||||||
|
return [...totals.entries()].sort((left, right) => right[1] - left[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicTrainingStats(laps) {
|
||||||
|
const lapTimes = laps.map((lap) => lap.lapMs);
|
||||||
|
const recent = lapTimes.slice(-30);
|
||||||
|
const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0;
|
||||||
|
const sortedRecent = [...recent].sort((left, right) => left - right);
|
||||||
|
const percentile = (ratio) => sortedRecent.length
|
||||||
|
? sortedRecent[Math.min(sortedRecent.length - 1, Math.max(0, Math.round((sortedRecent.length - 1) * ratio)))]
|
||||||
|
: 0;
|
||||||
|
const recentAverageMs = average(recent);
|
||||||
|
const daily = new Map();
|
||||||
|
laps.forEach((lap) => {
|
||||||
|
const date = new Date(lap.timestamp);
|
||||||
|
const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 };
|
||||||
|
day.laps += 1;
|
||||||
|
day.totalMs += lap.lapMs;
|
||||||
|
day.bestLapMs = day.bestLapMs ? Math.min(day.bestLapMs, lap.lapMs) : lap.lapMs;
|
||||||
|
day.latestTimestamp = Math.max(day.latestTimestamp, lap.timestamp);
|
||||||
|
daily.set(dateKey, day);
|
||||||
|
});
|
||||||
|
const dailySummaries = [...daily.values()]
|
||||||
|
.map((day) => ({
|
||||||
|
dateKey: day.dateKey,
|
||||||
|
laps: day.laps,
|
||||||
|
bestLapMs: day.bestLapMs,
|
||||||
|
latestTimestamp: day.latestTimestamp,
|
||||||
|
averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => right.latestTimestamp - left.latestTimestamp)
|
||||||
|
.slice(0, 30);
|
||||||
|
const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0;
|
||||||
|
const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
|
||||||
|
let controlledLapStreak = 0;
|
||||||
|
for (let index = lapTimes.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (!controlTargetMs || lapTimes[index] > controlTargetMs) break;
|
||||||
|
controlledLapStreak += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalLaps: laps.length,
|
||||||
|
totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size,
|
||||||
|
trainingDays: daily.size,
|
||||||
|
bestLapMs,
|
||||||
|
averageLapMs: average(lapTimes),
|
||||||
|
recentAverageMs,
|
||||||
|
recentLapCount: recent.length,
|
||||||
|
consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0,
|
||||||
|
controlledLapStreak,
|
||||||
|
dailySummaries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePublicLogoDataUrl(value) {
|
||||||
|
const logo = String(value || "").trim();
|
||||||
|
return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicTrainingMilestone(currentLaps) {
|
||||||
|
const presets = [100, 250, 500, 1000, 1500, 2500, 5000];
|
||||||
|
const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000;
|
||||||
|
return {
|
||||||
|
currentLaps,
|
||||||
|
targetLaps,
|
||||||
|
remainingLaps: Math.max(0, targetLaps - currentLaps),
|
||||||
|
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicTrainingRecommendation(stats) {
|
||||||
|
if (stats.totalLaps < 10) return { level: "baseline", targetLapMs: 0 };
|
||||||
|
if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) {
|
||||||
|
return { level: "consistency", targetLapMs: Math.round(stats.bestLapMs * 1.08) };
|
||||||
|
}
|
||||||
|
return { level: "progression", targetLapMs: Math.round(stats.bestLapMs * 1.05) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildPublicTrainingProfile,
|
||||||
|
findPublicTrainingShare,
|
||||||
|
hasPublicTrainingShare,
|
||||||
|
normalizePublicTrainingSince,
|
||||||
|
normalizePublicTrainingWindow,
|
||||||
|
};
|
||||||
111
tests/public_training_profile.test.js
Normal file
111
tests/public_training_profile.test.js
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const {
|
||||||
|
buildPublicTrainingProfile,
|
||||||
|
findPublicTrainingShare,
|
||||||
|
hasPublicTrainingShare,
|
||||||
|
normalizePublicTrainingSince,
|
||||||
|
normalizePublicTrainingWindow,
|
||||||
|
} = require("../server/public_training_profile.js");
|
||||||
|
|
||||||
|
const DRIVER_ONE_TOKEN = "driver-one-token-12345678901234567890";
|
||||||
|
const DRIVER_TWO_TOKEN = "driver-two-token-12345678901234567890";
|
||||||
|
const DISABLED_TOKEN = "disabled-token-123456789012345678901";
|
||||||
|
|
||||||
|
function buildState(now = Date.now()) {
|
||||||
|
return {
|
||||||
|
drivers: [
|
||||||
|
{ id: "driver_1", name: "Driver One", brand: "Associated", classId: "class_1", transponder: "secret-transponder-one" },
|
||||||
|
{ id: "driver_2", name: "Driver Two", brand: "Xray", classId: "class_2", transponder: "secret-transponder-two" },
|
||||||
|
],
|
||||||
|
classes: [
|
||||||
|
{ id: "class_1", name: "Modified" },
|
||||||
|
{ id: "class_2", name: "Stock" },
|
||||||
|
],
|
||||||
|
settings: {
|
||||||
|
clubName: "Test Club",
|
||||||
|
clubTagline: "Practice",
|
||||||
|
logoDataUrl: "javascript:alert(1)",
|
||||||
|
wsUrl: "ws://secret-decoder:9000",
|
||||||
|
},
|
||||||
|
training: {
|
||||||
|
shares: [
|
||||||
|
{ driverId: "driver_1", token: DRIVER_ONE_TOKEN, enabled: true },
|
||||||
|
{ driverId: "driver_2", token: DRIVER_TWO_TOKEN, enabled: true },
|
||||||
|
{ driverId: "driver_1", token: DISABLED_TOKEN, enabled: false },
|
||||||
|
],
|
||||||
|
goals: [
|
||||||
|
{ driverId: "driver_1", title: "Bronze", reward: "Badge", targetLaps: 5 },
|
||||||
|
{ driverId: "driver_2", title: "Other goal", reward: "Other reward", targetLaps: 10 },
|
||||||
|
],
|
||||||
|
ledger: [
|
||||||
|
{ driverId: "driver_1", sessionId: "free", timestamp: now - 25 * 60000, lapMs: 19000 },
|
||||||
|
{ driverId: "driver_1", sessionId: "free", timestamp: now - 120000, lapMs: 18000 },
|
||||||
|
{ driverId: "driver_1", sessionId: "free", timestamp: now - 12000, lapMs: 16500 },
|
||||||
|
{ driverId: "driver_1", sessionId: "free", timestamp: now - 4000, lapMs: 16000 },
|
||||||
|
{ driverId: "driver_1", sessionId: "free", timestamp: now - 2000, lapMs: 500 },
|
||||||
|
{ driverId: "driver_2", sessionId: "free", timestamp: now - 3000, lapMs: 12000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("public share lookup rejects short, disabled, and unknown tokens", () => {
|
||||||
|
const state = buildState();
|
||||||
|
assert.equal(findPublicTrainingShare(state, "short"), null);
|
||||||
|
assert.equal(findPublicTrainingShare(state, DISABLED_TOKEN), null);
|
||||||
|
assert.equal(hasPublicTrainingShare(state, "unknown-token-123456789012345678"), false);
|
||||||
|
assert.equal(hasPublicTrainingShare(state, DRIVER_ONE_TOKEN), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public profile contains only the selected driver's sanitized aggregates", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "2026-08-24T12:00:00.000Z", now - 30000, 30000);
|
||||||
|
|
||||||
|
assert.equal(profile.profile.name, "Driver One");
|
||||||
|
assert.equal(profile.profile.brand, "Associated");
|
||||||
|
assert.equal(profile.profile.className, "Modified");
|
||||||
|
assert.equal(profile.profile.clubRank, 1);
|
||||||
|
assert.equal(profile.profile.rankedDrivers, 2);
|
||||||
|
assert.equal(profile.branding.clubName, "Test Club");
|
||||||
|
assert.equal(profile.branding.logoDataUrl, "");
|
||||||
|
assert.equal(profile.stats.totalLaps, 4);
|
||||||
|
assert.equal(profile.stats.bestLapMs, 16000);
|
||||||
|
assert.deepEqual(profile.currentSession, { laps: 2, bestLapMs: 16000, averageLapMs: 16250 });
|
||||||
|
assert.equal(profile.goals.length, 1);
|
||||||
|
assert.equal(profile.goals[0].title, "Bronze");
|
||||||
|
assert.equal(profile.goals[0].percent, 80);
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(profile);
|
||||||
|
assert.equal(serialized.includes("Driver Two"), false);
|
||||||
|
assert.equal(serialized.includes("driver_1"), false);
|
||||||
|
assert.equal(serialized.includes("secret-transponder"), false);
|
||||||
|
assert.equal(serialized.includes("secret-decoder"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public timer window freezes aggregates after the personal session", () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "", now - 30 * 60000, 20 * 60000);
|
||||||
|
|
||||||
|
assert.deepEqual(profile.currentSession, { laps: 1, bestLapMs: 19000, averageLapMs: 19000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public profile returns null when token or driver is unavailable", () => {
|
||||||
|
const state = buildState();
|
||||||
|
assert.equal(buildPublicTrainingProfile(state, DISABLED_TOKEN, ""), null);
|
||||||
|
state.drivers = state.drivers.filter((driver) => driver.id !== "driver_1");
|
||||||
|
assert.equal(buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, ""), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public timer query normalization bounds age and window values", () => {
|
||||||
|
const before = Date.now();
|
||||||
|
const since = normalizePublicTrainingSince(undefined, "30000");
|
||||||
|
const after = Date.now();
|
||||||
|
assert.ok(since >= before - 30000 && since <= after - 30000);
|
||||||
|
assert.equal(normalizePublicTrainingSince(undefined, String(25 * 60 * 60 * 1000)), 0);
|
||||||
|
assert.equal(normalizePublicTrainingWindow("-100"), 0);
|
||||||
|
assert.equal(normalizePublicTrainingWindow(String(25 * 60 * 60 * 1000)), 24 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
2
tests/run.js
Normal file
2
tests/run.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import "./training_logic.test.js";
|
||||||
|
import "./public_training_profile.test.js";
|
||||||
Reference in New Issue
Block a user