From abf887dccd36cdbfb4a49d330c24c88aa0400c69 Mon Sep 17 00:00:00 2001 From: larssand Date: Tue, 25 Aug 2026 20:00:55 +0200 Subject: [PATCH] =?UTF-8?q?Split-=20och=20dep=C3=A5statistik=20=C3=A4r=20i?= =?UTF-8?q?mplementerad?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++ README.sv.md | 8 ++ docs/javascript-architecture.md | 48 +++++++++- src/app.js | 160 ++++++++++++++++++++++++++------ src/decoder_runtime.js | 19 +++- src/event_manager_controller.js | 2 + src/event_manager_view.js | 21 +++++ src/misc_views.js | 2 +- src/race_render_helpers.js | 98 +++++++++++++++++-- src/timing_logic.js | 72 ++++++++++++++ src/timing_views.js | 10 +- src/training_logic.js | 52 +++++++++++ src/training_views.js | 26 ++++++ tests/decoder_runtime.test.js | 17 +++- tests/race_timing_logic.test.js | 24 +++++ tests/training_logic.test.js | 43 +++++++++ 16 files changed, 564 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 8f0e133..74caac6 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,14 @@ The decoder IP and WebSocket address are different things. The decoder IP points Use only the channels physically installed at the track. One decoder on `9000` remains the default and needs no optional settings. Split timing requires one additional decoder. Pit timing requires two additional decoders because entry and exit are separate events. A complete setup therefore uses four decoders. +Split and pit data is available in several places: +- `Timing` shows the active heat summary and complete pit-stop log live. +- `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. + +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. + 1. Place AMMC binaries in the `AMMC/` project folder. 2. Open `Settings`. 3. Enable `Managed AMMC`. diff --git a/README.sv.md b/README.sv.md index 303ad9d..e803f25 100644 --- a/README.sv.md +++ b/README.sv.md @@ -435,6 +435,14 @@ Decoder-IP och WebSocket-adress är två olika saker. Decoder-IP är den fysiska Aktivera bara de kanaler som finns fysiskt på banan. En decoder på `9000` är fortsatt standard och kräver inga extrainställningar. Mellantid kräver en extra decoder. Depåtid kräver två extra decoders eftersom in- och utpassering är separata händelser. Komplett installation använder därför fyra decoders. +Mellantids- och depådata visas på flera ställen: +- `Tidtagning` visar aktivt heats sammanställning och fullständig depåstoppslogg live. +- `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. + +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. + 1. Lägg AMMC-binärerna i projektmappen `AMMC/` (redan gjort i denna repo). 2. Öppna `Settings`. 3. Aktivera `Hanterad AMMC / Managed AMMC`. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index d739335..664b23d 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -968,7 +968,53 @@ Race data is stored under `resultsBySession[sessionId]`: The full result object is included in `/api/state` persistence. Only main passings are posted to `/api/passings` and the SQLite passings table. This preserves the existing lap ledger and prevents split/pit records from being interpreted as laps. If split or pit history later needs SQL reporting, add dedicated tables and endpoints rather than inserting those records into `passings`. -### 8.3.3 Managed AMMC process lifecycle +### 8.3.3 Split/pit attribution, analysis, and presentation + +`src/decoder_runtime.js` snapshots the active member on `pit_entry` into the competitor record: + +```js +{ + pitEntryDriverId, pitEntryDriverName, + pitEntryCarId, pitEntryCarName, + pitEntryTransponder +} +``` + +On `pit_exit`, the resulting `PitStop` uses that entry snapshot rather than the currently active team member. This is essential for endurance races: a team may switch driver, car, or transponder while stopped. `exitTransponder` is stored separately and must not overwrite the entry attribution. After the stop is appended, the snapshot fields are cleared. + +Analysis ownership is deliberately split into two pure functions: + +| Function | Module | Scope | Consumers | +|---|---|---|---| +| `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 | + +`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. + +Presentation/data path: + +```text +decoder_runtime -> resultsBySession[sessionId].splits/pitStops + -> buildSessionChannelStats + -> renderSessionChannelAnalysis + -> Timing / Race Setup / HTML print + -> buildSessionChannelPdfSections -> PDF export + +resultsBySession -> buildDriverTimingChannelStats + -> Training / More statistics +``` + +Important invariants: + +- all displayed pit-stop counts come from completed `pitStops`, never unmatched `pitEvents` +- the pit log is available for every session type, not only `team_race` +- an optional split/pit channel never changes race laps, lap ranking, or finish timing +- old stored pit stops without entry-attribution fields remain readable via competitor fallback labels +- reports must use the same builders as live views to avoid differing totals + +### 8.3.4 Managed AMMC process lifecycle `server.js` owns four process states in `ammcState.channels`: `main`, `split`, `pitEntry`, and `pitExit`. `startAmmcProcesses()` normalizes and validates the complete configuration before spawning one child process per enabled role. All roles use the same resolved AMMC executable and working directory but receive different `-w ` arguments. diff --git a/src/app.js b/src/app.js index 3cb5de9..93b29e5 100644 --- a/src/app.js +++ b/src/app.js @@ -6,7 +6,7 @@ import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js"; -import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic } from "./timing_logic.js"; +import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildSessionChannelStats as buildSessionChannelStatsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic } from "./timing_logic.js"; import { renderDashboardView, renderClassesView, renderDriversView, renderCarsView } from "./core_views.js"; @@ -17,14 +17,14 @@ import { renderEventManagerMarkup } from "./event_manager_view.js"; import { renderEventManagerView } from "./event_manager_controller.js"; import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, getSpeechVoicesHelper, speakTextHelper, announcePassingHelper, announceRaceStartedHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js"; -import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic } from "./training_logic.js"; +import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic } from "./training_logic.js"; import { renderTrainingView } from "./training_views.js"; import { renderTimingView, renderJudgingView } from "./timing_views.js"; -import { renderTeamStintLog as renderTeamStintLogHelper, renderTeamRaceStandings as renderTeamRaceStandingsHelper, getSessionSortWeight as getSessionSortWeightHelper, getDriverDisplayById as getDriverDisplayByIdHelper, renderPositionGrid as renderPositionGridHelper, renderGridEditor as renderGridEditorHelper, getFinalMainLayouts as getFinalMainLayoutsHelper, renderFinalMatrix as renderFinalMatrixHelper, buildPrintBrandBlock as buildPrintBrandBlockHelper, buildRaceStartListsHtml as buildRaceStartListsHtmlHelper, buildRaceResultsHtml as buildRaceResultsHtmlHelper, buildTeamRaceResultsHtml as buildTeamRaceResultsHtmlHelper } from "./race_render_helpers.js"; +import { renderTeamStintLog as renderTeamStintLogHelper, renderSessionChannelAnalysis as renderSessionChannelAnalysisHelper, renderTeamRaceStandings as renderTeamRaceStandingsHelper, getSessionSortWeight as getSessionSortWeightHelper, getDriverDisplayById as getDriverDisplayByIdHelper, renderPositionGrid as renderPositionGridHelper, renderGridEditor as renderGridEditorHelper, getFinalMainLayouts as getFinalMainLayoutsHelper, renderFinalMatrix as renderFinalMatrixHelper, buildPrintBrandBlock as buildPrintBrandBlockHelper, buildRaceStartListsHtml as buildRaceStartListsHtmlHelper, buildRaceResultsHtml as buildRaceResultsHtmlHelper, buildTeamRaceResultsHtml as buildTeamRaceResultsHtmlHelper } from "./race_render_helpers.js"; import { getManualCorrectionSummary as getManualCorrectionSummaryLogic, applyCompetitorCorrection as applyCompetitorCorrectionLogic, recalculateCompetitorFromPassings as recalculateCompetitorFromPassingsLogic, invalidateCompetitorLastLap as invalidateCompetitorLastLapLogic, restoreCompetitorLastInvalidLap as restoreCompetitorLastInvalidLapLogic, findPassingByUndoMarker as findPassingByUndoMarkerLogic, undoJudgingAdjustment as undoJudgingAdjustmentLogic, getJudgeFilteredRows as getJudgeFilteredRowsLogic, getJudgeFilteredLog as getJudgeFilteredLogLogic } from "./judging_logic.js"; @@ -76,6 +76,8 @@ const getVisiblePassings = (result) => getVisiblePassingsLogic(result, { isCount const getPassingValidationLabel = (passing) => getPassingValidationLabelLogic(passing, { t }); const getSessionTiming = (session, nowTs = Date.now()) => getSessionTimingLogic(session, nowTs, { getSessionTargetMs }); const ensureSessionResult = (sessionId) => ensureSessionResultLogic(sessionId, { state }); +const buildSessionChannelStats = (session) => buildSessionChannelStatsLogic(session, { ensureSessionResult }); +const renderSessionChannelAnalysis = (session) => renderSessionChannelAnalysisHelper(session, { t, escapeHtml, renderTable, formatLap, formatRaceClock, buildSessionChannelStats }); const formatLapDelta = (deltaMs) => formatLapDeltaLogic(deltaMs, { formatLap }); const formatLeaderboardGap = (row, referenceRow, options = {}) => formatLeaderboardGapLogic(row, referenceRow, options, { t }); const getCompetitorElapsedMs = (session, row) => getCompetitorElapsedMsLogic(session, row); @@ -100,7 +102,7 @@ const buildFinalStandings = (event) => buildFinalStandingsLogic(event, { getSess const getManualCorrectionSummary = (row) => getManualCorrectionSummaryLogic(row, { formatLap }); const renderTeamStintLog = (session, rows) => renderTeamStintLogHelper(session, rows, { t, escapeHtml, buildTeamStintLog, formatTeamActiveMemberLabel, renderTable, formatRaceClock }); -const renderTeamRaceStandings = (event) => renderTeamRaceStandingsHelper(event, { t, escapeHtml, buildTeamRaceStandings, getSessionTypeLabel, renderTable, formatTeamActiveMemberLabel, formatLap, renderTeamStintLog }); +const renderTeamRaceStandings = (event) => renderTeamRaceStandingsHelper(event, { t, escapeHtml, buildTeamRaceStandings, getSessionTypeLabel, renderTable, formatTeamActiveMemberLabel, formatLap, formatRaceClock, renderTeamStintLog, buildSessionChannelStats }); const getSessionSortWeight = (session) => getSessionSortWeightHelper(session); const getDriverDisplayById = (driverId) => getDriverDisplayByIdHelper(driverId, { state, t }); const renderPositionGrid = (session) => renderPositionGridHelper(session, { t, escapeHtml, getSessionGridEntries }); @@ -109,7 +111,7 @@ const getFinalMainLayouts = (event) => getFinalMainLayoutsHelper(event, { getSes const renderFinalMatrix = (event) => renderFinalMatrixHelper(event, { t, escapeHtml, getFinalMainLayouts, getStatusLabel }); const buildPrintBrandBlock = (branding) => buildPrintBrandBlockHelper(branding, { escapeHtml }); const buildRaceStartListsHtml = (event) => buildRaceStartListsHtmlHelper(event, { t, state, escapeHtml, resolveEventBranding, getSessionsForEvent, getSessionSortWeight, getClassName, buildPrintBrandBlock, getSessionGridEntries, getSessionTypeLabel, getStartModeLabel, renderTable }); -const buildRaceResultsHtml = (event) => buildRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, renderRaceStandingsTableView, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings }); +const buildRaceResultsHtml = (event) => buildRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, renderRaceStandingsTableView, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings, getSessionsForEvent, renderSessionChannelAnalysis }); const getSessionsForEvent = (eventId) => getSessionsForEventHelper(state, eventId); const getModeLabel = (mode) => getModeLabelHelper(mode, { t }); const normalizeStartMode = (mode) => normalizeStartModeHelper(mode); @@ -149,7 +151,7 @@ const announceRaceStarted = (session) => announceRaceStartedHelper(session, { st const announceRaceFinished = () => announceRaceFinishedHelper({ state, t, getActiveSession, pushOverlayEvent, playFinishSiren, speakText }); const handleSessionTimerTick = () => handleSessionTimerTickHelper({ getActiveSession, isUntimedSession, getSessionTiming, saveState, getLastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, announceRaceFinished }); const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, updateHeaderState }); -const buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog }); +const buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog, renderSessionChannelAnalysis }); const connectDecoder = () => connectDecoderHelper({ state, t, @@ -198,7 +200,7 @@ const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar }); const renderGuide = () => renderGuideView({ dom, t }); const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl }); -const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } }); +const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } }); const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } }); const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid }); const syncTrainingLedger = () => { @@ -210,7 +212,7 @@ const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.no const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats); const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats); const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats); -const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } }); +const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } }); const applyCompetitorCorrection = (session, row, options = {}) => applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState }); const recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing }); const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState }); @@ -343,7 +345,15 @@ const TRANSLATIONS = { "training.no_activity": "Ingen aktivitet ännu", "training.history": "Träningshistorik per dag", "training.detailed_stats": "Mer statistik", - "training.detailed_stats_hint": "Snitt, varvserie och historik per träningsdag.", + "training.detailed_stats_hint": "Snitt, varvserie, mellantider, depåstopp och historik per träningsdag.", + "training.split_passings": "Mellantidspasseringar", + "training.best_split": "Bästa mellantid", + "training.average_split": "Snitt mellantid", + "training.pit_stops": "Depåstopp", + "training.total_pit_time": "Total depåtid", + "training.average_pit_stop": "Snitt depåstopp", + "training.best_pit_stop": "Bästa depåstopp", + "training.recent_pit_stops": "Senaste depåstopp", "training.no_history": "Ingen träningshistorik finns ännu.", "training.date": "Datum", "training.laps": "Varv", @@ -793,6 +803,19 @@ const TRANSLATIONS = { "timing.pit_entry_decoder": "Depå in", "timing.pit_exit_decoder": "Depå ut", "timing.pit_active": "I DEPÅ", + "timing.channel_analysis": "Mellantider och depåstopp", + "timing.channel_analysis_hint": "Mellantider räknar inte varv. Depåtiden mäts från Depå in till Depå ut och kopplas till föraren och bilen vid infarten.", + "timing.channel_analysis_race_hint": "Sammanställning och full depålogg per heat. Använd detta exempelvis för att granska alla stopp i ett 45-minutersrace.", + "timing.split_count": "Antal split", + "timing.best_split": "Bästa split", + "timing.average_split": "Snitt split", + "timing.pit_stops": "Depåstopp", + "timing.total_pit": "Total depåtid", + "timing.average_pit": "Snitt depåstopp", + "timing.best_pit": "Bästa depåstopp", + "timing.pit_stop_log": "Depåstoppslogg", + "timing.pit_in": "Depå in", + "timing.pit_out": "Depå ut", "timing.control": "Sessionkontroll", "timing.speaker_panel": "Speaker-panel", "timing.speaker_panel_hint": "Dessa växlar slår av/på cues direkt för pågående session och overlay utan att lämna Tidtagning.", @@ -1218,6 +1241,8 @@ const TRANSLATIONS = { "guide.decoder_roles_6": "6. I Inställningar aktiverar du endast de extrakanaler som finns fysiskt, sparar Managed AMMC och klickar Starta AMMC. Använd sedan serverns WS-url och Anslut decoder.", "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.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`.", @@ -1352,7 +1377,15 @@ const TRANSLATIONS = { "training.no_activity": "No activity yet", "training.history": "Daily practice history", "training.detailed_stats": "More statistics", - "training.detailed_stats_hint": "Averages, lap streak and daily practice history.", + "training.detailed_stats_hint": "Averages, lap streak, split timing, pit stops, and daily practice history.", + "training.split_passings": "Split passings", + "training.best_split": "Best split", + "training.average_split": "Average split", + "training.pit_stops": "Pit stops", + "training.total_pit_time": "Total pit time", + "training.average_pit_stop": "Average pit stop", + "training.best_pit_stop": "Best pit stop", + "training.recent_pit_stops": "Recent pit stops", "training.no_history": "No practice history is available yet.", "training.date": "Date", "training.laps": "Laps", @@ -1802,6 +1835,19 @@ const TRANSLATIONS = { "timing.pit_entry_decoder": "Pit entry", "timing.pit_exit_decoder": "Pit exit", "timing.pit_active": "IN PIT", + "timing.channel_analysis": "Split timing and pit stops", + "timing.channel_analysis_hint": "Split timing does not count laps. Pit time is measured from Pit entry to Pit exit and attributed to the driver and car at entry.", + "timing.channel_analysis_race_hint": "Summary and complete pit log per heat. Use it, for example, to review every stop in a 45-minute race.", + "timing.split_count": "Split count", + "timing.best_split": "Best split", + "timing.average_split": "Average split", + "timing.pit_stops": "Pit stops", + "timing.total_pit": "Total pit time", + "timing.average_pit": "Average pit stop", + "timing.best_pit": "Best pit stop", + "timing.pit_stop_log": "Pit stop log", + "timing.pit_in": "Pit entry", + "timing.pit_out": "Pit exit", "timing.control": "Session Control", "timing.speaker_panel": "Speaker panel", "timing.speaker_panel_hint": "These toggles enable or disable cues live for the current session and overlay without leaving Timing.", @@ -2227,6 +2273,8 @@ const TRANSLATIONS = { "guide.decoder_roles_6": "6. In Settings, enable only physically installed extra channels, save Managed AMMC, and click Start AMMC. Then use the server WS URLs and Connect decoder.", "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.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`.", @@ -3428,6 +3476,7 @@ function renderEventManager(eventId) { buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings, + renderSessionChannelAnalysis, renderFinalMatrix, getModeLabel, renderTable, @@ -3788,6 +3837,7 @@ async function exportRaceStartListsPdf(event) { async function exportRaceResultsPdf(event) { const branding = resolveEventBranding(event); + const channelSections = getSessionsForEvent(event.id).flatMap((session) => buildSessionChannelPdfSections(session)); await requestPdfExport({ filename: `${event.name.replaceAll(/\s+/g, "_")}_results.pdf`, title: event.name, @@ -3820,35 +3870,92 @@ async function exportRaceResultsPdf(event) { ...buildTeamRaceStandings(event).map(({ session, rows }) => buildPdfSection( `${t("events.team_standings")} • ${session.name}`, - [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")], - rows.map((row, index) => [ - String(index + 1), - row.displayName || row.driverName || "-", - String(row.laps || 0), - row.resultDisplay || "-", - formatLap(row.bestLapMs), - ]) + [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap"), t("timing.pit_stops"), t("timing.total_pit")], + rows.map((row, index) => { + const channel = buildSessionChannelStats(session).competitors.find((item) => item.competitorKey === row.key) || {}; + return [ + String(index + 1), + row.displayName || row.driverName || "-", + String(row.laps || 0), + row.resultDisplay || "-", + formatLap(row.bestLapMs), + String(channel.pitStopCount || 0), + channel.pitStopCount ? formatRaceClock(channel.totalPitMs) : "-", + ]; + }) ) ), + ...channelSections, ], }); } +function buildSessionChannelPdfSections(session) { + const stats = buildSessionChannelStats(session); + if (!stats.splitCount && !stats.pitStopCount) { + return []; + } + const sections = [ + buildPdfSection( + `${timingSessionPdfTitle(session)} • ${t("timing.channel_analysis")}`, + [t("table.driver"), t("timing.split_count"), t("timing.best_split"), t("timing.average_split"), t("timing.pit_stops"), t("timing.total_pit"), t("timing.average_pit"), t("timing.best_pit")], + stats.competitors.map((row) => [ + row.displayName || "-", + String(row.splitCount || 0), + row.splitCount ? formatLap(row.bestSplitMs) : "-", + row.splitCount ? formatLap(row.averageSplitMs) : "-", + String(row.pitStopCount || 0), + row.pitStopCount ? formatRaceClock(row.totalPitMs) : "-", + row.pitStopCount ? formatLap(row.averagePitStopMs) : "-", + row.pitStopCount ? formatLap(row.bestPitStopMs) : "-", + ]) + ), + ]; + if (stats.pitStops.length) { + sections.push( + buildPdfSection( + `${timingSessionPdfTitle(session)} • ${t("timing.pit_stop_log")}`, + ["#", t("events.team_name"), t("table.driver"), t("table.car"), t("timing.pit_in"), t("timing.pit_out"), t("table.duration")], + stats.pitStops.map((stop) => [ + String(stop.index), + stop.displayName || "-", + stop.driverName || "-", + stop.carName || "-", + stop.enteredAt ? new Date(stop.enteredAt).toLocaleTimeString() : "-", + stop.exitedAt ? new Date(stop.exitedAt).toLocaleTimeString() : "-", + formatLap(stop.pitStopMs), + ]) + ) + ); + } + return sections; +} + +function timingSessionPdfTitle(session) { + return `${session.name} • ${getSessionTypeLabel(session.type)}`; +} + async function exportTeamRaceResultsPdf(event) { const branding = resolveEventBranding(event); const sections = []; buildTeamRaceStandings(event).forEach(({ session, rows }) => { + const channelRows = new Map(buildSessionChannelStats(session).competitors.map((row) => [row.competitorKey, row])); sections.push( buildPdfSection( `${t("events.team_report")} • ${session.name}`, - [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")], - rows.map((row, index) => [ - String(index + 1), - row.displayName || row.driverName || "-", - String(row.laps || 0), - row.resultDisplay || "-", - formatLap(row.bestLapMs), - ]) + [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap"), t("timing.pit_stops"), t("timing.total_pit")], + rows.map((row, index) => { + const channel = channelRows.get(row.key) || {}; + return [ + String(index + 1), + row.displayName || row.driverName || "-", + String(row.laps || 0), + row.resultDisplay || "-", + formatLap(row.bestLapMs), + String(channel.pitStopCount || 0), + channel.pitStopCount ? formatRaceClock(channel.totalPitMs) : "-", + ]; + }) ) ); @@ -3869,6 +3976,7 @@ async function exportTeamRaceResultsPdf(event) { ) ); }); + sections.push(...buildSessionChannelPdfSections(session)); }); await requestPdfExport({ diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index 76ef54a..0dac3d2 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -319,6 +319,11 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { if (entry.pitActive) return; entry.pitEnteredAt = timestamp; entry.pitActive = true; + entry.pitEntryDriverId = entry.driverId || null; + entry.pitEntryDriverName = entry.driverName || ""; + entry.pitEntryCarId = entry.carId || null; + entry.pitEntryCarName = entry.carName || ""; + entry.pitEntryTransponder = transponder; if (!Array.isArray(result.pitEvents)) result.pitEvents = []; result.pitEvents.push({ timestamp, type: "entry", transponder, competitorKey: key, driverId: entry.driverId, carId: entry.carId }); pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT IN`); @@ -330,6 +335,11 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { const enteredAt = Number(entry.pitEnteredAt || 0); if (!enteredAt || timestamp <= enteredAt) return; const pitStopMs = timestamp - enteredAt; + const pitDriverId = entry.pitEntryDriverId ?? entry.driverId ?? null; + const pitDriverName = entry.pitEntryDriverName || entry.driverName || ""; + const pitCarId = entry.pitEntryCarId ?? entry.carId ?? null; + const pitCarName = entry.pitEntryCarName || entry.carName || ""; + const pitTransponder = entry.pitEntryTransponder || transponder; entry.pitActive = false; entry.pitEnteredAt = null; entry.pitStops = Math.max(0, Number(entry.pitStops || 0)) + 1; @@ -338,8 +348,13 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { if (!entry.bestPitStopMs || pitStopMs < entry.bestPitStopMs) entry.bestPitStopMs = pitStopMs; if (!Array.isArray(result.pitEvents)) result.pitEvents = []; if (!Array.isArray(result.pitStops)) result.pitStops = []; - result.pitEvents.push({ timestamp, type: "exit", transponder, competitorKey: key, driverId: entry.driverId, carId: entry.carId, pitStopMs }); - result.pitStops.push({ enteredAt, exitedAt: timestamp, pitStopMs, transponder, competitorKey: key, driverId: entry.driverId, carId: entry.carId }); + result.pitEvents.push({ timestamp, type: "exit", transponder, competitorKey: key, driverId: pitDriverId, driverName: pitDriverName, carId: pitCarId, carName: pitCarName, pitStopMs }); + result.pitStops.push({ enteredAt, exitedAt: timestamp, pitStopMs, transponder: pitTransponder, exitTransponder: transponder, competitorKey: key, driverId: pitDriverId, driverName: pitDriverName, carId: pitCarId, carName: pitCarName }); + entry.pitEntryDriverId = null; + entry.pitEntryDriverName = ""; + entry.pitEntryCarId = null; + entry.pitEntryCarName = ""; + entry.pitEntryTransponder = ""; pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT ${formatLap(pitStopMs)}`); saveState(); return; diff --git a/src/event_manager_controller.js b/src/event_manager_controller.js index 786eb66..4b844be 100644 --- a/src/event_manager_controller.js +++ b/src/event_manager_controller.js @@ -40,6 +40,7 @@ export function renderEventManagerView(context) { buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings, + renderSessionChannelAnalysis, renderFinalMatrix, getModeLabel, renderTable, @@ -184,6 +185,7 @@ export function renderEventManagerView(context) { buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings, + renderSessionChannelAnalysis, renderFinalMatrix, editingTeam, editingSession, diff --git a/src/event_manager_view.js b/src/event_manager_view.js index 46679ed..a064f8f 100644 --- a/src/event_manager_view.js +++ b/src/event_manager_view.js @@ -40,12 +40,23 @@ export function renderEventManagerMarkup(context) { buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings, + renderSessionChannelAnalysis, renderFinalMatrix, editingTeam, editingSession, getModeLabel, renderTable, } = context; + const sessionChannelMarkup = sessions + .map((session) => ({ session, markup: renderSessionChannelAnalysis(session) })) + .filter((entry) => entry.markup) + .map(({ session, markup }) => ` +
+

${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}

+ ${markup} +
+ `) + .join(""); return `
@@ -698,6 +709,16 @@ export function renderEventManagerMarkup(context) {
+ ${sessionChannelMarkup ? ` +
+

${t("timing.channel_analysis")}

+
+

${t("timing.channel_analysis_race_hint")}

+ ${sessionChannelMarkup} +
+
+ ` : ""} +

${t("events.final_matrix")}

diff --git a/src/misc_views.js b/src/misc_views.js index 5b306c6..0cf74d6 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -72,7 +72,7 @@ export function renderGuideView({ dom, t }) {
- ${renderGuidePanel("guide.decoder_roles_title", ["guide.decoder_roles_1", "guide.decoder_roles_2", "guide.decoder_roles_3", "guide.decoder_roles_4", "guide.decoder_roles_5", "guide.decoder_roles_6", "guide.decoder_roles_7", "guide.decoder_roles_8"], "", { t })} + ${renderGuidePanel("guide.decoder_roles_title", ["guide.decoder_roles_1", "guide.decoder_roles_2", "guide.decoder_roles_3", "guide.decoder_roles_4", "guide.decoder_roles_5", "guide.decoder_roles_6", "guide.decoder_roles_7", "guide.decoder_roles_8", "guide.decoder_roles_9", "guide.decoder_roles_10"], "", { t })} ${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })}
diff --git a/src/race_render_helpers.js b/src/race_render_helpers.js index 04e06d2..f18eb06 100644 --- a/src/race_render_helpers.js +++ b/src/race_render_helpers.js @@ -39,7 +39,69 @@ export function renderTeamStintLog(session, rows, { t, escapeHtml, buildTeamStin `; } -export function renderTeamRaceStandings(event, { t, escapeHtml, buildTeamRaceStandings, getSessionTypeLabel, renderTable, formatTeamActiveMemberLabel, formatLap, renderTeamStintLog }) { +export function renderSessionChannelAnalysis(session, { t, escapeHtml, renderTable, formatLap, formatRaceClock, buildSessionChannelStats }) { + const stats = buildSessionChannelStats(session); + if (!stats.splitCount && !stats.pitStopCount) { + return ""; + } + + const formatClockTime = (timestamp) => timestamp ? new Date(timestamp).toLocaleTimeString() : "-"; + return ` +
+

${t("timing.channel_analysis_hint")}

+ ${renderTable( + [ + t("table.driver"), + t("timing.split_count"), + t("timing.best_split"), + t("timing.average_split"), + t("timing.pit_stops"), + t("timing.total_pit"), + t("timing.average_pit"), + t("timing.best_pit"), + ], + stats.competitors.map((row) => ` + + +
${escapeHtml(row.displayName || "-")}
+ ${(row.driverName || row.carName) ? `
${escapeHtml([row.driverName, row.carName].filter(Boolean).join(" • "))}
` : ""} + + ${row.splitCount} + ${row.splitCount ? formatLap(row.bestSplitMs) : "-"} + ${row.splitCount ? formatLap(row.averageSplitMs) : "-"} + ${row.pitStopCount} + ${row.pitStopCount ? formatRaceClock(row.totalPitMs) : "-"} + ${row.pitStopCount ? formatLap(row.averagePitStopMs) : "-"} + ${row.pitStopCount ? formatLap(row.bestPitStopMs) : "-"} + + `) + )} + ${stats.pitStops.length ? ` +
+

${t("timing.pit_stop_log")}

+ ${renderTable( + ["#", t("table.driver"), t("table.car"), t("timing.pit_in"), t("timing.pit_out"), t("table.duration")], + stats.pitStops.map((stop) => ` + + ${stop.index} + +
${escapeHtml(stop.displayName || stop.driverName || "-")}
+ ${stop.driverName && stop.driverName !== stop.displayName ? `
${escapeHtml(stop.driverName)}
` : ""} + + ${escapeHtml(stop.carName || "-")} + ${formatClockTime(stop.enteredAt)} + ${formatClockTime(stop.exitedAt)} + ${formatLap(stop.pitStopMs)} + + `) + )} +
+ ` : ""} +
+ `; +} + +export function renderTeamRaceStandings(event, { t, escapeHtml, buildTeamRaceStandings, getSessionTypeLabel, renderTable, formatTeamActiveMemberLabel, formatLap, formatRaceClock, renderTeamStintLog, buildSessionChannelStats }) { const groups = buildTeamRaceStandings(event); if (!groups.length) { return `

${t("events.no_team_results")}

`; @@ -47,15 +109,18 @@ export function renderTeamRaceStandings(event, { t, escapeHtml, buildTeamRaceSta return groups .map( - ({ session, rows }) => ` + ({ session, rows }) => { + const channelRows = new Map(buildSessionChannelStats(session).competitors.map((row) => [row.competitorKey, row])); + return `

${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}

${ rows.length ? renderTable( - [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")], - rows.map( - (row, index) => ` + [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap"), t("timing.pit_stops"), t("timing.total_pit")], + rows.map((row, index) => { + const channel = channelRows.get(row.key) || {}; + return ` ${index + 1} @@ -65,9 +130,11 @@ export function renderTeamRaceStandings(event, { t, escapeHtml, buildTeamRaceSta ${row.laps} ${escapeHtml(row.resultDisplay)} ${formatLap(row.bestLapMs)} + ${channel.pitStopCount || 0} + ${channel.pitStopCount ? formatRaceClock(channel.totalPitMs) : "-"} - ` - ) + `; + }) ) : `

${t("events.no_team_results")}

` } @@ -76,7 +143,8 @@ export function renderTeamRaceStandings(event, { t, escapeHtml, buildTeamRaceSta ${rows.length ? renderTeamStintLog(session, rows) : `

${t("events.no_team_results")}

`}
- ` + `; + } ) .join(""); } @@ -347,8 +415,11 @@ export function buildRaceStartListsHtml(event, { t, state, escapeHtml, resolveEv `; } -export function buildRaceResultsHtml(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, renderRaceStandingsTableView, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings }) { +export function buildRaceResultsHtml(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, renderRaceStandingsTableView, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings, getSessionsForEvent, renderSessionChannelAnalysis }) { const branding = resolveEventBranding(event); + const channelSections = getSessionsForEvent(event.id) + .map((session) => ({ session, markup: renderSessionChannelAnalysis(session) })) + .filter((entry) => entry.markup); return `
+ ${channelSections.map(({ session, markup }) => ` + + `).join("")} `; } -export function buildTeamRaceResultsHtml(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog }) { +export function buildTeamRaceResultsHtml(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog, renderSessionChannelAnalysis }) { const branding = resolveEventBranding(event); const groups = buildTeamRaceStandings(event); return ` @@ -418,6 +495,7 @@ export function buildTeamRaceResultsHtml(event, { t, escapeHtml, resolveEventBra }

${t("events.team_stint_log")}

${renderTeamStintLog(session, rows)} + ${renderSessionChannelAnalysis(session) ? `

${t("timing.channel_analysis")}

${renderSessionChannelAnalysis(session)}` : ""} ` ) diff --git a/src/timing_logic.js b/src/timing_logic.js index d7d512e..d889851 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -644,6 +644,78 @@ export function buildTeamRaceStandings(event, { getSessionsForEvent, buildLeader })); } +export function buildSessionChannelStats(session, { ensureSessionResult }) { + const result = ensureSessionResult(session.id); + const summaries = new Map(); + const ensureSummary = (competitorKey) => { + const key = String(competitorKey || "unknown"); + if (!summaries.has(key)) { + const row = result.competitors?.[key] || {}; + summaries.set(key, { + competitorKey: key, + displayName: row.displayName || row.teamName || row.driverName || row.transponder || key, + driverName: row.driverName || "", + carName: row.carName || "", + splitCount: 0, + totalSplitMs: 0, + bestSplitMs: 0, + lastSplitMs: 0, + pitStopCount: 0, + totalPitMs: 0, + bestPitStopMs: 0, + lastPitStopMs: 0, + }); + } + return summaries.get(key); + }; + + (Array.isArray(result.splits) ? result.splits : []).forEach((split) => { + const splitMs = Math.max(0, Number(split?.splitMs || 0) || 0); + if (!splitMs) return; + const summary = ensureSummary(split.competitorKey); + summary.splitCount += 1; + summary.totalSplitMs += splitMs; + summary.bestSplitMs = summary.bestSplitMs ? Math.min(summary.bestSplitMs, splitMs) : splitMs; + summary.lastSplitMs = splitMs; + }); + + const pitStops = (Array.isArray(result.pitStops) ? result.pitStops : []) + .filter((stop) => Number(stop?.pitStopMs || 0) > 0) + .map((stop, index) => { + const summary = ensureSummary(stop.competitorKey); + const pitStopMs = Math.max(0, Number(stop.pitStopMs || 0) || 0); + summary.pitStopCount += 1; + summary.totalPitMs += pitStopMs; + summary.bestPitStopMs = summary.bestPitStopMs ? Math.min(summary.bestPitStopMs, pitStopMs) : pitStopMs; + summary.lastPitStopMs = pitStopMs; + return { + ...stop, + index: index + 1, + pitStopMs, + displayName: summary.displayName, + driverName: stop.driverName || summary.driverName || "", + carName: stop.carName || summary.carName || "", + }; + }) + .sort((left, right) => Number(left.enteredAt || 0) - Number(right.enteredAt || 0)); + + const competitors = [...summaries.values()] + .map((summary) => ({ + ...summary, + averageSplitMs: summary.splitCount ? Math.round(summary.totalSplitMs / summary.splitCount) : 0, + averagePitStopMs: summary.pitStopCount ? Math.round(summary.totalPitMs / summary.pitStopCount) : 0, + })) + .sort((left, right) => right.pitStopCount - left.pitStopCount || right.splitCount - left.splitCount || left.displayName.localeCompare(right.displayName)); + + return { + splitCount: competitors.reduce((sum, row) => sum + row.splitCount, 0), + pitStopCount: pitStops.length, + totalPitMs: competitors.reduce((sum, row) => sum + row.totalPitMs, 0), + competitors, + pitStops, + }; +} + export function buildTeamStintLog(session, row, { getCompetitorPassings, getSessionLapWindow, formatTeamActiveMemberLabel }) { diff --git a/src/timing_views.js b/src/timing_views.js index 11755ee..729e7fb 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -265,7 +265,7 @@ export function renderTimingView(deps) { ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey, setSelectedLeaderboardKey, getQuickAddDraft, setQuickAddDraft, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, - getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock, + getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock, renderSessionChannelAnalysis, setLastFinishAnnouncementSessionId, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents, setOverlayEvents, } = deps; @@ -294,6 +294,7 @@ export function renderTimingView(deps) { const showFinishedBanner = Boolean(active && active.status === "finished" && active.finishedByTimer); const showFollowUpBanner = Boolean(active && sessionTiming?.followUpActive); const selectedRow = leaderboard.find((row) => row.key === getSelectedLeaderboardKey()) || null; + const channelAnalysis = active ? renderSessionChannelAnalysis(active) : ""; if (getSelectedLeaderboardKey() && !selectedRow) { setSelectedLeaderboardKey(null); } @@ -409,6 +410,13 @@ export function renderTimingView(deps) { + ${channelAnalysis ? ` +
+

${t("timing.channel_analysis")}

+
${channelAnalysis}
+
+ ` : ""} +

${t("timing.recent_passings")}

diff --git a/src/training_logic.js b/src/training_logic.js index ed5621b..e246e21 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -314,6 +314,58 @@ export function buildDriverTrainingStats(state, driverId, classId = "") { }; } +export function buildDriverTimingChannelStats(state, driverId, classId = "") { + const profileReferences = getTrainingDriverIds(state, driverId, classId); + const sessions = new Map((Array.isArray(state?.sessions) ? state.sessions : []).map((session) => [String(session.id || ""), session])); + const events = new Map((Array.isArray(state?.events) ? state.events : []).map((event) => [String(event.id || ""), event])); + const splits = []; + const pitStops = []; + + Object.entries(state?.resultsBySession || {}).forEach(([sessionId, result]) => { + const session = sessions.get(String(sessionId)); + const event = session ? events.get(String(session.eventId || "")) : null; + (Array.isArray(result?.splits) ? result.splits : []).forEach((split) => { + if (!profileReferences.has(String(split?.driverId || "")) || Number(split?.splitMs || 0) <= 0) return; + splits.push({ + ...split, + sessionId: String(sessionId), + sessionName: String(session?.name || ""), + eventName: String(event?.name || ""), + splitMs: Math.round(Number(split.splitMs)), + }); + }); + (Array.isArray(result?.pitStops) ? result.pitStops : []).forEach((stop) => { + if (!profileReferences.has(String(stop?.driverId || "")) || Number(stop?.pitStopMs || 0) <= 0) return; + pitStops.push({ + ...stop, + sessionId: String(sessionId), + sessionName: String(session?.name || ""), + eventName: String(event?.name || ""), + pitStopMs: Math.round(Number(stop.pitStopMs)), + }); + }); + }); + + splits.sort((left, right) => Number(left.timestamp || 0) - Number(right.timestamp || 0)); + pitStops.sort((left, right) => Number(left.enteredAt || 0) - Number(right.enteredAt || 0)); + const splitTimes = splits.map((split) => split.splitMs); + const pitTimes = pitStops.map((stop) => stop.pitStopMs); + return { + splitCount: splits.length, + splitSessions: new Set(splits.map((split) => split.sessionId)).size, + bestSplitMs: splitTimes.length ? Math.min(...splitTimes) : 0, + averageSplitMs: splitTimes.length ? Math.round(splitTimes.reduce((sum, value) => sum + value, 0) / splitTimes.length) : 0, + lastSplitMs: splitTimes.at(-1) || 0, + pitStopCount: pitStops.length, + pitSessions: new Set(pitStops.map((stop) => stop.sessionId)).size, + totalPitMs: pitTimes.reduce((sum, value) => sum + value, 0), + averagePitStopMs: pitTimes.length ? Math.round(pitTimes.reduce((sum, value) => sum + value, 0) / pitTimes.length) : 0, + bestPitStopMs: pitTimes.length ? Math.min(...pitTimes) : 0, + longestPitStopMs: pitTimes.length ? Math.max(...pitTimes) : 0, + recentPitStops: pitStops.slice(-10).reverse(), + }; +} + export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now(), classId = "") { const session = (Array.isArray(state?.sessions) ? state.sessions : []) .find((item) => String(item?.id || "") === String(state?.activeSessionId || "")); diff --git a/src/training_views.js b/src/training_views.js index 38fb56d..33fb987 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -14,6 +14,7 @@ export function renderTrainingView(deps) { normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, + buildDriverTimingChannelStats, buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, @@ -57,6 +58,7 @@ export function renderTrainingView(deps) { const selectedDriver = selectedProfile.representative; const profileReferences = getDriverProfileReferences(state, selectedProfileId); const stats = buildDriverTrainingStats(state, selectedProfileId, selectedClassId); + const channelStats = buildDriverTimingChannelStats(state, selectedProfileId, selectedClassId); const activePractice = buildDriverActivePracticeStats(state, selectedProfileId, Date.now(), selectedClassId); const recommendation = buildTrainingRecommendation(stats); const nextMilestone = getNextTrainingMilestone(stats); @@ -294,7 +296,31 @@ export function renderTrainingView(deps) { ${trainingInsight(t("training.best_three"), stats.bestThreeConsecutive ? `3/${formatRaceClock(stats.bestThreeConsecutive.totalMs)}` : "-")} ${trainingInsight(t("training.best_five"), stats.bestFiveMinuteResult ? `${stats.bestFiveMinuteResult.lapCount}/${formatRaceClock(stats.bestFiveMinuteResult.totalMs)}` : "-")} ${trainingInsight(t("training.five_avg_pace"), stats.bestFiveMinuteResult ? formatLap(stats.bestFiveMinuteResult.averageLapMs) : "-")} + ${trainingInsight(t("training.split_passings"), String(channelStats.splitCount))} + ${trainingInsight(t("training.best_split"), channelStats.splitCount ? formatLap(channelStats.bestSplitMs) : "-")} + ${trainingInsight(t("training.average_split"), channelStats.splitCount ? formatLap(channelStats.averageSplitMs) : "-")} + ${trainingInsight(t("training.pit_stops"), String(channelStats.pitStopCount))} + ${trainingInsight(t("training.total_pit_time"), channelStats.pitStopCount ? formatRaceClock(channelStats.totalPitMs) : "-")} + ${trainingInsight(t("training.average_pit_stop"), channelStats.pitStopCount ? formatLap(channelStats.averagePitStopMs) : "-")} + ${trainingInsight(t("training.best_pit_stop"), channelStats.pitStopCount ? formatLap(channelStats.bestPitStopMs) : "-")}
+ ${channelStats.recentPitStops.length ? ` +

${escapeHtml(t("training.recent_pit_stops"))}

+
+ + + ${channelStats.recentPitStops.map((stop) => ` + + + + + + + + `).join("")} +
${escapeHtml(t("training.date"))}${escapeHtml(t("table.event"))}${escapeHtml(t("table.session"))}${escapeHtml(t("table.car"))}${escapeHtml(t("table.duration"))}
${escapeHtml(new Date(stop.enteredAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }))}${escapeHtml(stop.eventName || "-")}${escapeHtml(stop.sessionName || "-")}${escapeHtml(stop.carName || "-")}${formatLap(stop.pitStopMs)}
+
+ ` : ""}

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

${stats.personalBestGainMs ? `${escapeHtml(t("training.pb_improvement", { lap: formatLap(stats.personalBestGainMs) }))}` : ""} diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js index e2655e9..7f75d9c 100644 --- a/tests/decoder_runtime.test.js +++ b/tests/decoder_runtime.test.js @@ -18,9 +18,12 @@ function createHarness() { events: [{ id: "event_1", mode: "race", - teams: [{ id: "team_1", name: "Team One", driverIds: ["driver_1"], carIds: [] }], + teams: [{ id: "team_1", name: "Team One", driverIds: ["driver_1", "driver_2"], carIds: [] }], }], - drivers: [{ id: "driver_1", name: "Driver One", transponder: "123" }], + drivers: [ + { id: "driver_1", name: "Driver One", transponder: "123" }, + { id: "driver_2", name: "Driver Two", transponder: "456" }, + ], cars: [], resultsBySession: {}, }; @@ -44,9 +47,9 @@ function createHarness() { lastOverlayTop3BySession: {}, lastOverlayBestLapByKey: {}, }; - const passing = (timestamp, role) => processDecoderMessageHelper({ + const passing = (timestamp, role, transponder = "123") => processDecoderMessageHelper({ msg: "PASSING", - transponder: "123", + transponder, rtc_time: new Date(timestamp).toISOString(), }, deps, role); return { state, session, passing, overlayEvents, getSaveCount: () => saveCount }; @@ -74,7 +77,7 @@ test("pit entry and exit create one timed stop without adding laps", () => { harness.passing(17_000, "main"); harness.passing(30_000, "pit_entry"); harness.passing(31_000, "pit_entry"); - harness.passing(42_500, "pit_exit"); + harness.passing(42_500, "pit_exit", "456"); const result = harness.state.resultsBySession.session_1; const entry = result.competitors.team_team_1; @@ -85,6 +88,10 @@ test("pit entry and exit create one timed stop without adding laps", () => { assert.equal(entry.totalPitMs, 12_500); assert.equal(result.pitEvents.length, 2); assert.equal(result.pitStops.length, 1); + assert.equal(result.pitStops[0].driverId, "driver_1"); + assert.equal(result.pitStops[0].driverName, "Driver One"); + assert.equal(result.pitStops[0].transponder, "123"); + assert.equal(result.pitStops[0].exitTransponder, "456"); assert.equal(result.passings.length, 1); }); diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js index bdd21f9..45dae46 100644 --- a/tests/race_timing_logic.test.js +++ b/tests/race_timing_logic.test.js @@ -13,6 +13,7 @@ import { } from "../src/event_race_logic.js"; import { buildFinalStandings, + buildSessionChannelStats, compareNumberSet, formatLeaderboardGap, getCompetitorSeedMetric, @@ -423,3 +424,26 @@ test("final standings count the configured best legs per main", () => { assert.equal(standings[0].score, "2 (1+1+2)"); assert.equal(standings[1].score, "3 (1+2+2)"); }); + +test("session channel statistics summarize splits and pit stops per competitor", () => { + const result = { + competitors: { + team_1: { displayName: "Team One", driverName: "Driver One", carName: "Car One" }, + }, + splits: [ + { competitorKey: "team_1", splitMs: 8000 }, + { competitorKey: "team_1", splitMs: 7600 }, + ], + pitStops: [ + { competitorKey: "team_1", driverName: "Driver One", carName: "Car One", enteredAt: 100000, exitedAt: 112000, pitStopMs: 12000 }, + { competitorKey: "team_1", driverName: "Driver Two", carName: "Car Two", enteredAt: 200000, exitedAt: 209000, pitStopMs: 9000 }, + ], + }; + const stats = buildSessionChannelStats({ id: "session_1" }, { ensureSessionResult: () => result }); + assert.equal(stats.splitCount, 2); + assert.equal(stats.pitStopCount, 2); + assert.equal(stats.totalPitMs, 21000); + assert.equal(stats.competitors[0].averageSplitMs, 7800); + assert.equal(stats.competitors[0].bestPitStopMs, 9000); + assert.equal(stats.pitStops[1].driverName, "Driver Two"); +}); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index a0c6adb..159f66a 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { buildDriverActivePracticeStats, buildDriverTrainingStats, + buildDriverTimingChannelStats, buildTrainingRecommendation, getBestConsecutiveTrainingLaps, getBestTrainingDurationResult, @@ -110,6 +111,48 @@ test("driver statistics remain isolated and aggregate days and sessions", () => assert.equal(stats.dailySummaries[1].laps, 2); }); +test("split and pit statistics aggregate by personal profile and selected class", () => { + const state = { + drivers: [ + { id: "driver_2wd_a", name: "Driver", profileId: "profile_1", classId: "class_2wd" }, + { id: "driver_2wd_b", name: "Driver", profileId: "profile_1", classId: "class_2wd" }, + { id: "driver_4wd", name: "Driver", profileId: "profile_1", classId: "class_4wd" }, + ], + sessions: [ + { id: "session_1", name: "Practice", eventId: "event_1" }, + { id: "session_2", name: "Endurance", eventId: "event_1" }, + ], + events: [{ id: "event_1", name: "Club day" }], + resultsBySession: { + session_1: { + splits: [ + { driverId: "driver_2wd_a", splitMs: 8100, timestamp: 1000 }, + { driverId: "driver_2wd_b", splitMs: 7900, timestamp: 2000 }, + { driverId: "driver_4wd", splitMs: 6500, timestamp: 3000 }, + ], + pitStops: [], + }, + session_2: { + splits: [], + pitStops: [ + { driverId: "driver_2wd_a", pitStopMs: 12500, enteredAt: 4000 }, + { driverId: "driver_2wd_b", pitStopMs: 9500, enteredAt: 5000 }, + { driverId: "driver_4wd", pitStopMs: 7000, enteredAt: 6000 }, + ], + }, + }, + }; + + const stats = buildDriverTimingChannelStats(state, "profile_1", "class_2wd"); + assert.equal(stats.splitCount, 2); + assert.equal(stats.bestSplitMs, 7900); + assert.equal(stats.averageSplitMs, 8000); + assert.equal(stats.pitStopCount, 2); + assert.equal(stats.totalPitMs, 22000); + assert.equal(stats.bestPitStopMs, 9500); + assert.equal(stats.recentPitStops[0].sessionName, "Endurance"); +}); + test("one driver profile combines transponders within a class but keeps 2WD and 4WD separate", () => { const state = { drivers: [