diff --git a/README.md b/README.md index bc82013..7541cfb 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/README.sv.md b/README.sv.md index a68a481..fc74513 100644 --- a/README.sv.md +++ b/README.sv.md @@ -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: diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 023111b..c134d20 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -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 diff --git a/server.js b/server.js index aa427ec..7b7f27a 100644 --- a/server.js +++ b/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, }; } diff --git a/src/app.js b/src/app.js index 49796f8..d2c6bc8 100644 --- a/src/app.js +++ b/src/app.js @@ -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=` 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=` 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", diff --git a/src/public_training.css b/src/public_training.css index 87099a5..16c73b5 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -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; diff --git a/src/public_training.js b/src/public_training.js index 8befe25..8f303e8 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -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) {

${escapeHtml(recommendationText.body)}

20:00
${escapeHtml(t("ready"))}
+ ${currentSession ? ` +
${escapeHtml(t("sessionStats"))}
+
+
${escapeHtml(t("sessionLaps"))}${currentSession.laps}
+
${escapeHtml(t("best"))}${formatLap(currentSession.bestLapMs)}
+
${escapeHtml(t("average"))}${formatLap(currentSession.averageLapMs)}
+
+ ` : ""}
@@ -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);