Add per-driver live stats for timed practice sessions

This commit is contained in:
larssand
2026-08-24 17:36:03 +02:00
parent 40d0c3fa90
commit aa74da0bdf
7 changed files with 114 additions and 9 deletions

View File

@@ -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",

View File

@@ -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;

View File

@@ -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);