diff --git a/README.md b/README.md index 74caac6..485ef7a 100644 --- a/README.md +++ b/README.md @@ -451,6 +451,7 @@ Split and pit data is available in several places: - `Race Setup -> Manage -> Live / results` shows the summary and pit log for every heat, including long 45-minute endurance heats. - regular and team result exports include split/pit summaries and the complete entry/exit log in PDF. - `Training -> More statistics` aggregates split and pit performance for the selected personal driver profile and class; different classes are never mixed. +- the personal mobile profile shows only sanitized counts and aggregate times for the selected class. It never exposes transponders, other drivers, or the raw pit-stop log. If a team changes driver or car between pit entry and pit exit, the completed stop remains attributed to the driver, car, and transponder detected at pit entry. The exit transponder is retained separately for diagnostics. diff --git a/README.sv.md b/README.sv.md index e803f25..931b879 100644 --- a/README.sv.md +++ b/README.sv.md @@ -440,6 +440,7 @@ Mellantids- och depådata visas på flera ställen: - `Race Setup -> Hantera -> Live / resultat` visar sammanställning och depålogg för varje heat, inklusive långa 45-minuters endurance-heat. - vanliga resultat- och lagrapporter inkluderar split-/depåsammanställning och fullständig in-/utlogg i PDF. - `Träning -> Mer statistik` summerar mellantider och depåstopp för vald personlig förarprofil och klass; olika klasser blandas aldrig. +- den personliga mobilprofilen visar endast sanerade antal och aggregerade tider för vald klass. Den exponerar aldrig transpondrar, andra förare eller den råa depåstoppsloggen. Om ett lag byter förare eller bil mellan depå in och depå ut kopplas det färdiga stoppet till föraren, bilen och transpondern som registrerades vid depåinfarten. Transpondern vid utfarten sparas separat för felsökning. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 664b23d..4f82dce 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -988,11 +988,14 @@ Analysis ownership is deliberately split into two pure functions: |---|---|---|---| | `buildSessionChannelStats(session, deps)` | `src/timing_logic.js` | one heat/session, grouped by competitor key | Timing, Race Setup, team standings, HTML reports, PDF reports | | `buildDriverTimingChannelStats(state, driverId, classId)` | `src/training_logic.js` | one personal driver profile and selected class across sessions | Training detailed statistics | +| `buildPublicTimingChannelStats(state, classDriverIds)` | `server/public_training_profile.js` | sanitized aggregate for one shared profile class | public training profile API and mobile view | `buildSessionChannelStats()` aggregates split count/best/average and pit count/total/best/average, and returns a chronological enriched pit-stop log. `renderSessionChannelAnalysis()` in `src/race_render_helpers.js` is the canonical HTML presentation. Do not independently recalculate these values in Timing or Race Setup. `buildDriverTimingChannelStats()` resolves every driver record belonging to the same personal profile but applies the selected class filter before reading `resultsBySession`. Consequently multiple transponders in 2WD can be aggregated while 2WD and 4WD data remain separate. +The public builder repeats the class isolation on the server because the client must never receive the full state. Its output is limited to split count/best/average and pit count/total/best/average. It intentionally excludes transponders, competitor keys, driver IDs, event names, timestamps, and individual pit stops. Keep the serialization security assertion in `tests/public_training_profile.test.js` whenever this payload changes. + Presentation/data path: ```text @@ -1004,6 +1007,10 @@ decoder_runtime -> resultsBySession[sessionId].splits/pitStops resultsBySession -> buildDriverTimingChannelStats -> Training / More statistics + +resultsBySession -> buildPublicTimingChannelStats (server-side sanitized) + -> /public-training/api/:shareToken + -> personal mobile profile ``` Important invariants: diff --git a/server/public_training_profile.js b/server/public_training_profile.js index 97d4ad0..22a9052 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -54,6 +54,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp })) .sort((left, right) => left.timestamp - right.timestamp); const stats = buildPublicTrainingStats(classLaps); + const channelStats = buildPublicTimingChannelStats(state, classDriverIds); const rankedTotals = getRankedTrainingTotals(ledger, drivers, classId); const rankIndex = rankedTotals.findIndex(([profileId]) => profileId === driverProfile.id); const classGoals = (Array.isArray(state?.training?.goals) ? state.training.goals : []) @@ -67,6 +68,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp clubRank: rankIndex >= 0 ? rankIndex + 1 : 0, rankedDrivers: rankedTotals.length, stats, + channelStats, nextMilestone: getPublicTrainingMilestone(stats.totalLaps), recommendation: getPublicTrainingRecommendation(stats), goals: classGoals, @@ -78,6 +80,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp const activeClass = classProfiles[0] || { classId: "", className: "", brand: "", clubRank: 0, rankedDrivers: 0, stats: buildPublicTrainingStats([]), nextMilestone: getPublicTrainingMilestone(0), + channelStats: buildPublicTimingChannelStats({}, new Set()), recommendation: getPublicTrainingRecommendation({ totalLaps: 0 }), goals: [], currentSession: null, }; const settings = state?.settings && typeof state.settings === "object" ? state.settings : {}; @@ -98,6 +101,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp }, classProfiles, stats: activeClass.stats, + channelStats: activeClass.channelStats, nextMilestone: activeClass.nextMilestone, recommendation: activeClass.recommendation, goals: activeClass.goals, @@ -105,6 +109,33 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp }; } +function buildPublicTimingChannelStats(state, classDriverIds) { + const splitTimes = []; + const pitTimes = []; + Object.values(state?.resultsBySession || {}).forEach((result) => { + (Array.isArray(result?.splits) ? result.splits : []).forEach((split) => { + const splitMs = Math.round(Number(split?.splitMs || 0) || 0); + if (classDriverIds.has(String(split?.driverId || "")) && splitMs > 0) splitTimes.push(splitMs); + }); + (Array.isArray(result?.pitStops) ? result.pitStops : []).forEach((stop) => { + const pitStopMs = Math.round(Number(stop?.pitStopMs || 0) || 0); + if (classDriverIds.has(String(stop?.driverId || "")) && pitStopMs > 0) pitTimes.push(pitStopMs); + }); + }); + const average = (values) => values.length + ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) + : 0; + return { + splitCount: splitTimes.length, + bestSplitMs: splitTimes.length ? Math.min(...splitTimes) : 0, + averageSplitMs: average(splitTimes), + pitStopCount: pitTimes.length, + totalPitMs: pitTimes.reduce((sum, value) => sum + value, 0), + bestPitStopMs: pitTimes.length ? Math.min(...pitTimes) : 0, + averagePitStopMs: average(pitTimes), + }; +} + function normalizePublicTrainingSince(value, startedAgoValue) { const now = Date.now(); if (startedAgoValue !== undefined) { diff --git a/src/app.js b/src/app.js index 93b29e5..8975c33 100644 --- a/src/app.js +++ b/src/app.js @@ -1242,7 +1242,7 @@ const TRANSLATIONS = { "guide.decoder_roles_7": "7. Öppna TCP 9000 och de valfria portarna 9001-9003 i backend-hostens brandvägg om webbläsaren kör på en annan maskin. Port 8081 används för webb/API.", "guide.decoder_roles_8": "8. Kontrollera i Tidtagning att varje aktiverad roll står som Ansluten. Om main är offline räknas inga varv; om en extrakanal är offline fortsätter vanlig varvräkning.", "guide.decoder_roles_9": "9. Tidtagning visar live-sammanställning och depåstoppslogg. Race Setup visar samma data per heat, och resultat-PDF inkluderar stoppantal, total depåtid samt varje depå in/ut.", - "guide.decoder_roles_10": "10. Träningsstatistik visar mellantid och depåstopp per personlig förarprofil och vald klass. Vid förarbyte i depån tillhör stoppet föraren och bilen som registrerades vid Depå in.", + "guide.decoder_roles_10": "10. Träningsstatistik och den personliga mobilprofilen visar sanerade mellantids- och depåaggregat per vald klass. Mobilprofilen visar inte transpondrar eller rå depålogg. Vid förarbyte tillhör stoppet föraren och bilen vid Depå in.", "guide.host_1": "1. AMMC körs alltid på samma maskin som backend körs på (`npm.cmd start` i Windows eller `npm start` i Linux).", "guide.host_2": "2. Om du bara surfar in från en laptop/webbläsare startas ingen process där. Webbläsaren styr bara backend via HTTP.", "guide.host_3": "3. Kör backend på Linux-servern -> Linux-binären används: `AMMC/linux_x86-64/ammc-amb`.", @@ -2274,7 +2274,7 @@ const TRANSLATIONS = { "guide.decoder_roles_7": "7. Allow TCP 9000 and optional ports 9001-9003 through the backend host firewall when the browser runs on another machine. Port 8081 serves the web UI/API.", "guide.decoder_roles_8": "8. Verify in Timing that every enabled role is Connected. If main is offline no laps are counted; an offline optional channel does not stop normal lap counting.", "guide.decoder_roles_9": "9. Timing shows a live summary and pit-stop log. Race Setup shows the same data per heat, and result PDFs include stop count, total pit time, and every pit entry/exit.", - "guide.decoder_roles_10": "10. Training statistics show split and pit data per personal driver profile and selected class. During a pit driver change, the stop remains attributed to the driver and car recorded at Pit entry.", + "guide.decoder_roles_10": "10. Training statistics and the personal mobile profile show sanitized split/pit aggregates per selected class. The mobile profile does not expose transponders or the raw pit log. During a driver change, the stop remains attributed to the driver and car at Pit entry.", "guide.host_1": "1. AMMC always runs on the backend host (`npm.cmd start` on Windows or `npm start` on Linux).", "guide.host_2": "2. If you only browse from a laptop/browser, no process is started there. The browser only controls the backend over HTTP.", "guide.host_3": "3. Run the backend on Linux -> the Linux binary is used: `AMMC/linux_x86-64/ammc-amb`.", diff --git a/src/public_training.js b/src/public_training.js index 9290dc7..4d556ca 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -58,6 +58,10 @@ const messages = { bestThree: "Bästa 3 sammanhängande", bestFive: "Bästa 5 min", fiveAverage: "Avg pace, bästa 5 min", + splitPassings: "Mellantidspasseringar", + bestSplit: "Bästa mellantid", + pitStops: "Depåstopp", + totalPit: "Total depåtid", }, en: { loading: "Loading training profile...", @@ -108,6 +112,10 @@ const messages = { bestThree: "Best 3 consecutive", bestFive: "Best 5 min", fiveAverage: "Average pace, best 5 min", + splitPassings: "Split passings", + bestSplit: "Best split", + pitStops: "Pit stops", + totalPit: "Total pit time", }, }; @@ -167,6 +175,7 @@ function renderProfile(data) { let activeClass = classProfiles.find((item) => String(item.classId || "") === selectedClassId) || classProfiles[0] || null; if (activeClass && selectedClassId !== activeClass.classId) selectedClassId = activeClass.classId; const stats = activeClass?.stats || data.stats; + const channelStats = activeClass?.channelStats || data.channelStats || {}; const nextMilestone = activeClass?.nextMilestone || data.nextMilestone; const recommendation = activeClass?.recommendation || data.recommendation; const goals = activeClass?.goals || data.goals || []; @@ -275,6 +284,10 @@ function renderProfile(data) {