From 285babcfcff96d7f9fc38c2bf76afe17f346c528 Mon Sep 17 00:00:00 2001 From: larssand Date: Fri, 4 Sep 2026 16:41:51 +0200 Subject: [PATCH] add more stats to mobile and fix refresh stint --- README.md | 12 +- README.sv.md | 12 +- docs/javascript-architecture.md | 12 +- server.js | 16 ++ server/public_training_profile.js | 49 ++++- src/app.js | 12 +- src/public_training.css | 190 +++++++++++++++++++ src/public_training.js | 50 ++++- tests/public_training_profile.test.js | 12 ++ tests/server_persistence.integration.test.js | 28 ++- 10 files changed, 370 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0afdb1e..e64bada 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ Training statistics currently include only valid laps linked to a registered dri Judging corrections update ledger laps while the source session still exists. Once a session is deleted, its synchronized training laps remain in the ledger so lifetime progression is not lost. Full data export includes the ledger, reward goals, and setup markers. -### Read-only mobile profile +### Restricted mobile profile Each registered driver can have an optional personal mobile link: @@ -278,7 +278,9 @@ Each registered driver can have an optional personal mobile link: 4. Scan `Local QR code` on the same network as RaceCore, or `Public QR code` over the internet. The local QR is generated from the RaceCore server LAN address, so it also works when the admin UI was opened as `localhost`. 5. Use `Create new link` to revoke the previous URL, or `Disable profile` to turn sharing off. `Detect public IP` is an explicit suggestion only; use the FQDN when the TLS certificate is issued for the domain. -The URL uses a 192-bit random bearer token rather than a driver ID. The same link provides a class selector with fully separate lap times and practice results for each linked class. The public API does not return transponder IDs, other driver names, club ranking, race setup or admin state. +The URL uses a 192-bit random bearer token rather than a driver ID. The same link provides a class selector with fully separate lap times and practice results for each linked class. The public API does not return transponder IDs, other driver names, club ranking, race setup or admin state. The only server-side mobile action is adding a setup marker for the token owner and selected linked class. + +The performance section also shows active 30-minute pace blocks and lets the driver mark a class-specific car/setup change. Open detail sections remain expanded during live polling. The mobile page also includes a local practice timer where the driver chooses minutes for `Maximum focus` and `Controlled`. The default is `10 + 10`, while a five-minute heat can for example use `5 + 0`. 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 and duration choices stay in that phone's browser, separately per driver and class, and do not modify race data. Durations are locked while a session runs and can be changed after finishing or resetting it. @@ -669,8 +671,10 @@ Linux: ```bash npm stop -backup=$(find data/backups -maxdepth 1 -name '*.sqlite' -printf '%T@ %f\n' | sort -nr | head -n 1 | cut -d' ' -f2-) -printf 'Restoring %s\n' "$backup" +backup=$(find data/backups -maxdepth 1 -name '*.sqlite' -printf '%T@ %f +' | sort -nr | head -n 1 | cut -d' ' -f2-) +printf 'Restoring %s +' "$backup" npm run backup:restore -- "$backup" --confirm npm start ``` diff --git a/README.sv.md b/README.sv.md index 0cb41a8..898a87b 100644 --- a/README.sv.md +++ b/README.sv.md @@ -257,7 +257,7 @@ Träningsstatistiken räknar just nu bara giltiga varv som är kopplade till en Domarkorrigeringar uppdaterar loggade varv så länge källsessionen finns kvar. När sessionen senare tas bort ligger de synkade träningsvarven kvar så förarens livstidsutveckling inte försvinner. Full dataexport inkluderar varvloggen, belöningsmålen och setupmarkörerna. -### Skrivskyddad mobilprofil +### Begränsad mobilprofil Varje registrerad förare kan få en valfri personlig mobillänk: @@ -267,7 +267,9 @@ Varje registrerad förare kan få en valfri personlig mobillänk: 4. Skanna `Lokal QR-kod` på samma nätverk som RaceCore, eller `Publik QR-kod` via internet. Den lokala QR-koden skapas från RaceCore-serverns LAN-IP och fungerar därför även om adminvyn öppnades som `localhost`. 5. Använd `Skapa ny länk` för att återkalla den gamla eller `Stäng av profil` för att stoppa delningen. Knappen `Hämta publik IP` är endast ett manuellt förslag; FQDN är rätt val när TLS-certifikatet använder domännamnet. -Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Samma länk visar profilens klasser i ett klassval, med helt separata varvtider och träningsresultat. Det publika API:t lämnar inte ut transponder-ID, andra förares namn, klubbrankning, race setup eller admin-state. +Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Samma länk visar profilens klasser i ett klassval, med helt separata varvtider och träningsresultat. Det publika API:t lämnar inte ut transponder-ID, andra förares namn, klubbrankning, race setup eller admin-state. Den enda skrivande mobilfunktionen är att lägga till en setupmarkör för länkens förare och vald kopplad klass. + +Prestationssektionen visar även aktiva 30-minutersblock och låter föraren markera en klasspecifik bil- eller setupändring. Öppna detaljsektioner ligger kvar utfällda under live-polling. Mobilsidan har också en lokal träningstimer där föraren själv väljer minuter för `Maxfokus` och `Kontrollerad`. Standard är `10 + 10`, men ett femminutersheat kan exempelvis köras som `5 + 0`. 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. Timer och tidsval sparas bara i mobilens webbläsare, separat per förare och klass, och ändrar ingen racedata. Tiderna låses medan passet pågår och kan ändras efter målgång eller nollställning. @@ -658,8 +660,10 @@ Linux: ```bash npm stop -backup=$(find data/backups -maxdepth 1 -name '*.sqlite' -printf '%T@ %f\n' | sort -nr | head -n 1 | cut -d' ' -f2-) -printf 'Återställer %s\n' "$backup" +backup=$(find data/backups -maxdepth 1 -name '*.sqlite' -printf '%T@ %f +' | sort -nr | head -n 1 | cut -d' ' -f2-) +printf 'Återställer %s +' "$backup" npm run backup:restore -- "$backup" --confirm npm start ``` diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 20b7475..08db8ef 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -195,7 +195,8 @@ Owns: - strict per-class lap filtering and aggregate statistics - sanitized branding plus class-specific goals and recommendation response - server-side visibility filtering for progress, achievements, performance/stints, goals, and history -- bounded class-specific pace history and recent stint summaries for the mobile profile +- bounded class-specific pace history, active 30-minute pace buckets, and recent stint summaries for the mobile profile +- token-, profile-, and class-validated creation of public car/setup markers - clock-skew-safe personal timer start/window normalization - bounded current-session lap, best-lap, and average calculations @@ -621,7 +622,7 @@ Mutations: Route behavior: - `/?view=training` opens the training view directly - `driver=` selects a personal profile and `class=` selects its performance class for bookmarks -- an explicitly enabled personal profile receives `/public-training/` as a separate read-only profile with a class selector +- an explicitly enabled personal profile receives `/public-training/` as a separate restricted profile with a class selector and one token-limited setup-marker action - replacing or disabling the share invalidates its old route immediately after state sync ### `public-training.html` @@ -639,7 +640,8 @@ Type: - standalone public profile frontend Owns: -- fetching only `/public-training/api/:shareToken` +- fetching profile data from `GET /public-training/api/:shareToken` +- posting only class-scoped setup markers to `POST /public-training/api/:shareToken/markers` - rendering one person's sanitized statistics, pace chart, recent stints, goals, rewards, and daily history with a class selector - hiding optional sections according to the server-provided share visibility map - switching between class-specific statistics without combining lap performance across classes @@ -650,7 +652,7 @@ Owns: Important isolation: - does not import `app.js` - does not call `/api/state` -- does not mutate backend or race state +- cannot mutate race, register, timing, goal, or reward state; its only backend mutation is an authenticated class-scoped setup marker - timer state is stored in the mobile browser under a token-and-class-scoped localStorage key - session-window responses contain aggregates only; individual lap rows and timestamps are not exposed @@ -2306,6 +2308,7 @@ Use for: Routes: - page: `GET /public-training/:shareToken` - data: `GET /public-training/api/:shareToken` +- restricted marker action: `POST /public-training/api/:shareToken/markers` Authorization: - `shareToken` must match an enabled entry in `state.training.shares` @@ -2318,6 +2321,7 @@ Sanitized response fields: - no club rank or other driver names; the public profile focuses on personal development - class-specific totals plus optional pace history, achievements, stint summaries, best-three, five-minute result, goals, and daily summaries - optional detail sections are omitted server-side according to the share visibility map; core lap/day/best/consistency totals remain visible +- performance data includes bounded active 30-minute pace buckets and recent stint summaries without raw laps - optional class-specific `currentSession` aggregates when a valid start age (or backward-compatible recent `since` timestamp) is supplied - class-specific next automatic milestone, recommendation level, target lap, goals, and reward progress - public club branding and safe PNG/JPEG/WebP logo data diff --git a/server.js b/server.js index 1f83781..6105d20 100644 --- a/server.js +++ b/server.js @@ -371,6 +371,22 @@ app.get("/public-training/api/:shareToken", (req, res) => { } }); +app.post("/public-training/api/:shareToken/markers", (req, res) => { + try { + const stored = readStoredAppState(); + const marker = stored ? publicTrainingProfile.addPublicTrainingMarker(stored.state, String(req.params.shareToken || ""), req.body || {}) : null; + if (!marker) { + res.status(404).json({ error: "Profile or class not found" }); + return; + } + const nowIso = new Date().toISOString(); + db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso); + res.status(201).json({ ok: true, marker: { type: marker.type, timestamp: marker.timestamp, note: marker.note } }); + } catch (error) { + res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); + } +}); + app.get("/public-training/qr/:shareToken", async (req, res) => { try { const stored = readStoredAppState(); diff --git a/server/public_training_profile.js b/server/public_training_profile.js index 03cea6c..33cf8da 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -56,7 +56,7 @@ function filterPublicTrainingStats(stats, visibility) { outlierLaps: stats.outlierLaps, strongFinishStints: stats.strongFinishStints, cleanFiveMinuteStints: stats.cleanFiveMinuteStints, stintCount: stats.stintCount, stintSummaries: stats.stintSummaries, bestThreeConsecutive: stats.bestThreeConsecutive, - bestFiveMinuteResult: stats.bestFiveMinuteResult, + bestFiveMinuteResult: stats.bestFiveMinuteResult, paceBuckets: stats.paceBuckets, }); if (visibility.history) filtered.dailySummaries = stats.dailySummaries; return filtered; @@ -91,6 +91,8 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp })) .sort((left, right) => left.timestamp - right.timestamp); const stats = buildPublicTrainingStats(classLaps); + const classMarkers = (Array.isArray(state?.training?.markers) ? state.training.markers : []).filter((marker) => classDriverIds.has(String(marker?.driverId || "")) && String(marker?.classId || "") === classId); + stats.paceBuckets = buildPublicTrainingPaceBuckets(classLaps, classMarkers); const channelStats = buildPublicTimingChannelStats(state, classDriverIds); const classGoals = (Array.isArray(state?.training?.goals) ? state.training.goals : []) .filter((goal) => profileReferences.has(String(goal?.driverId || "")) && (!goal?.classId || String(goal.classId) === classId)) @@ -217,6 +219,31 @@ function buildPublicTrainingGoal(goal, currentLaps) { }; } +function buildPublicTrainingPaceBuckets(laps, markers, bucketMinutes = 30) { + const bucketMs = Math.max(5, Number(bucketMinutes || 30)) * 60 * 1000; + const buckets = new Map(); + laps.filter((lap) => !lap.excludedFromPace).forEach((lap) => { + const date = new Date(lap.timestamp); + const minuteBlock = bucketMs / 60000; + const startTs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), Math.floor(date.getMinutes() / minuteBlock) * minuteBlock, 0, 0).getTime(); + const bucket = buckets.get(startTs) || { startTs, values: [] }; + bucket.values.push(lap.lapMs); + buckets.set(startTs, bucket); + }); + return [...buckets.values()].sort((left, right) => right.startTs - left.startTs).slice(0, 24).map((bucket) => { + const sorted = [...bucket.values].sort((left, right) => left - right); + const trim = sorted.length >= 5 ? Math.max(1, Math.floor(sorted.length * 0.1)) : 0; + const kept = trim ? sorted.slice(trim, sorted.length - trim) : sorted; + return { + startTs: bucket.startTs, + lapCount: bucket.values.length, + paceMs: kept.length ? Math.round(kept.reduce((sum, value) => sum + value, 0) / kept.length) : 0, + bestLapMs: bucket.values.length ? Math.min(...bucket.values) : 0, + markerCount: markers.filter((marker) => Number(marker?.timestamp || 0) >= bucket.startTs && Number(marker?.timestamp || 0) < bucket.startTs + bucketMs).length, + }; + }); +} + function buildPublicTrainingStats(laps) { const paceLaps = laps.filter((lap) => !lap.excludedFromPace); const lapTimes = paceLaps.map((lap) => lap.lapMs); @@ -449,6 +476,25 @@ function getPublicBestDurationResult(stints, targetMs) { return best; } +function addPublicTrainingMarker(state, shareToken, input, timestamp = Date.now()) { + const share = findPublicTrainingShare(state, shareToken); + if (!share) return null; + const drivers = Array.isArray(state?.drivers) ? state.drivers : []; + const profile = getPublicDriverProfile(drivers, share.driverId); + const classId = String(input?.classId || ""); + const classDriver = profile?.drivers.find((driver) => String(driver?.classId || "") === classId); + if (!classDriver) return null; + const type = ["setup", "tires", "suspension", "car", "note"].includes(String(input?.type || "")) ? String(input.type) : "car"; + const marker = { + id: "training_marker_public_" + Math.round(timestamp) + "_" + Math.random().toString(36).slice(2, 9), + driverId: String(classDriver.id || ""), classId, sessionId: "", timestamp: Math.round(timestamp), type, + note: String(input?.note || "").trim().slice(0, 160), createdAt: new Date(timestamp).toISOString(), + }; + if (!state.training || typeof state.training !== "object") state.training = {}; + state.training.markers = [...(Array.isArray(state.training.markers) ? state.training.markers : []), marker]; + return marker; +} + function normalizePublicLogoDataUrl(value) { const logo = String(value || "").trim(); return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : ""; @@ -482,4 +528,5 @@ module.exports = { splitPublicTrainingStints, getPublicBestConsecutiveLaps, getPublicBestDurationResult, + addPublicTrainingMarker, }; diff --git a/src/app.js b/src/app.js index 301bcb8..2cca74a 100644 --- a/src/app.js +++ b/src/app.js @@ -568,7 +568,7 @@ const TRANSLATIONS = { "training.public_profile": "Mobilprofil för förare", "training.public_profile_active": "Aktiv", "training.public_profile_off": "Avstängd", - "training.public_profile_help": "Skapa en skrivskyddad mobilvy med den här förarens träningsstatistik, mål, belöningar och valbara träningstimer.", + "training.public_profile_help": "Skapa en personlig mobilvy med träningsstatistik, mål, belöningar, träningstimer och en begränsad biländringsmarkör.", "training.public_profile_private": "Länken innehåller en unik hemlig nyckel. Den visar inte transponder-ID, övriga förare eller adminfunktioner.", "training.public_sections": "Synligt i mobilprofilen", "training.public_sections_hint": "Valen sparas direkt. Avstängda sektioner skickas inte till den publika profilen.", @@ -1613,7 +1613,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. Föraren väljer själv minuter för blocken Maxfokus och Kontrollerad körning; timern visar förarens varv, bästa varv och snitt under det egna passet.", + "guide.training_8": "8. Mobilprofilen visar bara vald förares statistik. Föraren väljer minuter för Maxfokus och Kontrollerad körning och kan markera en biländring för vald klass. Inga race- eller registerinställningar kan ändras.", "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`.", "guide.training_10": "10. När Open Practice, Free Practice eller Practice pågår visar Träning ett livekort för vald förare med varv, bästa varv, snitt och senaste varv. Alla förare kör fortfarande i samma gemensamma session.", "guide.training_11": "11. Knappen högst upp skapar eller öppnar den valda förarens personliga mobillänk. Föraren ställer in och startar sin egen timer i mobilen utan att starta om eller påverka den gemensamma practice-sessionen. Tiden sparas separat per förare och klass.", @@ -1631,7 +1631,7 @@ const TRANSLATIONS = { "guide.training_23": "23. Skapa ett klubbmål från Träningsöversikten. Målet börjar på noll när det sparas och summerar därefter alla giltiga registrerade träningsvarv. När målet nås kan det markeras som firat och ersättas med ett nytt.", "guide.training_24": "24. Ljudhändelsen training stable pace ger valbar personlig röstfeedback efter fem kontrollerade varv i samma practice-stint. Den använder förarens uppläsningsnamn, har två minuters cooldown och aktiveras separat under Raceljud och utrop.", "guide.training_25": "25. Kontrollzonen går från personbästa till 8 procent långsammare. Flytserier bryts vid avvikande varv, ny stint eller ny session. Stark avslutning, rena femminuterspass och förbättrade mellantider ger positiv feedback men påverkar aldrig race-resultat.", - "guide.training_26": "26. Under Mål och mobilprofil väljer du vilka publika sektioner som visas: utvecklingsgraf, utmärkelser, stints/prestation, mål/belöningar och historik. Valen sparas direkt och avstängd detaljdata lämnas inte ut av profilens API.", + "guide.training_26": "26. Under Mål och mobilprofil väljer du vilka publika sektioner som visas. Stints/prestation innehåller även biländring och tempo per aktivt 30-minutersblock. Öppna sektioner ligger kvar vid live-uppdatering.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -1941,7 +1941,7 @@ const TRANSLATIONS = { "training.public_profile": "Driver mobile profile", "training.public_profile_active": "Active", "training.public_profile_off": "Disabled", - "training.public_profile_help": "Create a read-only mobile view with this driver's practice statistics, goals, rewards and configurable practice timer.", + "training.public_profile_help": "Create a personal mobile view with practice statistics, goals, rewards, a timer, and a restricted car-change marker.", "training.public_profile_private": "The link contains a unique secret key. It does not show transponder IDs, other drivers or admin controls.", "training.public_sections": "Visible in mobile profile", "training.public_sections_hint": "Changes are saved immediately. Disabled sections are not sent to the public profile.", @@ -2986,7 +2986,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. The driver chooses minutes for Maximum focus and Controlled driving; the timer shows that driver's laps, best lap and average during the personal session.", + "guide.training_8": "8. The mobile profile only shows the selected driver's statistics. The driver chooses Maximum focus and Controlled minutes and may mark a car change for the selected class. Race and register settings cannot be changed.", "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`.", "guide.training_10": "10. While Open Practice, Free Practice, or Practice is running, Training shows a live card for the selected driver with laps, best lap, average, and latest lap. Every driver still uses the same shared session.", "guide.training_11": "11. The top action creates or opens the selected driver's personal mobile link. The driver configures and starts an individual timer on the phone without restarting or changing the shared practice session. Time is stored separately per driver and class.", @@ -3004,7 +3004,7 @@ const TRANSLATIONS = { "guide.training_23": "23. Create a club goal from Practice overview. The target starts at zero when saved and then aggregates every valid registered practice lap. Once reached, it can be marked as celebrated and replaced with a new goal.", "guide.training_24": "24. The training stable pace audio event provides optional personal voice feedback after five controlled laps in one practice stint. It uses the driver spoken name, has a two-minute cooldown, and can be enabled separately under Race sounds and announcements.", "guide.training_25": "25. The control zone runs from the personal best to 8 percent slower. Flow streaks reset on an outlier lap, a new stint, or a new session. Strong finishes, clean five-minute runs, and improved split times provide positive feedback but never affect race results.", - "guide.training_26": "26. Under Goals and mobile profile, choose which public sections are visible: development chart, achievements, stints/performance, goals/rewards, and history. Changes save immediately and disabled detail data is not exposed by the profile API.", + "guide.training_26": "26. Under Goals and mobile profile, choose which public sections are visible. Stints/performance also contains car-change marking and pace per active 30-minute block. Open sections remain open during live updates.", "overlay.title": "Overlay", "overlay.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", diff --git a/src/public_training.css b/src/public_training.css index 01cd3f7..7d70b19 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -866,3 +866,193 @@ button { padding: 8px 10px 14px; } } + +.profile-marker-form { + display: grid; + grid-template-columns: minmax(130px, 0.55fr) minmax(190px, 1.45fr) auto; + gap: 10px; + align-items: end; + padding: 16px; + border-bottom: 1px solid var(--line); +} + +.profile-marker-form > div, +.profile-marker-form > small { + grid-column: 1 / -1; +} + +.profile-marker-form h3, +.profile-pace-blocks h3 { + margin: 0; + font-family: Orbitron, sans-serif; + font-size: 0.9rem; +} + +.profile-marker-form p, +.profile-pace-blocks > p { + margin: 5px 0 0; + color: var(--muted); + font-size: 0.76rem; +} + +.profile-marker-form label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; +} + +.profile-marker-form input, +.profile-marker-form select { + width: 100%; + min-height: 42px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-2); + color: var(--text); + font: inherit; +} + +.profile-marker-form > small { + min-height: 1em; + color: var(--green); +} + +.profile-pace-blocks { + padding: 16px; + border-top: 1px solid var(--line); +} + +.profile-pace-blocks > div { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(155px, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.profile-pace-blocks article { + display: grid; + gap: 4px; + padding: 11px; + border: 1px solid var(--line); + border-radius: 11px; + background: rgba(255, 255, 255, 0.025); +} + +.profile-pace-blocks article span, +.profile-pace-blocks article small { + color: var(--muted); + font-size: 0.68rem; +} + +.profile-pace-blocks article strong { + font-family: Orbitron, sans-serif; +} + +@media (max-width: 620px) { + .profile-marker-form { + grid-template-columns: 1fr; + } + + .profile-marker-form > div, + .profile-marker-form > small { + grid-column: auto; + } +} + +.profile-marker-form { + display: grid; + grid-template-columns: minmax(130px, 0.55fr) minmax(190px, 1.45fr) auto; + gap: 10px; + align-items: end; + padding: 16px; + border-bottom: 1px solid var(--line); +} + +.profile-marker-form > div, +.profile-marker-form > small { + grid-column: 1 / -1; +} + +.profile-marker-form h3, +.profile-pace-blocks h3 { + margin: 0; + font-family: Orbitron, sans-serif; + font-size: 0.9rem; +} + +.profile-marker-form p, +.profile-pace-blocks > p { + margin: 5px 0 0; + color: var(--muted); + font-size: 0.76rem; +} + +.profile-marker-form label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; +} + +.profile-marker-form input, +.profile-marker-form select { + width: 100%; + min-height: 42px; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-2); + color: var(--text); + font: inherit; +} + +.profile-marker-form > small { + min-height: 1em; + color: var(--green); +} + +.profile-pace-blocks { + padding: 16px; + border-top: 1px solid var(--line); +} + +.profile-pace-blocks > div { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(155px, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.profile-pace-blocks article { + display: grid; + gap: 4px; + padding: 11px; + border: 1px solid var(--line); + border-radius: 11px; + background: rgba(255, 255, 255, 0.025); +} + +.profile-pace-blocks article span, +.profile-pace-blocks article small { + color: var(--muted); + font-size: 0.68rem; +} + +.profile-pace-blocks article strong { + font-family: Orbitron, sans-serif; +} + +@media (max-width: 620px) { + .profile-marker-form { + grid-template-columns: 1fr; + } + + .profile-marker-form > div, + .profile-marker-form > small { + grid-column: auto; + } +} diff --git a/src/public_training.js b/src/public_training.js index f0046b5..94d6de6 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -64,6 +64,14 @@ const messages = { stintNumber: "Stint", duration: "Tid", noStints: "Inga stintar registrerade ännu.", + paceBlocks: "Tempo per aktivt 30-minutersblock", + paceBlocksHint: "Visar bara block där bilen har registrerat giltiga varv.", + markerTitle: "Markera en biländring", + markerHint: "Sparas för vald förare och klass vid aktuell tid.", + markerType: "Typ", markerNote: "Beskrivning", markerSave: "Markera ändring nu", + markerSetup: "Ny setup", markerTires: "Däck", markerSuspension: "Dämpare", markerCar: "Bil", markerNoteType: "Anteckning", + markerSaved: "Biländringen är sparad.", markerError: "Kunde inte spara biländringen.", + blockLaps: "{laps} varv", blockMarkers: "{count} ändring(ar)", stints: "Stints", bestThree: "Bästa 3 sammanhängande", bestFive: "Bästa 5 min", @@ -176,6 +184,14 @@ const messages = { stintNumber: "Stint", duration: "Duration", noStints: "No stints have been recorded yet.", + paceBlocks: "Pace per active 30-minute block", + paceBlocksHint: "Only blocks containing valid recorded laps are shown.", + markerTitle: "Mark a car change", + markerHint: "Saved for the selected driver and class at the current time.", + markerType: "Type", markerNote: "Description", markerSave: "Mark change now", + markerSetup: "New setup", markerTires: "Tires", markerSuspension: "Suspension", markerCar: "Car", markerNoteType: "Note", + markerSaved: "The car change was saved.", markerError: "The car change could not be saved.", + blockLaps: "{laps} laps", blockMarkers: "{count} change(s)", stints: "Stints", bestThree: "Best 3 consecutive", bestFive: "Best 5 min", @@ -332,6 +348,7 @@ function renderPaceChart(points) { return `
${coordinates.map((item) => `${escapeHtml(item.point.dateKey)}: ${escapeHtml(formatLap(item.point.averageLapMs))}`).join("")}
${escapeHtml(values[0].dateKey)}${escapeHtml(formatLap(values.at(-1).averageLapMs))}${escapeHtml(values.at(-1).dateKey)}
`; } function renderProfile(data) { + const openSections = new Set([...root.querySelectorAll("details[data-persist-open][open]")].map((element) => element.dataset.persistOpen)); profileData = data; const { profile, branding } = data; const visibility = { progress: true, achievements: true, performance: true, goals: true, history: true, ...(data.visibility || {}) }; @@ -404,6 +421,8 @@ function renderProfile(data) { `).join("") : `-`; + const paceBuckets = Array.isArray(stats.paceBuckets) ? stats.paceBuckets : []; + const paceBucketMarkup = paceBuckets.length ? paceBuckets.map((bucket) => `
${escapeHtml(new Date(bucket.startTs).toLocaleString(language, { dateStyle: "short", timeStyle: "short" }))}${formatLap(bucket.paceMs)}${escapeHtml(t("blockLaps", { laps: bucket.lapCount }))} · PB ${formatLap(bucket.bestLapMs)}${bucket.markerCount ? " · " + escapeHtml(t("blockMarkers", { count: bucket.markerCount })) : ""}
`).join("") : `

-

`; const stintRows = stats.stintSummaries.length ? stats.stintSummaries.slice(0, 6).map((stint) => ` ${stint.number}${escapeHtml(new Date(stint.startedAt).toLocaleString(language, { dateStyle: "short", timeStyle: "short" }))}${stint.lapCount}${formatLap(stint.bestLapMs)}${formatLap(stint.averageLapMs)}${formatRaceResultTime(stint.durationMs)} `).join("") : `${escapeHtml(t("noStints"))}`; @@ -510,8 +529,15 @@ function renderProfile(data) { -
+

${escapeHtml(t("performance"))}

+
+
+

${escapeHtml(t("markerTitle"))}

${escapeHtml(t("markerHint"))}

+ + + + +
${escapeHtml(t("stints"))}${Number(stats.stintCount || 0)}
${escapeHtml(t("controlZone"))}${stats.controlZoneMaxMs ? `${formatLap(stats.controlZoneMinMs)} - ${formatLap(stats.controlZoneMaxMs)}` : "-"}
@@ -526,6 +552,7 @@ function renderProfile(data) {
${escapeHtml(t("pitStops"))}${Number(channelStats.pitStopCount || 0)}
${escapeHtml(t("totalPit"))}${channelStats.pitStopCount ? formatRaceResultTime(channelStats.totalPitMs) : "-"}
+

${escapeHtml(t("paceBlocks"))}

${escapeHtml(t("paceBlocksHint"))}

${paceBucketMarkup}

${escapeHtml(t("stintDetails"))}

${stintRows}
${escapeHtml(t("stintNumber"))}${escapeHtml(t("date"))}${escapeHtml(t("laps"))}${escapeHtml(t("best"))}${escapeHtml(t("average"))}${escapeHtml(t("duration"))}
@@ -539,7 +566,7 @@ function renderProfile(data) { ` : ""} -
+

${escapeHtml(t("history"))}

+
@@ -549,6 +576,25 @@ function renderProfile(data) { `; + document.getElementById("publicTrainingMarkerForm")?.addEventListener("submit", async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const status = document.getElementById("publicTrainingMarkerStatus"); + const button = form.querySelector("button"); + if (button) button.disabled = true; + try { + const values = new FormData(form); + const response = await fetch("/public-training/api/" + encodeURIComponent(shareToken) + "/markers", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classId: selectedClassId, type: values.get("type"), note: values.get("note") }) }); + if (!response.ok) throw new Error("HTTP " + response.status); + if (status) status.textContent = t("markerSaved"); + form.reset(); + await loadProfile(); + } catch { + if (status) status.textContent = t("markerError"); + } finally { + if (button) button.disabled = false; + } + }); document.getElementById("profileClass")?.addEventListener("change", (event) => { selectedClassId = String(event.currentTarget.value || ""); const nextUrl = new URL(window.location.href); diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index 63b314c..8f3c308 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -11,6 +11,7 @@ const { normalizePublicTrainingSince, normalizePublicTrainingWindow, splitPublicTrainingStints, + addPublicTrainingMarker, } = require("../server/public_training_profile.js"); const DRIVER_ONE_TOKEN = "driver-one-token-12345678901234567890"; @@ -81,6 +82,16 @@ test("public share lookup rejects short, disabled, and unknown tokens", () => { assert.equal(hasPublicTrainingShare(state, DRIVER_ONE_TOKEN), true); }); +test("public mobile marker is restricted to the shared profile and selected class", () => { + const state = buildState(); + const marker = addPublicTrainingMarker(state, DRIVER_ONE_TOKEN, { classId: "class_2", type: "tires", note: " New rear tires " }, 123456); + + assert.deepEqual({ driverId: marker.driverId, classId: marker.classId, type: marker.type, note: marker.note, timestamp: marker.timestamp }, { driverId: "driver_1_4wd", classId: "class_2", type: "tires", note: "New rear tires", timestamp: 123456 }); + assert.equal(state.training.markers.length, 1); + assert.equal(addPublicTrainingMarker(state, DRIVER_TWO_TOKEN, { classId: "class_2" }, 123457), null); + assert.equal(addPublicTrainingMarker(state, DISABLED_TOKEN, { classId: "class_1" }, 123458), null); +}); + 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); @@ -101,6 +112,7 @@ test("public profile contains only the selected driver's sanitized aggregates", assert.equal(profile.classProfiles.length, 2); assert.equal(profile.classProfiles[0].stats.totalLaps, 4); assert.equal(profile.classProfiles[0].stats.achievements.find((item) => item.id === "first_session").unlocked, true); + assert.ok(Array.isArray(profile.classProfiles[0].stats.paceBuckets)); assert.deepEqual(profile.classProfiles[0].channelStats, { splitCount: 2, bestSplitMs: 7000, diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index 7654100..8549e69 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -26,7 +26,8 @@ function waitForServer(child, timeoutMs = 10000) { return new Promise((resolve, reject) => { let output = ""; const timeout = setTimeout(() => { - reject(new Error(`Server startup timed out. Output:\n${output}`)); + reject(new Error(`Server startup timed out. Output: +${output}`)); }, timeoutMs); const onData = (chunk) => { output += chunk.toString(); @@ -42,7 +43,8 @@ function waitForServer(child, timeoutMs = 10000) { }); child.once("exit", (code, signal) => { clearTimeout(timeout); - reject(new Error(`Server exited before startup (code=${code}, signal=${signal}). Output:\n${output}`)); + reject(new Error(`Server exited before startup (code=${code}, signal=${signal}). Output: +${output}`)); }); }); } @@ -211,6 +213,28 @@ test("SQLite API persists state, passings, and overlay session results", { timeo const storedPayload = await storedResponse.json(); assert.deepEqual(storedPayload.state, initialState); + const markerResponse = await fetch(baseUrl + "/public-training/api/" + shareToken + "/markers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classId: "class_1", type: "car", note: "Ride height change" }), + }); + const markerPayload = await markerResponse.json(); + assert.equal(markerResponse.status, 201); + assert.equal(markerPayload.marker.type, "car"); + const markerStateResponse = await fetch(baseUrl + "/api/state"); + const markerState = await markerStateResponse.json(); + assert.equal(markerState.state.training.markers.length, 1); + assert.equal(markerState.state.training.markers[0].driverId, "driver_1"); + assert.equal(markerState.state.training.markers[0].classId, "class_1"); + assert.equal(markerState.state.training.markers[0].note, "Ride height change"); + + const invalidMarkerResponse = await fetch(baseUrl + "/public-training/api/" + shareToken + "/markers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classId: "unknown_class", type: "car" }), + }); + assert.equal(invalidMarkerResponse.status, 404); + const sessionResult = { competitors: { driver_1: { key: "driver_1", driverName: "Driver One", laps: 1, bestLapMs: 16500 },