Add per-driver live stats for timed practice sessions
This commit is contained in:
@@ -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