import { getOverlayModeLabel, buildOverlayPanels, renderOverlaySidePanel, renderOverlayLeaderboard, renderTeamOverlay, renderObsOverlay } from "./overlays.js"; import { renderSettings } from "./settings.js"; import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js"; 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, 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"; import { renderGuideView, renderOverlayPageView } from "./misc_views.js"; import { getSessionsForEventHelper, getModeLabelHelper, normalizeStartModeHelper, getStartModeLabelHelper, getClassNameHelper, getEventNameHelper, renderAssignmentListView, renderSessionsTableView } from "./event_common.js"; import { renderEventWorkspaceView } from "./event_workspace_controller.js"; 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, 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, 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"; const renderRaceFormatFieldView = (labelKey, hintKey, controlHtml, options = {}) => renderRaceFormatField(labelKey, hintKey, controlHtml, options, { t }); const renderRaceFormatContextCardView = (titleKey, hintKey) => renderRaceFormatContextCard(titleKey, hintKey, { t }); const renderManageStatusBadgeView = (status) => renderManageStatusBadge(status, { t }); const renderRaceWizardStepsView = () => renderRaceWizardSteps({ t, raceWizardStep, escapeHtml }); const renderRaceWizardContentView = (draft, classOptions, wizardDrivers, preset) => renderRaceWizardContent(draft, classOptions, wizardDrivers, preset, { t, escapeHtml, raceWizardStep, getRaceFormatPresets, getClassName, getRaceWizardSessionPlan }); const renderRaceStandingsTableView = (rows, emptyLabel) => renderRaceStandingsTable(rows, emptyLabel, { t, renderTable, escapeHtml }); const normalizeRaceTeam = (team) => normalizeRaceTeamLogic(team, { uid }); const normalizeStoredRacePreset = (preset) => normalizeStoredRacePresetLogic(preset, { uid }); const getRaceFormatPresets = () => getRaceFormatPresetsLogic({ state, t, uid, normalizeStoredRacePreset }); const applyRaceFormatPreset = (event, presetId) => applyRaceFormatPresetLogic(event, presetId, { getRaceFormatPresets }); const buildRaceFormatConfigFromForm = (form, event) => buildRaceFormatConfigFromFormLogic(form, event, { normalizeStartMode, getEventTeams }); const normalizeBrandingConfig = (branding) => normalizeBrandingConfigLogic(branding); const normalizeEvent = (event) => normalizeEventLogic(event, { normalizeBrandingConfig, normalizeStartMode, normalizeRaceTeam }); const resolveEventBranding = (event) => resolveEventBrandingLogic(event, { normalizeBrandingConfig, state }); const getRaceManageStatuses = (event, sessions, raceDrivers, raceTeams, selectedPreset) => getRaceManageStatusesLogic(event, sessions, raceDrivers, raceTeams, selectedPreset, { t, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings }); const buildDefaultRaceWizardDraft = () => buildDefaultRaceWizardDraftLogic({ state, getDriversForClass }); const getRaceWizardPreset = (presetId) => getRaceWizardPresetLogic(presetId, { getRaceFormatPresets }); const applyRaceWizardPresetDefaults = (draft, presetId) => applyRaceWizardPresetDefaultsLogic(draft, presetId, { getRaceWizardPreset }); const ensureRaceParticipantsConfigured = (event) => ensureRaceParticipantsConfiguredLogic(event, { state }); const buildRaceSession = (eventId, name, type, durationMin, overrides = {}) => buildRaceSessionLogic(eventId, name, type, durationMin, overrides, { normalizeSession, uid }); const buildOpenPracticeBundle = (input) => buildOpenPracticeBundleLogic(input, { normalizeEvent, buildRaceSession, uid }); const buildTrackSession = (eventId, name, type, durationMin) => buildTrackSessionLogic(eventId, name, type, durationMin, { normalizeSession, uid }); const createSponsorRounds = (eventId, config) => createSponsorRoundsLogic(eventId, config, { state, t, buildTrackSession }); const buildRaceSessionsFromWizard = (event, draft) => buildRaceSessionsFromWizardLogic(event, draft, { t, buildRaceSession }); const getRaceWizardSessionPlan = (draft) => getRaceWizardSessionPlanLogic(draft, { t }); const getEventDrivers = (event) => getEventDriversLogic(event, { state }); const getEventTeams = (event) => getEventTeamsLogic(event, { normalizeRaceTeam }); const getTeamDriverPool = (event) => getTeamDriverPoolLogic(event, { getEventDrivers, state }); const findEventTeamForPassing = (event, driverId, carId) => findEventTeamForPassingLogic(event, driverId, carId, { getEventTeams }); const generateQualifyingForRace = (event) => generateQualifyingForRaceLogic(event, { buildPracticeStandings, getEventDrivers, state, normalizeStartMode, normalizeSession, uid, chunkArray, clearGeneratedQualifying }); const reseedUpcomingQualifying = (event) => reseedUpcomingQualifyingLogic(event, { buildQualifyingStandings, buildPracticeStandings, getEventDrivers, getSessionsForEvent, chunkArray }); const generateFinalsForRace = (event) => generateFinalsForRaceLogic(event, { buildPracticeStandings, buildQualifyingStandings, clearGeneratedFinals, state, normalizeStartMode, normalizeSession, uid, chunkArray, t }); const applyBumpsForRace = (event) => applyBumpsForRaceLogic(event, { buildFinalStandings, getSessionsForEvent }); const getSessionTypeLabel = (type) => getSessionTypeLabelLogic(type, { t }); const getStatusLabel = (status) => getStatusLabelLogic(status, { t }); const isUntimedSession = (session) => isUntimedSessionLogic(session); const getActiveSession = () => getActiveSessionLogic({ state }); const getSessionTargetMs = (session) => getSessionTargetMsLogic(session, { isUntimedSession }); const getSessionLapWindow = (session) => getSessionLapWindowLogic(session, { state }); const isCountedPassing = (passing) => isCountedPassingLogic(passing); const getVisiblePassings = (result) => getVisiblePassingsLogic(result, { isCountedPassing }); 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); const getCompetitorPassings = (session, row, options = {}) => getCompetitorPassingsLogic(session, row, options, { ensureSessionResult, isCountedPassing }); const getCompetitorSeedMetric = (session, row) => getCompetitorSeedMetricLogic(session, row, { getCompetitorPassings }); const getSessionEntrants = (session) => getSessionEntrantsLogic(session, { state, getEventDrivers }); const buildLeaderboard = (session) => buildLeaderboardLogic(session, { ensureSessionResult, getSessionTargetMs, getCompetitorPassings, isCountedPassing, getCompetitorElapsedMs, getCompetitorSeedMetric, getSessionLapWindow, getPassingValidationLabel, formatSeedMetric, formatRaceClock, formatLeaderboardGap, formatLapDelta, formatLap, t }); const buildPracticeStandings = (event) => buildPracticeStandingsLogic(event, { getSessionsForEvent, buildLeaderboard, getCompetitorSeedMetric, formatSeedMetric, formatRaceClock }); const getQualifyingPointsValue = (place, fieldSize, tableType) => getQualifyingPointsValueLogic(place, fieldSize, tableType); const isHighPointsTable = (tableType) => isHighPointsTableLogic(tableType); const compareNumberSet = (left, right, highWins = false) => compareNumberSetLogic(left, right, highWins); const buildQualifyingTieBreakNote = (row, tieBreak) => buildQualifyingTieBreakNoteLogic(row, tieBreak, { t, formatLap }); const hasQualifyingPrimaryTie = (left, right, scoringMode) => hasQualifyingPrimaryTieLogic(left, right, scoringMode); const buildQualifyingStandings = (event) => buildQualifyingStandingsLogic(event, { getSessionsForEvent, buildLeaderboard, getSessionEntrants, isHighPointsTable, getQualifyingPointsValue, compareNumberSet, buildQualifyingTieBreakNote, formatLap, formatRaceClock, t, hasQualifyingPrimaryTie }); const formatTeamActiveMemberLabel = (rowOrPassing) => formatTeamActiveMemberLabelLogic(rowOrPassing); const buildTeamRaceStandings = (event) => buildTeamRaceStandingsLogic(event, { getSessionsForEvent, buildLeaderboard, getSessionSortWeight }); const buildTeamStintLog = (session, row) => buildTeamStintLogLogic(session, row, { getCompetitorPassings, getSessionLapWindow, formatTeamActiveMemberLabel }); const getSessionGridEntries = (session) => getSessionGridEntriesLogic(session, { state, t, getEventTeams, getDriverDisplayById, getSessionGridOrder }); const getSessionGridOrder = (session) => getSessionGridOrderLogic(session); const ensureSessionDriverOrder = (session) => ensureSessionDriverOrderLogic(session, { getSessionEntrants }); const buildFinalStandings = (event) => buildFinalStandingsLogic(event, { getSessionsForEvent, buildLeaderboard }); 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, formatRaceClock, renderTeamStintLog, buildSessionChannelStats }); const getSessionSortWeight = (session) => getSessionSortWeightHelper(session); const getDriverDisplayById = (driverId) => getDriverDisplayByIdHelper(driverId, { state, t }); const renderPositionGrid = (session) => renderPositionGridHelper(session, { t, escapeHtml, getSessionGridEntries }); const renderGridEditor = (session) => renderGridEditorHelper(session, { t, escapeHtml, ensureSessionDriverOrder, state }); const getFinalMainLayouts = (event) => getFinalMainLayoutsHelper(event, { getSessionsForEvent, getSessionGridOrder, getDriverDisplayById, t }); 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, getSessionsForEvent, renderSessionChannelAnalysis }); const getSessionsForEvent = (eventId) => getSessionsForEventHelper(state, eventId); const getModeLabel = (mode) => getModeLabelHelper(mode, { t }); const normalizeStartMode = (mode) => normalizeStartModeHelper(mode); const getStartModeLabel = (mode) => getStartModeLabelHelper(mode, { t, normalizeStartMode }); const getClassName = (classId) => getClassNameHelper(state, classId, { t }); const getEventName = (eventId) => getEventNameHelper(state, eventId, { t }); const renderAssignmentList = (eventId) => renderAssignmentListView(eventId, { state, t, escapeHtml, getSessionsForEvent, getSessionTypeLabel, saveState, renderAssignmentList }); const renderSessionsTable = (sessions) => renderSessionsTableView(sessions, { t, renderTable, escapeHtml, getEventName, getSessionTypeLabel, getStatusLabel, getModeLabel }); const createDefaultAmmcConfig = () => createDefaultAmmcConfigHelper(); const getManagedWsUrl = (role = "main") => getManagedWsUrlHelper({ ammc, getBackendUrl }, role); const loadAmmcConfigFromBackend = () => loadAmmcConfigFromBackendHelper({ ammc, t, getBackendUrl, createDefaultAmmcConfig }); const saveAmmcConfigToBackend = (config) => saveAmmcConfigToBackendHelper(config, { ammc, t, getBackendUrl, createDefaultAmmcConfig }); const refreshAmmcStatus = () => refreshAmmcStatusHelper({ ammc, t, getBackendUrl }); const startManagedAmmc = () => startManagedAmmcHelper({ ammc, t, getBackendUrl }); const stopManagedAmmc = () => stopManagedAmmcHelper({ ammc, t, getBackendUrl }); const applyPersistedState = (persisted) => applyPersistedStateHelper(persisted, { state, backend, t, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, applyTheme }); const hydrateFromBackend = () => hydrateFromBackendHelper({ backend, t, getBackendUrl, applyPersistedState, saveState }); const scheduleBackendSync = () => scheduleBackendSyncHelper({ getBackendSyncTimer: () => backendSyncTimer, setBackendSyncTimer: (value) => { backendSyncTimer = value; }, syncStateToBackend }); const syncStateToBackend = () => syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion: () => localStateVersion, getSyncedStateVersion: () => syncedStateVersion, setSyncedStateVersion: (value) => { syncedStateVersion = value; }, getLocalStateDirty: () => localStateDirty, setLocalStateDirty: (value) => { localStateDirty = value; } }); const pingBackend = () => pingBackendHelper({ backend, t, getBackendUrl }); const checkAppVersion = () => checkAppVersionHelper({ getBackendUrl, getBaselineAppVersion: () => baselineAppVersion, setBaselineAppVersion: (value) => { baselineAppVersion = value; }, getPendingAppVersion: () => pendingAppVersion, setPendingAppVersion: (value) => { pendingAppVersion = value; }, getLocalStateDirty: () => localStateDirty }); const startAppVersionPolling = () => startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer: () => appVersionPollTimer, setAppVersionPollTimer: (value) => { appVersionPollTimer = value; } }); const startOverlaySync = () => startOverlaySyncHelper({ getOverlaySyncTimer: () => overlaySyncTimer, setOverlaySyncTimer: (value) => { overlaySyncTimer = value; }, getLocalStateDirty: () => localStateDirty, hydrateFromBackend, getCurrentView: () => currentView, renderView }); const startOverlayRotation = () => startOverlayRotationHelper({ getOverlayRotationTimer: () => overlayRotationTimer, setOverlayRotationTimer: (value) => { overlayRotationTimer = value; }, getOverlayRotationIndex: () => overlayRotationIndex, setOverlayRotationIndex: (value) => { overlayRotationIndex = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderView }); const startOverlayLiveRefresh = () => startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer: () => overlayLiveRefreshTimer, setOverlayLiveRefreshTimer: (value) => { overlayLiveRefreshTimer = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderOverlay }); const ensureAudioContext = () => ensureAudioContextHelper({ state, getAudioCtx: () => audioCtx, setAudioCtx: (value) => { audioCtx = value; } }); const playPassingBeep = () => playPassingBeepHelper({ ensureAudioContext }); const playFinishSiren = () => playFinishSirenHelper({ ensureAudioContext }); const playLeaderCue = () => playLeaderCueHelper({ ensureAudioContext }); const playStartCue = () => playStartCueHelper({ ensureAudioContext }); const playBestLapCue = () => playBestLapCueHelper({ ensureAudioContext }); const pushOverlayEvent = (type, label, options = {}) => pushOverlayEventHelper(type, label, { uid, state, t, getOverlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, getOverlayMode: () => overlayMode, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, playLeaderCue, playPassingBeep, playFinishSiren, playStartCue, playBestLapCue }, options); const getSpeechVoices = () => getSpeechVoicesHelper(); const speakText = (text, options = {}) => speakTextHelper(text, { state, currentLanguage }, options); const announcePassing = (entry) => announcePassingHelper(entry, { state, t, playPassingBeep, speakText }); const announceRaceStarted = (session) => announceRaceStartedHelper(session, { state, t, pushOverlayEvent, playStartCue, speakText }); 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, renderSessionChannelAnalysis }); const connectDecoder = () => connectDecoderHelper({ state, t, saveState, getWsClient: (role) => wsClients[role] || null, setWsClient: (role, value) => { wsClients[role] = value; }, getReconnectTimer: (role) => reconnectTimers[role] || null, setReconnectTimer: (role, value) => { reconnectTimers[role] = value; }, getCurrentView: () => currentView, renderView, updateConnectionBadge, getActiveSession, processDecoderMessage, disconnectDecoder, }); const disconnectDecoder = (options = {}) => disconnectDecoderHelper(options, { state, saveState, getWsClient: (role) => wsClients[role] || null, setWsClient: (role, value) => { wsClients[role] = value; }, getReconnectTimer: (role) => reconnectTimers[role] || null, setReconnectTimer: (role, value) => { reconnectTimers[role] = value; }, updateConnectionBadge, }); const processDecoderMessage = (msg, decoderRole = "main") => processDecoderMessageHelper(msg, { state, t, getActiveSession, ensureSessionResult, normalizeStartMode, getSessionLapWindow, findEventTeamForPassing, persistPassingToBackend, pushOverlayEvent, buildLeaderboard, formatLap, announcePassing, saveState, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, lastOverlayBestLapByKey, }, decoderRole); const renderDashboard = () => renderDashboardView({ state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, updateViewUrl, renderNav, renderView, connectDecoder, disconnectDecoder, openOverlayWindow, ensureAudioContext, playPassingBeep, playFinishSiren, escapeHtml }); const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEditId: () => selectedClassEditId, setSelectedClassEditId: (value) => { selectedClassEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell }); const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName }); 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, 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 = () => { state.training = syncTrainingLedgerLogic(state.training, state, { uid }); return state.training; }; const buildDriverTrainingStats = (trainingState, driverId, classId = "") => buildDriverTrainingStatsLogic(trainingState, driverId, classId); const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.now(), classId = "") => buildDriverActivePracticeStatsLogic(trainingState, driverId, nowTs, classId); 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, 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 }); const restoreCompetitorLastInvalidLap = (session, row) => restoreCompetitorLastInvalidLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, recalculateCompetitorFromPassings, uid, t, formatLap, saveState }); const findPassingByUndoMarker = (session, rowKey, passingTimestamp) => findPassingByUndoMarkerLogic(session, rowKey, passingTimestamp, { ensureSessionResult }); const undoJudgingAdjustment = (session, adjustmentId) => undoJudgingAdjustmentLogic(session, adjustmentId, { ensureSessionResult, findPassingByUndoMarker, recalculateCompetitorFromPassings, uid, t, saveState }); const getJudgeFilteredRows = (rows, filterValue) => getJudgeFilteredRowsLogic(rows, filterValue); const getJudgeFilteredLog = (adjustments, filterValue) => getJudgeFilteredLogLogic(adjustments, filterValue); const NAV_ITEMS = [ { id: "dashboard", titleKey: "nav.dashboard", subtitleKey: "nav.dashboard_sub", groupKey: "nav.group_start" }, { id: "race_setup", titleKey: "nav.race_setup", subtitleKey: "nav.race_setup_sub", groupKey: "nav.group_create" }, { id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub", groupKey: "nav.group_create" }, { id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub", groupKey: "nav.group_run" }, { id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub", groupKey: "nav.group_run" }, { id: "overlay", titleKey: "nav.overlay", subtitleKey: "nav.overlay_sub", groupKey: "nav.group_run" }, { id: "training", titleKey: "nav.training", subtitleKey: "nav.training_sub", groupKey: "nav.group_run" }, { id: "classes", titleKey: "nav.classes", subtitleKey: "nav.classes_sub", groupKey: "nav.group_register" }, { id: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub", groupKey: "nav.group_register" }, { id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub", groupKey: "nav.group_register" }, { id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub", groupKey: "nav.group_system" }, { id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub", groupKey: "nav.group_system" }, ]; const SESSION_TYPES = ["open_practice", "free_practice", "practice", "qualification", "heat", "final", "team_race"]; function getSessionTypeChoices(mode) { return mode === "track" ? SESSION_TYPES.filter((type) => type !== "team_race") : SESSION_TYPES; } const STORAGE_KEY = "rc_timing_control_v1"; const DEFAULT_LANGUAGE = "sv"; const DEFAULT_THEME = "dark"; const AVAILABLE_THEMES = ["dark", "nord", "light"]; const EXPORT_SCHEMA_VERSION = 2; const TRANSLATIONS = { sv: { "nav.dashboard": "Översikt", "nav.dashboard_sub": "Status och liveinformation", "nav.group_start": "Start", "nav.group_create": "Skapa", "nav.group_run": "Kör och följ", "nav.group_register": "Register", "nav.group_system": "System och hjälp", "nav.events": "Sponsor Event", "nav.events_sub": "Sponsor-event och delade bilar", "nav.race_setup": "Race Setup", "nav.race_setup_sub": "Open Practice, klubbrace och endurance", "nav.overlay": "Overlay", "nav.overlay_sub": "Extern leaderboard-skärm", "nav.classes": "Klasser", "nav.classes_sub": "Hantera tävlingsklasser", "nav.drivers": "Förare", "nav.drivers_sub": "Förare och personliga transpondrar", "nav.cars": "Bilar", "nav.cars_sub": "Bilar med fasta transpondrar", "nav.training": "Träningsstatistik", "nav.training_sub": "Utveckling, delmål och belöningar per förare", "nav.timing": "Tidtagning", "nav.timing_sub": "Live timing-board", "nav.judging": "Domare", "nav.judging_sub": "Korrigeringar och penalties", "nav.settings": "Inställningar", "nav.settings_sub": "Decoder, backend och lagring", "nav.guide": "Guide", "nav.guide_sub": "Dokumentation och uppstart", "ui.language": "Språk", "ui.theme": "Tema", "ui.theme_dark": "Mörk", "ui.theme_nord": "Nord", "ui.theme_light": "Ljus", "brand.title": "JMK RB RaceController", "brand.subtitle": "RC Timing System", "ui.no_active_session": "Ingen aktiv session", "ui.event": "Event", "ui.decoder_online": "Decoder online", "ui.decoder_offline": "Decoder offline", "training.eyebrow": "Förarutveckling", "training.intro": "Följ träning över flera sessioner och gör utvecklingen synlig mellan tävlingarna.", "training.driver": "Förare", "training.class": "Klass", "training.no_drivers_title": "Ingen förare registrerad", "training.no_drivers_body": "Skapa en förare först. Träningsstatistik kräver en registrerad förare på passeringarna.", "training.total_laps": "Totala varv", "training.valid_laps": "Giltiga registrerade varv", "training.sessions": "Sessioner", "training.recorded_sessions": "Med registrerade förarvarv", "training.best_lap": "Bästa varv", "training.personal_best": "Personbästa i sparad data", "training.consistency": "Spridning", "training.last_30_laps": "P10-P90, senaste 30 varven", "training.recommendation": "Rekommenderat träningspass", "training.plan_baseline_title": "Bygg en baslinje", "training.plan_baseline_body": "Kör lugnt och jämnt tills minst 10 rena varv är registrerade. Fokusera på samma linje och bromspunkt varje varv.", "training.plan_consistency_title": "Höj din lägstanivå", "training.plan_consistency_body": "Spridningen är hög. Kör först 10 minuter nära max, sedan 10 minuter på 80-90 procent där rena, repeterbara varv är viktigare än ett enstaka snabbt varv.", "training.plan_progression_title": "Balansera fart och kontroll", "training.plan_progression_body": "Du har en stabil grund. Kör 10 minuter med maxfokus och avsluta med 10 minuter kontrollerad körning där målet är en obruten serie jämna varv.", "training.plan_target": "Kontrollmål omkring {lap}", "training.plan_no_target": "Registrera fler varv för tidsmål", "training.block_push": "Maxfokus: hitta gränsen", "training.block_control": "80-90%: höj lägstanivån", "training.new_goal": "Nytt delmål", "training.manage_goals_sharing": "Mål och mobilprofil", "training.manage_goals_sharing_hint": "Skapa belöningsmål eller hantera förarens delningslänk.", "training.target_laps": "Antal varv", "training.quick_targets": "Snabbval för antal varv", "training.goal_name": "Namn på delmålet", "training.goal_name_placeholder": "Till exempel Bronsnivå", "training.reward": "Belöning", "training.reward_placeholder": "Till exempel ny kaross eller klubbmärke", "training.add_goal": "Lägg till delmål", "training.goals": "Delmål och rewards", "training.no_goals": "Inga delmål finns för den här föraren ännu.", "training.default_goal_title": "Nå {laps} varv", "training.goal_progress": "Framsteg mot delmål", "training.goal_remaining": "{laps} varv kvar", "training.goal_reached": "Målet är nått", "training.goal_claimed": "Belöning utdelad", "training.claim_reward": "Markera belöning utdelad", "training.training_days": "Träningsdagar", "training.sessions_count": "{sessions} sparade sessioner", "training.next_level": "Nästa automatiska nivå", "training.next_level_target": "Nå {laps} varv", "training.average_lap": "Snittvarv", "training.recent_average": "Snitt senaste 30", "training.controlled_streak": "Kontrollerad serie", "training.lap_count": "{laps} varv", "training.last_activity": "Senaste aktivitet", "training.no_activity": "Ingen aktivitet ännu", "training.history": "Träningshistorik per dag", "training.detailed_stats": "Mer statistik", "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", "training.pb_improvement": "Senaste 30: {lap} snabbare", "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 20-minuterstimer.", "training.public_profile_private": "Länken innehåller en unik hemlig nyckel. Den visar inte transponder-ID, övriga förare eller adminfunktioner.", "training.public_profile_link": "Personlig delningslänk", "training.copy_link": "Kopiera länk", "training.open_profile": "Öppna profil", "training.replace_link": "Skapa ny länk", "training.disable_profile": "Stäng av profil", "training.enable_profile": "Aktivera mobilprofil", "training.replace_link_confirm": "Den gamla länken slutar fungera direkt. Vill du skapa en ny?", "training.club_progress": "Klubbens träningsöversikt", "training.club_progress_hint": "Jämför totalvarv och nästa mål när det behövs.", "training.next_target": "Nästa mål", "training.start_personal": "Öppna personlig träning", "training.copy_mobile_link": "Kopiera mobillänk", "training.create_personal_link": "Skapa personlig träningslänk", "training.live_now": "Live just nu", "training.active_practice": "Aktiv träning", "training.shared_practice": "Gemensam practice", "training.session_laps": "Varv i passet", "training.latest_lap": "Senaste varv", "training.live_practice_hint": "Alla kan fortsätta köra i samma Open/Free Practice. Kortet visar bara den valda förarens giltiga varv sedan den aktuella sessionen startades.", "training.stints": "Stints", "training.best_three": "Bästa 3 sammanhängande", "training.best_five": "Bästa 5 min", "training.five_avg_pace": "Avg pace, bästa 5 min", "mode.track": "Sponsor Event", "mode.race": "Race", "dashboard.events": "Event", "dashboard.drivers": "Förare", "dashboard.cars": "Bilar", "dashboard.passings": "Passeringar", "dashboard.created": "Skapade", "dashboard.registered": "Registrerade", "dashboard.track_fleet": "Banans bilar", "dashboard.captured": "Mottagna", "dashboard.live_session": "Live-session", "dashboard.idle": "inaktiv", "dashboard.duration": "Tid", "dashboard.no_session": "Ingen session är aktiv. Gå till Event eller Tidtagning för att starta.", "dashboard.quick_actions": "Snabbval", "dashboard.create_event": "Skapa Event", "dashboard.open_timing": "Öppna Tidtagning", "dashboard.connect_decoder": "Anslut Decoder", "dashboard.recent_sessions": "Senaste sessioner", "dashboard.free_practice": "Fri träning", "dashboard.open_practice": "Open Practice", "dashboard.live_board": "Live Board", "dashboard.decoder_feed": "Decoder-feed", "dashboard.backend_link": "Backend-länk", "dashboard.audio_profile": "Ljudprofil", "dashboard.schedule_drift": "Schemaavvikelse", "dashboard.schedule_plan": "Planerad tid", "dashboard.schedule_actual": "Faktisk tid", "dashboard.on_time": "I fas", "dashboard.ahead": "Före schema", "dashboard.behind": "Efter schema", "dashboard.live_note": "Snabb driftpanel för anslutning, overlay och ljud. Djupare konfig ligger kvar under Inställningar.", "dashboard.get_started": "Kom igång", "dashboard.get_started_hint": "Följ arbetsordningen första gången. Vid vanlig träning kan du gå direkt till Open Practice.", "dashboard.step_system": "1. Anslut systemet", "dashboard.step_system_hint": "Konfigurera backend, decoder och AMMC i Inställningar.", "dashboard.step_register": "2. Lägg in register", "dashboard.step_register_hint": "Skapa klasser, förare och bilar när du behöver namngivna resultat.", "dashboard.step_create": "3. Skapa körning", "dashboard.step_create_hint": "Starta enkel Open Practice eller bygg klubbrace, endurance och sponsor-event.", "dashboard.step_run": "4. Kör och visa", "dashboard.step_run_hint": "Starta sessionen i Tidtagning och öppna Overlay på extern skärm.", "dashboard.open_settings": "Öppna Inställningar", "dashboard.open_register": "Öppna Förare", "dashboard.open_race_setup": "Open Practice / Race Setup", "dashboard.open_overlay": "Öppna Overlay", "dashboard.untimed": "Ingen tidsgräns", "session.none_yet": "Inga sessioner ännu.", "classes.create": "Skapa klass", "classes.placeholder": "Klassnamn (t.ex. 2WD Buggy)", "classes.add": "Lägg till klass", "classes.title": "Klasser", "drivers.create": "Skapa förare", "drivers.name_placeholder": "Förarnamn", "drivers.brand_placeholder": "Team / märke (valfritt)", "drivers.brand_filter_placeholder": "Sök namn / transponder / märke / klubb / land", "drivers.transponder_placeholder": "Personlig transponder (valfritt)", "drivers.speech_name": "Uttalsnamn", "drivers.speech_name_placeholder": "Uttal för uppläsning (valfritt)", "drivers.speech_name_hint": "Skriv namnet som webbläsarrösten ska uttala det. Lämna tomt för vanligt förarnamn.", "drivers.training_profile": "Träningsprofil", "drivers.training_profile_hint": "Länka flera klass- och transponderposter för samma person till en gemensam träningsprofil.", "drivers.training_profile_auto": "Automatiskt: samma namn eller ny profil", "drivers.training_profile_new": "Ny separat profil", "drivers.training_profile_separate": "Koppla loss till ny separat profil", "drivers.club": "Klubb", "drivers.club_placeholder": "Klubb (valfritt)", "drivers.country": "Land", "drivers.country_placeholder": "Land (valfritt)", "drivers.add": "Lägg till förare", "drivers.title": "Förare", "cars.create": "Skapa bil", "cars.name_placeholder": "Bilnamn eller nummer", "cars.brand_placeholder": "Märke / modell (valfritt)", "cars.brand_filter_placeholder": "Sök namn / transponder / brand", "cars.transponder_placeholder": "Bilens transponder", "cars.add": "Lägg till bil", "cars.title": "Bilar", "events.create": "Skapa event", "events.create_race": "Skapa race", "events.create_competition_race": "Skapa tävlingsrace", "events.quick_open_practice_title": "Snabbstart Open Practice", "events.quick_open_practice_intro": "För löpande tidtagning utan raceformat. Skapa sessionen och kör tills du själv stoppar den.", "events.quick_open_practice_default": "Open Practice", "events.quick_open_practice_create": "Skapa och öppna Tidtagning", "events.open_practice_untimed": "Otidsstyrd", "events.open_practice_all_transponders": "Alla transpondrar", "events.open_practice_no_registration": "Fungerar utan registrerade förare", "events.open_practice_until_stopped": "Pågår tills du stoppar den", "events.name_placeholder": "Eventnamn", "events.field_name": "Eventnamn", "events.field_name_hint": "Namnet som visas i listor, overlay och resultat.", "events.field_date": "Datum", "events.field_date_hint": "Tävlings- eller eventdatum för planering och sortering.", "events.field_class": "Klass", "events.field_class_hint": "Den klass som eventet eller sponsorupplägget tillhör.", "events.add": "Lägg till event", "events.add_race": "Lägg till race", "events.mode_race_option": "Race (förare med egen transponder)", "events.mode_track_option": "Sponsor Event (delade bilar)", "events.title": "Event", "events.race_title": "Race", "events.track_only_intro": "Här skapar du sponsor-event med delade bilar/transpondrar.", "events.race_only_intro": "Här skapar du riktiga race med personlig transponder per förare.", "events.manage": "Hantera", "events.open_timing": "Öppna Tidtagning", "events.edit": "Redigera", "events.sessions": "Sessioner", "events.participants": "Deltagare", "events.select_participants": "Välj racedeltagare", "events.select_all_participants": "Markera alla", "events.clear_participants": "Rensa deltagare", "events.reseed_qualifying": "Reseeda kommande kval", "events.reseed_done": "Kommande kval heat reseedade från aktuell ranking.", "events.no_reseed_done": "Inga kommande kval kunde reseedas.", "events.reseed_locked": "{count} heat hoppades över eftersom manuell grid är låst.", "events.reserve_bump_slots": "Reservera bump-platser i finaler", "events.bump_reserved_note": "Om bump används kan finalgeneratorn reservera platser i högre finaler redan från start.", "events.actions": "Åtgärder", "events.manage_title": "Hantera", "events.wizard_hint": "Bygg upp racet i fyra steg: grunddata, deltagare, sessionsplan och bekräftelse. Finjustering sker sedan i Hantera.", "events.wizard_step_1": "Grunddata", "events.wizard_step_2": "Deltagare", "events.wizard_step_3": "Sessionsplan", "events.wizard_step_4": "Bekräfta", "events.wizard_no_class_drivers": "Inga förare finns i vald klass ännu. Lägg upp förare först eller byt klass.", "events.wizard_create": "Skapa race", "events.wizard_use_practice": "Skapa practice-sessioner", "events.wizard_practice_sessions": "Antal practice-sessioner", "events.wizard_use_qualifying": "Skapa kvalomgångar", "events.wizard_qualifying_rounds": "Antal kvalomgångar", "events.wizard_use_team_race": "Skapa endurance / Team Race-session", "events.wizard_team_duration": "Team Race-längd (min)", "events.wizard_finals_note": "Finaler skapas senare från kvalrankingen via Race actions, inte direkt i wizard-steget.", "events.wizard_summary_title": "Nytt race", "events.wizard_summary_sessions": "Sessioner som skapas", "events.manage_step_setup": "1. Setup", "events.manage_step_setup_hint": "Deltagare och lag för just detta race.", "events.manage_step_format": "2. Format", "events.manage_step_format_hint": "Practice, kval, finaler och validering.", "events.manage_step_generate": "3. Generering", "events.manage_step_generate_hint": "Skapa kval/finaler, reseeda och bump-up.", "events.manage_step_live": "4. Live / resultat", "events.manage_step_live_hint": "Grid, standings, print och PDF.", "events.branding": "Branding för detta event", "events.branding_note": "Lämna fält tomma för att ärva global branding från Inställningar. Logo används i overlay och bäddas in i PDF-export när den kan konverteras.", "events.brand_name": "Brandnamn", "events.brand_tagline": "Brandtext", "events.brand_footer": "PDF-footer", "events.brand_theme": "PDF-tema", "events.brand_logo": "Eventlogo", "events.branding_use_global": "Använd globalt standardtema", "events.branding_save": "Spara branding", "events.session_name": "Sessionsnamn", "events.duration_placeholder": "Längd (min)", "events.max_cars_placeholder": "Max bilar (valfritt)", "events.start_mode": "Starttyp", "events.seed_best_laps": "Seedning bästa varv", "events.seed_method": "Seedmetod", "events.seed_method_hint": "Hur bästa varv ska räknas när seedBestLapCount är större än 0.", "events.seed_method_best_sum": "Bästa N varv, summa", "events.seed_method_average": "Bästa N varv, snitt", "events.seed_method_consecutive": "Bästa N konsekutiva varv", "events.stagger_gap_sec": "Stagger-gap (sek)", "events.session_settings": "Sessioninställningar", "events.edit_session": "Inställningar", "events.start_mode_mass": "Mass-start", "events.start_mode_position": "Positionsstart", "events.start_mode_staggered": "Staggered / individuell start", "events.seed_best_laps_hint": "0 = av, 2 eller 3 för practice/kval-seedning", "events.race_format": "Raceformat", "events.race_format_intro": "Ställ in hur kval och finaler ska skapas och rankas för just detta race.", "events.setup_mode_basic": "Grundläge", "events.setup_mode_advanced": "Avancerat", "events.practice_block": "Practice / kval", "events.practice_block_hint": "Bygg seedning, kvalomgångar och poäng/tie-break här.", "events.finals_block": "Finaler", "events.finals_block_hint": "Styr A/B/C-finaler, starttyp, leg och bump-up.", "events.rules_block": "Validering och avslut", "events.rules_block_hint": "Min/max-varv och follow-up påverkar både livekörning och statistik.", "events.race_summary": "Sammanfattning", "events.race_summary_hint": "Snabb kontroll av vad som faktiskt kommer att skapas och köras.", "events.race_summary_preset": "Preset", "events.race_summary_participants": "Deltagare", "events.race_summary_created_sessions": "Skapade sessioner", "events.race_summary_qualifying": "Kvalupplägg", "events.race_summary_finals": "Finalupplägg", "events.race_summary_validation": "Varvfönster", "events.race_summary_follow_up": "Follow-up", "events.race_summary_focus": "Fokus", "events.race_summary_generation": "Generering", "events.race_actions_title": "Race actions", "events.race_actions_hint": "Generering och reseeding ligger separat från formatinställningarna så setupen blir lättare att läsa.", "events.export_race_package": "Exportera racepaket", "events.import_race_package": "Importera racepaket", "events.race_package_hint": "Exportera eller importera ett enskilt race med dess sessioner, resultat och refererade registerdata.", "events.context_standard_title": "Klubbrace-läge", "events.context_standard_hint": "Börja med preset och Grundläge. När deltagare och tider ser rätt ut använder du Race actions för kval, reseeding och finaler.", "events.context_endurance_title": "Endurance-läge", "events.context_endurance_hint": "I Grundläge döljs kval- och finalfälten här eftersom endurance normalt byggs runt Team Race. Öppna Avancerat om eventet även ska ha stödheat eller finaler.", "events.context_rules_title": "Valideringsexempel", "events.context_rules_hint": "Exempel på kort teknisk bana: 11s min / 60s max. Justera efter verklig varvtid så statistik och stintar blir rimliga.", "events.status_complete": "Klar", "events.status_pending": "Väntar", "events.status_attention": "Åtgärda", "events.summary_warnings_title": "Kontrollpunkter", "events.summary_warning_no_participants": "Inga racedeltagare är valda ännu.", "events.summary_warning_no_sessions": "Inga sessioner finns ännu. Skapa dem i wizarden eller via Race actions.", "events.summary_warning_no_qualifying": "Inga kvalsessioner finns ännu.", "events.summary_warning_no_finals": "Inga finalsessioner finns ännu.", "events.summary_warning_no_teams": "Endurance/Team Race saknar lag. Lägg upp lag i Setup innan körning.", "events.summary_warning_invalid_lap_window": "Min/max varvtid ser fel ut. Max måste vara större än min.", "events.detail_drivers": "förare", "events.detail_teams": "lag", "events.detail_sessions": "sessioner", "events.detail_results": "resultat", "events.detail_active": "aktiva", "guide.manage_steps_6": "6. Statusbrickorna visar om varje steg är klart, väntar eller behöver åtgärdas. Sammanfattningen till höger visar också varningar om deltagare, lag eller sessioner saknas.", "guide.manage_steps_7": "7. Klicka på en varning i sammanfattningen för att hoppa direkt till rätt sektion i Hantera i stället för att leta manuellt.", "guide.manage_steps_8": "8. Stegkorten högst upp fungerar också som snabbhopp. Klicka på Setup, Format, Generering eller Live / resultat för att gå direkt till rätt block.", "guide.race_wizard_title": "Create Race Wizard", "guide.race_wizard_1": "1. Börja i Race Setup och använd wizarden när du skapar ett nytt race, i stället för att bygga allt direkt i Hantera.", "guide.race_wizard_2": "2. Steg 1 sätter namn, datum, klass och preset. Presetet fyller rimliga standardvärden innan du finjusterar något.", "guide.race_wizard_3": "3. Steg 2 väljer exakt vilka förare som ska vara giltiga i racet. De blir då race-specifika deltagare och filter för timing/seedning.", "guide.race_wizard_4": "4. Steg 3 väljer vilka sessioner som ska skapas direkt: practice, kval och/eller team race beroende på preset.", "guide.race_wizard_5": "5. Wizarden skapar de första sessionerna automatiskt. Finaler skapas senare från Race actions när kval eller practice är klara.", "guide.race_wizard_6": "6. Efter skapandet går du vidare till Hantera för raceformat, generering, grid, standings och utskrift.", "guide.race_wizard_7": "7. I Hantera -> Race actions kan du exportera eller importera ett enskilt racepaket om du vill flytta just det racet till en annan installation.", "guide.manage_steps_title": "Hantera race i fyra steg", "guide.manage_steps_1": "1. Setup: välj racedeltagare och bygg eventuella lag för team race eller endurance.", "guide.manage_steps_2": "2. Format: justera Race Format i Grundläge eller Avancerat. Practice/kval, finaler och validering ligger i egna block.", "guide.manage_steps_3": "3. Generering: skapa kval, reseeda kommande heat, skapa finaler och applicera bump-ups från en separat Race actions-panel.", "guide.manage_steps_4": "4. Live / resultat: använd grid editor, standings, finalmatris, print och PDF när tävlingen väl är byggd.", "guide.manage_steps_5": "5. Sammanfattningskortet till höger fungerar som snabb kontroll av upplägget innan du börjar generera eller köra race.", "events.qualifying_scoring_hint": "Välj om kval ska rankas på poäng per omgång eller bästa enskilda resultat.", "events.qualifying_scoring": "Kval-scoring", "events.qualifying_scoring_points": "Poäng per runda", "events.qualifying_scoring_best": "Bästa runda / rank", "events.qualifying_rounds": "Antal kvalrundor", "events.qualifying_rounds_hint": "Hur många kvalomgångar som ska skapas totalt.", "events.cars_per_heat": "Förare per kvalheat", "events.cars_per_heat_hint": "Hur många förare som placeras i varje kvalheat.", "events.qual_duration": "Kvaltid (min)", "events.qual_duration_hint": "Längd per kvalheat i minuter.", "events.qual_start_mode": "Kval-start", "events.qual_start_mode_hint": "Mass, position eller staggered för kvalomgångarna.", "events.qual_seed_laps": "Kval bästa varv", "events.qual_seed_laps_hint": "Antal varv som används för ranking i varje kvalheat när seedmetod är aktiv.", "events.qual_seed_method": "Kval seedmetod", "events.qual_seed_method_hint": "Hur kvalheat räknar varv när bästa-varvsläget används.", "events.counted_qual_rounds": "Räknade kvalrundor", "events.counted_qual_rounds_hint": "Hur många av kvalrundorna som räknas i slutrankingen.", "events.qual_points_table": "Poängtabell", "events.qual_points_table_hint": "Välj hur poäng per kvalomgång delas ut när Kval-scoring använder poäng.", "events.qual_points_rank": "Placeringstal (1,2,3...)", "events.qual_points_desc": "Fallande efter fältstorlek", "events.qual_points_ifmar": "10-9-8-7-6-5-4-3-2-1", "events.qual_tie_break": "Tie-break", "events.qual_tie_break_hint": "Välj vilken regel som avgör lika resultat i kvalrankingen.", "events.qual_tie_break_rounds": "Jämför räknade rundor", "events.qual_tie_break_best_lap": "Bästa enskilda varv", "events.qual_tie_break_best_round": "Bästa runda / heatresultat", "events.race_preset": "Preset", "events.race_preset_hint": "Snabbstart för bana/klass. Applicera preset och finjustera sedan manuellt.", "events.apply_preset": "Applicera preset", "events.save_preset": "Spara klubb-preset", "events.delete_preset": "Ta bort klubb-preset", "events.preset_name": "Presetnamn", "events.preset_custom": "Custom / nuvarande värden", "events.preset_short_technical": "Kort teknisk bana 16s", "events.preset_club_qualifying": "Klubbrace kval + final", "events.preset_ifmar": "IFMAR-stil kval/final", "events.preset_endurance": "Endurance / lagrace", "events.tie_break_note": "Tie-break", "events.counted_rounds_label": "Räknade rundor", "events.tie_break_won": "Vann mot", "events.tie_break_lost": "Förlorade mot", "events.invalid_recent": "Senaste träff ogiltig", "timing.invalid_manual": "Manuellt ogiltigförklarat", "timing.invalidate_last_lap": "Ogiltigförklara senaste varv", "events.cars_per_final": "Förare per final", "events.cars_per_final_hint": "Max antal förare i varje A/B/C-final.", "events.final_legs": "Final-heat per final", "events.final_legs_hint": "Hur många finalheat som ska skapas per main.", "events.counted_final_legs": "Räknade finalheat", "events.counted_final_legs_hint": "Hur många finalheat som ska räknas i sammanlagd finalranking.", "events.final_duration": "Finaltid (min)", "events.final_duration_hint": "Längd per finalheat i minuter.", "events.final_start_mode": "Final-start", "events.final_start_mode_hint": "Starttyp för finaler, ofta positionsstart.", "events.bump_count": "Bump-up per final", "events.bump_count_hint": "Antal förare som kan flyttas upp från lägre final till nästa main.", "events.save_race_format": "Spara raceformat", "events.open_grid": "Grid", "events.grid_editor": "Grid-editor", "events.grid_editor_hint": "Dra förare upp eller ner för att ändra startordningen manuellt för positionsstart.", "events.grid_reset": "Återställ från deltagarlista", "events.grid_lock": "Lås grid", "events.grid_unlock": "Lås upp grid", "events.grid_locked": "Manuell grid är låst mot auto-reseed.", "events.grid_unlocked": "Grid följer auto-seed/reseed tills du låser den.", "events.grid_empty": "Ingen grid att redigera ännu.", "events.print_heat_sheet": "Skriv ut heatsheet", "events.export_heat_sheet": "Exportera heatsheet", "events.pdf_heat_sheet": "PDF heatsheet", "events.free_practice_note": "Free Practice visar löpande varvtider och används inte för seedning.", "events.open_practice_note": "Open Practice visar alla inkommande transpondrar löpande. Om ingen förare matchar visas bara transpondern.", "events.generate_qualifying": "Skapa kval från practice", "events.clear_generated_qualifying": "Rensa genererade kval", "events.generate_finals": "Skapa finaler från kval", "events.clear_generated_finals": "Rensa genererade finaler", "events.apply_bumps": "Applicera bump-ups", "events.practice_standings": "Practice-ranking", "events.qualifying_standings": "Kval-ranking", "events.final_standings": "Final-ranking", "events.generated_qualifying": "Kvalheat skapade från ranking.", "events.finals_generated": "Finaler skapade från ranking.", "events.bumps_applied": "Bump-ups applicerade till nästa final.", "events.no_bumps_applied": "Inga bump-ups kunde appliceras ännu.", "events.no_practice_results": "Inga practice-resultat ännu.", "events.no_qualifying_results": "Inga kval-resultat ännu.", "events.no_final_results": "Inga final-resultat ännu.", "events.final_matrix": "Finalmatris", "events.print_startlists": "Skriv ut startlistor", "events.print_results": "Skriv ut resultat", "events.pdf_startlists": "PDF startlistor", "events.pdf_results": "PDF resultat", "events.reserved_slot": "Reserverad bump-plats", "events.position_grid": "Grid / startordning", "events.start_lists": "Startlistor", "events.no_final_matrix": "Inga finaler skapade ännu.", "events.results_overview": "Resultatöversikt", "events.main": "Main", "events.slot": "Ruta", "events.leg_status": "Heatstatus", "events.source_for_finals": "Källa för finaler", "events.finals_from_qualifying": "Kval-ranking", "events.finals_from_practice": "Practice-ranking", "events.finals_source_hint": "Välj om finalerna ska seedas från practice eller kval.", "events.follow_up_sec": "Follow-up tid (sek)", "events.follow_up_sec_hint": "Extra tid efter ordinarie racetid så sista bilarna kan avsluta innan sessionen stängs.", "events.min_lap_time": "Min varvtid (sek)", "events.min_lap_time_hint": "Varv snabbare än detta ignoreras som shortcut eller felträff.", "events.max_lap_time": "Max varvtid (sek)", "events.max_lap_time_hint": "Varv långsammare än detta räknas inte som giltigt varv och bryter lap-basen för nästa varv.", "events.race_driver_scope": "Race i denna klass använder alla förare i vald klass om sessionen inte har egen deltagarlista.", "events.reserve_bump_slots_hint": "Reserverar tomma platser i högre finaler så bumpade förare kan flyttas in utan att skriva över seedade platser.", "events.team_race": "Lagrace", "events.team_race_intro": "Skapa lag för långlopp. Alla passeringar från lagets förare eller bilar summeras till lagets totalvarv i Team Race-sessioner.", "events.team_name": "Lagnamn", "events.add_team": "Lägg till lag", "events.teams": "Lag", "events.team_drivers": "Lagförare", "events.team_cars": "Lagbilar", "events.team_hint": "Välj minst en förare eller bil per lag. Team Race-sessioner summerar lagets totala varv under hela körtiden, t.ex. 4 timmar.", "events.team_steps": "1. Skriv lagnamn. 2. Kryssa förare och/eller bilar här under. 3. Klicka Lägg till lag. 4. Använd Redigera lag för ändringar efteråt.", "events.team_form_drivers": "Markera lagförare innan du sparar laget.", "events.team_form_cars": "Markera lagbilar innan du sparar laget.", "events.team_driver_fallback": "Inga förare matchade race-klassen eller deltagarlistan. Visar alla förare som fallback.", "events.no_teams": "Inga lag skapade ännu.", "events.team_standings": "Lagställning", "events.no_team_results": "Inga teamresultat ännu.", "events.edit_team": "Redigera lag", "events.team_stint_log": "Stint- och förarbyteslogg", "events.team_report": "Lagrapport", "events.print_team_results": "Skriv ut lagrapport", "events.pdf_team_results": "PDF lagrapport", "events.add_session": "Lägg till session", "events.session_type_label": "Sessionstyp", "events.session_type_hint_track": "Välj vilken del av sponsor-eventet du skapar: träning, kval, heat eller final. Eventet i sig är redan ett sponsor-event.", "events.session_type_hint_race": "Välj vilken session i racet du skapar: träning, kval, heat, final eller lagrace.", "events.session_duration_label": "Längd (min)", "events.session_duration_hint": "Ordinarie körtid för sessionen i minuter.", "events.session_followup_label": "Follow-up (sek)", "events.session_followup_hint": "Extra tid efter ordinarie sluttid så sista bilarna kan fullfölja.", "events.session_start_mode_label": "Starttyp", "events.session_start_mode_hint": "Mass-start, positionsstart eller individuell staggered start.", "events.session_seed_laps_label": "Seedning bästa varv", "events.session_seed_laps_hint": "0 = av. Använd 2 eller 3 för practice/kval-seedning.", "events.session_seed_method_label": "Seedmetod", "events.session_seed_method_hint": "Hur de bästa varven ska räknas: summa, snitt eller följdvarv.", "events.session_stagger_gap_label": "Stagger-gap (sek)", "events.session_stagger_gap_hint": "Används bara för staggered start. 0 = ingen extra lucka.", "events.session_max_cars_label": "Max bilar (valfritt)", "events.session_max_cars_hint": "Sätt gräns om just den sessionen bara ska ha ett visst antal bilar.", "events.edit_session_help": "Ändra sessionens namn, typ, tid och seed-/startinställningar här.", "events.set_active": "Sätt aktiv", "events.assignments": "Tilldelningar", "events.na": "ej relevant", "events.sponsor_tools": "Sponsorverktyg", "events.qual_rounds": "Kvalrundor", "events.heat_rounds": "Heat-rundor", "events.final_rounds": "Finalrundor", "events.round_duration": "Rundtid (min)", "events.create_rounds": "Skapa rundor", "events.tp_rule": "Samma transponder kan återanvändas mellan sessioner (Heat 1 -> Heat 2 -> Final 1). I en pågående session måste alla aktiva bilar ha unikt transponder-ID.", "events.assign_title": "Tilldelningar (Förare -> Bil)", "events.assign": "Tilldela", "events.auto_assign": "Auto-tilldela vald session", "events.clear_assign": "Rensa vald session", "events.no_assignments": "Inga tilldelningar", "events.duplicate_car": "Den bilen är redan tilldelad i denna session.", "events.duplicate_driver": "Den föraren är redan tilldelad i denna session.", "events.duplicate_tp": "Dubblett-transponder i samma session är inte tillåtet. Återanvänd i nästa heat/final.", "timing.decoder_connection": "Decoder-anslutning", "timing.connect": "Anslut decoder", "timing.disconnect": "Koppla ner", "timing.simulate": "Simulera passering", "timing.status": "Status", "timing.connected": "Ansluten", "timing.disconnected": "Frånkopplad", "timing.last_message": "Senaste meddelande", "timing.main_decoder": "Main start/mål", "timing.split_decoder": "Mellantid", "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.", "timing.select_session": "Välj session", "timing.set_active": "Sätt aktiv", "timing.start": "Starta", "timing.stop": "Stoppa", "timing.reset": "Nollställ data", "timing.total_passings": "Totala passeringar", "timing.started": "Startad", "timing.remaining": "Nedräkning", "timing.elapsed": "Körtid", "timing.race_finished": "Racet är avslutat", "timing.race_started_voice": "Racet har startat", "timing.race_finished_voice": "Racet är avslutat", "timing.practice_started_voice": "Träningen har startat", "timing.practice_finished_voice": "Träningen är avslutad", "timing.follow_up": "Follow-up", "timing.follow_up_active": "Follow-up aktiv", "timing.no_active": "Ingen aktiv session vald.", "timing.leaderboard": "Live leaderboard", "timing.recent_passings": "Senaste passeringar", "timing.no_laps": "Inga varv ännu.", "timing.no_session_selected": "Ingen session vald.", "timing.no_passings": "Inga passeringar registrerade.", "timing.details": "Detaljer", "timing.add_driver": "Lägg till förare", "timing.add_car": "Lägg till bil", "timing.quick_add_hint": "Snabbregistrera transponder", "timing.quick_add_title": "Snabbregistrering", "timing.quick_add_driver_title": "Lägg till förare från transponder", "timing.quick_add_car_title": "Lägg till bil från transponder", "timing.open_overlay": "Öppna overlay", "timing.open_speaker_overlay": "Speaker overlay", "timing.open_results_overlay": "Result overlay", "timing.open_tv_overlay": "TV overlay", "timing.open_team_overlay": "Team overlay", "timing.open_obs_overlay": "OBS overlay", "timing.close_details": "Stang", "timing.detail_title": "Leaderboard-detaljer", "timing.lap_history": "Varvhistorik", "timing.no_lap_history": "Inga varv att visa.", "timing.manual_corrections": "Manuella korrigeringar", "timing.lap_adjustment": "Varvjustering", "timing.time_penalty": "Tidspåslag", "timing.penalty_add_lap": "+1 varv", "timing.penalty_remove_lap": "-1 varv", "timing.penalty_add_sec": "+1 sek", "timing.penalty_add_5sec": "+5 sek", "timing.penalty_remove_sec": "-1 sek", "timing.penalty_reset": "Nollställ korrigering", "timing.restore_last_invalid": "Återställ senaste manuellt ogiltiga varv", "timing.no_manual_invalid": "Inget manuellt ogiltigt varv hittades.", "timing.valid_passing": "Giltigt varv", "timing.invalid_short": "För kort varv", "timing.invalid_long": "Över maxvarv", "judging.title": "Domarvy", "judging.active_session": "Aktiv session", "judging.no_active_session": "Ingen aktiv session vald.", "judging.select_competitor": "Välj förare eller lag", "judging.manual_actions": "Manuella åtgärder", "judging.action_log": "Domarlogg", "judging.no_action_log": "Inga manuella åtgärder registrerade ännu.", "judging.selected_none": "Ingen rad vald.", "judging.restore_done": "Senaste manuellt ogiltiga varv återställdes.", "judging.filter_competitors": "Filter rader", "judging.filter_log": "Filter logg", "judging.filter_all": "Alla", "judging.filter_invalid": "Ogiltiga", "judging.filter_corrected": "Korrigerade", "judging.filter_team": "Team race", "judging.filter_log_corrections": "Korrigeringar", "judging.filter_log_invalidations": "Invalidate/restore", "judging.filter_log_undo": "Undo", "judging.export_log": "Exportera domarlogg", "judging.undo_last": "Ångra senaste", "judging.undo_action": "Ångra", "judging.undo_done": "Senaste manuella åtgärden ångrades.", "judging.no_undo": "Ingen åtgärd att ångra.", "timing.total_time": "Total tid", "timing.clear_confirm": "Rensa all tiddata för denna session?", "timing.prompt_transponder": "Transponder", "timing.first_crossing_start": "Första crossing satte personlig start", "timing.seeding_mode": "Seedning", "timing.position_grid_hint": "Griden visar startordningen för positionsstart i aktiv session.", "settings.decoder": "Decoder", "settings.main_ws_url": "Main WebSocket-url", "settings.enable_split_decoder": "Aktivera mellantidsdecoder", "settings.split_ws_url": "Mellantid WebSocket-url", "settings.enable_pit_decoders": "Aktivera depådecoders", "settings.decoder_roles_hint": "Klientens WebSocket-url ska använda backend-datorns IP, inte den fysiska decoderns IP. Main räknar varv; mellantid och depå gör det aldrig.", "settings.managed_channels_hint": "Fälten Decoder IP / host nedan är de fysiska decodrarnas adresser. Backend startar en AMMC-process per roll på unika portar: main 9000, mellantid 9001, depå in 9002 och depå ut 9003.", "settings.pit_entry_ws_url": "Depå in WebSocket-url", "settings.pit_exit_ws_url": "Depå ut WebSocket-url", "settings.split_decoder_host": "Mellantid decoder IP / host", "settings.split_ws_port": "Mellantid WebSocket-port", "settings.pit_entry_decoder_host": "Depå in decoder IP / host", "settings.pit_entry_ws_port": "Depå in WebSocket-port", "settings.pit_exit_decoder_host": "Depå ut decoder IP / host", "settings.pit_exit_ws_port": "Depå ut WebSocket-port", "settings.auto_reconnect": "Auto-återanslut", "settings.save": "Spara", "settings.connect_now": "Anslut nu", "settings.expected_json": "Förväntat AMMC JSON-format", "settings.managed_ammc": "Hanterad AMMC", "settings.managed_ammc_sub": "Starta lokal AMMC från backend på denna maskin.", "settings.enable_managed": "Aktivera hanterad AMMC", "settings.auto_start_ammc": "Auto-starta AMMC när backend startar", "settings.decoder_host": "Decoder IP / host", "settings.ws_port": "AMMC WebSocket-port", "settings.executable_path": "AMMC binär", "settings.working_dir": "Arbetskatalog (valfritt)", "settings.extra_args": "Extra argument (valfritt)", "settings.save_ammc": "Spara AMMC", "settings.start_ammc": "Starta AMMC", "settings.stop_ammc": "Stoppa AMMC", "settings.refresh_ammc": "Uppdatera status", "settings.ammc_status": "AMMC-status", "settings.running": "Kör", "settings.stopped": "Stoppad", "settings.server_platform": "Server-OS", "settings.pid": "PID", "settings.started_at": "Startad", "settings.stopped_at": "Stoppad", "settings.last_error": "Senaste fel", "settings.output": "Senaste AMMC-logg", "settings.executable_found": "Binär hittad", "settings.executable_missing": "Binär saknas", "settings.bundled_hint": "Bundlad standardbana i appen används automatiskt om den finns.", "settings.use_server_ws": "Använd serverns WS-url", "settings.audio": "Ljud", "settings.audio_enabled": "Aktivera ljud i browsern", "settings.speaker_passing_cue": "Speaker-cue vid passing", "settings.speaker_leader_cue": "Speaker-cue vid ny ledare", "settings.speaker_finish_cue": "Speaker-cue vid finish", "settings.speaker_bestlap_cue": "Speaker-cue vid nytt bästa varv", "settings.speaker_top3_cue": "Speaker-cue vid topp 3-ändring", "settings.speaker_start_cue": "Speaker-cue vid sessionstart", "settings.passing_sound": "Passing-ljud", "settings.passing_sound_off": "Av", "settings.passing_sound_beep": "Blipp", "settings.passing_sound_name": "Säg förarnamn", "settings.finish_voice": "Läs upp start och avslut", "settings.start_sound": "Startsignal", "settings.finish_sound": "Målsignal", "settings.speech_voice": "Talröst", "settings.system_default_voice": "Automatisk röst för valt språk", "settings.speech_rate": "Talhastighet", "settings.speech_pitch": "Tonhöjd", "settings.test_start": "Testa start", "settings.test_finish": "Testa mål", "settings.test_driver_voice": "Testa förarnamn", "settings.driver_voice_sample": "Testförare", "settings.test_audio": "Testa ljud", "settings.audio_note": "Browsern kräver oftast ett klick först innan ljud/tal tillåts.", "settings.voice_note": "Rösterna kommer från operativsystemet. På Windows ger Microsofts svenska röst oftast bäst uttal. Ange vid behov ett särskilt uttalsnamn under Förare.", "settings.branding": "Klubbinfo / PDF", "settings.club_name": "Klubbnamn", "settings.club_tagline": "Klubbtext", "settings.pdf_footer": "PDF-footer", "settings.pdf_theme": "PDF-tema", "settings.pdf_theme_classic": "Classic", "settings.pdf_theme_minimal": "Minimal", "settings.pdf_theme_motorsport": "Motorsport", "settings.logo": "Logo / overlay", "settings.logo_upload": "Ladda logo", "settings.logo_clear": "Rensa logo", "settings.logo_note": "Logon visas i overlay. PDF-export försöker bädda in loggan automatiskt via backend.", "settings.storage": "Lagring", "settings.race_presets": "Klubb-presetar", "settings.race_presets_note": "Exportera eller importera lokala klubb-presetar mellan installationer.", "settings.export_presets": "Exportera presets", "settings.import_presets": "Importera presets", "settings.backend_url": "Backend URL", "settings.backend_status": "Backend-status", "settings.online": "Online", "settings.offline": "Offline", "settings.last_sync": "Senaste synk", "settings.test_backend": "Testa backend", "settings.sync_now": "Synka nu", "settings.export_json": "Exportera JSON", "settings.export_all_data": "Exportera all race-data", "settings.import_all_data": "Importera all race-data", "settings.export_directory": "Exportera förare/klasser/bilar", "settings.import_directory": "Importera förare/klasser/bilar", "settings.export_drivers_csv": "Exportera förare CSV", "settings.export_cars_csv": "Exportera bilar CSV", "settings.storage_note_full": "Fullt datapaket innehåller klasser, förare, bilar, race/event, sessioner och resultat.", "settings.storage_note_directory": "Registerpaket innehåller bara klasser, förare och bilar och mergeas in i befintlig data.", "settings.storage_note_csv": "CSV-exporten är snabbast för registerlistor och enklare att öppna i Excel eller LibreOffice.", "settings.import_full_success": "Race-data importerad och ersatte nuvarande tävlingsdata.", "settings.import_directory_success": "Klasser, förare och bilar importerades in i befintlig data.", "settings.import_failed": "Import misslyckades: {msg}", "table.name": "Namn", "table.brand": "Märke", "table.class": "Klass", "table.transponder": "Transponder", "table.delete": "Ta bort", "table.car": "Bil", "table.date": "Datum", "table.mode": "Läge", "table.start_mode": "Start", "table.seeding": "Seedning", "table.score": "Poäng", "table.session": "Session", "table.type": "Typ", "table.duration": "Tid", "table.status": "Status", "table.time": "Tid", "table.driver": "Förare", "table.loop": "Loop", "table.strength": "Signal", "table.pos": "Pos", "table.laps": "Varv", "table.last_lap": "Senaste varv", "table.best_lap": "Bästa varv", "table.split": "Mellantid", "table.pit": "Depå", "table.gap": "Gap", "table.event": "Event", "table.result": "Resultat", "table.lap": "Varv", "table.leader_gap": "Gap ledare", "table.ahead_gap": "Gap fram", "table.own_delta": "Eget delta", "common.delete": "Ta bort", "common.cancel": "Avbryt", "common.save": "Spara", "common.edit": "Redigera", "common.unknown_driver": "Okänd förare", "common.unknown_car": "Okänd bil", "common.unknown": "Okänd", "common.unassigned_driver": "Otilldelad förare", "common.driver_car": "Förarbil", "common.unknown_event": "Okänt event", "common.no_rows": "Inga rader", "common.no_entries": "Inga poster.", "common.previous": "Tillbaka", "common.next": "Nästa", "common.reset": "Återställ", "status.ready": "redo", "status.running": "pågår", "status.finished": "klar", "status.leader": "LEDARE", "status.seeded": "SEED", "status.free_practice": "FREE", "status.open_practice": "OPEN", "session.open_practice": "open practice", "session.free_practice": "fri träning", "session.practice": "träning", "session.qualification": "kval", "session.heat": "heat", "session.final": "final", "session.team_race": "lagrace", "validation.no_assignments": "Ingen förar-/biltilldelning i denna session.", "validation.missing_tp": "En eller flera tilldelade bilar saknar transponder-ID.", "validation.duplicate_tp": "Dubblett-transponder i session: {ids}.", "validation.invalid_date": "Datum måste vara i format YYYY-MM-DD.", "validation.invalid_selection": "Välj ett giltigt alternativ.", "validation.required_name": "Namn måste fyllas i.", "validation.required_transponder": "Transponder måste fyllas i.", "validation.required_date": "Datum måste fyllas i.", "validation.required_duration": "Duration måste vara minst 1 minut.", "edit.class_name": "Redigera klassnamn", "edit.driver_name": "Redigera förarnamn", "edit.driver_class": "Redigera förarklass", "edit.new_driver_name": "Namn på ny förare", "edit.driver_transponder": "Redigera personlig transponder (kan vara tom)", "edit.car_name": "Redigera bilnamn", "edit.new_car_name": "Namn på ny bil", "edit.car_transponder": "Redigera bilens transponder", "edit.event_name": "Redigera eventnamn", "edit.event_date": "Redigera eventdatum (YYYY-MM-DD)", "guide.title": "Guide och dokumentation", "guide.intro": "Här finns steg-för-steg för sponsor-event, vanligt race, träningsstatistik och Create Race Wizard i Race Setup. Guiden beskriver också hur Hantera-flödet är uppdelat samt var AMMC faktiskt körs i Managed AMMC-läget.", "guide.card_sponsor_blurb": "Delade bilar, roterande förare och heat/finaler för sponsor- och prova-på-event.", "guide.card_race_blurb": "Personliga transpondrar, kval/finaler, raceformat och seedning för riktiga tävlingsrace.", "guide.card_team_blurb": "Lagrace och endurance med team, stintlogg och summerade varv över lång tid.", "guide.card_training_blurb": "Förarutveckling, beständig varvhistorik, träningsmål och belöningar i mobilvänlig vy.", "guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux och lokal SQLite-lagring.", "guide.audio_title": "Start-, mål- och förarljud", "guide.audio_1": "1. Öppna Inställningar -> Ljud och aktivera webbläsarljud, startsignal, målsignal och uppläsning efter behov.", "guide.audio_2": "2. Välj en svensk systemröst och provlyssna med Testa start, Testa mål och Testa förarnamn. Webbläsaren kräver normalt att du klickar på sidan innan ljud tillåts.", "guide.audio_3": "3. Välj Blipp eller Säg förarnamn för passeringar. Om ett namn redan läses upp används ett kort blipp i stället så att röster inte staplas ovanpå varandra.", "guide.audio_4": "4. Om ett namn uttalas fel går du till Förare och anger Uttalsnamn, exempelvis en fonetisk stavning. Visningsnamnet ändras inte.", "guide.audio_5": "5. Ljudet spelas i webbläsaren på datorn där Tidtagning körs. Kontrollera därför rätt ljudutgång och håll den fliken öppen under sessionen.", "guide.sponsor_title": "Skapa Sponsor Event: 10 personer, 4 bilar", "guide.sponsor_1": "1. Lägg upp 4 bilar i sidan Bilar med unikt transponder-ID. Lägg gärna också in märke/modell i brandfältet.", "guide.sponsor_2": "2. Lägg upp 10 förare i sidan Förare. Du kan också spara team/märke i brandfältet och filtrera listan på det senare.", "guide.sponsor_3": "3. Skapa event med läge Sponsor Event.", "guide.sponsor_4": "4. Klicka Hantera på eventet och skapa rundor (kval/heat/final).", "guide.sponsor_5": "5. Tilldela 4 förare till 4 bilar i Heat 1, byt förare till Heat 2/3 osv.", "guide.sponsor_6": "6. I Tidtagning: välj session, Sätt aktiv, Starta, Stoppa.", "guide.race_title": "Skapa vanligt race (förare har egna transpondrar)", "guide.race_1": "1. Lägg in förare med personlig transponder. Brandfältet kan användas för team, sponsor eller bilmärke.", "guide.race_2": "2. Skapa event med läge Race.", "guide.race_3": "3. Klicka Hantera på racet och markera exakt vilka förare som ska få vara med i just detta race.", "guide.race_4": "4. Gå igenom Raceformat och välj antal kvalrundor, förare per heat, tider, starttyp och hur finaler ska seedas.", "guide.race_4a": "4a. Börja i Grundläge med en preset. Öppna Avancerat först när du behöver tie-break, poängtabell, reservplatser eller finjusterad seedning.", "guide.race_5": "5. Kör practice om du vill använda practice som första seedning.", "guide.race_6": "6. Klicka Skapa kval från practice för att bygga kvalheat från practice-ranking eller klasslista.", "guide.race_7": "7. Kör kvalomgångarna. Om du vill omfördela kommande heat efter aktuell ranking klickar du Reseeda kommande kval.", "guide.race_8": "8. Klicka Skapa finaler från kval när kvalen är färdiga.", "guide.race_9": "9. Kör finalerna. Om bump används klickar du Applicera bump-ups mellan mains när en lägre final är färdig.", "guide.race_10": "10. Använd Finalmatris och Skriv ut startlistor/resultat för att kontrollera och dela upplägget.", "guide.race_format_title": "Förklaring av Raceformat", "guide.race_format_0": "Raceformat är uppdelat i block för Practice/Kval, Finaler och Validering. Till höger visas en sammanfattning av upplägget innan du genererar heat och finaler.", "guide.race_format_1": "Kval-scoring styr om ranking byggs på poäng per kvalomgång eller bästa enskilda kvalresultat.", "guide.race_format_2": "Antal kvalrundor och Förare per kvalheat styr hur många kvalheat som skapas och hur de fylls.", "guide.race_format_3": "Kvaltid, Kval-start och Räknade kvalrundor styr hur kvalen körs och vilka rundor som räknas.", "guide.race_format_4": "Förare per final, Final-heat per final och Räknade finalheat styr hur A/B/C-finalerna byggs och räknas.", "guide.race_format_5": "Finaltid och Final-start styr varje finalleg, ofta med positionsstart.", "guide.race_format_6": "Bump-up per final och Reservera bump-platser används om förare ska kunna flyttas från lägre final till nästa main.", "guide.race_format_7": "Källa för finaler avgör om finalerna seedas från practice eller kvalrankingen.", "guide.race_format_8": "Follow-up tid ger en extra uppsamlingsperiod efter ordinarie racetid innan heatet verkligen stängs.", "guide.race_format_9": "Min varvtid filtrerar bort shortcuts och felträffar. Exempel: på en 16-sekundersbana kan du sätta 11 sekunder som min-gräns.", "guide.race_format_10": "Max varvtid stoppar långa felvarv från att räknas och används också för att bryta stintar och förbättra statistik. Exempel: 60 sekunder.", "guide.race_format_11": "Preset låter dig snabbt fylla raceformat med vettiga grundvärden för kort teknisk bana, klubbrace, IFMAR-liknande upplägg eller endurance.", "guide.race_format_11a": "I Grundläge döljer endurance-presetet de flesta kval- och finalfälten för att hålla fokus på Team Race. Öppna Avancerat om eventet även ska ha stödheat eller vanliga finaler.", "guide.race_format_12": "Du kan applicera preset och sedan justera enskilda fält manuellt innan du sparar raceformatet.", "guide.race_format_13": "Spara klubb-preset lagrar dina egna lokala raceformat så du kan återanvända dem på samma installation utan att bygga om allt varje gång.", "guide.race_format_14": "Klubb-presetar kan också exporteras och importeras från Inställningar om du vill flytta dem mellan olika servrar eller laptops.", "guide.free_practice_title": "Free Practice", "guide.free_practice_1": "Använd sessionstypen fri träning när du bara vill visa löpande varvtider.", "guide.free_practice_2": "Free Practice påverkar inte seedning till kval eller finaler.", "guide.free_practice_3": "Leaderboarden visar varv, senaste varv, bästa varv, gap till framförvarande och eget delta mot föregående varv.", "guide.open_practice_title": "Open Practice", "guide.open_practice_1": "Använd Open Practice när du vill att systemet bara ska lista alla transpondrar som kommer in.", "guide.open_practice_2": "Om transpondern inte matchar en registrerad förare visas transpondernumret som namn.", "guide.open_practice_3": "Open Practice påverkar inte seedning, kval eller finaler.", "guide.open_practice_4": "Gå till Race Setup och använd Snabbstart Open Practice. Du behöver bara ange ett namn; klass, deltagare, preset och racetid behövs inte.", "guide.open_practice_5": "Efter skapandet öppnas Tidtagning direkt. Klicka Starta och låt sessionen gå tills du själv stoppar den.", "guide.team_title": "Lagrace / Endurance", "guide.team_1": "Gå till Race Setup och skapa ett race i rätt klass.", "guide.team_2": "Öppna Hantera och gå till sektionen Lag.", "guide.team_3": "Skriv lagnamn och kryssa förare och/eller bilar i samma teamblock innan du klickar Lägg till lag.", "guide.team_4": "Efter att laget skapats kan du klicka Redigera lag för att ändra förare eller bilar.", "guide.team_5": "Skapa en session med typ Team Race och sätt tiden, t.ex. 240 minuter för 4 timmar.", "guide.team_6": "Starta sessionen i Tidtagning. Alla passeringar från lagets medlemmar summeras till lagets totalvarv.", "guide.validation_title": "Ogiltiga varv, follow-up och manuella korrigeringar", "guide.validation_1": "Senaste passeringar visar nu både giltiga och ogiltiga varv. För korta varv markeras som För kort varv och för långa som Över maxvarv.", "guide.validation_2": "Ogiltiga kortvarv under min-gränsen räknas inte alls i leaderboard eller statistik.", "guide.validation_3": "Ogiltiga långvarv över max-gränsen räknas inte som varv, men de kan bryta lap-basen så nästa giltiga varv börjar om korrekt.", "guide.validation_4": "När ordinarie tid är slut kan sessionen gå in i Follow-up aktiv om du har satt Follow-up tid i raceformat eller sessionen.", "guide.validation_5": "I Tidtagning -> Detaljer kan du ge +1/-1 varv och +1/+5/-1 sekunder som manuell korrigering. Det slår igenom direkt i leaderboarden.", "guide.validation_6": "I samma detaljvy kan du också manuellt ogiltigförklara senaste räknade varvet om du behöver ta bort en felträff i efterhand.", "guide.validation_7": "Menyn Domare samlar samma korrigeringar i en separat arbetsvy med leaderboard, lap history och domarlogg för pågående session.", "guide.validation_8": "Domarvyn kan filtrera på ogiltiga rader, korrigerade rader eller teamrace, exportera domarloggen och ångra flera senaste manuella åtgärder via undo-knappar.", "guide.qualifying_title": "Seedning, poängtabeller och tie-break", "guide.qualifying_1": "Practice och kval kan nu använda tre seedmetoder: bästa N varv som summa, bästa N varv som snitt eller bästa N konsekutiva varv.", "guide.qualifying_2": "Raceformat styr både Kval seedvarv och Kval seedmetod när nya kvalheat skapas från practice eller deltagarlistan.", "guide.qualifying_3": "Kval-scoring kan kombinera poängläge med poängtabell: placeringstal, fallande efter fältstorlek eller IFMAR 10-9-8-7-6-5-4-3-2-1.", "guide.qualifying_4": "Tie-break i kvalrankingen kan avgöras på räknade rundor, bästa enskilda varv eller bästa runda/heatresultat.", "guide.qualifying_5": "Leaderboarden visar seedningsresultat i rätt format, till exempel 3/00:48.321, 3 avg 16.107 eller 3 con 00:49.005.", "guide.dashboard_title": "Schemaavvikelse på Översikt", "guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.", "guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.", "guide.dashboard_3": "Det gör det lättare att se om tävlingsdagen ligger före eller efter schema direkt från dashboarden.", "guide.dashboard_4": "Kom igång-korten och den grupperade sidomenyn följer samma ordning: anslut systemet, registrera vid behov, skapa körning och öppna sedan Tidtagning/Overlay.", "guide.training_title": "Träning, utveckling och belöningar", "guide.training_1": "1. Registrera föraren och transpondern under Förare. Klubb och land är valfria profilfält. Har personen flera klasser väljer du samma Träningsprofil på varje förarpost.", "guide.training_2": "2. Kör Free Practice, Open Practice eller en vanlig racesession. Giltiga varv kopieras automatiskt till en beständig träningslogg; korta/långa ogiltiga varv räknas inte.", "guide.training_3": "3. Öppna Träning och välj förare. Grundvyn visar bara totalvarv, träningsdagar, personbästa, jämnhet, nästa mål och dagens pass. Öppna Mer statistik eller Klubbens träningsöversikt bara när det behövs.", "guide.training_4": "4. Nästa automatiska nivå räknas utan konfiguration. Skapa egna delmål om klubben eller en förälder vill koppla en fysisk belöning till exempelvis 500, 1000 eller 1500 varv.", "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. Den lokala 20-minuterstimern växlar från 10 minuter maxfokus till 10 minuter kontrollerad körning och visar förarens varv, bästa varv och snitt under det egna passet.", "guide.training_9": "9. Klicka Skapa ny länk eller Stäng av profil om länken spridits fel. Publicera bara `/public-training/*`, `/src/public_training.js` och `/src/public_training.css` externt, aldrig hela admin-sidan eller `/api/state`.", "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 startar sin egen 20-minuterstimer i mobilen utan att starta om eller påverka den gemensamma practice-sessionen.", "guide.training_12": "12. Ett glapp över 2 minuter mellan passeringar startar automatiskt en ny stint. Ett långt pausvarv används som gräns men räknas inte som prestationsvarv i stintstatistiken.", "guide.training_13": "13. Bästa 5 min använder 300 sekunder. Ett slutvarv räknas om det startade före 5:00 även om målgången sker efter tiden, exempelvis 17/05:14.12:123. Avg pace visas för varven i just det resultatet.", "guide.training_14": "14. En personlig profil och mobillänk kan innehålla flera klasser, men 2WD/4WD-statistik, stints, varvtider och rekommendationer visas alltid separat per vald klass.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", "overlay.last_passings": "Senaste passeringar", "overlay.window_title": "JMK RB RaceController Overlay", "overlay.mode_leaderboard": "Leaderboard", "overlay.mode_speaker": "Speaker", "overlay.mode_results": "Resultat", "overlay.mode_tv": "TV", "overlay.mode_team": "Team", "overlay.mode_obs": "OBS", "overlay.fastest_lap": "Snabbaste varv", "overlay.fullscreen": "Fullscreen", "overlay.leaderboard_live": "Live leaderboard", "overlay.rotating_panel": "Displaypanel", "overlay.next_predicted_lap": "Nästa varv", "overlay.event_markers": "Eventmarkörer", "overlay.obs_config": "OBS-konfiguration", "overlay.obs_public_hint": "Bygg en minimal publik overlay-länk för OBS eller extern webb.", "overlay.obs_rows": "Antal rader", "overlay.obs_show_clock": "Visa raceklocka", "overlay.obs_show_fastest": "Visa snabbaste varv", "overlay.obs_show_grid": "Visa startgrid när racet är klart att starta", "overlay.obs_show_laps": "Visa varv", "overlay.obs_show_result": "Visa resultat/tid", "overlay.obs_show_best": "Visa bästa varv", "overlay.obs_show_gap": "Visa gap", "overlay.obs_copy_url": "Kopiera OBS-url", "overlay.obs_layout": "OBS-layout", "overlay.obs_theme": "OBS-tema", "overlay.obs_public_token": "Publik token", "overlay.obs_public_token_hint": "Valfri klienttoken som läggs i public-overlay-länken.", "overlay.obs_layout_leaderboard": "Leaderboard", "overlay.obs_layout_grid": "Startgrid", "overlay.obs_layout_lowerthird": "Lower third", "overlay.obs_theme_panel": "Panel", "overlay.obs_theme_transparent": "Transparent", "overlay.obs_theme_chroma": "Chroma", "overlay.team_battle": "Lagkamp", "overlay.active_member": "Aktiv förare/bil", "overlay.top_three": "Topp 3", "guide.host_title": "Hur Managed AMMC körs", "guide.decoder_roles_title": "Decoderroller, IP och portar", "guide.decoder_roles_1": "1. Main start/mål är obligatorisk och är den enda kanalen som får räkna varv. Exempel: decoder `192.168.1.10` -> AMMC-port `9000` -> `ws://server-ip:9000`.", "guide.decoder_roles_2": "2. Mellantid är valfri och registrerar tiden från senaste main-passering utan att öka varvantalet. Exempel: `192.168.1.11` -> `9001`.", "guide.decoder_roles_3": "3. Depåtid kräver två loopar: Depå in öppnar stoppet och Depå ut stänger det. Exempel: `192.168.1.12` -> `9002`, samt `192.168.1.13` -> `9003`.", "guide.decoder_roles_4": "4. Decoder-IP är den fysiska decoderns adress. WebSocket-porten tillhör AMMC-processen på backend-hosten. Blanda inte ihop dessa två adresser.", "guide.decoder_roles_5": "5. Varje fysisk decoder ska ha unik IP och varje aktiverad AMMC-process ska ha unik port. Main/split/depå får aldrig dela samma WebSocket-port.", "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 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.decoder_roles_11": "11. Välj installation: 1 decoder = Main; 2 = Main + Mellantid; 3 = Main + Depå in/ut; 4 = alla roller. Depå kräver alltid både in och ut.", "guide.decoder_roles_12": "12. Exempel: backend 192.168.1.100, main-decoder .10, split .11, depå in .12 och depå ut .13. Decoder-IP-fälten får .10-.13; klientens WS-url använder alltid backend .100 med port 9000-9003.", "guide.decoder_roles_13": "13. Startordning: pinga decoders från backend, starta backend, starta Managed AMMC, kontrollera AMMC-loggen, använd serverns WS-url och anslut sedan i Tidtagning.", "guide.decoder_roles_14": "14. Funktionstest: Main ska öka varv, Split ska inte öka varv, Depå in ska visa I DEPÅ och Depå ut ska skapa ett tidtaget stopp. Komplett guide finns i docs/decoder-setup.sv.md.", "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`.", "guide.host_4": "4. Kör backend på Windows-burken -> Windows-binären används: `AMMC/windows64/ammc-amb.exe`.", "guide.host_5": "5. Fältet `AMMC binär` i Settings är sökvägen på hosten där backend kör, inte på klient-laptopen.", "guide.host_6": "6. Publicera helst bara `/public-overlay/*` och valda `/public-training/*`-routes externt via reverse proxy, inte hela admin-UI:t eller `/api/state`.", "guide.host_7": "7. OBS overlay finns nu som publik URL: `/public-overlay/obs` och kan konfigureras från Overlay-menyn.", "guide.host_8": "8. Sätt `PUBLIC_OVERLAY_TOKEN` på servern om du vill kräva `?token=...` för publika overlay-länkar.", "guide.windows_title": "Windows + AMMC + npm", "guide.windows_1": "1. Använd 64-bitars Windows 11. Dubbelklicka på `windows/install.bat`; skriptet installerar vid behov Node.js LTS med winget, kör `npm.cmd ci` och startar om backend.", "guide.windows_2": "2. Installationen kräver Node 22 eller 24 LTS och verifierar automatiskt Express, SQLite samt `AMMC/windows64`.", "guide.windows_3": "3. `windows/install.bat` kör `npm.cmd restart` automatiskt. Starta annars med `npm.cmd start` eller `windows/start_backend.bat`, kontrollera med `npm.cmd run status` och starta om med `npm.cmd restart`.", "guide.windows_4": "4. I Inställningar: `Decoder IP / host` = decoderns IP, t.ex. `192.168.1.10`. Managed AMMC körs på Windows-hosten.", "guide.windows_5": "5. Från andra enheter används `http://:8081` och `ws://:9000`. Se `windows/README.md` för brandvägg och felsökning.", "guide.linux_title": "Linux + npm", "guide.linux_1": "1. Installera Node 22 LTS, build-essential och python3 på Linux-hosten.", "guide.linux_2": "2. Standardbinär för Managed AMMC på Linux-host: `AMMC/linux_x86-64/ammc-amb`.", "guide.linux_3": "3. Kör `npm install`, `npm start`. Servern lyssnar på 0.0.0.0:8081. Öppna ev. brandvägg: `sudo ufw allow 8081/tcp` och använd `ws://:9000` i klienten.", "guide.sqlite_title": "SQLite-lagring", "guide.sqlite_1": "Databasfil: data/rc_timing.sqlite", "guide.sqlite_2": "API: /api/state och /api/passings", "guide.sqlite_3": "I Inställningar -> Lagring kan du exportera hela racepaket för backup eller flytt till annan server/laptop.", "guide.sqlite_4": "Du kan också exportera eller importera bara klasser, förare och bilar om du vill börja om med race men behålla registret.", "guide.sqlite_5": "Exportera förare och bilar som CSV om du snabbt vill öppna registret i Excel eller skriva ut en enkel lista.", "guide.ammc_ref": "AMMC referens: https://www.ammconverter.eu/docs/intro/quick-start/", "error.backend_offline": "Backend offline: {msg}", "error.sync_failed": "Synk misslyckades: {msg}", "error.health_failed": "Hälsokontroll misslyckades: {msg}", "error.ws_invalid": "Ogiltig WebSocket URL: {msg}", "error.decoder_connection": "Decoder-anslutningsfel.", "error.passing_save_failed": "Sparning av passering misslyckades: {msg}", "error.ammc_load_failed": "Kunde inte läsa AMMC-status: {msg}", "error.ammc_save_failed": "Kunde inte spara AMMC-konfig: {msg}", "error.ammc_start_failed": "Kunde inte starta AMMC: {msg}", "error.ammc_stop_failed": "Kunde inte stoppa AMMC: {msg}", "error.print_blocked": "Popup blockerad. Tillåt popup-fönster för att skriva ut.", "error.pdf_export_failed": "PDF-export misslyckades: {msg}" }, en: { "nav.dashboard": "Dashboard", "nav.dashboard_sub": "Overview and live status", "nav.group_start": "Start", "nav.group_create": "Create", "nav.group_run": "Run and follow", "nav.group_register": "Directory", "nav.group_system": "System and help", "nav.events": "Sponsor Events", "nav.events_sub": "Sponsor events and shared cars", "nav.race_setup": "Race Setup", "nav.race_setup_sub": "Open Practice, club races and endurance", "nav.overlay": "Overlay", "nav.overlay_sub": "External leaderboard screen", "nav.classes": "Classes", "nav.classes_sub": "Manage competition classes", "nav.drivers": "Drivers", "nav.drivers_sub": "Drivers and personal transponders", "nav.cars": "Cars", "nav.cars_sub": "Track cars with fixed transponders", "nav.training": "Training stats", "nav.training_sub": "Development, milestones and rewards per driver", "nav.timing": "Timing", "nav.timing_sub": "Live timing board", "nav.judging": "Judging", "nav.judging_sub": "Corrections and penalties", "nav.settings": "Settings", "nav.settings_sub": "Decoder, backend and storage", "nav.guide": "Guide", "nav.guide_sub": "Documentation and setup", "ui.language": "Language", "ui.theme": "Theme", "ui.theme_dark": "Dark", "ui.theme_nord": "Nord", "ui.theme_light": "Light", "brand.title": "JMK RB RaceController", "brand.subtitle": "RC Timing System", "ui.no_active_session": "No Active Session", "ui.event": "Event", "ui.decoder_online": "Decoder Online", "ui.decoder_offline": "Decoder Offline", "training.eyebrow": "Driver development", "training.intro": "Track practice across sessions and make progress visible between race days.", "training.driver": "Driver", "training.class": "Class", "training.no_drivers_title": "No driver registered", "training.no_drivers_body": "Create a driver first. Training statistics require passings linked to a registered driver.", "training.total_laps": "Total laps", "training.valid_laps": "Valid recorded laps", "training.sessions": "Sessions", "training.recorded_sessions": "With recorded driver laps", "training.best_lap": "Best lap", "training.personal_best": "Personal best in stored data", "training.consistency": "Spread", "training.last_30_laps": "P10-P90, latest 30 laps", "training.recommendation": "Recommended practice session", "training.plan_baseline_title": "Build a baseline", "training.plan_baseline_body": "Drive calmly and consistently until at least 10 clean laps are recorded. Repeat the same line and braking point every lap.", "training.plan_consistency_title": "Raise your minimum level", "training.plan_consistency_body": "Lap spread is high. Run 10 minutes near maximum pace, then 10 minutes at 80-90 percent where clean, repeatable laps matter more than one fast lap.", "training.plan_progression_title": "Balance pace and control", "training.plan_progression_body": "You have a stable baseline. Run 10 minutes at maximum focus and finish with 10 controlled minutes aiming for an unbroken sequence of consistent laps.", "training.plan_target": "Control target around {lap}", "training.plan_no_target": "Record more laps to calculate a target", "training.block_push": "Maximum focus: find the limit", "training.block_control": "80-90%: raise the floor", "training.new_goal": "New milestone", "training.manage_goals_sharing": "Goals and mobile profile", "training.manage_goals_sharing_hint": "Create reward goals or manage the driver's share link.", "training.target_laps": "Target laps", "training.quick_targets": "Quick lap targets", "training.goal_name": "Milestone name", "training.goal_name_placeholder": "For example Bronze level", "training.reward": "Reward", "training.reward_placeholder": "For example a new body or club badge", "training.add_goal": "Add milestone", "training.goals": "Milestones and rewards", "training.no_goals": "This driver has no milestones yet.", "training.default_goal_title": "Reach {laps} laps", "training.goal_progress": "Milestone progress", "training.goal_remaining": "{laps} laps remaining", "training.goal_reached": "Milestone reached", "training.goal_claimed": "Reward delivered", "training.claim_reward": "Mark reward delivered", "training.training_days": "Practice days", "training.sessions_count": "{sessions} recorded sessions", "training.next_level": "Next automatic level", "training.next_level_target": "Reach {laps} laps", "training.average_lap": "Average lap", "training.recent_average": "Latest 30 average", "training.controlled_streak": "Controlled streak", "training.lap_count": "{laps} laps", "training.last_activity": "Latest activity", "training.no_activity": "No activity yet", "training.history": "Daily practice history", "training.detailed_stats": "More statistics", "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", "training.pb_improvement": "Latest 30: {lap} faster", "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 20-minute timer.", "training.public_profile_private": "The link contains a unique secret key. It does not show transponder IDs, other drivers or admin controls.", "training.public_profile_link": "Personal share link", "training.copy_link": "Copy link", "training.open_profile": "Open profile", "training.replace_link": "Create new link", "training.disable_profile": "Disable profile", "training.enable_profile": "Enable mobile profile", "training.replace_link_confirm": "The old link will stop working immediately. Create a new one?", "training.club_progress": "Club practice overview", "training.club_progress_hint": "Compare total laps and next targets when needed.", "training.next_target": "Next target", "training.start_personal": "Open personal training", "training.copy_mobile_link": "Copy mobile link", "training.create_personal_link": "Create personal training link", "training.live_now": "Live now", "training.active_practice": "Active practice", "training.shared_practice": "Shared practice", "training.session_laps": "Session laps", "training.latest_lap": "Latest lap", "training.live_practice_hint": "Everyone can keep driving in the same Open/Free Practice. This card shows only the selected driver's valid laps since the current session started.", "training.stints": "Stints", "training.best_three": "Best 3 consecutive", "training.best_five": "Best 5 min", "training.five_avg_pace": "Average pace, best 5 min", "mode.track": "Track Event", "mode.race": "Race", "dashboard.events": "Events", "dashboard.drivers": "Drivers", "dashboard.cars": "Cars", "dashboard.passings": "Passings", "dashboard.created": "Created", "dashboard.registered": "Registered", "dashboard.track_fleet": "Track Fleet", "dashboard.captured": "Captured", "dashboard.live_session": "Live Session", "dashboard.idle": "idle", "dashboard.duration": "Duration", "dashboard.no_session": "No session is active. Go to Events or Timing to create/start one.", "dashboard.quick_actions": "Quick Actions", "dashboard.create_event": "Create Event", "dashboard.open_timing": "Open Timing Board", "dashboard.connect_decoder": "Connect Decoder", "dashboard.recent_sessions": "Recent Sessions", "dashboard.free_practice": "Free Practice", "dashboard.open_practice": "Open Practice", "dashboard.live_board": "Live Board", "dashboard.decoder_feed": "Decoder feed", "dashboard.backend_link": "Backend link", "dashboard.audio_profile": "Audio profile", "dashboard.schedule_drift": "Schedule drift", "dashboard.schedule_plan": "Planned time", "dashboard.schedule_actual": "Actual time", "dashboard.on_time": "On time", "dashboard.ahead": "Ahead of schedule", "dashboard.behind": "Behind schedule", "dashboard.live_note": "Quick operations panel for connection, overlay and audio. Deeper configuration remains under Settings.", "dashboard.get_started": "Getting started", "dashboard.get_started_hint": "Follow this order for initial setup. For normal practice, go directly to Open Practice.", "dashboard.step_system": "1. Connect the system", "dashboard.step_system_hint": "Configure backend, decoder and AMMC in Settings.", "dashboard.step_register": "2. Build the directory", "dashboard.step_register_hint": "Add classes, drivers and cars when named results are required.", "dashboard.step_create": "3. Create a run", "dashboard.step_create_hint": "Start a simple Open Practice or build club races, endurance and sponsor events.", "dashboard.step_run": "4. Run and display", "dashboard.step_run_hint": "Start the session in Timing and open Overlay on an external display.", "dashboard.open_settings": "Open Settings", "dashboard.open_register": "Open Drivers", "dashboard.open_race_setup": "Open Practice / Race Setup", "dashboard.open_overlay": "Open Overlay", "dashboard.untimed": "No time limit", "session.none_yet": "No sessions yet.", "classes.create": "Create Class", "classes.placeholder": "Class name (e.g. 2WD Buggy)", "classes.add": "Add Class", "classes.title": "Classes", "drivers.create": "Create Driver", "drivers.name_placeholder": "Driver name", "drivers.brand_placeholder": "Team / brand (optional)", "drivers.brand_filter_placeholder": "Search name / transponder / brand / club / country", "drivers.transponder_placeholder": "Personal transponder (optional)", "drivers.speech_name": "Spoken name", "drivers.speech_name_placeholder": "Pronunciation for announcements (optional)", "drivers.speech_name_hint": "Enter the name as the browser voice should pronounce it. Leave blank to use the regular driver name.", "drivers.training_profile": "Practice profile", "drivers.training_profile_hint": "Link multiple class and transponder entries for the same person to one shared practice profile.", "drivers.training_profile_auto": "Automatic: same name or new profile", "drivers.training_profile_new": "New separate profile", "drivers.training_profile_separate": "Unlink to a new separate profile", "drivers.club": "Club", "drivers.club_placeholder": "Club (optional)", "drivers.country": "Country", "drivers.country_placeholder": "Country (optional)", "drivers.add": "Add Driver", "drivers.title": "Drivers", "cars.create": "Create Track Car", "cars.name_placeholder": "Car name or number", "cars.brand_placeholder": "Brand / model (optional)", "cars.brand_filter_placeholder": "Search name / transponder / brand", "cars.transponder_placeholder": "Car transponder", "cars.add": "Add Car", "cars.title": "Cars", "events.create": "Create Event", "events.create_race": "Create Race", "events.create_competition_race": "Create competition race", "events.quick_open_practice_title": "Quick Open Practice", "events.quick_open_practice_intro": "For continuous timing without a race format. Create the session and run it until you stop it.", "events.quick_open_practice_default": "Open Practice", "events.quick_open_practice_create": "Create and open Timing", "events.open_practice_untimed": "Untimed", "events.open_practice_all_transponders": "All transponders", "events.open_practice_no_registration": "Works without registered drivers", "events.open_practice_until_stopped": "Runs until you stop it", "events.name_placeholder": "Event name", "events.field_name": "Event name", "events.field_name_hint": "Name shown in lists, overlays and results.", "events.field_date": "Date", "events.field_date_hint": "Event or race date used for planning and sorting.", "events.field_class": "Class", "events.field_class_hint": "Class used for this event or sponsor format.", "events.add": "Add Event", "events.add_race": "Add Race", "events.mode_race_option": "Race (driver transponders)", "events.mode_track_option": "Track Event (shared cars)", "events.title": "Events", "events.race_title": "Races", "events.track_only_intro": "Create sponsor events with shared cars/transponders here.", "events.race_only_intro": "Create proper races with personal driver transponders here.", "events.manage": "Manage", "events.open_timing": "Open Timing", "events.edit": "Edit", "events.sessions": "Sessions", "events.participants": "Participants", "events.select_participants": "Select race participants", "events.select_all_participants": "Select all", "events.clear_participants": "Clear participants", "events.reseed_qualifying": "Reseed upcoming qualifying", "events.reseed_done": "Upcoming qualifying heats reseeded from current standings.", "events.no_reseed_done": "No upcoming qualifying heats could be reseeded.", "events.reseed_locked": "{count} heats were skipped because manual grid is locked.", "events.reserve_bump_slots": "Reserve bump slots in finals", "events.bump_reserved_note": "If bump-up is used, finals can reserve slots in upper mains from the start.", "events.actions": "Actions", "events.manage_title": "Manage", "events.wizard_hint": "Build the race in four steps: basics, participants, session plan and confirmation. Fine tuning happens later in Manage.", "events.wizard_step_1": "Basics", "events.wizard_step_2": "Participants", "events.wizard_step_3": "Session plan", "events.wizard_step_4": "Confirm", "events.wizard_no_class_drivers": "No drivers exist in the selected class yet. Add drivers first or switch class.", "events.wizard_create": "Create race", "events.wizard_use_practice": "Create practice sessions", "events.wizard_practice_sessions": "Practice session count", "events.wizard_use_qualifying": "Create qualifying rounds", "events.wizard_qualifying_rounds": "Qualifying rounds", "events.wizard_use_team_race": "Create endurance / Team Race session", "events.wizard_team_duration": "Team Race duration (min)", "events.wizard_finals_note": "Finals are generated later from qualifying standings via Race actions, not directly in the wizard.", "events.wizard_summary_title": "New race", "events.wizard_summary_sessions": "Sessions to create", "events.manage_step_setup": "1. Setup", "events.manage_step_setup_hint": "Participants and teams for this race.", "events.manage_step_format": "2. Format", "events.manage_step_format_hint": "Practice, qualifying, finals and validation.", "events.manage_step_generate": "3. Generation", "events.manage_step_generate_hint": "Generate qualifying/finals, reseed and bump-up.", "events.manage_step_live": "4. Live / results", "events.manage_step_live_hint": "Grid, standings, print and PDF.", "events.branding": "Branding for this event", "events.branding_note": "Leave fields empty to inherit global branding from Settings. The logo is used in overlay and embedded in PDF exports when it can be converted.", "events.brand_name": "Brand name", "events.brand_tagline": "Brand tagline", "events.brand_footer": "PDF footer", "events.brand_theme": "PDF theme", "events.brand_logo": "Event logo", "events.branding_use_global": "Use global default theme", "events.branding_save": "Save branding", "events.session_name": "Session name", "events.duration_placeholder": "Duration (min)", "events.max_cars_placeholder": "Max cars (optional)", "events.start_mode": "Start mode", "events.seed_best_laps": "Best laps for seeding", "events.seed_method": "Seed method", "events.seed_method_hint": "How best laps should be evaluated when seedBestLapCount is greater than 0.", "events.seed_method_best_sum": "Best N laps, total", "events.seed_method_average": "Best N laps, average", "events.seed_method_consecutive": "Best N consecutive laps", "events.stagger_gap_sec": "Stagger gap (sec)", "events.session_settings": "Session settings", "events.edit_session": "Settings", "events.start_mode_mass": "Mass start", "events.start_mode_position": "Position start", "events.start_mode_staggered": "Staggered / individual start", "events.seed_best_laps_hint": "0 = off, 2 or 3 for practice/qualifying seeding", "events.race_format": "Race format", "events.race_format_intro": "Set how qualifying and finals should be generated and scored for this race.", "events.setup_mode_basic": "Basic", "events.setup_mode_advanced": "Advanced", "events.practice_block": "Practice / qualifying", "events.practice_block_hint": "Build seeding, qualifying rounds and points/tie-break rules here.", "events.finals_block": "Finals", "events.finals_block_hint": "Control A/B/C finals, start mode, legs and bump-up here.", "events.rules_block": "Validation and finish logic", "events.rules_block_hint": "Min/max lap and follow-up affect both live timing and statistics.", "events.race_summary": "Summary", "events.race_summary_hint": "Quick check of what will actually be generated and raced.", "events.race_summary_preset": "Preset", "events.race_summary_participants": "Participants", "events.race_summary_created_sessions": "Created sessions", "events.race_summary_qualifying": "Qualifying setup", "events.race_summary_finals": "Finals setup", "events.race_summary_validation": "Lap window", "events.race_summary_follow_up": "Follow-up", "events.race_summary_focus": "Focus", "events.race_summary_generation": "Generation", "events.race_actions_title": "Race actions", "events.race_actions_hint": "Generation and reseeding live separately from the format fields so setup is easier to read.", "events.export_race_package": "Export race package", "events.import_race_package": "Import race package", "events.race_package_hint": "Export or import a single race with its sessions, results, and referenced directory data.", "events.context_standard_title": "Club race mode", "events.context_standard_hint": "Start with a preset and Basic mode. When participants and timings look right, use Race Actions for qualifying, reseeding and finals.", "events.context_endurance_title": "Endurance mode", "events.context_endurance_hint": "Basic mode hides most qualifying and finals fields here because endurance is normally built around Team Race. Open Advanced if the event also needs support heats or finals.", "events.context_rules_title": "Validation example", "events.context_rules_hint": "Example for a short technical track: 11s min / 60s max. Adjust to the real lap pace so stats and stints remain meaningful.", "events.status_complete": "Complete", "events.status_pending": "Pending", "events.status_attention": "Needs action", "events.summary_warnings_title": "Checkpoints", "events.summary_warning_no_participants": "No race participants are selected yet.", "events.summary_warning_no_sessions": "No sessions exist yet. Create them in the wizard or via Race Actions.", "events.summary_warning_no_qualifying": "No qualifying sessions exist yet.", "events.summary_warning_no_finals": "No finals exist yet.", "events.summary_warning_no_teams": "Endurance/Team Race has no teams yet. Add teams in Setup before running.", "events.summary_warning_invalid_lap_window": "The min/max lap window looks invalid. Max must be greater than min.", "events.detail_drivers": "drivers", "events.detail_teams": "teams", "events.detail_sessions": "sessions", "events.detail_results": "results", "events.detail_active": "active", "guide.manage_steps_6": "6. The status badges show whether each step is complete, pending, or needs action. The summary on the right also warns when participants, teams, or sessions are missing.", "guide.manage_steps_7": "7. Click a warning in the summary to jump straight to the matching Manage section instead of hunting for it manually.", "guide.manage_steps_8": "8. The step cards at the top also work as quick jumps. Click Setup, Format, Generation or Live / results to move straight to the right block.", "guide.race_wizard_title": "Create Race Wizard", "guide.race_wizard_1": "1. Start in Race Setup and use the wizard when creating a new race instead of building everything directly in Manage.", "guide.race_wizard_2": "2. Step 1 sets name, date, class and preset. The preset fills sensible defaults before you fine-tune anything.", "guide.race_wizard_3": "3. Step 2 selects exactly which drivers should be valid for this race. They then become the race-specific participant scope for timing and seeding.", "guide.race_wizard_4": "4. Step 3 chooses which sessions should be created immediately: practice, qualifying and/or team race depending on the preset.", "guide.race_wizard_5": "5. The wizard creates the initial sessions automatically. Finals are generated later from Race Actions after qualifying or practice is complete.", "guide.race_wizard_6": "6. After creation, continue in Manage for race format, generation, grid, standings and print/export.", "guide.race_wizard_7": "7. In Manage -> Race Actions you can export or import a single race package when you want to move just that race to another installation.", "guide.manage_steps_title": "Manage Race In Four Steps", "guide.manage_steps_1": "1. Setup: choose race participants and build any teams for team race or endurance.", "guide.manage_steps_2": "2. Format: adjust Race Format in Basic or Advanced mode. Practice/qualifying, finals and validation are split into separate blocks.", "guide.manage_steps_3": "3. Generation: create qualifying, reseed upcoming heats, generate finals and apply bump-ups from a separate Race Actions panel.", "guide.manage_steps_4": "4. Live / results: use the grid editor, standings, finals matrix, print and PDF after the competition structure is built.", "guide.manage_steps_5": "5. The summary card on the right acts as a quick sanity check before you start generating or running races.", "events.qualifying_scoring_hint": "Choose whether qualifying should rank by round points or by the single best result.", "events.qualifying_scoring": "Qualifying scoring", "events.qualifying_scoring_points": "Points per round", "events.qualifying_scoring_best": "Best round / rank", "events.qualifying_rounds": "Qualifying rounds", "events.qualifying_rounds_hint": "How many qualifying rounds should be generated in total.", "events.cars_per_heat": "Drivers per qualifying heat", "events.cars_per_heat_hint": "How many drivers go into each qualifying heat.", "events.qual_duration": "Qualifying duration (min)", "events.qual_duration_hint": "Length of each qualifying heat in minutes.", "events.qual_start_mode": "Qualifying start", "events.qual_start_mode_hint": "Mass, position or staggered for qualifying rounds.", "events.qual_seed_laps": "Qualifying best laps", "events.qual_seed_laps_hint": "Number of laps used for ranking in each qualifying heat when seed mode is active.", "events.qual_seed_method": "Qualifying seed method", "events.qual_seed_method_hint": "How qualifying heats evaluate laps when best-lap mode is used.", "events.counted_qual_rounds": "Counted qualifying rounds", "events.counted_qual_rounds_hint": "How many qualifying rounds count toward the final ranking.", "events.qual_points_table": "Points table", "events.qual_points_table_hint": "Choose how each qualifying round awards points when Qualifying scoring uses points.", "events.qual_points_rank": "Placement values (1,2,3...)", "events.qual_points_desc": "Descending by field size", "events.qual_points_ifmar": "10-9-8-7-6-5-4-3-2-1", "events.qual_tie_break": "Tie-break", "events.qual_tie_break_hint": "Choose which rule resolves equal results in qualifying standings.", "events.qual_tie_break_rounds": "Compare counted rounds", "events.qual_tie_break_best_lap": "Best single lap", "events.qual_tie_break_best_round": "Best round / heat result", "events.race_preset": "Preset", "events.race_preset_hint": "Quick start for track/class. Apply the preset and then fine tune manually.", "events.apply_preset": "Apply preset", "events.save_preset": "Save club preset", "events.delete_preset": "Delete club preset", "events.preset_name": "Preset name", "events.preset_custom": "Custom / current values", "events.preset_short_technical": "Short technical track 16s", "events.preset_club_qualifying": "Club race qual + finals", "events.preset_ifmar": "IFMAR-style qual/finals", "events.preset_endurance": "Endurance / team race", "events.tie_break_note": "Tie-break", "events.counted_rounds_label": "Counted rounds", "events.tie_break_won": "Won against", "events.tie_break_lost": "Lost against", "events.invalid_recent": "Latest hit invalid", "timing.invalid_manual": "Manually invalidated", "timing.invalidate_last_lap": "Invalidate last lap", "events.cars_per_final": "Drivers per final", "events.cars_per_final_hint": "Maximum number of drivers in each A/B/C final.", "events.final_legs": "Final heats per main", "events.final_legs_hint": "How many final legs should be generated per main.", "events.counted_final_legs": "Counted final heats", "events.counted_final_legs_hint": "How many final legs count in the combined final standings.", "events.final_duration": "Final duration (min)", "events.final_duration_hint": "Length of each final leg in minutes.", "events.final_start_mode": "Final start", "events.final_start_mode_hint": "Start mode for finals, often position start.", "events.bump_count": "Bump-up per main", "events.bump_count_hint": "How many drivers can move up from a lower final into the next main.", "events.save_race_format": "Save race format", "events.open_grid": "Grid", "events.grid_editor": "Grid editor", "events.grid_editor_hint": "Drag drivers up or down to change the manual start order for position start.", "events.grid_reset": "Reset from participant list", "events.grid_lock": "Lock grid", "events.grid_unlock": "Unlock grid", "events.grid_locked": "Manual grid is locked against auto-reseed.", "events.grid_unlocked": "Grid follows auto seed/reseed until you lock it.", "events.grid_empty": "No grid available to edit yet.", "events.print_heat_sheet": "Print heat sheet", "events.export_heat_sheet": "Export heat sheet", "events.pdf_heat_sheet": "PDF heat sheet", "events.free_practice_note": "Free Practice shows rolling lap times and is not used for seeding.", "events.open_practice_note": "Open Practice shows all incoming transponders live. If no driver matches, only the transponder is shown.", "events.generate_qualifying": "Generate qualifying from practice", "events.clear_generated_qualifying": "Clear generated qualifying", "events.generate_finals": "Generate finals from qualifying", "events.clear_generated_finals": "Clear generated finals", "events.apply_bumps": "Apply bump-ups", "events.practice_standings": "Practice standings", "events.qualifying_standings": "Qualifying standings", "events.final_standings": "Final standings", "events.generated_qualifying": "Qualifying heats generated from standings.", "events.finals_generated": "Finals generated from standings.", "events.bumps_applied": "Bump-ups applied to the next main.", "events.no_bumps_applied": "No bump-ups could be applied yet.", "events.no_practice_results": "No practice results yet.", "events.no_qualifying_results": "No qualifying results yet.", "events.no_final_results": "No final results yet.", "events.final_matrix": "Final matrix", "events.print_startlists": "Print start lists", "events.print_results": "Print results", "events.pdf_startlists": "PDF start lists", "events.pdf_results": "PDF results", "events.reserved_slot": "Reserved bump slot", "events.position_grid": "Grid / start order", "events.start_lists": "Start lists", "events.no_final_matrix": "No finals generated yet.", "events.results_overview": "Results overview", "events.main": "Main", "events.slot": "Slot", "events.leg_status": "Leg status", "events.source_for_finals": "Source for finals", "events.finals_from_qualifying": "Qualifying standings", "events.finals_from_practice": "Practice standings", "events.finals_source_hint": "Choose whether finals should be seeded from practice or qualifying.", "events.follow_up_sec": "Follow-up time (sec)", "events.follow_up_sec_hint": "Extra time after race duration so the last cars can finish before the session closes.", "events.min_lap_time": "Min lap time (sec)", "events.min_lap_time_hint": "Laps faster than this are ignored as shortcuts or false hits.", "events.max_lap_time": "Max lap time (sec)", "events.max_lap_time_hint": "Laps slower than this are not counted as valid laps and reset the lap base for the next lap.", "events.race_driver_scope": "Race mode uses all drivers in the event class unless a session has its own participant list.", "events.reserve_bump_slots_hint": "Reserve empty slots in higher finals so bumped drivers can be inserted without overwriting seeded spots.", "events.team_race": "Team Race", "events.team_race_intro": "Create endurance teams. All passings from the team's drivers or cars are added to the team's total laps in Team Race sessions.", "events.team_name": "Team name", "events.add_team": "Add team", "events.teams": "Teams", "events.team_drivers": "Team drivers", "events.team_cars": "Team cars", "events.team_hint": "Select at least one driver or car per team. Team Race sessions sum the team's total laps across the whole race duration, for example 4 hours.", "events.team_steps": "1. Enter the team name. 2. Tick drivers and/or cars below. 3. Click Add team. 4. Use Edit team for later changes.", "events.team_form_drivers": "Select team drivers before saving the team.", "events.team_form_cars": "Select team cars before saving the team.", "events.team_driver_fallback": "No drivers matched the race class or participant list. Showing all drivers as fallback.", "events.no_teams": "No teams created yet.", "events.team_standings": "Team standings", "events.no_team_results": "No team results yet.", "events.edit_team": "Edit team", "events.team_stint_log": "Stint and driver-change log", "events.team_report": "Team report", "events.print_team_results": "Print team report", "events.pdf_team_results": "PDF team report", "events.add_session": "Add Session", "events.session_type_label": "Session type", "events.session_type_hint_track": "Choose which part of the sponsor event you are creating: practice, qualifying, heat or final. The event itself is already a sponsor event.", "events.session_type_hint_race": "Choose which race session you are creating: practice, qualifying, heat, final or team race.", "events.session_duration_label": "Duration (min)", "events.session_duration_hint": "Main running time for the session in minutes.", "events.session_followup_label": "Follow-up (sec)", "events.session_followup_hint": "Extra time after the main timer so the last cars can finish.", "events.session_start_mode_label": "Start mode", "events.session_start_mode_hint": "Mass start, position start or individual staggered start.", "events.session_seed_laps_label": "Seed best laps", "events.session_seed_laps_hint": "0 = off. Use 2 or 3 for practice/qualifying seeding.", "events.session_seed_method_label": "Seed method", "events.session_seed_method_hint": "How to count the best laps: sum, average or consecutive laps.", "events.session_stagger_gap_label": "Stagger gap (sec)", "events.session_stagger_gap_hint": "Only used for staggered starts. 0 = no extra gap.", "events.session_max_cars_label": "Max cars (optional)", "events.session_max_cars_hint": "Set a limit if this session should only allow a certain number of cars.", "events.edit_session_help": "Edit the session name, type, timing and seed/start settings here.", "events.set_active": "Set Active", "events.assignments": "Assignments", "events.na": "n/a", "events.sponsor_tools": "Sponsor Event Tools", "events.qual_rounds": "Qualification rounds", "events.heat_rounds": "Heat rounds", "events.final_rounds": "Final rounds", "events.round_duration": "Round duration (min)", "events.create_rounds": "Create Rounds", "events.tp_rule": "Same transponder can be reused across sessions (Heat 1 -> Heat 2 -> Final 1). In a running session, each active car must have a unique transponder.", "events.assign_title": "Track Assignments (Driver -> Car)", "events.assign": "Assign", "events.auto_assign": "Auto Assign Selected Session", "events.clear_assign": "Clear Selected Session", "events.no_assignments": "No assignments", "events.duplicate_car": "That car is already assigned in this session.", "events.duplicate_driver": "That driver is already assigned in this session.", "events.duplicate_tp": "Duplicate transponder in same session is not allowed. Reuse in next heat/final.", "timing.decoder_connection": "Decoder Connection", "timing.connect": "Connect Decoder", "timing.disconnect": "Disconnect", "timing.simulate": "Simulate Passing", "timing.status": "Status", "timing.connected": "Connected", "timing.disconnected": "Disconnected", "timing.last_message": "Last message", "timing.main_decoder": "Main start/finish", "timing.split_decoder": "Split timing", "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.", "timing.select_session": "Select session", "timing.set_active": "Set Active", "timing.start": "Start", "timing.stop": "Stop", "timing.reset": "Reset Data", "timing.total_passings": "Total passings", "timing.started": "Started", "timing.remaining": "Countdown", "timing.elapsed": "Elapsed", "timing.race_finished": "Race is finished", "timing.race_started_voice": "Race started", "timing.race_finished_voice": "Race finished", "timing.practice_started_voice": "Practice started", "timing.practice_finished_voice": "Practice finished", "timing.follow_up": "Follow-up", "timing.follow_up_active": "Follow-up active", "timing.no_active": "No active session selected.", "timing.leaderboard": "Live Leaderboard", "timing.recent_passings": "Recent Passings", "timing.no_laps": "No laps yet.", "timing.no_session_selected": "No session selected.", "timing.no_passings": "No passings recorded.", "timing.details": "Details", "timing.add_driver": "Add driver", "timing.add_car": "Add car", "timing.quick_add_hint": "Quick-register transponder", "timing.quick_add_title": "Quick add", "timing.quick_add_driver_title": "Add driver from transponder", "timing.quick_add_car_title": "Add car from transponder", "timing.open_overlay": "Open overlay", "timing.open_speaker_overlay": "Speaker overlay", "timing.open_results_overlay": "Results overlay", "timing.open_tv_overlay": "TV overlay", "timing.open_team_overlay": "Team overlay", "timing.open_obs_overlay": "OBS overlay", "timing.close_details": "Close", "timing.detail_title": "Leaderboard details", "timing.lap_history": "Lap history", "timing.no_lap_history": "No laps to show.", "timing.manual_corrections": "Manual corrections", "timing.lap_adjustment": "Lap adjustment", "timing.time_penalty": "Time penalty", "timing.penalty_add_lap": "+1 lap", "timing.penalty_remove_lap": "-1 lap", "timing.penalty_add_sec": "+1 sec", "timing.penalty_add_5sec": "+5 sec", "timing.penalty_remove_sec": "-1 sec", "timing.penalty_reset": "Reset correction", "timing.restore_last_invalid": "Restore latest manually invalidated lap", "timing.no_manual_invalid": "No manually invalidated lap was found.", "timing.valid_passing": "Valid lap", "timing.invalid_short": "Short lap", "timing.invalid_long": "Over max lap", "judging.title": "Judging view", "judging.active_session": "Active session", "judging.no_active_session": "No active session selected.", "judging.select_competitor": "Select driver or team", "judging.manual_actions": "Manual actions", "judging.action_log": "Judging log", "judging.no_action_log": "No manual actions registered yet.", "judging.selected_none": "No row selected.", "judging.restore_done": "The latest manually invalidated lap was restored.", "judging.filter_competitors": "Filter rows", "judging.filter_log": "Filter log", "judging.filter_all": "All", "judging.filter_invalid": "Invalid", "judging.filter_corrected": "Corrected", "judging.filter_team": "Team race", "judging.filter_log_corrections": "Corrections", "judging.filter_log_invalidations": "Invalidate/restore", "judging.filter_log_undo": "Undo", "judging.export_log": "Export judging log", "judging.undo_last": "Undo latest", "judging.undo_action": "Undo", "judging.undo_done": "The latest manual action was undone.", "judging.no_undo": "No action to undo.", "timing.total_time": "Total time", "timing.clear_confirm": "Clear all timing data for this session?", "timing.prompt_transponder": "Transponder", "timing.first_crossing_start": "First crossing set personal start", "timing.seeding_mode": "Seeding", "timing.position_grid_hint": "The grid shows the start order for position start in the active session.", "settings.decoder": "Decoder", "settings.main_ws_url": "Main WebSocket URL", "settings.enable_split_decoder": "Enable split decoder", "settings.split_ws_url": "Split WebSocket URL", "settings.enable_pit_decoders": "Enable pit decoders", "settings.decoder_roles_hint": "Client WebSocket URLs must use the backend host IP, not a physical decoder IP. Main counts laps; split and pit channels never do.", "settings.managed_channels_hint": "Decoder IP / host fields below are physical decoder addresses. The backend starts one AMMC process per role on unique ports: main 9000, split 9001, pit entry 9002, and pit exit 9003.", "settings.pit_entry_ws_url": "Pit entry WebSocket URL", "settings.pit_exit_ws_url": "Pit exit WebSocket URL", "settings.split_decoder_host": "Split decoder IP / host", "settings.split_ws_port": "Split WebSocket port", "settings.pit_entry_decoder_host": "Pit entry decoder IP / host", "settings.pit_entry_ws_port": "Pit entry WebSocket port", "settings.pit_exit_decoder_host": "Pit exit decoder IP / host", "settings.pit_exit_ws_port": "Pit exit WebSocket port", "settings.auto_reconnect": "Auto reconnect", "settings.save": "Save", "settings.connect_now": "Connect Now", "settings.expected_json": "Expected AMMC JSON format", "settings.managed_ammc": "Managed AMMC", "settings.managed_ammc_sub": "Start local AMMC from the backend on this machine.", "settings.enable_managed": "Enable managed AMMC", "settings.auto_start_ammc": "Auto-start AMMC when backend starts", "settings.decoder_host": "Decoder IP / host", "settings.ws_port": "AMMC WebSocket port", "settings.executable_path": "AMMC executable", "settings.working_dir": "Working directory (optional)", "settings.extra_args": "Extra arguments (optional)", "settings.save_ammc": "Save AMMC", "settings.start_ammc": "Start AMMC", "settings.stop_ammc": "Stop AMMC", "settings.refresh_ammc": "Refresh status", "settings.ammc_status": "AMMC status", "settings.running": "Running", "settings.stopped": "Stopped", "settings.server_platform": "Server OS", "settings.pid": "PID", "settings.started_at": "Started at", "settings.stopped_at": "Stopped at", "settings.last_error": "Last error", "settings.output": "Recent AMMC log", "settings.executable_found": "Executable found", "settings.executable_missing": "Executable missing", "settings.bundled_hint": "The bundled app path is used automatically when present.", "settings.use_server_ws": "Use server WS URL", "settings.audio": "Audio", "settings.audio_enabled": "Enable browser audio", "settings.speaker_passing_cue": "Speaker cue on passing", "settings.speaker_leader_cue": "Speaker cue on new leader", "settings.speaker_finish_cue": "Speaker cue on finish", "settings.speaker_bestlap_cue": "Speaker cue on new best lap", "settings.speaker_top3_cue": "Speaker cue on top 3 change", "settings.speaker_start_cue": "Speaker cue on session start", "settings.passing_sound": "Passing sound", "settings.passing_sound_off": "Off", "settings.passing_sound_beep": "Beep", "settings.passing_sound_name": "Speak driver name", "settings.finish_voice": "Announce start and finish", "settings.start_sound": "Start signal", "settings.finish_sound": "Finish signal", "settings.speech_voice": "Speech voice", "settings.system_default_voice": "Automatic voice for selected language", "settings.speech_rate": "Speech rate", "settings.speech_pitch": "Speech pitch", "settings.test_start": "Test start", "settings.test_finish": "Test finish", "settings.test_driver_voice": "Test driver name", "settings.driver_voice_sample": "Test driver", "settings.test_audio": "Test audio", "settings.audio_note": "Browsers usually require a click first before sound/speech is allowed.", "settings.voice_note": "Voices are provided by the operating system. Select a matching local voice and set a separate spoken name under Drivers when pronunciation needs help.", "settings.branding": "Club Info / PDF", "settings.club_name": "Club name", "settings.club_tagline": "Club tagline", "settings.pdf_footer": "PDF footer", "settings.pdf_theme": "PDF theme", "settings.pdf_theme_classic": "Classic", "settings.pdf_theme_minimal": "Minimal", "settings.pdf_theme_motorsport": "Motorsport", "settings.logo": "Logo / overlay", "settings.logo_upload": "Upload logo", "settings.logo_clear": "Clear logo", "settings.logo_note": "The logo is shown in overlay. PDF export attempts to embed the logo automatically via the backend.", "settings.storage": "Storage", "settings.race_presets": "Club presets", "settings.race_presets_note": "Export or import local club presets between installations.", "settings.export_presets": "Export presets", "settings.import_presets": "Import presets", "settings.backend_url": "Backend URL", "settings.backend_status": "Backend status", "settings.online": "Online", "settings.offline": "Offline", "settings.last_sync": "Last sync", "settings.test_backend": "Test Backend", "settings.sync_now": "Sync Now", "settings.export_json": "Export JSON", "settings.export_all_data": "Export all race data", "settings.import_all_data": "Import all race data", "settings.export_directory": "Export drivers/classes/cars", "settings.import_directory": "Import drivers/classes/cars", "settings.export_drivers_csv": "Export drivers CSV", "settings.export_cars_csv": "Export cars CSV", "settings.storage_note_full": "Full data package contains classes, drivers, cars, races/events, sessions and results.", "settings.storage_note_directory": "Directory package only contains classes, drivers and cars and merges into existing data.", "settings.storage_note_csv": "CSV export is the quickest way to open the directory in Excel or LibreOffice.", "settings.import_full_success": "Race data imported and replaced the current competition data.", "settings.import_directory_success": "Classes, drivers and cars were imported into the existing data.", "settings.import_failed": "Import failed: {msg}", "table.name": "Name", "table.brand": "Brand", "table.class": "Class", "table.transponder": "Transponder", "table.delete": "Delete", "table.car": "Car", "table.date": "Date", "table.mode": "Mode", "table.start_mode": "Start", "table.seeding": "Seeding", "table.score": "Score", "table.session": "Session", "table.type": "Type", "table.duration": "Duration", "table.status": "Status", "table.time": "Time", "table.driver": "Driver", "table.loop": "Loop", "table.strength": "Strength", "table.pos": "Pos", "table.laps": "Laps", "table.last_lap": "Last Lap", "table.best_lap": "Best Lap", "table.split": "Split", "table.pit": "Pit", "table.gap": "Gap", "table.event": "Event", "table.result": "Result", "table.lap": "Lap", "table.leader_gap": "Leader gap", "table.ahead_gap": "Gap ahead", "table.own_delta": "Own delta", "common.delete": "Delete", "common.cancel": "Cancel", "common.save": "Save", "common.edit": "Edit", "common.unknown_driver": "Unknown Driver", "common.unknown_car": "Unknown Car", "common.unknown": "Unknown", "common.unassigned_driver": "Unassigned Driver", "common.driver_car": "Driver Car", "common.unknown_event": "Unknown Event", "common.no_rows": "No rows", "common.no_entries": "No entries.", "common.previous": "Back", "common.next": "Next", "common.reset": "Reset", "status.ready": "ready", "status.running": "running", "status.finished": "finished", "status.leader": "LEADER", "status.seeded": "SEED", "status.free_practice": "FREE", "status.open_practice": "OPEN", "session.open_practice": "open practice", "session.free_practice": "free practice", "session.practice": "practice", "session.qualification": "qualification", "session.heat": "heat", "session.final": "final", "session.team_race": "team race", "validation.no_assignments": "No driver/car assignments in this session.", "validation.missing_tp": "One or more assigned cars are missing transponder ID.", "validation.duplicate_tp": "Duplicate transponder(s) in session: {ids}.", "validation.invalid_date": "Date must be in YYYY-MM-DD format.", "validation.invalid_selection": "Select a valid option.", "validation.required_name": "Name is required.", "validation.required_transponder": "Transponder is required.", "validation.required_date": "Date is required.", "validation.required_duration": "Duration must be at least 1 minute.", "edit.class_name": "Edit class name", "edit.driver_name": "Edit driver name", "edit.driver_class": "Edit driver class", "edit.new_driver_name": "New driver name", "edit.driver_transponder": "Edit personal transponder (can be empty)", "edit.car_name": "Edit car name", "edit.new_car_name": "New car name", "edit.car_transponder": "Edit car transponder", "edit.event_name": "Edit event name", "edit.event_date": "Edit event date (YYYY-MM-DD)", "guide.title": "Guide and Documentation", "guide.intro": "Step-by-step setup for sponsor events, regular race mode, training statistics and the Create Race Wizard in Race Setup. The guide also explains how the Manage flow is split up and where Managed AMMC actually runs.", "guide.card_sponsor_blurb": "Shared cars, rotating drivers and heat/final flow for sponsor or try-out events.", "guide.card_race_blurb": "Personal transponders, qualifying/finals, race format and seeding for competition racing.", "guide.card_team_blurb": "Team race and endurance with teams, stint logs and total laps over long sessions.", "guide.card_training_blurb": "Driver development, durable lap history, practice goals and rewards in a mobile-friendly view.", "guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux and local SQLite storage.", "guide.audio_title": "Start, finish, and driver audio", "guide.audio_1": "1. Open Settings -> Audio and enable browser audio, the start signal, finish signal, and voice announcements as required.", "guide.audio_2": "2. Select a matching system voice and preview it with Test start, Test finish, and Test driver name. The browser normally requires a click before audio is allowed.", "guide.audio_3": "3. Select Beep or Speak driver name for passings. If speech is already busy, a short beep is used instead so announcements do not overlap.", "guide.audio_4": "4. If a name is pronounced incorrectly, open Drivers and enter a phonetic Spoken name. The displayed driver name does not change.", "guide.audio_5": "5. Audio plays in the browser on the computer running Timing. Check that computer's audio output and keep the tab open during the session.", "guide.sponsor_title": "Create Sponsor Event: 10 drivers, 4 cars", "guide.sponsor_1": "1. Add 4 cars in Cars with unique transponder IDs. You can also store brand/model in the brand field.", "guide.sponsor_2": "2. Add 10 drivers in Drivers. You can also store team/brand in the brand field and filter by it later.", "guide.sponsor_3": "3. Create event in Track Event mode.", "guide.sponsor_4": "4. Click Manage and create rounds (qualification/heat/final).", "guide.sponsor_5": "5. Assign 4 drivers to 4 cars in Heat 1, rotate drivers for Heat 2/3, then finals.", "guide.sponsor_6": "6. In Timing: select session, Set Active, Start, Stop.", "guide.race_title": "Create regular race (driver transponders)", "guide.race_1": "1. Add drivers with personal transponder IDs. The brand field can be used for team, sponsor or car brand.", "guide.race_2": "2. Create event in Race mode.", "guide.race_3": "3. Click Manage on the race and select exactly which drivers should be valid for this race.", "guide.race_4": "4. Go through Race Format and choose qualifying rounds, drivers per heat, times, start mode and how finals should be seeded.", "guide.race_4a": "4a. Start in Basic mode with a preset. Open Advanced only when you need tie-breaks, custom points, reserved bump slots or fine-grained seeding.", "guide.race_5": "5. Run practice if you want to use practice as the first seeding step.", "guide.race_6": "6. Click Generate qualifying from practice to build qualifying heats from practice standings or the class list.", "guide.race_7": "7. Run the qualifying rounds. If you want to reshuffle upcoming heats from current standings, click Reseed upcoming qualifying.", "guide.race_8": "8. Click Generate finals from qualifying when qualifying is done.", "guide.race_9": "9. Run the finals. If bump-up is used, click Apply bump-ups between mains after a lower final is completed.", "guide.race_10": "10. Use Final Matrix and Print start lists/results to verify and share the setup.", "guide.race_format_title": "Race Format Explained", "guide.race_format_0": "Race Format is split into blocks for Practice/Qualifying, Finals and Validation. A summary card on the right shows what will be generated before you create heats and finals.", "guide.race_format_1": "Qualifying scoring decides whether ranking is built from round points or the single best qualifying result.", "guide.race_format_2": "Qualifying rounds and Drivers per qualifying heat control how many heats are created and how they are filled.", "guide.race_format_3": "Qualifying duration, Qualifying start and Counted qualifying rounds control how qualifying is run and which rounds count.", "guide.race_format_4": "Drivers per final, Final heats per main and Counted final heats control how A/B/C finals are built and scored.", "guide.race_format_5": "Final duration and Final start control each final leg, often with position start.", "guide.race_format_6": "Bump-up per main and Reserve bump slots are used if drivers should move from a lower final into the next main.", "guide.race_format_7": "Source for finals decides whether finals are seeded from practice or qualifying standings.", "guide.race_format_8": "Follow-up time adds an extra collection period after the scheduled race time before the heat is actually closed.", "guide.race_format_9": "Min lap time filters out shortcuts and false hits. Example: on a 16-second track you can set 11 seconds as the minimum.", "guide.race_format_10": "Max lap time stops long false laps from counting and is also used to split stints and improve driver statistics. Example: 60 seconds.", "guide.race_format_11": "Preset lets you quickly fill the race format with sensible defaults for a short technical track, club race, IFMAR-like setup or endurance.", "guide.race_format_11a": "In Basic mode, the endurance preset hides most qualifying and finals fields so the UI stays focused on Team Race. Open Advanced if the event also needs support heats or normal finals.", "guide.race_format_12": "You can apply a preset and then adjust individual fields manually before saving the race format.", "guide.race_format_13": "Save club preset stores your own local race formats so you can reuse them on the same installation without rebuilding everything each time.", "guide.race_format_14": "Club presets can also be exported and imported from Settings if you want to move them between different servers or laptops.", "guide.free_practice_title": "Free Practice", "guide.free_practice_1": "Use the free practice session type when you only want to show live lap times.", "guide.free_practice_2": "Free Practice does not affect seeding for qualifying or finals.", "guide.free_practice_3": "The leaderboard shows laps, last lap, best lap, gap to the car ahead and your own delta versus the previous lap.", "guide.open_practice_title": "Open Practice", "guide.open_practice_1": "Use Open Practice when you want the system to simply list every transponder that comes in.", "guide.open_practice_2": "If the transponder does not match a registered driver, the transponder number is shown as the name.", "guide.open_practice_3": "Open Practice does not affect seeding, qualifying or finals.", "guide.open_practice_4": "Go to Race Setup and use Quick Open Practice. Only a name is required; class, participants, preset and race duration are not needed.", "guide.open_practice_5": "Timing opens directly after creation. Click Start and let the session run until you stop it yourself.", "guide.team_title": "Team Race / Endurance", "guide.team_1": "Go to Race Setup and create a race in the correct class.", "guide.team_2": "Open Manage and go to the Teams section.", "guide.team_3": "Enter the team name and tick drivers and/or cars in the same team block before you click Add team.", "guide.team_4": "After the team is created, click Edit team to change drivers or cars.", "guide.team_5": "Create a session with type Team Race and set the time, for example 240 minutes for 4 hours.", "guide.team_6": "Start the session in Timing. All passings from the team's members are added to the team's total laps.", "guide.validation_title": "Invalid laps, follow-up and manual corrections", "guide.validation_1": "Recent Passings now shows both valid and invalid laps. Short laps are marked as Short lap and long laps as Over max lap.", "guide.validation_2": "Invalid short laps under the minimum threshold do not count in the leaderboard or statistics.", "guide.validation_3": "Invalid long laps over the maximum threshold do not count as laps, but they can reset the lap base so the next valid lap starts correctly.", "guide.validation_4": "When the scheduled time ends, the session can enter Follow-up active if Follow-up time has been configured in race format or on the session.", "guide.validation_5": "In Timing -> Details you can apply +1/-1 lap and +1/+5/-1 seconds as manual corrections. The leaderboard updates immediately.", "guide.validation_6": "In the same detail view you can also manually invalidate the latest counted lap if you need to remove a false hit afterwards.", "guide.validation_7": "The Judging menu collects the same corrections in a separate work view with leaderboard, lap history and a judging log for the current session.", "guide.validation_8": "The Judging view can filter invalid rows, corrected rows or team race rows, export the judging log and undo multiple recent manual actions via undo buttons.", "guide.qualifying_title": "Seeding, points tables and tie-break", "guide.qualifying_1": "Practice and qualifying can now use three seed methods: best N laps as total, best N laps as average or best N consecutive laps.", "guide.qualifying_2": "Race format controls both Qualifying seed laps and Qualifying seed method when new qualifying heats are generated from practice or the participant list.", "guide.qualifying_3": "Qualifying scoring can combine points mode with a points table: placement values, descending by field size or IFMAR 10-9-8-7-6-5-4-3-2-1.", "guide.qualifying_4": "Qualifying tie-break can now be resolved by counted rounds, best single lap or best round / heat result.", "guide.qualifying_5": "The leaderboard now shows seeded results in the correct format, for example 3/00:48.321, 3 avg 16.107 or 3 con 00:49.005.", "guide.dashboard_title": "Schedule drift on overview", "guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.", "guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.", "guide.dashboard_3": "That makes it easier to see whether the race day is running ahead or behind schedule directly from the dashboard.", "guide.dashboard_4": "The Getting started cards and grouped side navigation follow the same order: connect the system, register entries when needed, create a run, then open Timing/Overlay.", "guide.training_title": "Practice, development and rewards", "guide.training_1": "1. Register the driver and transponder under Drivers. Club and country are optional profile fields. If the person races several classes, select the same Practice profile on every driver entry.", "guide.training_2": "2. Run Free Practice, Open Practice or a regular race session. Valid laps are copied automatically to a durable training ledger; invalid short or long laps do not count.", "guide.training_3": "3. Open Training and select a driver. The default view only shows total laps, practice days, personal best, consistency, the next target and today's session. Open More statistics or Club practice overview only when needed.", "guide.training_4": "4. The next automatic level needs no configuration. Add custom milestones when the club or a parent wants to attach a physical reward to targets such as 500, 1000 or 1500 laps.", "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. Its local 20-minute timer changes from 10 minutes maximum focus to 10 minutes controlled driving and shows the driver's laps, best lap and average during that personal session.", "guide.training_9": "9. Click Create new link or Disable profile if the link is shared incorrectly. Expose only `/public-training/*`, `/src/public_training.js` and `/src/public_training.css` externally, never the full admin site or `/api/state`.", "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 starts an individual 20-minute timer on the phone without restarting or changing the shared practice session.", "guide.training_12": "12. A gap longer than 2 minutes between passings automatically starts a new stint. A long return lap defines the boundary but is excluded from stint performance metrics.", "guide.training_13": "13. Best 5 min uses 300 seconds. A final lap counts when it started before 5:00 even if it finishes later, for example 17/05:14.12:123. Average pace is calculated from the laps in that exact result.", "guide.training_14": "14. One personal profile and mobile link can contain several classes, but 2WD/4WD statistics, stints, lap times, and recommendations are always shown separately for the selected class.", "overlay.title": "Overlay", "overlay.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", "overlay.last_passings": "Recent passings", "overlay.window_title": "JMK RB RaceController Overlay", "overlay.mode_leaderboard": "Leaderboard", "overlay.mode_speaker": "Speaker", "overlay.mode_results": "Results", "overlay.mode_tv": "TV", "overlay.mode_team": "Team", "overlay.mode_obs": "OBS", "overlay.fastest_lap": "Fastest Lap", "overlay.fullscreen": "Fullscreen", "overlay.leaderboard_live": "Live leaderboard", "overlay.rotating_panel": "Display panel", "overlay.next_predicted_lap": "Next lap", "overlay.event_markers": "Event markers", "overlay.obs_config": "OBS config", "overlay.obs_public_hint": "Build a minimal public overlay URL for OBS or an external website.", "overlay.obs_rows": "Rows", "overlay.obs_show_clock": "Show race clock", "overlay.obs_show_fastest": "Show fastest lap", "overlay.obs_show_grid": "Show start grid when the race is ready", "overlay.obs_show_laps": "Show laps", "overlay.obs_show_result": "Show result/time", "overlay.obs_show_best": "Show best lap", "overlay.obs_show_gap": "Show gap", "overlay.obs_copy_url": "Copy OBS URL", "overlay.obs_layout": "OBS layout", "overlay.obs_theme": "OBS theme", "overlay.obs_public_token": "Public token", "overlay.obs_public_token_hint": "Optional client token appended to the public overlay URL.", "overlay.obs_layout_leaderboard": "Leaderboard", "overlay.obs_layout_grid": "Start grid", "overlay.obs_layout_lowerthird": "Lower third", "overlay.obs_theme_panel": "Panel", "overlay.obs_theme_transparent": "Transparent", "overlay.obs_theme_chroma": "Chroma", "overlay.team_battle": "Team battle", "overlay.active_member": "Active driver/car", "overlay.top_three": "Top 3", "guide.host_title": "How Managed AMMC Runs", "guide.decoder_roles_title": "Decoder roles, IP addresses, and ports", "guide.decoder_roles_1": "1. Main start/finish is required and is the only channel allowed to count laps. Example: decoder `192.168.1.10` -> AMMC port `9000` -> `ws://server-ip:9000`.", "guide.decoder_roles_2": "2. Split timing is optional and records time from the latest main crossing without incrementing laps. Example: `192.168.1.11` -> `9001`.", "guide.decoder_roles_3": "3. Pit timing needs two loops: Pit entry opens the stop and Pit exit closes it. Example: `192.168.1.12` -> `9002`, and `192.168.1.13` -> `9003`.", "guide.decoder_roles_4": "4. Decoder IP is the physical decoder address. The WebSocket port belongs to the AMMC process on the backend host. Do not confuse these two addresses.", "guide.decoder_roles_5": "5. Every physical decoder needs a unique IP and every enabled AMMC process needs a unique port. Main/split/pit must never share a WebSocket port.", "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 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.decoder_roles_11": "11. Choose a layout: 1 decoder = Main; 2 = Main + Split; 3 = Main + Pit entry/exit; 4 = every role. Pit timing always needs both entry and exit.", "guide.decoder_roles_12": "12. Example: backend 192.168.1.100, main decoder .10, split .11, pit entry .12, and pit exit .13. Decoder host fields receive .10-.13; client WS URLs always use backend .100 with ports 9000-9003.", "guide.decoder_roles_13": "13. Start order: ping decoders from backend, start backend, start Managed AMMC, inspect the AMMC log, use server WS URLs, then connect in Timing.", "guide.decoder_roles_14": "14. Functional test: Main increments laps, Split does not, Pit entry shows IN PIT, and Pit exit creates a timed stop. See docs/decoder-setup.md for the complete guide.", "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`.", "guide.host_4": "4. Run the backend on Windows -> the Windows binary is used: `AMMC/windows64/ammc-amb.exe`.", "guide.host_5": "5. The `AMMC executable` field in Settings is a path on the backend host, not on the client laptop.", "guide.host_6": "6. Prefer publishing only `/public-overlay/*` and selected `/public-training/*` routes through a reverse proxy, not the full admin UI or `/api/state`.", "guide.host_7": "7. The OBS overlay is available at `/public-overlay/obs` and can be configured from the Overlay menu.", "guide.host_8": "8. Set `PUBLIC_OVERLAY_TOKEN` on the server if public overlay links should require `?token=...`.", "guide.windows_title": "Windows + AMMC + npm", "guide.windows_1": "1. Use 64-bit Windows 11. Double-click `windows/install.bat`; it installs Node.js LTS with winget when needed, runs `npm.cmd ci`, and restarts the backend.", "guide.windows_2": "2. Setup requires Node 22 or 24 LTS and automatically verifies Express, SQLite, and `AMMC/windows64`.", "guide.windows_3": "3. `windows/install.bat` runs `npm.cmd restart` automatically. Otherwise start with `npm.cmd start` or `windows/start_backend.bat`, check with `npm.cmd run status`, and restart with `npm.cmd restart`.", "guide.windows_4": "4. In Settings, `Decoder IP / host` is the decoder address, for example `192.168.1.10`. Managed AMMC runs on the Windows host.", "guide.windows_5": "5. Other devices use `http://:8081` and `ws://:9000`. See `windows/README.md` for firewall and troubleshooting.", "guide.linux_title": "Linux + npm", "guide.linux_1": "1. Install Node 22 LTS, build-essential and python3 on the Linux host.", "guide.linux_2": "2. Default Managed AMMC binary on a Linux host: `AMMC/linux_x86-64/ammc-amb`.", "guide.linux_3": "3. Run `npm install`, `npm start`. Server listens on 0.0.0.0:8081. Open firewall if needed: `sudo ufw allow 8081/tcp` and use `ws://:9000` in the client.", "guide.sqlite_title": "SQLite storage", "guide.sqlite_1": "Database file: data/rc_timing.sqlite", "guide.sqlite_2": "API endpoints: /api/state and /api/passings", "guide.sqlite_3": "In Settings -> Storage you can export a full race package for backup or moving to another server or laptop.", "guide.sqlite_4": "You can also export or import only classes, drivers and cars if you want to reset race data but keep the directory.", "guide.sqlite_5": "Export drivers and cars as CSV if you want to open the directory quickly in Excel or print a simple list.", "guide.ammc_ref": "AMMC reference: https://www.ammconverter.eu/docs/intro/quick-start/", "error.backend_offline": "Backend offline: {msg}", "error.sync_failed": "Sync failed: {msg}", "error.health_failed": "Health check failed: {msg}", "error.ws_invalid": "Invalid WebSocket URL: {msg}", "error.decoder_connection": "Decoder connection error.", "error.passing_save_failed": "Passing save failed: {msg}", "error.ammc_load_failed": "Could not load AMMC status: {msg}", "error.ammc_save_failed": "Could not save AMMC config: {msg}", "error.ammc_start_failed": "Could not start AMMC: {msg}", "error.ammc_stop_failed": "Could not stop AMMC: {msg}", "error.print_blocked": "Popup blocked. Allow popups to print.", "error.pdf_export_failed": "PDF export failed: {msg}" } }; const urlParams = new URLSearchParams(window.location.search); const allowedOverlayModes = ["leaderboard", "speaker", "results", "tv", "team", "obs"]; const publicOverlayMatch = window.location.pathname.match(/^\/public-overlay(?:\/([a-z0-9_-]+))?\/?$/i); const routeOverlayMode = String(publicOverlayMatch?.[1] || "").toLowerCase(); const queryOverlayMode = String(urlParams.get("overlayMode") || "").toLowerCase(); const overlayMode = urlParams.get("view") === "overlay" || Boolean(publicOverlayMatch); const overlayViewMode = allowedOverlayModes.includes(routeOverlayMode) ? routeOverlayMode : allowedOverlayModes.includes(queryOverlayMode) ? queryOverlayMode : "leaderboard"; const publicOverlayMode = Boolean(publicOverlayMatch); const state = loadState(); const requestedView = String(urlParams.get("view") || "").toLowerCase(); let currentView = overlayMode ? "overlay" : NAV_ITEMS.some((item) => item.id === requestedView) ? requestedView : "dashboard"; let wsClients = { main: null, split: null, pit_entry: null, pit_exit: null }; let reconnectTimers = { main: null, split: null, pit_entry: null, pit_exit: null }; let backendSyncTimer = null; let appVersionPollTimer = null; let baselineAppVersion = ""; let pendingAppVersion = ""; let localStateVersion = 0; let syncedStateVersion = 0; let localStateDirty = false; let selectedClassEditId = null; let selectedLeaderboardKey = null; let selectedGridSessionId = null; let selectedDriverEditId = null; let selectedCarEditId = null; let selectedEventEditId = null; let selectedSessionEditId = null; let selectedTeamEditId = null; let selectedJudgeKey = null; let selectedTrainingDriverId = String(urlParams.get("driver") || ""); let selectedTrainingClassId = String(urlParams.get("class") || ""); let judgingCompetitorFilter = "all"; let judgingLogFilter = "all"; let quickAddDraft = null; let driverBrandFilter = ""; let carBrandFilter = ""; let raceFormatAdvanced = false; function getObsLayouts() { return ["leaderboard", "grid", "lowerthird"]; } function getObsThemes() { return ["panel", "transparent", "chroma"]; } const OBS_LAYOUTS = getObsLayouts(); const OBS_THEMES = getObsThemes(); function createDefaultObsOverlaySettings() { return { rows: 10, showClock: true, showFastest: true, showGrid: true, showLaps: true, showResult: true, showBest: true, showGap: true, layout: "leaderboard", theme: "panel", publicToken: "", }; } const DEFAULT_OBS_OVERLAY_SETTINGS = Object.freeze(createDefaultObsOverlaySettings()); function normalizeObsOverlaySettings(raw = {}) { const defaults = createDefaultObsOverlaySettings(); const rowValue = Number(raw?.rows); const rows = Number.isFinite(rowValue) ? Math.max(3, Math.min(12, Math.round(rowValue))) : defaults.rows; const layoutOptions = getObsLayouts(); const themeOptions = getObsThemes(); const layout = layoutOptions.includes(String(raw?.layout || "").toLowerCase()) ? String(raw.layout).toLowerCase() : defaults.layout; const theme = themeOptions.includes(String(raw?.theme || "").toLowerCase()) ? String(raw.theme).toLowerCase() : defaults.theme; return { rows, showClock: raw?.showClock !== false, showFastest: raw?.showFastest !== false, showGrid: raw?.showGrid !== false, showLaps: raw?.showLaps !== false, showResult: raw?.showResult !== false, showBest: raw?.showBest !== false, showGap: raw?.showGap !== false, layout, theme, publicToken: String(raw?.publicToken || "").trim(), }; } function normalizeDriver(driver = {}) { return { id: String(driver?.id || uid("driver")), name: String(driver?.name || "").trim(), classId: String(driver?.classId || "").trim(), transponder: String(driver?.transponder || "").trim(), brand: String(driver?.brand || "").trim(), speechName: String(driver?.speechName || "").trim(), profileId: String(driver?.profileId || "").trim(), club: String(driver?.club || "").trim(), country: String(driver?.country || "").trim(), }; } function normalizeCar(car = {}) { return { id: String(car?.id || uid("car")), name: String(car?.name || "").trim(), transponder: String(car?.transponder || "").trim(), brand: String(car?.brand || "").trim(), }; } function normalizeSession(session = {}) { const type = SESSION_TYPES.includes(String(session?.type || "").toLowerCase()) ? String(session.type).toLowerCase() : "practice"; const status = ["ready", "running", "stopped", "finished"].includes(String(session?.status || "").toLowerCase()) ? String(session.status).toLowerCase() : "ready"; const seedMethod = ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase()) ? String(session.seedMethod).toLowerCase() : "best_sum"; const assignments = Array.isArray(session?.assignments) ? session.assignments .map((assignment) => ({ id: String(assignment?.id || uid("as")), driverId: String(assignment?.driverId || "").trim(), carId: String(assignment?.carId || "").trim(), })) .filter((assignment) => assignment.driverId || assignment.carId) : []; return { ...session, id: String(session?.id || uid("session")), eventId: String(session?.eventId || "").trim(), name: String(session?.name || "").trim(), type, durationMin: Math.max(1, Number(session?.durationMin || 1) || 1), followUpSec: Math.max(0, Number(session?.followUpSec || 0) || 0), startMode: normalizeStartMode(session?.startMode || "mass"), seedBestLapCount: Math.max(0, Number(session?.seedBestLapCount || 0) || 0), seedMethod, staggerGapSec: Math.max(0, Number(session?.staggerGapSec || 0) || 0), maxCars: session?.maxCars ? Math.max(1, Number(session.maxCars) || 1) : null, mode: session?.mode === "track" ? "track" : "race", status, startedAt: Number.isFinite(Number(session?.startedAt)) ? Number(session.startedAt) : null, endedAt: Number.isFinite(Number(session?.endedAt)) ? Number(session.endedAt) : null, finishedByTimer: session?.finishedByTimer === true, assignments, driverIds: Array.isArray(session?.driverIds) ? session.driverIds.map(String).filter(Boolean) : [], manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds.map(String).filter(Boolean) : [], gridCustomized: session?.gridCustomized === true, generated: session?.generated === true, }; } function parseOverlayBooleanParam(name, fallback) { const raw = urlParams.get(name); if (raw == null) { return fallback; } return ["1", "true", "yes", "on"].includes(String(raw).toLowerCase()); } function getObsOverlayConfig() { const base = normalizeObsOverlaySettings(state.settings?.obsOverlay || DEFAULT_OBS_OVERLAY_SETTINGS); return normalizeObsOverlaySettings({ ...base, rows: urlParams.get("rows") ?? base.rows, showClock: parseOverlayBooleanParam("showClock", base.showClock), showFastest: parseOverlayBooleanParam("showFastest", base.showFastest), showGrid: parseOverlayBooleanParam("showGrid", base.showGrid), showLaps: parseOverlayBooleanParam("showLaps", base.showLaps), showResult: parseOverlayBooleanParam("showResult", base.showResult), showBest: parseOverlayBooleanParam("showBest", base.showBest), showGap: parseOverlayBooleanParam("showGap", base.showGap), layout: getObsLayouts().includes(String(urlParams.get("layout") || "").toLowerCase()) ? String(urlParams.get("layout")).toLowerCase() : base.layout, theme: getObsThemes().includes(String(urlParams.get("obsTheme") || "").toLowerCase()) ? String(urlParams.get("obsTheme")).toLowerCase() : base.theme, publicToken: String(urlParams.get("token") || base.publicToken || "").trim(), }); } function writeObsOverlayParams(url, config) { const normalized = normalizeObsOverlaySettings(config); url.searchParams.set("rows", String(normalized.rows)); url.searchParams.set("showClock", normalized.showClock ? "1" : "0"); url.searchParams.set("showFastest", normalized.showFastest ? "1" : "0"); url.searchParams.set("showGrid", normalized.showGrid ? "1" : "0"); url.searchParams.set("showLaps", normalized.showLaps ? "1" : "0"); url.searchParams.set("showResult", normalized.showResult ? "1" : "0"); url.searchParams.set("showBest", normalized.showBest ? "1" : "0"); url.searchParams.set("showGap", normalized.showGap ? "1" : "0"); url.searchParams.set("layout", normalized.layout); url.searchParams.set("obsTheme", normalized.theme); if (normalized.publicToken) { url.searchParams.set("token", normalized.publicToken); } } let raceWizardStep = 1; let raceWizardDraft = null; let overlaySyncTimer = null; let overlayRotationTimer = null; let overlayLiveRefreshTimer = null; let overlayRotationIndex = 0; let overlayEvents = []; let lastOverlayLeaderKeyBySession = {}; let lastOverlayTop3BySession = {}; let lastOverlayBestLapByKey = {}; let activeModalEscapeHandler = null; let settingsStorageNotice = ""; let settingsStorageNoticeIsError = false; let settingsDraftActive = false; const backend = { available: false, lastSyncAt: null, lastError: "", }; let audioCtx = null; let lastFinishAnnouncementSessionId = null; const ammc = { config: createDefaultAmmcConfig(), status: null, lastError: "", loaded: false, }; const dom = { nav: document.getElementById("nav"), view: document.getElementById("view"), pageTitle: document.getElementById("pageTitle"), pageSubtitle: document.getElementById("pageSubtitle"), activeSessionChip: document.getElementById("activeSessionChip"), connectionBadge: document.getElementById("connectionBadge"), clock: document.getElementById("clock"), }; init(); async function init() { document.body.classList.toggle("overlay-mode", overlayMode); ["focusin", "input", "change"].forEach((eventName) => { dom.view.addEventListener(eventName, markSettingsDraftActive); }); if ("speechSynthesis" in window) { window.speechSynthesis.addEventListener("voiceschanged", () => { if (currentView === "settings") renderViewFromBackground(); }); } seedDefaultData(); applyTheme(); renderNav(); renderView(); setupLanguageControl(); updateHeaderState(); updateConnectionBadge(); tickClock(); setInterval(tickClock, 1000); startAppVersionPolling(); Promise.allSettled([hydrateFromBackend(), loadAmmcConfigFromBackend()]).finally(() => { applyTheme(); renderNav(); renderViewFromBackground(); setupLanguageControl(); updateHeaderState(); updateConnectionBadge(); if (state.settings.wsUrl && state.settings.autoReconnect && !state.decoder.connected) { connectDecoder(); } }); startOverlaySync(); startOverlayRotation(); startOverlayLiveRefresh(); if (overlayMode && state.settings.wsUrl && !state.decoder.connected) { connectDecoder(); } } function markSettingsDraftActive(event) { if (currentView !== "settings") return; const target = event.target; if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement) { settingsDraftActive = true; } } function seedDefaultData() { if (!state.classes.length) { state.classes.push( { id: uid("class"), name: "Stock 17.5T" }, { id: uid("class"), name: "Modified" } ); } if (!state.settings.wsUrl) { state.settings.wsUrl = "ws://127.0.0.1:9000"; } if (!state.settings.backendUrl) { state.settings.backendUrl = getDefaultBackendUrl(); } if (!state.settings.language) { state.settings.language = DEFAULT_LANGUAGE; } if (!AVAILABLE_THEMES.includes(String(state.settings.theme || "").toLowerCase())) { state.settings.theme = DEFAULT_THEME; } if (typeof state.settings.audioEnabled !== "boolean") { state.settings.audioEnabled = true; } if (!state.settings.passingSoundMode) { state.settings.passingSoundMode = "beep"; } if (typeof state.settings.finishVoiceEnabled !== "boolean") { state.settings.finishVoiceEnabled = true; } if (typeof state.settings.startSoundEnabled !== "boolean") { state.settings.startSoundEnabled = true; } if (typeof state.settings.finishSoundEnabled !== "boolean") { state.settings.finishSoundEnabled = true; } state.settings.speechVoiceUri = String(state.settings.speechVoiceUri || ""); state.settings.speechRate = Math.max(0.65, Math.min(1.35, Number(state.settings.speechRate || 0.95) || 0.95)); state.settings.speechPitch = Math.max(0.7, Math.min(1.3, Number(state.settings.speechPitch || 1) || 1)); if (typeof state.settings.speakerPassingCueEnabled !== "boolean") { state.settings.speakerPassingCueEnabled = false; } if (typeof state.settings.speakerLeaderCueEnabled !== "boolean") { state.settings.speakerLeaderCueEnabled = true; } if (typeof state.settings.speakerFinishCueEnabled !== "boolean") { state.settings.speakerFinishCueEnabled = true; } if (!state.settings.clubName) { state.settings.clubName = "JMK RB RaceController"; } if (!state.settings.clubTagline) { state.settings.clubTagline = "RC Timing System"; } if (!state.settings.pdfFooter) { state.settings.pdfFooter = "Generated by JMK RB RaceController"; } if (!state.settings.pdfTheme) { state.settings.pdfTheme = "classic"; } if (typeof state.settings.speakerBestLapCueEnabled !== "boolean") { state.settings.speakerBestLapCueEnabled = true; } if (typeof state.settings.speakerTop3CueEnabled !== "boolean") { state.settings.speakerTop3CueEnabled = false; } if (typeof state.settings.speakerSessionStartCueEnabled !== "boolean") { state.settings.speakerSessionStartCueEnabled = true; } if (!state.settings.logoDataUrl) { state.settings.logoDataUrl = ""; } if (!Array.isArray(state.settings.racePresets)) { state.settings.racePresets = []; } state.settings.obsOverlay = normalizeObsOverlaySettings(state.settings.obsOverlay); state.drivers = state.drivers.map((driver) => normalizeDriver(driver)).filter((driver) => driver.name); state.cars = state.cars.map((car) => normalizeCar(car)).filter((car) => car.name); state.events = state.events.map((event) => normalizeEvent(event)); state.sessions = state.sessions.map((session) => normalizeSession(session)); state.training = normalizeTrainingState(state.training); saveState({ skipBackend: true }); } function loadState() { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { try { const parsed = JSON.parse(raw); return { classes: parsed.classes || [], drivers: parsed.drivers || [], cars: parsed.cars || [], events: parsed.events || [], sessions: parsed.sessions || [], resultsBySession: parsed.resultsBySession || {}, training: normalizeTrainingState(parsed.training), activeSessionId: parsed.activeSessionId || null, settings: { wsUrl: parsed.settings?.wsUrl || "ws://127.0.0.1:9000", splitDecoderEnabled: parsed.settings?.splitDecoderEnabled === true, splitWsUrl: String(parsed.settings?.splitWsUrl || "ws://127.0.0.1:9001"), pitDecoderEnabled: parsed.settings?.pitDecoderEnabled === true, pitEntryWsUrl: String(parsed.settings?.pitEntryWsUrl || "ws://127.0.0.1:9002"), pitExitWsUrl: String(parsed.settings?.pitExitWsUrl || "ws://127.0.0.1:9003"), backendUrl: parsed.settings?.backendUrl || getDefaultBackendUrl(), language: parsed.settings?.language || DEFAULT_LANGUAGE, theme: AVAILABLE_THEMES.includes(String(parsed.settings?.theme || "").toLowerCase()) ? String(parsed.settings.theme).toLowerCase() : DEFAULT_THEME, autoReconnect: parsed.settings?.autoReconnect !== false, audioEnabled: parsed.settings?.audioEnabled !== false, passingSoundMode: parsed.settings?.passingSoundMode || "beep", finishVoiceEnabled: parsed.settings?.finishVoiceEnabled !== false, startSoundEnabled: parsed.settings?.startSoundEnabled !== false, finishSoundEnabled: parsed.settings?.finishSoundEnabled !== false, speechVoiceUri: String(parsed.settings?.speechVoiceUri || ""), speechRate: Math.max(0.65, Math.min(1.35, Number(parsed.settings?.speechRate || 0.95) || 0.95)), speechPitch: Math.max(0.7, Math.min(1.3, Number(parsed.settings?.speechPitch || 1) || 1)), speakerPassingCueEnabled: parsed.settings?.speakerPassingCueEnabled === true, speakerLeaderCueEnabled: parsed.settings?.speakerLeaderCueEnabled !== false, speakerFinishCueEnabled: parsed.settings?.speakerFinishCueEnabled !== false, speakerBestLapCueEnabled: parsed.settings?.speakerBestLapCueEnabled !== false, speakerTop3CueEnabled: parsed.settings?.speakerTop3CueEnabled === true, speakerSessionStartCueEnabled: parsed.settings?.speakerSessionStartCueEnabled !== false, clubName: parsed.settings?.clubName || "JMK RB RaceController", clubTagline: parsed.settings?.clubTagline || "RC Timing System", pdfFooter: parsed.settings?.pdfFooter || "Generated by JMK RB RaceController", pdfTheme: parsed.settings?.pdfTheme || "classic", logoDataUrl: parsed.settings?.logoDataUrl || "", racePresets: Array.isArray(parsed.settings?.racePresets) ? parsed.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [], obsOverlay: normalizeObsOverlaySettings(parsed.settings?.obsOverlay), }, decoder: { connected: false, splitConnected: false, pitEntryConnected: false, pitExitConnected: false, lastMessageAt: null, splitLastMessageAt: null, pitEntryLastMessageAt: null, pitExitLastMessageAt: null, lastError: "", splitLastError: "", pitEntryLastError: "", pitExitLastError: "", }, }; } catch { // fall through to defaults } } return { classes: [], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {}, training: normalizeTrainingState(), activeSessionId: null, settings: { wsUrl: "ws://127.0.0.1:9000", splitDecoderEnabled: false, splitWsUrl: "ws://127.0.0.1:9001", pitDecoderEnabled: false, pitEntryWsUrl: "ws://127.0.0.1:9002", pitExitWsUrl: "ws://127.0.0.1:9003", backendUrl: getDefaultBackendUrl(), language: DEFAULT_LANGUAGE, theme: DEFAULT_THEME, autoReconnect: true, audioEnabled: true, passingSoundMode: "beep", finishVoiceEnabled: true, startSoundEnabled: true, finishSoundEnabled: true, speechVoiceUri: "", speechRate: 0.95, speechPitch: 1, speakerPassingCueEnabled: false, speakerLeaderCueEnabled: true, speakerFinishCueEnabled: true, speakerBestLapCueEnabled: true, speakerTop3CueEnabled: false, speakerSessionStartCueEnabled: true, clubName: "JMK RB RaceController", clubTagline: "RC Timing System", pdfFooter: "Generated by JMK RB RaceController", pdfTheme: "classic", logoDataUrl: "", racePresets: [], obsOverlay: normalizeObsOverlaySettings(), }, decoder: { connected: false, splitConnected: false, pitEntryConnected: false, pitExitConnected: false, lastMessageAt: null, splitLastMessageAt: null, pitEntryLastMessageAt: null, pitExitLastMessageAt: null, lastError: "", splitLastError: "", pitEntryLastError: "", pitExitLastError: "", }, }; } function saveState(options = {}) { const persistable = buildPersistableState(); localStorage.setItem(STORAGE_KEY, JSON.stringify(persistable)); if (!options.skipBackend) { localStateVersion += 1; localStateDirty = true; scheduleBackendSync(); } } function uid(prefix) { return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; } function currentLanguage() { return state.settings.language === "en" ? "en" : "sv"; } function currentTheme() { return AVAILABLE_THEMES.includes(String(state.settings.theme || "").toLowerCase()) ? String(state.settings.theme).toLowerCase() : DEFAULT_THEME; } function applyTheme() { document.body.dataset.theme = currentTheme(); } function t(key, vars = {}) { const lang = currentLanguage(); const dict = TRANSLATIONS[lang] || TRANSLATIONS.en; const template = dict[key] ?? TRANSLATIONS.en[key] ?? key; return String(template).replace(/\{(\w+)\}/g, (_, token) => String(vars[token] ?? "")); } function setupLanguageControl() { const themeLabel = document.getElementById("themeLabel"); if (themeLabel) { themeLabel.textContent = t("ui.theme"); } const themeSelect = document.getElementById("themeSelect"); if (themeSelect instanceof HTMLSelectElement) { themeSelect.value = currentTheme(); Array.from(themeSelect.options).forEach((option) => { option.textContent = t(`ui.theme_${option.value}`); }); themeSelect.onchange = () => { state.settings.theme = AVAILABLE_THEMES.includes(themeSelect.value) ? themeSelect.value : DEFAULT_THEME; applyTheme(); saveState(); }; } const label = document.getElementById("languageLabel"); if (label) { label.textContent = t("ui.language"); } const brandTitle = document.getElementById("brandTitle"); if (brandTitle) { brandTitle.textContent = t("brand.title"); } const brandSubtitle = document.getElementById("brandSubtitle"); if (brandSubtitle) { brandSubtitle.textContent = t("brand.subtitle"); } const select = document.getElementById("languageSelect"); if (!(select instanceof HTMLSelectElement)) { return; } select.value = currentLanguage(); select.onchange = () => { state.settings.language = select.value === "en" ? "en" : "sv"; saveState(); renderNav(); renderView(); updateConnectionBadge(); updateHeaderState(); setupLanguageControl(); }; } function buildPersistableState() { syncTrainingLedger(); return { classes: state.classes, drivers: state.drivers.map((driver) => normalizeDriver(driver)), cars: state.cars.map((car) => normalizeCar(car)), events: state.events, sessions: state.sessions.map((session) => normalizeSession(session)), resultsBySession: state.resultsBySession, training: normalizeTrainingState(state.training), activeSessionId: state.activeSessionId, settings: state.settings, }; } function normalizeImportedClass(classItem) { return { id: String(classItem?.id || uid("class")), name: String(classItem?.name || "").trim(), }; } function buildExportMeta(exportType) { return { app: "JMK RB RaceController", schemaVersion: EXPORT_SCHEMA_VERSION, exportType, exportedAt: new Date().toISOString(), }; } function sanitizeFilenameSegment(value) { return String(value || "") .trim() .replace(/[^a-z0-9_-]+/gi, "_") .replace(/^_+|_+$/g, "") || "export"; } function downloadBlobFile(filename, blob) { const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function downloadJsonFile(filename, payload) { downloadBlobFile(filename, new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" })); } function rowToCsv(row) { return row.map((value) => `"${String(value || "").replaceAll('"', '""')}"`).join(","); } function downloadCsvFile(filename, rows) { const csv = rows.map((row) => rowToCsv(row)).join("\n"); downloadBlobFile(filename, new Blob([csv], { type: "text/csv;charset=utf-8" })); } function buildDataExportPayload() { return { ...buildExportMeta("full_data"), classes: state.classes, drivers: state.drivers, cars: state.cars, events: state.events, sessions: state.sessions, resultsBySession: state.resultsBySession, training: normalizeTrainingState(state.training), }; } function buildDirectoryExportPayload() { return { ...buildExportMeta("directory"), classes: state.classes, drivers: state.drivers, cars: state.cars, }; } function buildDriversCsvRows() { return [ ["id", "name", "class", "brand", "transponder", "spoken_name", "club", "country", "profile_id"], ...state.drivers.map((driver) => [driver.id, driver.name, getClassName(driver.classId), driver.brand || "", driver.transponder || "", driver.speechName || "", driver.club || "", driver.country || "", driver.profileId || ""]), ]; } function buildCarsCsvRows() { return [ ["id", "name", "brand", "transponder"], ...state.cars.map((car) => [car.id, car.name, car.brand || "", car.transponder || ""]), ]; } function collectEventPackageReferences(event, sessions) { const driverIds = new Set(event.classId ? getDriversForClass(event.classId).map((driver) => driver.id) : []); const carIds = new Set(); (event?.raceConfig?.driverIds || []).forEach((driverId) => driverIds.add(driverId)); (event?.raceConfig?.teams || []).forEach((team) => { (team.driverIds || []).forEach((driverId) => driverIds.add(driverId)); (team.carIds || []).forEach((carId) => carIds.add(carId)); }); (sessions || []).forEach((session) => { (session.driverIds || []).forEach((driverId) => driverIds.add(driverId)); (session.manualGridIds || []).forEach((driverId) => driverIds.add(driverId)); (session.assignments || []).forEach((assignment) => { if (assignment.driverId) driverIds.add(assignment.driverId); if (assignment.carId) carIds.add(assignment.carId); }); }); return { classes: state.classes.filter((item) => item.id === event.classId), drivers: state.drivers.filter((driver) => driverIds.has(driver.id)), cars: state.cars.filter((car) => carIds.has(car.id)), }; } function buildRacePackagePayload(eventId) { const event = state.events.find((item) => item.id === eventId); if (!event) { throw new Error("Event not found"); } const sessions = getSessionsForEvent(eventId); const refs = collectEventPackageReferences(event, sessions); const resultsBySession = {}; sessions.forEach((session) => { if (state.resultsBySession[session.id]) { resultsBySession[session.id] = state.resultsBySession[session.id]; } }); return { ...buildExportMeta("race_package"), classes: refs.classes, drivers: refs.drivers, cars: refs.cars, event, sessions, resultsBySession, }; } function mergeCollectionById(currentItems, incomingItems, normalizer) { const map = new Map((currentItems || []).map((item) => [item.id, normalizer(item)])); (incomingItems || []).map((item) => normalizer(item)).forEach((item) => { if (item && item.id) { map.set(item.id, item); } }); return [...map.values()]; } function importRacePackagePayload(parsed) { const importedEvent = parsed?.event ? normalizeEvent(parsed.event) : null; const incomingClasses = Array.isArray(parsed?.classes) ? parsed.classes : []; const incomingDrivers = Array.isArray(parsed?.drivers) ? parsed.drivers : []; const incomingCars = Array.isArray(parsed?.cars) ? parsed.cars : []; const incomingSessions = Array.isArray(parsed?.sessions) ? parsed.sessions.map((item) => normalizeSession(item)) : []; if (!importedEvent || !incomingSessions.length) { throw new Error("No race package found"); } state.classes = mergeCollectionById(state.classes, incomingClasses, normalizeImportedClass).filter((item) => item.name); state.drivers = mergeCollectionById(state.drivers, incomingDrivers, normalizeDriver).filter((driver) => driver.name); state.cars = mergeCollectionById(state.cars, incomingCars, normalizeCar).filter((car) => car.name); const newEventId = uid("event"); const sessionIdMap = new Map(); const clonedEvent = normalizeEvent({ ...importedEvent, id: newEventId, }); const clonedSessions = incomingSessions.map((session) => { const newSessionId = uid("session"); sessionIdMap.set(session.id, newSessionId); return normalizeSession({ ...session, id: newSessionId, eventId: newEventId, status: "ready", startedAt: null, endedAt: null, finishedByTimer: false, }); }); const importedResults = parsed?.resultsBySession && typeof parsed.resultsBySession === "object" ? parsed.resultsBySession : {}; Object.entries(importedResults).forEach(([oldSessionId, value]) => { const newSessionId = sessionIdMap.get(oldSessionId); if (newSessionId) { state.resultsBySession[newSessionId] = value; } }); state.events.push(clonedEvent); state.sessions.push(...clonedSessions); saveState(); renderView(); renderEventManager(newEventId); } function importDataPayload(parsed, mode = "full") { const incomingClasses = Array.isArray(parsed?.classes) ? parsed.classes : []; const incomingDrivers = Array.isArray(parsed?.drivers) ? parsed.drivers : []; const incomingCars = Array.isArray(parsed?.cars) ? parsed.cars : []; if (mode === "directory") { state.classes = mergeCollectionById(state.classes, incomingClasses, normalizeImportedClass).filter((item) => item.name); state.drivers = mergeCollectionById(state.drivers, incomingDrivers, normalizeDriver).filter((driver) => driver.name); state.cars = mergeCollectionById(state.cars, incomingCars, normalizeCar).filter((car) => car.name); settingsStorageNotice = t("settings.import_directory_success"); settingsStorageNoticeIsError = false; saveState(); renderView(); return; } const incomingEvents = Array.isArray(parsed?.events) ? parsed.events : []; const incomingSessions = Array.isArray(parsed?.sessions) ? parsed.sessions : []; if (!incomingClasses.length && !incomingDrivers.length && !incomingCars.length && !incomingEvents.length && !incomingSessions.length) { throw new Error("No importable data found"); } state.classes = incomingClasses.map((item) => normalizeImportedClass(item)).filter((item) => item.name); state.drivers = incomingDrivers.map((item) => normalizeDriver(item)).filter((item) => item.name); state.cars = incomingCars.map((item) => normalizeCar(item)).filter((item) => item.name); state.events = incomingEvents.map((item) => normalizeEvent(item)); state.sessions = incomingSessions.map((item) => normalizeSession(item)); state.resultsBySession = parsed?.resultsBySession && typeof parsed.resultsBySession === "object" ? parsed.resultsBySession : {}; state.training = normalizeTrainingState(parsed?.training); if (!state.sessions.some((session) => session.id === state.activeSessionId)) { state.activeSessionId = null; } settingsStorageNotice = t("settings.import_full_success"); settingsStorageNoticeIsError = false; saveState(); renderNav(); renderView(); } function getDefaultBackendUrl() { if (window.location.protocol.startsWith("http") && window.location.hostname) { return `${window.location.protocol}//${window.location.hostname}:8081`; } return "http://127.0.0.1:8081"; } function getBackendUrl() { return String(state.settings.backendUrl || getDefaultBackendUrl()).replace(/\/+$/, ""); } function renderNav() { if (overlayMode) { dom.nav.innerHTML = ""; return; } dom.nav.innerHTML = ""; let currentGroupKey = ""; NAV_ITEMS.forEach((item) => { if (item.groupKey && item.groupKey !== currentGroupKey) { currentGroupKey = item.groupKey; const groupLabel = document.createElement("div"); groupLabel.className = "nav-group-label"; groupLabel.textContent = t(currentGroupKey); dom.nav.appendChild(groupLabel); } const button = document.createElement("button"); button.className = `nav-item ${item.id === currentView ? "active" : ""}`; button.textContent = t(item.titleKey); button.addEventListener("click", () => { currentView = item.id; updateViewUrl(currentView); renderNav(); renderView(); }); dom.nav.appendChild(button); }); } function updateViewUrl(view, driverId = "", classId = "") { if (overlayMode) return; const nextUrl = new URL(window.location.href); if (view && view !== "dashboard") nextUrl.searchParams.set("view", view); else nextUrl.searchParams.delete("view"); if (view === "training" && driverId) nextUrl.searchParams.set("driver", driverId); else nextUrl.searchParams.delete("driver"); if (view === "training" && classId) nextUrl.searchParams.set("class", classId); else nextUrl.searchParams.delete("class"); window.history.replaceState({}, "", nextUrl); } function renderView() { if (currentView === "settings") settingsDraftActive = false; clearModalEscapeHandler(); const navMeta = NAV_ITEMS.find((x) => x.id === currentView); dom.pageTitle.textContent = navMeta ? t(navMeta.titleKey) : ""; dom.pageSubtitle.textContent = navMeta ? t(navMeta.subtitleKey) : ""; const languageLabel = document.getElementById("languageLabel"); if (languageLabel) { languageLabel.textContent = t("ui.language"); } const themeLabel = document.getElementById("themeLabel"); if (themeLabel) { themeLabel.textContent = t("ui.theme"); } try { switch (currentView) { case "dashboard": renderDashboard(); break; case "events": renderEvents(); break; case "race_setup": renderRaceSetup(); break; case "classes": renderClasses(); break; case "drivers": renderDrivers(); break; case "cars": renderCars(); break; case "training": renderTraining(); break; case "timing": renderTiming(); break; case "judging": renderJudging(); break; case "overlay": renderOverlay(); break; case "settings": renderSettings({ dom, ammc, state, t, backend, settingsStorageNotice, settingsStorageNoticeIsError, getManagedWsUrl, getBackendUrl, getDefaultBackendUrl, escapeHtml, saveState, renderView, connectDecoder, playPassingBeep, playFinishSiren, playStartCue, speakText, getSpeechVoices, openOverlayWindow, buildOverlayUrl, normalizeObsOverlaySettings, DEFAULT_OBS_OVERLAY_SETTINGS, OBS_LAYOUTS, OBS_THEMES, pingBackend, syncStateToBackend, buildDataExportPayload, downloadJsonFile, importDataPayload, buildDirectoryExportPayload, downloadCsvFile, buildDriversCsvRows, buildCarsCsvRows, buildExportMeta, normalizeStoredRacePreset, saveAmmcConfigToBackend, refreshAmmcStatus, startManagedAmmc, stopManagedAmmc, }); break; case "guide": renderGuide(); break; default: renderDashboard(); } } catch (error) { console.error("renderView failed", error); dom.view.innerHTML = `

Render error

${escapeHtml(error instanceof Error ? error.message : String(error))}

`; } updateHeaderState(); } function renderViewFromBackground() { if (currentView === "settings" && settingsDraftActive) return; renderView(); } function clearModalEscapeHandler() { if (activeModalEscapeHandler) { document.removeEventListener("keydown", activeModalEscapeHandler); activeModalEscapeHandler = null; } } function bindModalShell(overlayId, onClose, focusSelector = 'input, select, textarea, button') { const overlay = document.getElementById(overlayId); if (!overlay) { clearModalEscapeHandler(); return; } const focusTarget = overlay.querySelector(focusSelector); window.setTimeout(() => { if (focusTarget instanceof HTMLElement) { focusTarget.focus(); if (focusTarget instanceof HTMLInputElement) { focusTarget.select(); } } }, 0); clearModalEscapeHandler(); activeModalEscapeHandler = (event) => { if (event.key === "Escape") { event.preventDefault(); onClose(); } }; document.addEventListener("keydown", activeModalEscapeHandler); } function setFormError(errorId, message) { const errorNode = document.getElementById(errorId); if (!errorNode) { return; } errorNode.textContent = message || ""; errorNode.hidden = !message; } function updateHeaderState() { const session = getActiveSession(); if (!session) { dom.activeSessionChip.textContent = t("ui.no_active_session"); return; } const event = state.events.find((e) => e.id === session.eventId); dom.activeSessionChip.textContent = `${event?.name || t("ui.event")} • ${getSessionTypeLabel(session.type).toUpperCase()} • ${getStatusLabel( session.status ).toUpperCase()}`; } function updateConnectionBadge() { const isOnline = state.decoder.connected; const enabledExtraCount = Number(state.settings.splitDecoderEnabled) + (state.settings.pitDecoderEnabled ? 2 : 0); const connectedExtraCount = Number(state.decoder.splitConnected) + Number(state.decoder.pitEntryConnected) + Number(state.decoder.pitExitConnected); const extraStatus = enabledExtraCount ? ` • ${connectedExtraCount}/${enabledExtraCount}` : ""; dom.connectionBadge.textContent = `${isOnline ? t("ui.decoder_online") : t("ui.decoder_offline")}${extraStatus}`; dom.connectionBadge.className = `badge ${isOnline ? "badge-online" : "badge-offline"}`; } function renderEvents() { renderEventWorkspace("track"); } function renderRaceSetup() { renderEventWorkspace("race"); } function renderEventWorkspace(mode) { renderEventWorkspaceView({ mode, state, dom, t, escapeHtml, renderTable, renderRaceWizardStepsView, renderRaceWizardContentView, getRaceWizardDraft: () => raceWizardDraft, setRaceWizardDraft: (value) => { raceWizardDraft = value; }, getRaceWizardStep: () => raceWizardStep, setRaceWizardStep: (value) => { raceWizardStep = value; }, ensureRaceWizardDraft, getDriversForClass, getRaceWizardPreset, getSessionsForEvent, getClassName, getModeLabel, getSelectedEventEditId: () => selectedEventEditId, setSelectedEventEditId: (value) => { selectedEventEditId = value; }, renderView, renderEventManager, uid, normalizeEvent, applyRaceFormatPreset, buildOpenPracticeBundle, buildRaceSessionsFromWizard, buildDefaultRaceWizardDraft, applyRaceWizardPresetDefaults, setSelectedTeamEditId: (value) => { selectedTeamEditId = value; }, setSelectedSessionEditId: (value) => { selectedSessionEditId = value; }, setFormError, bindModalShell, isValidIsoDate, saveState, openTimingSession: (sessionId) => { state.activeSessionId = sessionId; saveState(); currentView = "timing"; updateViewUrl(currentView); renderNav(); updateHeaderState(); renderView(); }, }); } function renderEventManager(eventId) { renderEventManagerView({ eventId, state, t, escapeHtml, normalizeEvent, ensureRaceParticipantsConfigured, getSessionsForEvent, getSelectedTeamEditId: () => selectedTeamEditId, setSelectedTeamEditId: (value) => { selectedTeamEditId = value; }, getSelectedSessionEditId: () => selectedSessionEditId, setSelectedSessionEditId: (value) => { selectedSessionEditId = value; }, getSelectedGridSessionId: () => selectedGridSessionId, setSelectedGridSessionId: (value) => { selectedGridSessionId = value; }, getRaceFormatAdvanced: () => raceFormatAdvanced, setRaceFormatAdvanced: (value) => { raceFormatAdvanced = value; }, getEventDrivers, getTeamDriverPool, getEventTeams, normalizeBrandingConfig, getSessionTypeChoices, getRaceFormatPresets, getRaceSummaryItems, getRaceSummaryWarnings, getRaceManageStatuses, normalizeStartMode, renderEventManagerMarkup, getSessionEntrants, getSessionTypeLabel, getStartModeLabel, getStatusLabel, renderManageStatusBadgeView, getDriverDisplayById, renderRaceFormatContextCardView, renderRaceFormatFieldView, renderGridEditor, renderRaceStandingsTableView, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings, renderSessionChannelAnalysis, renderFinalMatrix, getModeLabel, renderTable, bindModalShell, setFormError, saveState, renderView, rerenderEventManager: renderEventManager, updateHeaderState, uid, normalizeSession, buildSessionHeatSheetHtml, openPrintWindow, exportSessionHeatSheet, exportSessionHeatSheetPdf, getSelectedAssignmentSessionId, autoAssignTrackSession, renderAssignmentList, normalizeRaceTeam, buildRaceFormatConfigFromForm, applyRaceFormatPreset, normalizeStoredRacePreset, generateQualifyingForRace, clearGeneratedQualifying, reseedUpcomingQualifying, generateFinalsForRace, clearGeneratedFinals, applyBumpsForRace, buildRacePackagePayload, downloadJsonFile, sanitizeFilenameSegment, importRacePackagePayload, buildRaceStartListsHtml, buildRaceResultsHtml, buildTeamRaceResultsHtml, exportRaceStartListsPdf, exportRaceResultsPdf, exportTeamRaceResultsPdf, ensureSessionDriverOrder, reorderList, getEventName, }); } function getFreePracticeSessions(eventId) { return getSessionsForEvent(eventId).filter((session) => session.type === "free_practice"); } function renderSimpleList(items, labelFn, idFn) { if (!items.length) { return `

${t("common.no_entries")}

`; } return `
    ${items .map( (item) => `
  • ${labelFn(item)}
  • ` ) .join("")}
`; } function renderTable(headers, rowHtml) { return ` ${headers.map((h) => ``).join("")} ${rowHtml.join("") || ``}
${escapeHtml(h)}
${t("common.no_rows")}
`; } function getDriversForClass(classId) { return state.drivers.filter((driver) => !classId || driver.classId === classId); } function ensureRaceWizardDraft() { if (!raceWizardDraft) { raceWizardDraft = applyRaceWizardPresetDefaults(buildDefaultRaceWizardDraft(), "club_qualifying"); } if (!state.classes.some((item) => item.id === raceWizardDraft.classId)) { raceWizardDraft.classId = state.classes[0]?.id || ""; raceWizardDraft.driverIds = getDriversForClass(raceWizardDraft.classId).map((driver) => driver.id); } if (!raceWizardDraft.date) { raceWizardDraft.date = new Date().toISOString().slice(0, 10); } } function formatLap(ms) { if (!ms && ms !== 0) { return "-"; } const total = Math.max(0, Math.floor(ms)); const m = Math.floor(total / 60000); const s = Math.floor((total % 60000) / 1000) .toString() .padStart(2, "0"); const t = Math.floor(total % 1000) .toString() .padStart(3, "0"); return `${m}:${s}.${t}`; } function formatPredictedLapDelta(ms) { if (ms === null || ms === undefined || Number.isNaN(ms)) { return "-"; } if (ms >= 0) { return formatLap(ms); } const cappedOverdueMs = Math.min(Math.abs(ms), 60000); return `+${formatLap(cappedOverdueMs)}`; } function formatCountdown(ms) { const total = Math.max(0, Math.floor(ms)); const m = Math.floor(total / 60000) .toString() .padStart(2, "0"); const s = Math.floor((total % 60000) / 1000) .toString() .padStart(2, "0"); return `${m}:${s}`; } function formatElapsedClock(ms) { const total = Math.max(0, Math.floor(ms / 1000)); const h = Math.floor(total / 3600) .toString() .padStart(1, "0"); const m = Math.floor((total % 3600) / 60) .toString() .padStart(2, "0"); const s = Math.floor(total % 60) .toString() .padStart(2, "0"); return `${h}:${m}:${s}`; } function formatRaceClock(ms) { const total = Math.max(0, Math.floor(ms)); const m = Math.floor(total / 60000) .toString() .padStart(2, "0"); const s = Math.floor((total % 60000) / 1000) .toString() .padStart(2, "0"); const centiseconds = Math.floor((total % 1000) / 10) .toString() .padStart(2, "0"); const millis = Math.floor(total % 1000) .toString() .padStart(3, "0"); return `${m}:${s}.${centiseconds}:${millis}`; } function getSeedMethodLabel(method) { const normalized = ["best_sum", "average", "consecutive"].includes(String(method || "").toLowerCase()) ? String(method).toLowerCase() : "best_sum"; return t(`events.seed_method_${normalized}`); } function formatSeedMetric(metric) { if (!metric) { return "-"; } if (metric.method === "average") { return `${metric.lapCount} avg ${formatLap(metric.averageMs)}`; } if (metric.method === "consecutive") { return `${metric.lapCount} con ${formatRaceClock(metric.totalMs)}`; } return `${metric.lapCount}/${formatRaceClock(metric.totalMs)}`; } function clearGeneratedQualifying(eventId) { const generatedIds = state.sessions .filter((session) => session.eventId === eventId && session.type === "qualification" && session.generated) .map((session) => session.id); state.sessions = state.sessions.filter((session) => !generatedIds.includes(session.id)); generatedIds.forEach((sessionId) => { delete state.resultsBySession[sessionId]; if (state.activeSessionId === sessionId) { state.activeSessionId = null; } }); } function clearGeneratedFinals(eventId) { const generatedIds = state.sessions.filter((session) => session.eventId === eventId && session.type === "final" && session.generated).map((session) => session.id); state.sessions = state.sessions.filter((session) => !generatedIds.includes(session.id)); generatedIds.forEach((sessionId) => { delete state.resultsBySession[sessionId]; if (state.activeSessionId === sessionId) { state.activeSessionId = null; } }); } function openPrintWindow(title, bodyHtml) { const printWindow = window.open("", "_blank", "noopener,noreferrer,width=1200,height=900"); if (!printWindow) { alert(t("error.print_blocked")); return; } printWindow.document.write(` ${escapeHtml(title)} ${bodyHtml} `); printWindow.document.close(); printWindow.focus(); setTimeout(() => { printWindow.print(); }, 150); } function buildPdfSection(title, headers, rows) { return { title, headers, rows }; } async function requestPdfExport(payload) { try { const response = await fetch(`${getBackendUrl()}/api/export/pdf`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { const errorPayload = await response.json().catch(() => ({})); throw new Error(errorPayload.error || `HTTP ${response.status}`); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = payload.filename || "export.pdf"; link.click(); URL.revokeObjectURL(url); } catch (error) { alert(t("error.pdf_export_failed", { msg: error instanceof Error ? error.message : String(error) })); } } function loadImageElement(src) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Image load failed")); image.src = src; }); } async function ensurePdfLogoDataUrl(dataUrl) { if (!dataUrl) { return ""; } if (/^data:image\/jpeg;base64,/i.test(dataUrl)) { return dataUrl; } try { const image = await loadImageElement(dataUrl); const canvas = document.createElement("canvas"); const width = Math.max(1, image.naturalWidth || image.width || 1); const height = Math.max(1, image.naturalHeight || image.height || 1); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) { return ""; } context.fillStyle = "#ffffff"; context.fillRect(0, 0, width, height); context.drawImage(image, 0, 0, width, height); return canvas.toDataURL("image/jpeg", 0.92); } catch { return ""; } } async function exportRaceStartListsPdf(event) { const branding = resolveEventBranding(event); const sessions = getSessionsForEvent(event.id) .filter((session) => session.mode === "race") .sort((left, right) => { const weightDiff = getSessionSortWeight(left) - getSessionSortWeight(right); if (weightDiff !== 0) { return weightDiff; } return String(left.name || "").localeCompare(String(right.name || "")); }); const sections = sessions.map((session) => buildPdfSection( `${session.name} • ${getSessionTypeLabel(session.type)}`, [t("events.slot"), t("table.driver"), t("table.brand"), t("table.transponder")], getSessionGridEntries(session).map((entry) => { const driver = state.drivers.find((item) => item.id === entry.id); return [String(entry.slot), entry.name, driver?.brand || "-", entry.meta || "-"]; }) ) ); await requestPdfExport({ filename: `${event.name.replaceAll(/\s+/g, "_")}_startlists.pdf`, title: event.name, subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`, brandName: branding.brandName, brandTagline: branding.brandTagline, footer: branding.pdfFooter, theme: branding.pdfTheme, logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), sections, }); } 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, subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`, brandName: branding.brandName, brandTagline: branding.brandTagline, footer: branding.pdfFooter, theme: branding.pdfTheme, logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), sections: [ buildPdfSection( t("events.practice_standings"), [t("table.pos"), t("table.driver"), t("table.score")], buildPracticeStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"]) ), buildPdfSection( t("events.qualifying_standings"), [t("table.pos"), t("table.driver"), t("table.score")], buildQualifyingStandings(event).map((row) => [ String(row.rank), row.driverName || "-", row.scoreNote ? `${row.score || "-"} | ${row.scoreNote}` : row.score || "-", ]) ), buildPdfSection( t("events.final_standings"), [t("table.pos"), t("table.driver"), t("table.score")], buildFinalStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"]) ), ...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"), 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"), 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) : "-", ]; }) ) ); rows.forEach((row) => { const stints = buildTeamStintLog(session, row); sections.push( buildPdfSection( `${session.name} • ${row.displayName || row.driverName} • ${t("events.team_stint_log")}`, [t("events.slot"), t("table.driver"), t("table.car"), t("table.time"), t("table.duration"), t("table.laps")], stints.map((stint) => [ String(stint.index), stint.driverName || "-", stint.carName || "-", new Date(stint.startTs).toLocaleTimeString(), formatRaceClock(stint.durationMs), String(stint.laps || 0), ]) ) ); }); sections.push(...buildSessionChannelPdfSections(session)); }); await requestPdfExport({ filename: `${event.name.replaceAll(/\s+/g, "_")}_team_report.pdf`, title: event.name, subtitle: `${t("events.team_report")} • ${getClassName(event.classId)} • ${event.date || "-"}`, brandName: branding.brandName, brandTagline: branding.brandTagline, footer: branding.pdfFooter, theme: branding.pdfTheme, logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), sections, }); } async function exportSessionHeatSheetPdf(session) { const event = state.events.find((item) => item.id === session.eventId); const branding = resolveEventBranding(event); await requestPdfExport({ filename: `${(event?.name || "event").replaceAll(/\s+/g, "_")}_${session.name.replaceAll(/\s+/g, "_")}.pdf`, title: event?.name || t("common.unknown_event"), subtitle: `${getSessionTypeLabel(session.type)} • ${session.name} • ${getClassName(event?.classId || "")}`, brandName: branding.brandName, brandTagline: branding.brandTagline, footer: branding.pdfFooter, theme: branding.pdfTheme, logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), sections: [ buildPdfSection( `${session.name} • ${getSessionTypeLabel(session.type)}`, [t("events.slot"), t("table.driver"), t("table.brand"), t("table.transponder")], getSessionGridEntries(session).map((entry) => { const driver = state.drivers.find((item) => item.id === entry.id); return [String(entry.slot), entry.name, driver?.brand || "-", entry.meta || "-"]; }) ), ], }); } function reorderList(items, fromIndex, toIndex) { const copy = [...items]; const [moved] = copy.splice(fromIndex, 1); copy.splice(toIndex, 0, moved); return copy; } function buildSessionHeatSheetHtml(session) { const event = state.events.find((item) => item.id === session.eventId); const branding = resolveEventBranding(event); const entries = getSessionGridEntries(session); return `

${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(session.startMode))} • ${t("table.duration")}: ${session.durationMin} min

${ entries.length ? renderTable( [t("events.slot"), t("table.driver"), t("table.brand"), t("table.transponder")], entries.map((entry) => { const driver = state.drivers.find((item) => item.id === entry.id); return ` ${entry.slot} ${escapeHtml(entry.name)} ${escapeHtml(driver?.brand || "-")} ${escapeHtml(entry.meta || "-")} `; }) ) : `

${t("common.no_entries")}

` } `; } function exportSessionHeatSheet(session) { const event = state.events.find((item) => item.id === session.eventId); const entries = getSessionGridEntries(session); const rows = [ ["event", "class", "session", "type", "start_mode", "duration_min", "slot", "driver", "brand", "transponder"], ...entries.map((entry) => [ event?.name || "", getClassName(event?.classId || ""), session.name, getSessionTypeLabel(session.type), getStartModeLabel(session.startMode), String(session.durationMin || ""), String(entry.slot), entry.name, state.drivers.find((item) => item.id === entry.id)?.brand || "", entry.meta || "", ]), ]; const csv = rows .map((row) => row.map((value) => `"${String(value || "").replaceAll('"', '""')}"`).join(",")) .join("\n"); const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${(event?.name || "event").replaceAll(/\s+/g, "_")}_${session.name.replaceAll(/\s+/g, "_")}.csv`; link.click(); URL.revokeObjectURL(url); } function buildOverlayUrl(mode = "leaderboard", options = {}) { const normalizedMode = allowedOverlayModes.includes(String(mode || "").toLowerCase()) ? String(mode).toLowerCase() : "leaderboard"; const url = new URL(window.location.href); if (options.public) { url.pathname = `/public-overlay/${normalizedMode}`; url.search = ""; if (normalizedMode === "obs") { writeObsOverlayParams(url, state.settings.obsOverlay); } return url.toString(); } url.searchParams.set("view", "overlay"); url.searchParams.set("overlayMode", normalizedMode); if (normalizedMode === "obs") { writeObsOverlayParams(url, state.settings.obsOverlay); } return url.toString(); } function openOverlayWindow(mode = "leaderboard", options = {}) { const width = Math.max(1280, window.screen?.availWidth || 1600); const height = Math.max(720, window.screen?.availHeight || 900); const overlayWindow = window.open( buildOverlayUrl(mode, options), "_blank", `noopener,noreferrer,popup=yes,left=0,top=0,width=${width},height=${height}` ); if (!overlayWindow) { alert(t("error.print_blocked")); return; } overlayWindow.focus(); } function chunkArray(items, size) { const chunks = []; for (let index = 0; index < items.length; index += size) { chunks.push(items.slice(index, index + size)); } return chunks; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function isValidIsoDate(value) { return /^\d{4}-\d{2}-\d{2}$/.test(String(value || "")); } function getSelectedAssignmentSessionId() { const form = document.getElementById("assignForm"); if (!(form instanceof HTMLFormElement)) { return ""; } const formData = new FormData(form); return String(formData.get("sessionId") || ""); } function autoAssignTrackSession(event, sessionId) { const session = state.sessions.find((x) => x.id === sessionId); if (!session) { return; } const driversForClass = state.drivers.filter((d) => d.classId === event.classId); const drivers = shuffle([...driversForClass.length ? driversForClass : state.drivers]); const uniqueCars = uniqueCarsByTransponder(state.cars); session.assignments = []; const limit = Math.min(uniqueCars.length, drivers.length); for (let i = 0; i < limit; i += 1) { session.assignments.push({ id: uid("as"), driverId: drivers[i].id, carId: uniqueCars[i].id, }); } } function uniqueCarsByTransponder(cars) { const seen = new Set(); const output = []; cars.forEach((car) => { const key = String(car.transponder || "").trim(); if (!key) { return; } if (seen.has(key)) { return; } seen.add(key); output.push(car); }); return output; } function shuffle(items) { for (let i = items.length - 1; i > 0; i -= 1) { const j = Math.floor(Math.random() * (i + 1)); const temp = items[i]; items[i] = items[j]; items[j] = temp; } return items; } function validateTrackSessionForStart(session) { const assignments = session.assignments || []; if (!assignments.length) { return { ok: false, message: t("validation.no_assignments") }; } const missingTransponder = assignments.find((assignment) => { const car = state.cars.find((c) => c.id === assignment.carId); return !String(car?.transponder || "").trim(); }); if (missingTransponder) { return { ok: false, message: t("validation.missing_tp") }; } const duplicateTransponders = findDuplicateSessionTransponders(session); if (duplicateTransponders.length) { return { ok: false, message: t("validation.duplicate_tp", { ids: duplicateTransponders.join(", ") }), }; } return { ok: true, message: "" }; } function findDuplicateSessionTransponders(session) { const counts = {}; (session.assignments || []).forEach((assignment) => { const car = state.cars.find((c) => c.id === assignment.carId); const tp = String(car?.transponder || "").trim(); if (!tp) { return; } counts[tp] = (counts[tp] || 0) + 1; }); return Object.keys(counts).filter((tp) => counts[tp] > 1); } async function persistPassingToBackend(sessionId, passing) { try { const res = await fetch(`${getBackendUrl()}/api/passings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, passing, sessionResult: state.resultsBySession[sessionId] || null }), }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } backend.available = true; backend.lastError = ""; } catch (error) { backend.available = false; backend.lastError = t("error.passing_save_failed", { msg: error instanceof Error ? error.message : String(error) }); } }