Add per-driver live stats for timed practice sessions
This commit is contained in:
@@ -200,7 +200,7 @@ Each registered driver can have an optional personal mobile link:
|
||||
|
||||
The URL uses a 192-bit random bearer token rather than a driver ID. The public API returns only that driver's display name, class/brand, aggregate practice statistics, daily summaries, goals and reward text. It does not return transponder IDs, other driver names, race setup or admin state.
|
||||
|
||||
The mobile page also includes a local 20-minute practice timer: 10 minutes maximum focus followed by 10 minutes controlled 80-90% driving. Timer state stays in that phone's browser and does not modify race data.
|
||||
The mobile page also includes a local 20-minute practice timer: 10 minutes maximum focus followed by 10 minutes controlled 80-90% driving. While a shared Free Practice session keeps running, the card shows that driver's lap count, best lap and average since the personal timer started. Timer state stays in that phone's browser and does not modify race data.
|
||||
|
||||
For internet publishing, proxy only these paths:
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ Varje registrerad förare kan få en valfri personlig mobillänk:
|
||||
|
||||
Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Det publika API:t lämnar bara ut den valda förarens visningsnamn, klass/märke, summerad träningsstatistik, dagshistorik, mål och belöningstext. Transponder-ID, andra förares namn, race setup och admin-state lämnas inte ut.
|
||||
|
||||
Mobilsidan har också en lokal 20-minuterstimer: 10 minuter maxfokus följt av 10 minuter kontrollerad körning på 80-90 procent. Timern sparas bara i mobilens webbläsare och ändrar ingen racedata.
|
||||
Mobilsidan har också en lokal 20-minuterstimer: 10 minuter maxfokus följt av 10 minuter kontrollerad körning på 80-90 procent. Medan en gemensam Free Practice fortsätter vara igång visar kortet just den förarens antal varv, bästa varv och snitt sedan den personliga timern startades. Timern sparas bara i mobilens webbläsare och ändrar ingen racedata.
|
||||
|
||||
Vid extern publicering ska bara dessa paths proxas:
|
||||
|
||||
|
||||
@@ -465,6 +465,7 @@ Owns:
|
||||
- fetching only `/public-training/api/:shareToken`
|
||||
- rendering one driver's sanitized statistics, goals, rewards, and daily history
|
||||
- local 20-minute practice timer with 10-minute push/control phases
|
||||
- token-scoped start-age and bounded-window polling for aggregate lap count, best lap, and average during the personal timer; using an age avoids mobile/server clock-skew errors and the bounded window freezes results after the timer finishes
|
||||
- Swedish/English copy selected by `lang` query or browser locale
|
||||
|
||||
Important isolation:
|
||||
@@ -472,6 +473,7 @@ Important isolation:
|
||||
- does not call `/api/state`
|
||||
- does not mutate backend or race state
|
||||
- timer state is stored in the mobile browser under a token-scoped localStorage key
|
||||
- session-window responses contain aggregates only; individual lap rows and timestamps are not exposed
|
||||
|
||||
### `src/public_training.css`
|
||||
Type:
|
||||
@@ -1904,6 +1906,7 @@ Sanitized response fields:
|
||||
- selected driver display name, class, and brand
|
||||
- numeric club rank without other driver names
|
||||
- aggregate totals, best/average time, consistency, controlled streak, and daily summaries
|
||||
- optional `currentSession` aggregates when a valid start age (or backward-compatible recent `since` timestamp) is supplied
|
||||
- next automatic milestone
|
||||
- recommendation level and target lap
|
||||
- selected driver's goal titles, reward text, progress, and claimed state
|
||||
|
||||
36
server.js
36
server.js
@@ -284,7 +284,9 @@ app.get(["/public-overlay", "/public-overlay/:mode"], (req, res) => {
|
||||
app.get("/public-training/api/:shareToken", (req, res) => {
|
||||
try {
|
||||
const stored = readStoredAppState();
|
||||
const profile = stored ? buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt) : null;
|
||||
const sinceTimestamp = normalizePublicTrainingSince(req.query.since, req.query.startedAgoMs);
|
||||
const windowMs = normalizePublicTrainingWindow(req.query.windowMs);
|
||||
const profile = stored ? buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs) : null;
|
||||
if (!profile) {
|
||||
res.status(404).json({ error: "Profile not found" });
|
||||
return;
|
||||
@@ -384,7 +386,7 @@ function hasPublicTrainingShare(state, shareToken) {
|
||||
return Boolean(findPublicTrainingShare(state, shareToken));
|
||||
}
|
||||
|
||||
function buildPublicTrainingProfile(state, shareToken, updatedAt) {
|
||||
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) {
|
||||
const share = findPublicTrainingShare(state, shareToken);
|
||||
if (!share) return null;
|
||||
const driverId = String(share.driverId || "");
|
||||
@@ -410,6 +412,9 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt) {
|
||||
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 || ""),
|
||||
@@ -429,6 +434,33 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1055,7 +1055,7 @@ const TRANSLATIONS = {
|
||||
"guide.training_5": "5. När målet nåtts kan belöningen markeras som utdelad. Direktlänken `/?view=training&driver=<id>` kan bokmärkas på förarens mobil.",
|
||||
"guide.training_6": "6. Träningsloggen uppdateras av domarkorrigeringar medan sessionen finns, men behålls när sessionen senare raderas. Full dataexport i Inställningar inkluderar både logg och mål.",
|
||||
"guide.training_7": "7. Öppna Mål och mobilprofil och klicka Aktivera mobilprofil. Kopiera den personliga länken och öppna den på förarens eller förälderns mobil.",
|
||||
"guide.training_8": "8. Mobilprofilen är skrivskyddad och visar bara vald förares statistik. Där finns också en lokal 20-minuterstimer som växlar från 10 minuter maxfokus till 10 minuter kontrollerad körning.",
|
||||
"guide.training_8": "8. Mobilprofilen är skrivskyddad och visar bara vald förares statistik. Den lokala 20-minuterstimern växlar från 10 minuter maxfokus till 10 minuter kontrollerad körning och visar förarens varv, bästa varv och snitt under det egna passet.",
|
||||
"guide.training_9": "9. Klicka Skapa ny länk eller Stäng av profil om länken spridits fel. Publicera bara `/public-training/*`, `/src/public_training.js` och `/src/public_training.css` externt, aldrig hela admin-sidan eller `/api/state`.",
|
||||
"overlay.title": "Overlay",
|
||||
"overlay.subtitle": "Extern leaderboard-skärm",
|
||||
@@ -1949,7 +1949,7 @@ const TRANSLATIONS = {
|
||||
"guide.training_5": "5. When a target is reached, mark its reward as delivered. Bookmark `/?view=training&driver=<id>` on the driver's phone for direct access.",
|
||||
"guide.training_6": "6. The training ledger follows judging corrections while the source session exists, but remains when that session is later deleted. Full data export in Settings includes both ledger and goals.",
|
||||
"guide.training_7": "7. Open Goals and mobile profile, then click Enable mobile profile. Copy the personal link and open it on the driver's or parent's phone.",
|
||||
"guide.training_8": "8. The mobile profile is read-only and only shows the selected driver's statistics. It also includes a local 20-minute timer that changes from 10 minutes maximum focus to 10 minutes controlled driving.",
|
||||
"guide.training_8": "8. The mobile profile is read-only and only shows the selected driver's statistics. Its local 20-minute timer changes from 10 minutes maximum focus to 10 minutes controlled driving and shows the driver's laps, best lap and average during that personal session.",
|
||||
"guide.training_9": "9. Click Create new link or Disable profile if the link is shared incorrectly. Expose only `/public-training/*`, `/src/public_training.js` and `/src/public_training.css` externally, never the full admin site or `/api/state`.",
|
||||
"overlay.title": "Overlay",
|
||||
"overlay.subtitle": "External leaderboard screen",
|
||||
|
||||
@@ -277,6 +277,52 @@ button {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-session-heading {
|
||||
margin: 16px 0 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-session-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.profile-session-stats > div {
|
||||
min-width: 0;
|
||||
padding: 10px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-session-stats span,
|
||||
.profile-session-stats strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-session-stats span {
|
||||
color: var(--muted);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-session-stats strong {
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
font-family: Orbitron, sans-serif;
|
||||
font-size: clamp(0.8rem, 3vw, 1rem);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -22,6 +22,8 @@ const messages = {
|
||||
reachLaps: "Nå {laps} varv",
|
||||
lapsRemaining: "{laps} varv kvar",
|
||||
plan: "Dagens 20-minuterspass",
|
||||
sessionStats: "Det här passet",
|
||||
sessionLaps: "Varv",
|
||||
baselineTitle: "Bygg en baslinje",
|
||||
baselineBody: "Kör lugnt och jämnt tills du har minst 10 rena varv. Upprepa samma linje och bromspunkt.",
|
||||
consistencyTitle: "Höj din lägstanivå",
|
||||
@@ -64,6 +66,8 @@ const messages = {
|
||||
reachLaps: "Reach {laps} laps",
|
||||
lapsRemaining: "{laps} laps remaining",
|
||||
plan: "Today's 20-minute session",
|
||||
sessionStats: "This session",
|
||||
sessionLaps: "Laps",
|
||||
baselineTitle: "Build a baseline",
|
||||
baselineBody: "Drive calmly and consistently until at least 10 clean laps are recorded. Repeat the same line and braking point.",
|
||||
consistencyTitle: "Raise your minimum level",
|
||||
@@ -137,7 +141,7 @@ function recommendationCopy(recommendation) {
|
||||
|
||||
function renderProfile(data) {
|
||||
profileData = data;
|
||||
const { profile, branding, stats, nextMilestone, recommendation, goals } = data;
|
||||
const { profile, branding, stats, nextMilestone, recommendation, goals, currentSession } = data;
|
||||
const recommendationText = recommendationCopy(recommendation);
|
||||
const rank = profile.clubRank && profile.rankedDrivers ? t("rank", { rank: profile.clubRank, count: profile.rankedDrivers }) : "";
|
||||
const logo = /^data:image\/(?:png|jpeg|webp);base64,/i.test(branding.logoDataUrl || "")
|
||||
@@ -215,6 +219,14 @@ function renderProfile(data) {
|
||||
<p class="profile-plan-copy">${escapeHtml(recommendationText.body)}</p>
|
||||
<div id="profileTimer" class="profile-timer">20:00</div>
|
||||
<div id="profileTimerPhase" class="profile-timer-phase">${escapeHtml(t("ready"))}</div>
|
||||
${currentSession ? `
|
||||
<div class="profile-session-heading">${escapeHtml(t("sessionStats"))}</div>
|
||||
<div class="profile-session-stats">
|
||||
<div><span>${escapeHtml(t("sessionLaps"))}</span><strong>${currentSession.laps}</strong></div>
|
||||
<div><span>${escapeHtml(t("best"))}</span><strong>${formatLap(currentSession.bestLapMs)}</strong></div>
|
||||
<div><span>${escapeHtml(t("average"))}</span><strong>${formatLap(currentSession.averageLapMs)}</strong></div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div class="profile-actions">
|
||||
<button id="profileTimerStart" class="profile-button profile-button-primary" type="button">${escapeHtml(t("start"))}</button>
|
||||
<button id="profileTimerPause" class="profile-button" type="button">${escapeHtml(t("pause"))}</button>
|
||||
@@ -279,6 +291,7 @@ function bindTimerControls() {
|
||||
} else if (!current || getTimerElapsed(current) >= TIMER_DURATION_MS) {
|
||||
writeTimerState({ startedAt: Date.now(), pausedAt: 0, pausedTotalMs: 0 });
|
||||
lastTimerPhase = "push";
|
||||
void loadProfile();
|
||||
}
|
||||
startTimerLoop();
|
||||
updateTimerDisplay();
|
||||
@@ -295,6 +308,7 @@ function bindTimerControls() {
|
||||
writeTimerState(null);
|
||||
lastTimerPhase = "";
|
||||
updateTimerDisplay();
|
||||
void loadProfile();
|
||||
});
|
||||
startTimerLoop();
|
||||
}
|
||||
@@ -336,7 +350,17 @@ function speak(text) {
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const response = await fetch(`/public-training/api/${encodeURIComponent(shareToken)}`, { cache: "no-store" });
|
||||
const timerState = readTimerState();
|
||||
const apiUrl = new URL(`/public-training/api/${encodeURIComponent(shareToken)}`, window.location.origin);
|
||||
if (timerState?.startedAt) {
|
||||
const now = Date.now();
|
||||
const startedAgoMs = Math.max(0, now - timerState.startedAt);
|
||||
const currentPauseMs = timerState.pausedAt ? Math.max(0, now - timerState.pausedAt) : 0;
|
||||
const maximumWindowMs = TIMER_DURATION_MS + Number(timerState.pausedTotalMs || 0) + currentPauseMs;
|
||||
apiUrl.searchParams.set("startedAgoMs", String(startedAgoMs));
|
||||
apiUrl.searchParams.set("windowMs", String(Math.min(startedAgoMs, maximumWindowMs)));
|
||||
}
|
||||
const response = await fetch(apiUrl, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
renderProfile(await response.json());
|
||||
} catch {
|
||||
@@ -347,4 +371,4 @@ async function loadProfile() {
|
||||
loadProfile();
|
||||
setInterval(() => {
|
||||
if (!document.hidden) loadProfile();
|
||||
}, 30000);
|
||||
}, 10000);
|
||||
|
||||
Reference in New Issue
Block a user