7379 lines
342 KiB
JavaScript
7379 lines
342 KiB
JavaScript
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, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js";
|
|
|
|
import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic } from "./timing_logic.js";
|
|
|
|
import { renderDashboardView, renderClassesView, renderDriversView, renderCarsView } from "./core_views.js";
|
|
|
|
import { renderTeamStintLog as renderTeamStintLogHelper, renderTeamRaceStandings as renderTeamRaceStandingsHelper, getSessionSortWeight as getSessionSortWeightHelper, getDriverDisplayById as getDriverDisplayByIdHelper, renderPositionGrid as renderPositionGridHelper, renderGridEditor as renderGridEditorHelper, getFinalMainLayouts as getFinalMainLayoutsHelper, renderFinalMatrix as renderFinalMatrixHelper, buildPrintBrandBlock as buildPrintBrandBlockHelper, buildRaceStartListsHtml as buildRaceStartListsHtmlHelper, buildRaceResultsHtml as buildRaceResultsHtmlHelper, buildTeamRaceResultsHtml as buildTeamRaceResultsHtmlHelper } from "./race_render_helpers.js";
|
|
|
|
import { 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 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 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, 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, renderTeamStintLog });
|
|
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 });
|
|
const buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog });
|
|
const renderDashboard = () => renderDashboardView({ state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, 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 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" },
|
|
{ id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub" },
|
|
{ id: "race_setup", titleKey: "nav.race_setup", subtitleKey: "nav.race_setup_sub" },
|
|
{ id: "overlay", titleKey: "nav.overlay", subtitleKey: "nav.overlay_sub" },
|
|
{ id: "classes", titleKey: "nav.classes", subtitleKey: "nav.classes_sub" },
|
|
{ id: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub" },
|
|
{ id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub" },
|
|
{ id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub" },
|
|
{ id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub" },
|
|
{ id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub" },
|
|
{ id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub" },
|
|
];
|
|
|
|
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 = 1;
|
|
|
|
const TRANSLATIONS = {
|
|
sv: {
|
|
"nav.dashboard": "Översikt",
|
|
"nav.dashboard_sub": "Status och liveinformation",
|
|
"nav.events": "Event",
|
|
"nav.events_sub": "Sponsor-event och delade bilar",
|
|
"nav.race_setup": "Race Setup",
|
|
"nav.race_setup_sub": "Tävlingsrace och heatupplägg",
|
|
"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.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",
|
|
"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.",
|
|
"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 / brand",
|
|
"drivers.transponder_placeholder": "Personlig transponder (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.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.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.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": "Race is finished",
|
|
"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.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": "Spela finish-siren",
|
|
"settings.test_audio": "Testa ljud",
|
|
"settings.audio_note": "Browsern kräver oftast ett klick först innan ljud/tal tillåts.",
|
|
"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.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 och nya 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_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux och lokal SQLite-lagring.",
|
|
"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.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.",
|
|
"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.host_1": "1. AMMC körs alltid på samma maskin som `npm start` eller `node server.js` körs på.",
|
|
"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/*` externt via reverse proxy, inte hela admin-UI:t.",
|
|
"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. Installera Node.js LTS och Visual C++ Runtime 2015-2022 på hosten som ska köra `live_event`.",
|
|
"guide.windows_2": "2. Standardbinär för Managed AMMC på Windows-host: `AMMC/windows64/ammc-amb.exe`.",
|
|
"guide.windows_3": "3. Kör `npm start` på Windows-hosten. Då är det där AMMC startas om du använder Managed AMMC.",
|
|
"guide.windows_4": "4. I Settings: `Decoder IP / host` = decoderns IP, t.ex. `192.168.1.11`.",
|
|
"guide.windows_5": "5. I appen: `Backend URL` = http://<windows-host>:8081, `WebSocket URL` = ws://<windows-host>:9000.",
|
|
"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://<linux-host>: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.events": "Events",
|
|
"nav.events_sub": "Sponsor events and shared cars",
|
|
"nav.race_setup": "Race Setup",
|
|
"nav.race_setup_sub": "Competition race and heat setup",
|
|
"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.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",
|
|
"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.",
|
|
"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",
|
|
"drivers.transponder_placeholder": "Personal transponder (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.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.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.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.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.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": "Play finish siren",
|
|
"settings.test_audio": "Test audio",
|
|
"settings.audio_note": "Browsers usually require a click first before sound/speech is allowed.",
|
|
"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.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 and the new 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_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux and local SQLite storage.",
|
|
"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.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.",
|
|
"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.host_1": "1. AMMC always runs on the same machine where `npm start` or `node server.js` is running.",
|
|
"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.windows_title": "Windows + AMMC + npm",
|
|
"guide.windows_1": "1. Install Node.js LTS and Visual C++ Runtime 2015-2022 on the host that will run `live_event`.",
|
|
"guide.windows_2": "2. Default Managed AMMC binary on a Windows host: `AMMC/windows64/ammc-amb.exe`.",
|
|
"guide.windows_3": "3. Run `npm start` on the Windows host. Managed AMMC starts there if enabled.",
|
|
"guide.windows_4": "4. In Settings: `Decoder IP / host` = decoder IP, for example `192.168.1.11`.",
|
|
"guide.windows_5": "5. In app: `Backend URL` = http://<windows-host>:8081, `WebSocket URL` = ws://<windows-host>:9000.",
|
|
"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://<linux-host>: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();
|
|
let currentView = overlayMode ? "overlay" : "dashboard";
|
|
let wsClient = null;
|
|
let reconnectTimer = null;
|
|
let backendSyncTimer = null;
|
|
let appVersionPollTimer = null;
|
|
let baselineAppVersion = "";
|
|
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 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 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;
|
|
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);
|
|
seedDefaultData();
|
|
applyTheme();
|
|
renderNav();
|
|
renderView();
|
|
setupLanguageControl();
|
|
updateHeaderState();
|
|
updateConnectionBadge();
|
|
tickClock();
|
|
setInterval(tickClock, 1000);
|
|
startAppVersionPolling();
|
|
|
|
Promise.allSettled([hydrateFromBackend(), loadAmmcConfigFromBackend()]).finally(() => {
|
|
applyTheme();
|
|
renderNav();
|
|
renderView();
|
|
setupLanguageControl();
|
|
updateHeaderState();
|
|
updateConnectionBadge();
|
|
});
|
|
|
|
startOverlaySync();
|
|
startOverlayRotation();
|
|
startOverlayLiveRefresh();
|
|
|
|
if (overlayMode) {
|
|
if (state.settings.wsUrl) {
|
|
connectDecoder();
|
|
}
|
|
}
|
|
}
|
|
|
|
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.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));
|
|
|
|
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 || {},
|
|
activeSessionId: parsed.activeSessionId || null,
|
|
settings: {
|
|
wsUrl: parsed.settings?.wsUrl || "ws://127.0.0.1:9000",
|
|
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,
|
|
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,
|
|
lastMessageAt: null,
|
|
lastError: "",
|
|
},
|
|
};
|
|
} catch {
|
|
// fall through to defaults
|
|
}
|
|
}
|
|
|
|
return {
|
|
classes: [],
|
|
drivers: [],
|
|
cars: [],
|
|
events: [],
|
|
sessions: [],
|
|
resultsBySession: {},
|
|
activeSessionId: null,
|
|
settings: {
|
|
wsUrl: "ws://127.0.0.1:9000",
|
|
backendUrl: getDefaultBackendUrl(),
|
|
language: DEFAULT_LANGUAGE,
|
|
theme: DEFAULT_THEME,
|
|
autoReconnect: true,
|
|
audioEnabled: true,
|
|
passingSoundMode: "beep",
|
|
finishVoiceEnabled: true,
|
|
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,
|
|
lastMessageAt: null,
|
|
lastError: "",
|
|
},
|
|
};
|
|
}
|
|
|
|
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() {
|
|
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,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function buildDirectoryExportPayload() {
|
|
return {
|
|
...buildExportMeta("directory"),
|
|
classes: state.classes,
|
|
drivers: state.drivers,
|
|
cars: state.cars,
|
|
};
|
|
}
|
|
|
|
function buildDriversCsvRows() {
|
|
return [
|
|
["id", "name", "class", "brand", "transponder"],
|
|
...state.drivers.map((driver) => [driver.id, driver.name, getClassName(driver.classId), driver.brand || "", driver.transponder || ""]),
|
|
];
|
|
}
|
|
|
|
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 : {};
|
|
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 createDefaultAmmcConfig() {
|
|
return {
|
|
managedEnabled: false,
|
|
autoStart: false,
|
|
decoderHost: "",
|
|
wsPort: 9000,
|
|
executablePath: "",
|
|
workingDirectory: "",
|
|
extraArgs: "",
|
|
};
|
|
}
|
|
|
|
function getManagedWsUrl() {
|
|
const port = Number(ammc.config?.wsPort || 9000);
|
|
try {
|
|
const backendUrl = new URL(getBackendUrl());
|
|
return `ws://${backendUrl.hostname}:${port}`;
|
|
} catch {
|
|
return `ws://127.0.0.1:${port}`;
|
|
}
|
|
}
|
|
|
|
async function loadAmmcConfigFromBackend() {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/config`);
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
const payload = await res.json();
|
|
ammc.config = {
|
|
...createDefaultAmmcConfig(),
|
|
...(payload.config || {}),
|
|
};
|
|
ammc.status = payload.status || null;
|
|
ammc.lastError = "";
|
|
ammc.loaded = true;
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
ammc.loaded = false;
|
|
}
|
|
}
|
|
|
|
async function saveAmmcConfigToBackend(config) {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/config`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(config),
|
|
});
|
|
const payload = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(payload.error || `HTTP ${res.status}`);
|
|
}
|
|
ammc.config = {
|
|
...createDefaultAmmcConfig(),
|
|
...(payload.config || {}),
|
|
};
|
|
ammc.status = payload.status || ammc.status;
|
|
ammc.lastError = "";
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_save_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
async function refreshAmmcStatus() {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/status`);
|
|
const payload = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(payload.error || `HTTP ${res.status}`);
|
|
}
|
|
ammc.status = payload;
|
|
ammc.lastError = "";
|
|
ammc.loaded = true;
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
async function startManagedAmmc() {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/start`, {
|
|
method: "POST",
|
|
});
|
|
const payload = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(payload.error || `HTTP ${res.status}`);
|
|
}
|
|
ammc.status = payload.status || null;
|
|
ammc.lastError = "";
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_start_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
async function stopManagedAmmc() {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/ammc/stop`, {
|
|
method: "POST",
|
|
});
|
|
const payload = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(payload.error || `HTTP ${res.status}`);
|
|
}
|
|
ammc.status = payload.status || null;
|
|
ammc.lastError = "";
|
|
} catch (error) {
|
|
ammc.lastError = t("error.ammc_stop_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
async function hydrateFromBackend() {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/state`);
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
|
|
const payload = await res.json();
|
|
if (payload && payload.state && typeof payload.state === "object") {
|
|
applyPersistedState(payload.state);
|
|
saveState({ skipBackend: true });
|
|
backend.lastSyncAt = payload.updatedAt || new Date().toISOString();
|
|
}
|
|
backend.available = true;
|
|
backend.lastError = "";
|
|
} catch (error) {
|
|
backend.available = false;
|
|
backend.lastError = t("error.backend_offline", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
function applyPersistedState(persisted) {
|
|
state.classes = persisted.classes || [];
|
|
state.drivers = (persisted.drivers || []).map((driver) => normalizeDriver(driver)).filter((driver) => driver.name);
|
|
state.cars = (persisted.cars || []).map((car) => normalizeCar(car)).filter((car) => car.name);
|
|
state.events = (persisted.events || []).map((event) => normalizeEvent(event));
|
|
state.sessions = (persisted.sessions || []).map((session) => normalizeSession(session));
|
|
state.resultsBySession = persisted.resultsBySession || {};
|
|
state.activeSessionId = persisted.activeSessionId || null;
|
|
state.settings = {
|
|
wsUrl: persisted.settings?.wsUrl || state.settings.wsUrl || "ws://127.0.0.1:9000",
|
|
backendUrl: persisted.settings?.backendUrl || state.settings.backendUrl || getDefaultBackendUrl(),
|
|
language: persisted.settings?.language || state.settings.language || DEFAULT_LANGUAGE,
|
|
theme: AVAILABLE_THEMES.includes(String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase()) ? String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase() : DEFAULT_THEME,
|
|
autoReconnect: persisted.settings?.autoReconnect !== false,
|
|
audioEnabled: persisted.settings?.audioEnabled !== false,
|
|
passingSoundMode: persisted.settings?.passingSoundMode || state.settings.passingSoundMode || "beep",
|
|
finishVoiceEnabled: persisted.settings?.finishVoiceEnabled !== false,
|
|
speakerPassingCueEnabled: persisted.settings?.speakerPassingCueEnabled === true,
|
|
speakerLeaderCueEnabled: persisted.settings?.speakerLeaderCueEnabled !== false,
|
|
speakerFinishCueEnabled: persisted.settings?.speakerFinishCueEnabled !== false,
|
|
speakerBestLapCueEnabled: persisted.settings?.speakerBestLapCueEnabled !== false,
|
|
speakerTop3CueEnabled: persisted.settings?.speakerTop3CueEnabled === true,
|
|
speakerSessionStartCueEnabled: persisted.settings?.speakerSessionStartCueEnabled !== false,
|
|
clubName: persisted.settings?.clubName || state.settings.clubName || "JMK RB RaceController",
|
|
clubTagline: persisted.settings?.clubTagline || state.settings.clubTagline || "RC Timing System",
|
|
pdfFooter: persisted.settings?.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB RaceController",
|
|
pdfTheme: persisted.settings?.pdfTheme || state.settings.pdfTheme || "classic",
|
|
logoDataUrl: persisted.settings?.logoDataUrl || state.settings.logoDataUrl || "",
|
|
racePresets: Array.isArray(persisted.settings?.racePresets)
|
|
? persisted.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name)
|
|
: Array.isArray(state.settings?.racePresets)
|
|
? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name)
|
|
: [],
|
|
obsOverlay: normalizeObsOverlaySettings(persisted.settings?.obsOverlay || state.settings?.obsOverlay),
|
|
};
|
|
applyTheme();
|
|
}
|
|
|
|
function normalizeSession(session) {
|
|
return {
|
|
...session,
|
|
startMode: session?.startMode || "mass",
|
|
staggerGapSec: Number(session?.staggerGapSec || 5) || 5,
|
|
seedBestLapCount: Math.max(0, Number(session?.seedBestLapCount || 0) || 0),
|
|
seedMethod: ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase())
|
|
? String(session.seedMethod).toLowerCase()
|
|
: "best_sum",
|
|
followUpSec: Math.max(0, Number(session?.followUpSec || 0) || 0),
|
|
followUpStartedAt: Number(session?.followUpStartedAt || 0) || null,
|
|
driverIds: Array.isArray(session?.driverIds) ? session.driverIds : [],
|
|
manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds : [],
|
|
gridCustomized: Boolean(session?.gridCustomized),
|
|
reservedBumpSlots: Math.max(0, Number(session?.reservedBumpSlots || 0) || 0),
|
|
generated: Boolean(session?.generated),
|
|
assignments: Array.isArray(session?.assignments) ? session.assignments : [],
|
|
};
|
|
}
|
|
|
|
function normalizeDriver(driver) {
|
|
const item = driver && typeof driver === "object" ? driver : {};
|
|
return {
|
|
id: item.id || uid("driver"),
|
|
name: String(item.name || "").trim(),
|
|
classId: String(item.classId || ""),
|
|
brand: String(item.brand || "").trim(),
|
|
transponder: String(item.transponder || "").trim(),
|
|
};
|
|
}
|
|
|
|
function normalizeCar(car) {
|
|
const item = car && typeof car === "object" ? car : {};
|
|
return {
|
|
id: item.id || uid("car"),
|
|
name: String(item.name || "").trim(),
|
|
brand: String(item.brand || "").trim(),
|
|
transponder: String(item.transponder || "").trim(),
|
|
};
|
|
}
|
|
|
|
function scheduleBackendSync() {
|
|
clearTimeout(backendSyncTimer);
|
|
backendSyncTimer = setTimeout(() => {
|
|
syncStateToBackend();
|
|
}, 350);
|
|
}
|
|
|
|
async function syncStateToBackend() {
|
|
const syncVersion = localStateVersion;
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/state`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildPersistableState()),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
backend.available = true;
|
|
backend.lastError = "";
|
|
backend.lastSyncAt = new Date().toISOString();
|
|
syncedStateVersion = Math.max(syncedStateVersion, syncVersion);
|
|
if (syncVersion === localStateVersion) {
|
|
localStateDirty = false;
|
|
}
|
|
} catch (error) {
|
|
backend.available = false;
|
|
backend.lastError = t("error.sync_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
async function pingBackend() {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/health`);
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
backend.available = true;
|
|
backend.lastError = "";
|
|
} catch (error) {
|
|
backend.available = false;
|
|
backend.lastError = t("error.health_failed", { msg: error instanceof Error ? error.message : String(error) });
|
|
}
|
|
}
|
|
|
|
function startAppVersionPolling() {
|
|
if (!window.location.protocol.startsWith("http")) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(appVersionPollTimer);
|
|
checkAppVersion();
|
|
appVersionPollTimer = setInterval(checkAppVersion, 3000);
|
|
}
|
|
|
|
async function checkAppVersion() {
|
|
try {
|
|
const res = await fetch(`${getBackendUrl()}/api/app-version`, { cache: "no-store" });
|
|
if (!res.ok) {
|
|
return;
|
|
}
|
|
const payload = await res.json();
|
|
const key = `${payload.revision}:${payload.updatedAt}`;
|
|
if (!baselineAppVersion) {
|
|
baselineAppVersion = key;
|
|
return;
|
|
}
|
|
if (key !== baselineAppVersion) {
|
|
window.location.reload();
|
|
}
|
|
} catch {
|
|
// silent - normal when backend is temporarily unavailable
|
|
}
|
|
}
|
|
|
|
function startOverlaySync() {
|
|
clearInterval(overlaySyncTimer);
|
|
overlaySyncTimer = setInterval(async () => {
|
|
if (!localStateDirty) {
|
|
await hydrateFromBackend();
|
|
}
|
|
if (currentView === "overlay") {
|
|
renderView();
|
|
}
|
|
}, 800);
|
|
}
|
|
|
|
function startOverlayRotation() {
|
|
clearInterval(overlayRotationTimer);
|
|
overlayRotationTimer = setInterval(() => {
|
|
overlayRotationIndex = (overlayRotationIndex + 1) % 3;
|
|
if (currentView === "overlay" && overlayViewMode === "leaderboard") {
|
|
renderView();
|
|
}
|
|
}, 8000);
|
|
}
|
|
|
|
function startOverlayLiveRefresh() {
|
|
clearInterval(overlayLiveRefreshTimer);
|
|
overlayLiveRefreshTimer = setInterval(() => {
|
|
if (currentView === "overlay" && ["leaderboard", "tv", "team"].includes(overlayViewMode)) {
|
|
renderOverlay();
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function renderNav() {
|
|
if (overlayMode) {
|
|
dom.nav.innerHTML = "";
|
|
return;
|
|
}
|
|
dom.nav.innerHTML = "";
|
|
NAV_ITEMS.forEach((item) => {
|
|
const button = document.createElement("button");
|
|
button.className = `nav-item ${item.id === currentView ? "active" : ""}`;
|
|
button.textContent = t(item.titleKey);
|
|
button.addEventListener("click", () => {
|
|
currentView = item.id;
|
|
renderNav();
|
|
renderView();
|
|
});
|
|
dom.nav.appendChild(button);
|
|
});
|
|
}
|
|
|
|
function renderView() {
|
|
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 "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,
|
|
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 = `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>Render error</h3></div>
|
|
<div class="panel-body">
|
|
<p>${escapeHtml(error instanceof Error ? error.message : String(error))}</p>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
updateHeaderState();
|
|
}
|
|
|
|
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;
|
|
dom.connectionBadge.textContent = isOnline ? t("ui.decoder_online") : t("ui.decoder_offline");
|
|
dom.connectionBadge.className = `badge ${isOnline ? "badge-online" : "badge-offline"}`;
|
|
}
|
|
|
|
function tickClock() {
|
|
dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US");
|
|
const timerState = handleSessionTimerTick();
|
|
const active = getActiveSession();
|
|
if (currentView === "timing" && active && (active.status === "running" || timerState.changed)) {
|
|
renderView();
|
|
}
|
|
if (currentView === "dashboard" && timerState.changed) {
|
|
renderView();
|
|
}
|
|
if (overlayMode && currentView === "overlay" && active) {
|
|
renderView();
|
|
}
|
|
if (timerState.changed) {
|
|
updateHeaderState();
|
|
}
|
|
}
|
|
|
|
function ensureAudioContext() {
|
|
if (!state.settings.audioEnabled) {
|
|
return null;
|
|
}
|
|
const Ctx = window.AudioContext || window.webkitAudioContext;
|
|
if (!Ctx) {
|
|
return null;
|
|
}
|
|
if (!audioCtx) {
|
|
audioCtx = new Ctx();
|
|
}
|
|
if (audioCtx.state === "suspended") {
|
|
audioCtx.resume().catch(() => {});
|
|
}
|
|
return audioCtx;
|
|
}
|
|
|
|
function playPassingBeep() {
|
|
const ctx = ensureAudioContext();
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = "square";
|
|
osc.frequency.setValueAtTime(1320, ctx.currentTime);
|
|
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.14);
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.start();
|
|
osc.stop(ctx.currentTime + 0.16);
|
|
}
|
|
|
|
function playFinishSiren() {
|
|
const ctx = ensureAudioContext();
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = "sawtooth";
|
|
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.03);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.2);
|
|
osc.frequency.setValueAtTime(720, ctx.currentTime);
|
|
osc.frequency.linearRampToValueAtTime(1280, ctx.currentTime + 0.28);
|
|
osc.frequency.linearRampToValueAtTime(720, ctx.currentTime + 0.56);
|
|
osc.frequency.linearRampToValueAtTime(1280, ctx.currentTime + 0.84);
|
|
osc.frequency.linearRampToValueAtTime(720, ctx.currentTime + 1.12);
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.start();
|
|
osc.stop(ctx.currentTime + 1.22);
|
|
}
|
|
|
|
function playLeaderCue() {
|
|
const ctx = ensureAudioContext();
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = "triangle";
|
|
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.09, ctx.currentTime + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.26);
|
|
osc.frequency.setValueAtTime(880, ctx.currentTime);
|
|
osc.frequency.linearRampToValueAtTime(1320, ctx.currentTime + 0.12);
|
|
osc.frequency.linearRampToValueAtTime(1760, ctx.currentTime + 0.24);
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.start();
|
|
osc.stop(ctx.currentTime + 0.28);
|
|
}
|
|
|
|
function playStartCue() {
|
|
const ctx = ensureAudioContext();
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = "triangle";
|
|
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
|
osc.frequency.setValueAtTime(520, ctx.currentTime);
|
|
osc.frequency.linearRampToValueAtTime(1040, ctx.currentTime + 0.4);
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.start();
|
|
osc.stop(ctx.currentTime + 0.42);
|
|
}
|
|
|
|
function playBestLapCue() {
|
|
const ctx = ensureAudioContext();
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.type = "sine";
|
|
gain.gain.setValueAtTime(0.001, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.07, ctx.currentTime + 0.01);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.22);
|
|
osc.frequency.setValueAtTime(1540, ctx.currentTime);
|
|
osc.frequency.linearRampToValueAtTime(1980, ctx.currentTime + 0.2);
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.start();
|
|
osc.stop(ctx.currentTime + 0.24);
|
|
}
|
|
|
|
function pushOverlayEvent(type, label) {
|
|
overlayEvents.unshift({
|
|
id: uid("overlay"),
|
|
type,
|
|
label,
|
|
ts: Date.now(),
|
|
});
|
|
if (overlayEvents.length > 12) {
|
|
overlayEvents = overlayEvents.slice(0, 12);
|
|
}
|
|
if (overlayMode && currentView === "overlay" && overlayViewMode === "speaker") {
|
|
if (type === "leader" && state.settings.speakerLeaderCueEnabled) {
|
|
playLeaderCue();
|
|
} else if (type === "passing" && state.settings.speakerPassingCueEnabled) {
|
|
playPassingBeep();
|
|
} else if (type === "finish" && state.settings.speakerFinishCueEnabled) {
|
|
playFinishSiren();
|
|
} else if (type === "start" && state.settings.speakerSessionStartCueEnabled) {
|
|
playStartCue();
|
|
} else if (type === "bestlap" && state.settings.speakerBestLapCueEnabled) {
|
|
playBestLapCue();
|
|
} else if (type === "top3" && state.settings.speakerTop3CueEnabled) {
|
|
playLeaderCue();
|
|
}
|
|
}
|
|
}
|
|
|
|
function speakText(text) {
|
|
if (!state.settings.audioEnabled || !("speechSynthesis" in window) || !text) {
|
|
return;
|
|
}
|
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
utterance.lang = currentLanguage() === "sv" ? "sv-SE" : "en-US";
|
|
utterance.rate = 1;
|
|
window.speechSynthesis.cancel();
|
|
window.speechSynthesis.speak(utterance);
|
|
}
|
|
|
|
function announcePassing(entry) {
|
|
if (!state.settings.audioEnabled) {
|
|
return;
|
|
}
|
|
if (state.settings.passingSoundMode === "beep") {
|
|
playPassingBeep();
|
|
return;
|
|
}
|
|
if (state.settings.passingSoundMode === "name") {
|
|
speakText(entry?.displayName || entry?.driverName || t("common.unknown_driver"));
|
|
}
|
|
}
|
|
|
|
function announceRaceFinished() {
|
|
if (!state.settings.audioEnabled || !state.settings.finishVoiceEnabled) {
|
|
const session = getActiveSession();
|
|
if (session) {
|
|
pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`);
|
|
}
|
|
return;
|
|
}
|
|
const session = getActiveSession();
|
|
if (session) {
|
|
pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`);
|
|
}
|
|
playFinishSiren();
|
|
}
|
|
|
|
function handleSessionTimerTick() {
|
|
const active = getActiveSession();
|
|
if (!active || active.status !== "running") {
|
|
return { changed: false };
|
|
}
|
|
|
|
if (isUntimedSession(active)) {
|
|
return { changed: false };
|
|
}
|
|
|
|
const timing = getSessionTiming(active);
|
|
if (timing.remainingMs > 0) {
|
|
return { changed: false };
|
|
}
|
|
|
|
if (Number(active.followUpSec || 0) > 0) {
|
|
if (!active.followUpStartedAt) {
|
|
active.followUpStartedAt = Date.now();
|
|
saveState();
|
|
return { changed: true };
|
|
}
|
|
if (timing.followUpRemainingMs > 0) {
|
|
return { changed: false };
|
|
}
|
|
}
|
|
|
|
active.status = "finished";
|
|
active.endedAt = Date.now();
|
|
active.finishedByTimer = true;
|
|
active.followUpStartedAt = null;
|
|
if (lastFinishAnnouncementSessionId !== active.id) {
|
|
announceRaceFinished();
|
|
lastFinishAnnouncementSessionId = active.id;
|
|
}
|
|
saveState();
|
|
return { changed: true };
|
|
}
|
|
|
|
function renderEvents() {
|
|
renderEventWorkspace("track");
|
|
}
|
|
|
|
function renderRaceSetup() {
|
|
renderEventWorkspace("race");
|
|
}
|
|
|
|
function renderEventWorkspace(mode) {
|
|
const isRaceMode = mode === "race";
|
|
if (isRaceMode) {
|
|
ensureRaceWizardDraft();
|
|
}
|
|
const filteredEvents = state.events.filter((event) => event.mode === mode);
|
|
const classOptions = state.classes
|
|
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`)
|
|
.join("");
|
|
const wizardClassOptions = state.classes
|
|
.map((c) => `<option value="${c.id}" ${c.id === raceWizardDraft?.classId ? "selected" : ""}>${escapeHtml(c.name)}</option>`)
|
|
.join("");
|
|
const editingEvent = filteredEvents.find((event) => event.id === selectedEventEditId) || null;
|
|
|
|
dom.view.innerHTML = `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t(isRaceMode ? "events.create_race" : "events.create")}</h3></div>
|
|
<div class="panel-body">
|
|
<p>${t(isRaceMode ? "events.race_only_intro" : "events.track_only_intro")}</p>
|
|
${isRaceMode ? `<p class="hint">${t("events.wizard_hint")}</p>` : ""}
|
|
</div>
|
|
${
|
|
isRaceMode
|
|
? `
|
|
<div class="panel-body race-wizard-steps">
|
|
${renderRaceWizardStepsView()}
|
|
</div>
|
|
<div class="panel-body">
|
|
${renderRaceWizardContentView(raceWizardDraft, wizardClassOptions, getDriversForClass(raceWizardDraft.classId), getRaceWizardPreset(raceWizardDraft.presetId))}
|
|
</div>
|
|
<div class="panel-body race-wizard-footer">
|
|
<button id="raceWizardReset" class="btn" type="button">${t("common.reset")}</button>
|
|
<div class="actions-inline">
|
|
${raceWizardStep > 1 ? `<button id="raceWizardPrev" class="btn" type="button">${t("common.previous")}</button>` : ""}
|
|
${raceWizardStep < 4 ? `<button id="raceWizardNext" class="btn btn-primary" type="button">${t("common.next")}</button>` : `<button id="raceWizardCreate" class="btn btn-primary" type="button">${t("events.wizard_create")}</button>`}
|
|
</div>
|
|
</div>
|
|
`
|
|
: `
|
|
<form id="eventForm" class="panel-body form-grid cols-4">
|
|
<label>
|
|
${t("events.field_name")}
|
|
<input required name="name" placeholder="${t("events.name_placeholder")}" />
|
|
<small>${t("events.field_name_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.field_date")}
|
|
<input required type="date" name="date" />
|
|
<small>${t("events.field_date_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.field_class")}
|
|
<select name="classId">${classOptions}</select>
|
|
<small>${t("events.field_class_hint")}</small>
|
|
</label>
|
|
<div class="actions-inline align-end">
|
|
<button class="btn btn-primary" type="submit">${t("events.add")}</button>
|
|
</div>
|
|
</form>
|
|
`
|
|
}
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t(isRaceMode ? "events.race_title" : "events.title")}</h3></div>
|
|
<div class="panel-body">
|
|
${renderTable(
|
|
[t("table.name"), t("table.date"), t("table.class"), t("table.mode"), t("events.sessions"), t("events.actions")],
|
|
filteredEvents.map((e) => {
|
|
const sessions = getSessionsForEvent(e.id);
|
|
return `
|
|
<tr>
|
|
<td>${escapeHtml(e.name)}</td>
|
|
<td>${escapeHtml(e.date)}</td>
|
|
<td>${escapeHtml(getClassName(e.classId))}</td>
|
|
<td>${getModeLabel(e.mode)}</td>
|
|
<td>${sessions.length}</td>
|
|
<td class="actions-inline">
|
|
<button id="event-edit-${e.id}" class="btn">${t("events.edit")}</button>
|
|
<button id="event-manage-${e.id}" class="btn">${t("events.manage")}</button>
|
|
<button id="event-delete-${e.id}" class="btn btn-danger">${t("common.delete")}</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
})
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section id="eventManageArea"></section>
|
|
|
|
${
|
|
editingEvent
|
|
? `
|
|
<div class="modal-overlay" id="eventEditModalOverlay">
|
|
<div class="modal-card">
|
|
<div class="panel-header">
|
|
<h3>${t("common.edit")}</h3>
|
|
<button class="btn" id="eventEditCancel">${t("common.cancel")}</button>
|
|
</div>
|
|
<form id="eventEditForm" class="panel-body form-grid cols-3">
|
|
<label>
|
|
${t("events.field_name")}
|
|
<input name="name" required value="${escapeHtml(editingEvent.name)}" placeholder="${t("events.name_placeholder")}" />
|
|
<small>${t("events.field_name_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.field_date")}
|
|
<input name="date" required type="date" value="${escapeHtml(editingEvent.date || "")}" />
|
|
<small>${t("events.field_date_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.field_class")}
|
|
<select name="classId">
|
|
${state.classes
|
|
.map(
|
|
(item) =>
|
|
`<option value="${item.id}" ${item.id === editingEvent.classId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
|
|
)
|
|
.join("")}
|
|
</select>
|
|
<small>${t("events.field_class_hint")}</small>
|
|
</label>
|
|
<p class="form-error" id="eventEditError" hidden></p>
|
|
<div class="actions-inline">
|
|
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
|
|
<button class="btn" id="eventEditCancelFooter" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
`;
|
|
|
|
if (isRaceMode) {
|
|
const persistWizardStepOne = () => {
|
|
const form = document.getElementById("raceWizardStepForm");
|
|
if (!(form instanceof HTMLFormElement)) {
|
|
return true;
|
|
}
|
|
const data = new FormData(form);
|
|
const nextName = String(data.get("name") || "").trim();
|
|
const nextDate = String(data.get("date") || "").trim();
|
|
const nextClassId = String(data.get("classId") || "").trim();
|
|
const nextPresetId = String(data.get("presetId") || "club_qualifying").trim() || "club_qualifying";
|
|
if (!nextName || !nextDate || !nextClassId) {
|
|
return false;
|
|
}
|
|
const classChanged = raceWizardDraft.classId !== nextClassId;
|
|
raceWizardDraft.name = nextName;
|
|
raceWizardDraft.date = nextDate;
|
|
raceWizardDraft.classId = nextClassId;
|
|
if (classChanged) {
|
|
raceWizardDraft.driverIds = getDriversForClass(nextClassId).map((driver) => driver.id);
|
|
}
|
|
if (raceWizardDraft.presetId !== nextPresetId) {
|
|
applyRaceWizardPresetDefaults(raceWizardDraft, nextPresetId);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const persistWizardParticipants = () => {
|
|
raceWizardDraft.driverIds = Array.from(document.querySelectorAll(".wizard-participant:checked")).map((node) => node.value);
|
|
return true;
|
|
};
|
|
|
|
const persistWizardPlan = () => {
|
|
const form = document.getElementById("raceWizardPlanForm");
|
|
if (!(form instanceof HTMLFormElement)) {
|
|
return true;
|
|
}
|
|
const data = new FormData(form);
|
|
raceWizardDraft.createPractice = data.get("createPractice") === "on";
|
|
raceWizardDraft.practiceSessions = Math.max(0, Number(data.get("practiceSessions") || 0) || 0);
|
|
raceWizardDraft.createQualifying = data.get("createQualifying") === "on";
|
|
raceWizardDraft.qualifyingRounds = Math.max(0, Number(data.get("qualifyingRounds") || 0) || 0);
|
|
raceWizardDraft.createTeamRace = data.get("createTeamRace") === "on";
|
|
raceWizardDraft.teamRaceDurationMin = Math.max(1, Number(data.get("teamRaceDurationMin") || 1) || 1);
|
|
return raceWizardDraft.createPractice || raceWizardDraft.createQualifying || raceWizardDraft.createTeamRace;
|
|
};
|
|
|
|
document.getElementById("raceWizardReset")?.addEventListener("click", () => {
|
|
raceWizardDraft = applyRaceWizardPresetDefaults(buildDefaultRaceWizardDraft(), "club_qualifying");
|
|
raceWizardStep = 1;
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("raceWizardPrev")?.addEventListener("click", () => {
|
|
if (raceWizardStep === 2) {
|
|
persistWizardParticipants();
|
|
}
|
|
if (raceWizardStep === 3) {
|
|
persistWizardPlan();
|
|
}
|
|
raceWizardStep = Math.max(1, raceWizardStep - 1);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("raceWizardNext")?.addEventListener("click", () => {
|
|
let valid = true;
|
|
if (raceWizardStep === 1) {
|
|
valid = persistWizardStepOne();
|
|
} else if (raceWizardStep === 2) {
|
|
valid = persistWizardParticipants();
|
|
} else if (raceWizardStep === 3) {
|
|
valid = persistWizardPlan();
|
|
}
|
|
if (!valid) {
|
|
return;
|
|
}
|
|
raceWizardStep = Math.min(4, raceWizardStep + 1);
|
|
renderView();
|
|
});
|
|
|
|
const wizardBasicsForm = document.getElementById("raceWizardStepForm");
|
|
const syncWizardBasicsDraft = () => {
|
|
if (!(wizardBasicsForm instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
const data = new FormData(wizardBasicsForm);
|
|
raceWizardDraft.name = String(data.get("name") || "").trim();
|
|
raceWizardDraft.date = String(data.get("date") || raceWizardDraft.date || "").trim();
|
|
};
|
|
|
|
wizardBasicsForm?.querySelector('[name="classId"]')?.addEventListener("change", (event) => {
|
|
syncWizardBasicsDraft();
|
|
const nextClassId = String(event.currentTarget?.value || "").trim();
|
|
if (!nextClassId) {
|
|
return;
|
|
}
|
|
const classChanged = raceWizardDraft.classId !== nextClassId;
|
|
raceWizardDraft.classId = nextClassId;
|
|
if (classChanged) {
|
|
raceWizardDraft.driverIds = getDriversForClass(nextClassId).map((driver) => driver.id);
|
|
}
|
|
renderView();
|
|
});
|
|
|
|
wizardBasicsForm?.querySelector('[name="presetId"]')?.addEventListener("change", (event) => {
|
|
syncWizardBasicsDraft();
|
|
const nextPresetId = String(event.currentTarget?.value || "club_qualifying").trim() || "club_qualifying";
|
|
applyRaceWizardPresetDefaults(raceWizardDraft, nextPresetId);
|
|
renderView();
|
|
});
|
|
|
|
const wizardPlanForm = document.getElementById("raceWizardPlanForm");
|
|
const toggleWizardPlanField = (toggleName, fieldName) => {
|
|
const toggle = wizardPlanForm?.querySelector(`[name="${toggleName}"]`);
|
|
const field = wizardPlanForm?.querySelector(`[name="${fieldName}"]`);
|
|
if (!(toggle instanceof HTMLInputElement) || !(field instanceof HTMLInputElement)) {
|
|
return;
|
|
}
|
|
const applyDisabledState = () => {
|
|
field.disabled = !toggle.checked;
|
|
};
|
|
applyDisabledState();
|
|
toggle.addEventListener("change", applyDisabledState);
|
|
};
|
|
toggleWizardPlanField("createPractice", "practiceSessions");
|
|
toggleWizardPlanField("createQualifying", "qualifyingRounds");
|
|
toggleWizardPlanField("createTeamRace", "teamRaceDurationMin");
|
|
|
|
document.getElementById("wizardSelectAllParticipants")?.addEventListener("click", () => {
|
|
raceWizardDraft.driverIds = getDriversForClass(raceWizardDraft.classId).map((driver) => driver.id);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("wizardClearParticipants")?.addEventListener("click", () => {
|
|
raceWizardDraft.driverIds = [];
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("raceWizardCreate")?.addEventListener("click", () => {
|
|
const selectedDrivers = raceWizardDraft.driverIds.length ? raceWizardDraft.driverIds : getDriversForClass(raceWizardDraft.classId).map((driver) => driver.id);
|
|
const event = normalizeEvent({
|
|
id: uid("event"),
|
|
name: raceWizardDraft.name.trim(),
|
|
date: raceWizardDraft.date,
|
|
classId: raceWizardDraft.classId,
|
|
mode,
|
|
});
|
|
applyRaceFormatPreset(event, raceWizardDraft.presetId);
|
|
event.raceConfig.driverIds = selectedDrivers;
|
|
event.raceConfig.participantsConfigured = true;
|
|
state.events.push(event);
|
|
buildRaceSessionsFromWizard(event, raceWizardDraft).forEach((session) => state.sessions.push(session));
|
|
selectedTeamEditId = null;
|
|
selectedSessionEditId = null;
|
|
raceWizardDraft = applyRaceWizardPresetDefaults(buildDefaultRaceWizardDraft(), "club_qualifying");
|
|
raceWizardStep = 1;
|
|
saveState();
|
|
renderView();
|
|
renderEventManager(event.id);
|
|
});
|
|
} else {
|
|
document.getElementById("eventForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
const event = {
|
|
id: uid("event"),
|
|
name: String(form.get("name")).trim(),
|
|
date: String(form.get("date")),
|
|
classId: String(form.get("classId")),
|
|
mode,
|
|
};
|
|
state.events.push(normalizeEvent(event));
|
|
saveState();
|
|
renderView();
|
|
});
|
|
}
|
|
|
|
filteredEvents.forEach((e) => {
|
|
document.getElementById(`event-edit-${e.id}`)?.addEventListener("click", () => {
|
|
selectedEventEditId = e.id;
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById(`event-delete-${e.id}`)?.addEventListener("click", () => {
|
|
const sessionIds = getSessionsForEvent(e.id).map((s) => s.id);
|
|
state.events = state.events.filter((x) => x.id !== e.id);
|
|
state.sessions = state.sessions.filter((x) => x.eventId !== e.id);
|
|
sessionIds.forEach((id) => delete state.resultsBySession[id]);
|
|
if (state.activeSessionId && sessionIds.includes(state.activeSessionId)) {
|
|
state.activeSessionId = null;
|
|
}
|
|
saveState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById(`event-manage-${e.id}`)?.addEventListener("click", () => {
|
|
renderEventManager(e.id);
|
|
});
|
|
});
|
|
|
|
document.getElementById("eventEditCancel")?.addEventListener("click", () => {
|
|
selectedEventEditId = null;
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("eventEditCancelFooter")?.addEventListener("click", () => {
|
|
selectedEventEditId = null;
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("eventEditModalOverlay")?.addEventListener("click", (event) => {
|
|
if (event.target?.id === "eventEditModalOverlay") {
|
|
selectedEventEditId = null;
|
|
renderView();
|
|
}
|
|
});
|
|
|
|
bindModalShell("eventEditModalOverlay", () => {
|
|
selectedEventEditId = null;
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("eventEditForm")?.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
if (!editingEvent) {
|
|
return;
|
|
}
|
|
const form = new FormData(event.currentTarget);
|
|
const cleanedName = String(form.get("name") || "").trim();
|
|
const cleanedDate = String(form.get("date") || "").trim();
|
|
const cleanedClassId = String(form.get("classId") || "").trim();
|
|
if (!cleanedName) {
|
|
setFormError("eventEditError", t("validation.required_name"));
|
|
return;
|
|
}
|
|
if (!cleanedDate) {
|
|
setFormError("eventEditError", t("validation.required_date"));
|
|
return;
|
|
}
|
|
if (!isValidIsoDate(cleanedDate)) {
|
|
setFormError("eventEditError", t("validation.invalid_date"));
|
|
return;
|
|
}
|
|
if (cleanedClassId && !state.classes.some((item) => item.id === cleanedClassId)) {
|
|
setFormError("eventEditError", t("validation.invalid_selection"));
|
|
return;
|
|
}
|
|
setFormError("eventEditError", "");
|
|
editingEvent.name = cleanedName;
|
|
editingEvent.date = cleanedDate;
|
|
editingEvent.classId = cleanedClassId || editingEvent.classId;
|
|
selectedEventEditId = null;
|
|
saveState();
|
|
renderView();
|
|
});
|
|
}
|
|
|
|
function renderEventManager(eventId) {
|
|
const event = state.events.find((e) => e.id === eventId);
|
|
if (!event) {
|
|
return;
|
|
}
|
|
const normalizedEvent = normalizeEvent(event);
|
|
if (normalizedEvent !== event) {
|
|
Object.assign(event, normalizedEvent);
|
|
}
|
|
ensureRaceParticipantsConfigured(event);
|
|
|
|
const sessions = getSessionsForEvent(eventId);
|
|
const eventManageArea = document.getElementById("eventManageArea");
|
|
if (!eventManageArea) {
|
|
return;
|
|
}
|
|
|
|
const driverOptions = state.drivers
|
|
.map((d) => `<option value="${d.id}">${escapeHtml(d.name)}</option>`)
|
|
.join("");
|
|
const teamDriverPool = event.mode === "race" ? getTeamDriverPool(event) : { drivers: [], fallback: false };
|
|
const raceDrivers = event.mode === "race" ? teamDriverPool.drivers : [];
|
|
const raceTeams = event.mode === "race" ? getEventTeams(event) : [];
|
|
if (selectedTeamEditId && !raceTeams.some((team) => team.id === selectedTeamEditId)) {
|
|
selectedTeamEditId = null;
|
|
}
|
|
const editingTeam = event.mode === "race" ? raceTeams.find((team) => team.id === selectedTeamEditId) || null : null;
|
|
const carOptions = state.cars
|
|
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)} (${escapeHtml(c.transponder)})</option>`)
|
|
.join("");
|
|
const branding = normalizeBrandingConfig(event.branding);
|
|
const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null;
|
|
const sessionTypeChoices = getSessionTypeChoices(event.mode);
|
|
const sessionTypeHintKey = event.mode === "track" ? "events.session_type_hint_track" : "events.session_type_hint_race";
|
|
const racePresets = getRaceFormatPresets();
|
|
const selectedPreset = racePresets.find((preset) => preset.id === event.raceConfig.presetId) || racePresets[0];
|
|
const isEndurancePreset = event.mode === "race" && selectedPreset?.id === "endurance";
|
|
const showBasicQualifyingFields = raceFormatAdvanced || !isEndurancePreset;
|
|
const showBasicFinalFields = raceFormatAdvanced || !isEndurancePreset;
|
|
const selectedParticipantCount = event.mode === "race" ? (event.raceConfig.participantsConfigured ? (event.raceConfig.driverIds || []).length : raceDrivers.length) : 0;
|
|
const raceSummaryItems = event.mode === "race" ? getRaceSummaryItems(event, sessions, raceDrivers, selectedPreset, { t, getStartModeLabel }) : [];
|
|
const raceSummaryWarnings = event.mode === "race" ? getRaceSummaryWarnings(event, sessions, raceDrivers, raceTeams, selectedPreset, { t }) : [];
|
|
const manageStatuses = event.mode === "race" ? getRaceManageStatuses(event, sessions, raceDrivers, raceTeams, selectedPreset) : null;
|
|
const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : [];
|
|
if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) {
|
|
selectedGridSessionId = "";
|
|
}
|
|
const selectedGridSession =
|
|
gridSessions.find((session) => session.id === selectedGridSessionId) || gridSessions[0] || null;
|
|
if (!selectedGridSessionId && selectedGridSession) {
|
|
selectedGridSessionId = selectedGridSession.id;
|
|
}
|
|
|
|
eventManageArea.innerHTML = `
|
|
<section class="panel" id="manage-session-plan">
|
|
<div class="panel-header">
|
|
<h3>${t("events.manage_title")}: ${escapeHtml(event.name)}</h3>
|
|
</div>
|
|
<div class="panel-body">
|
|
<form id="sessionForm" class="form-grid cols-5">
|
|
<label>
|
|
${t("events.session_name")}
|
|
<input required name="name" placeholder="${t("events.session_name")}" />
|
|
</label>
|
|
<label>
|
|
${t("events.session_type_label")}
|
|
<select name="type">
|
|
${sessionTypeChoices.map((s) => `<option value="${s}">${getSessionTypeLabel(s)}</option>`).join("")}
|
|
</select>
|
|
<small>${t(sessionTypeHintKey)}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_duration_label")}
|
|
<input required type="number" min="1" name="durationMin" placeholder="${t("events.duration_placeholder")}" />
|
|
<small>${t("events.session_duration_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_followup_label")}
|
|
<input type="number" min="0" step="1" name="followUpSec" placeholder="${t("events.follow_up_sec")}" value="${event.mode === "race" ? event.raceConfig.followUpSec || 0 : 0}" />
|
|
<small>${t("events.session_followup_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_start_mode_label")}
|
|
<select name="startMode">
|
|
<option value="mass">${t("events.start_mode_mass")}</option>
|
|
<option value="position">${t("events.start_mode_position")}</option>
|
|
<option value="staggered">${t("events.start_mode_staggered")}</option>
|
|
</select>
|
|
<small>${t("events.session_start_mode_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_seed_laps_label")}
|
|
<input type="number" min="0" name="seedBestLapCount" placeholder="${t("events.seed_best_laps")}" />
|
|
<small>${t("events.session_seed_laps_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_seed_method_label")}
|
|
<select name="seedMethod">
|
|
<option value="best_sum">${t("events.seed_method_best_sum")}</option>
|
|
<option value="average">${t("events.seed_method_average")}</option>
|
|
<option value="consecutive">${t("events.seed_method_consecutive")}</option>
|
|
</select>
|
|
<small>${t("events.session_seed_method_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_stagger_gap_label")}
|
|
<input type="number" min="0" step="1" name="staggerGapSec" placeholder="${t("events.stagger_gap_sec")}" />
|
|
<small>${t("events.session_stagger_gap_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_max_cars_label")}
|
|
<input type="number" min="1" name="maxCars" placeholder="${t("events.max_cars_placeholder")}" />
|
|
<small>${t("events.session_max_cars_hint")}</small>
|
|
</label>
|
|
<div class="actions-inline align-end">
|
|
<button class="btn btn-primary" type="submit">${t("events.add_session")}</button>
|
|
</div>
|
|
</form>
|
|
<p class="hint">${t("events.seed_best_laps_hint")}</p>
|
|
<p class="hint">${t("events.free_practice_note")}</p>
|
|
<p class="hint">${t("events.open_practice_note")}</p>
|
|
|
|
<div class="mt-16">
|
|
${renderTable(
|
|
[t("table.session"), t("table.type"), t("table.duration"), t("table.start_mode"), t("table.seeding"), t("table.status"), t(event.mode === "track" ? "events.assignments" : "events.participants"), t("events.actions")],
|
|
sessions.map((s) => {
|
|
const assignCount =
|
|
event.mode === "track"
|
|
? (s.assignments || []).length
|
|
: s.type === "team_race"
|
|
? raceTeams.length
|
|
: getSessionEntrants(s).length;
|
|
return `
|
|
<tr>
|
|
<td>${escapeHtml(s.name)}</td>
|
|
<td>${escapeHtml(getSessionTypeLabel(s.type))}</td>
|
|
<td>${s.durationMin} min</td>
|
|
<td>${escapeHtml(getStartModeLabel(s.startMode))}</td>
|
|
<td>${s.seedBestLapCount > 0 ? `${s.seedBestLapCount}` : "-"}</td>
|
|
<td>${escapeHtml(getStatusLabel(s.status))}</td>
|
|
<td>${assignCount || (event.mode === "track" ? t("events.na") : "-")}</td>
|
|
<td class="actions-inline">
|
|
<button id="session-edit-${s.id}" class="btn">${t("events.edit_session")}</button>
|
|
<button id="session-active-${s.id}" class="btn">${t("events.set_active")}</button>
|
|
${
|
|
event.mode === "race" && normalizeStartMode(s.startMode) === "position"
|
|
? `<button id="session-grid-${s.id}" class="btn" type="button">${t("events.open_grid")}</button>`
|
|
: ""
|
|
}
|
|
${
|
|
event.mode === "race" && ["qualification", "final"].includes(s.type)
|
|
? `
|
|
<button id="session-sheet-print-${s.id}" class="btn" type="button">${t("events.print_heat_sheet")}</button>
|
|
<button id="session-sheet-export-${s.id}" class="btn" type="button">${t("events.export_heat_sheet")}</button>
|
|
<button id="session-sheet-pdf-${s.id}" class="btn" type="button">${t("events.pdf_heat_sheet")}</button>
|
|
`
|
|
: ""
|
|
}
|
|
<button id="session-delete-${s.id}" class="btn btn-danger">${t("common.delete")}</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("events.branding")}</h3></div>
|
|
<form id="eventBrandingForm" class="panel-body form-grid cols-3">
|
|
<input name="brandName" value="${escapeHtml(branding.brandName)}" placeholder="${t("events.brand_name")}" />
|
|
<input name="brandTagline" value="${escapeHtml(branding.brandTagline)}" placeholder="${t("events.brand_tagline")}" />
|
|
<input name="pdfFooter" value="${escapeHtml(branding.pdfFooter)}" placeholder="${t("events.brand_footer")}" />
|
|
<select name="pdfTheme">
|
|
<option value="" ${!branding.pdfTheme ? "selected" : ""}>${t("events.branding_use_global")}</option>
|
|
<option value="classic" ${branding.pdfTheme === "classic" ? "selected" : ""}>${t("settings.pdf_theme_classic")}</option>
|
|
<option value="minimal" ${branding.pdfTheme === "minimal" ? "selected" : ""}>${t("settings.pdf_theme_minimal")}</option>
|
|
<option value="motorsport" ${branding.pdfTheme === "motorsport" ? "selected" : ""}>${t("settings.pdf_theme_motorsport")}</option>
|
|
</select>
|
|
<button class="btn btn-primary" type="submit">${t("events.branding_save")}</button>
|
|
</form>
|
|
<div class="panel-body">
|
|
<p class="hint">${t("events.branding_note")}</p>
|
|
<div class="actions">
|
|
<input id="eventLogoUpload" type="file" accept="image/*" />
|
|
<button id="eventLogoClear" class="btn" type="button">${t("settings.logo_clear")}</button>
|
|
</div>
|
|
${branding.logoDataUrl ? `<div class="logo-preview mt-16"><img src="${escapeHtml(branding.logoDataUrl)}" alt="event-logo" /></div>` : ""}
|
|
</div>
|
|
</section>
|
|
|
|
${
|
|
event.mode === "track"
|
|
? `
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("events.sponsor_tools")}</h3></div>
|
|
<div class="panel-body">
|
|
<form id="sponsorRoundsForm" class="form-grid cols-5">
|
|
<input type="number" min="0" name="qualificationRounds" value="1" placeholder="${t("events.qual_rounds")}" />
|
|
<input type="number" min="0" name="heatRounds" value="3" placeholder="${t("events.heat_rounds")}" />
|
|
<input type="number" min="0" name="finalRounds" value="3" placeholder="${t("events.final_rounds")}" />
|
|
<input type="number" min="1" name="roundDuration" value="5" placeholder="${t("events.round_duration")}" />
|
|
<button class="btn btn-primary" type="submit">${t("events.create_rounds")}</button>
|
|
</form>
|
|
<p class="hint">
|
|
${t("events.tp_rule")}
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("events.assign_title")}</h3></div>
|
|
<div class="panel-body">
|
|
<form id="assignForm" class="form-grid cols-4">
|
|
<select name="sessionId">${sessions
|
|
.map((s) => `<option value="${s.id}">${escapeHtml(s.name)}</option>`)
|
|
.join("")}</select>
|
|
<select name="driverId">${driverOptions}</select>
|
|
<select name="carId">${carOptions}</select>
|
|
<button class="btn btn-primary" type="submit">${t("events.assign")}</button>
|
|
</form>
|
|
<div class="actions mt-16">
|
|
<button id="autoAssignSession" class="btn" type="button">${t("events.auto_assign")}</button>
|
|
<button id="clearAssignSession" class="btn btn-danger" type="button">${t("events.clear_assign")}</button>
|
|
</div>
|
|
<div id="assignmentList" class="mt-16"></div>
|
|
</div>
|
|
</section>
|
|
`
|
|
: ""
|
|
}
|
|
|
|
${
|
|
event.mode === "race"
|
|
? `
|
|
<section class="panel mt-16">
|
|
<div class="panel-body manage-step-grid">
|
|
<article class="manage-step-card manage-step-card-${manageStatuses?.setup?.status || "pending"} manage-step-card-link" data-target="manage-session-plan" role="button" tabindex="0">
|
|
<div class="manage-step-head"><strong>${t("events.manage_step_setup")}</strong>${renderManageStatusBadgeView(manageStatuses?.setup?.status || "pending")}</div>
|
|
<div class="manage-step-meta">${escapeHtml(manageStatuses?.setup?.detail || "")}</div>
|
|
<p>${t("events.manage_step_setup_hint")}</p>
|
|
</article>
|
|
<article class="manage-step-card manage-step-card-${manageStatuses?.format?.status || "pending"} manage-step-card-link" data-target="manage-format" role="button" tabindex="0">
|
|
<div class="manage-step-head"><strong>${t("events.manage_step_format")}</strong>${renderManageStatusBadgeView(manageStatuses?.format?.status || "pending")}</div>
|
|
<div class="manage-step-meta">${escapeHtml(manageStatuses?.format?.detail || "")}</div>
|
|
<p>${t("events.manage_step_format_hint")}</p>
|
|
</article>
|
|
<article class="manage-step-card manage-step-card-${manageStatuses?.generation?.status || "pending"} manage-step-card-link" data-target="manage-generation" role="button" tabindex="0">
|
|
<div class="manage-step-head"><strong>${t("events.manage_step_generate")}</strong>${renderManageStatusBadgeView(manageStatuses?.generation?.status || "pending")}</div>
|
|
<div class="manage-step-meta">${escapeHtml(manageStatuses?.generation?.detail || "")}</div>
|
|
<p>${t("events.manage_step_generate_hint")}</p>
|
|
</article>
|
|
<article class="manage-step-card manage-step-card-${manageStatuses?.live?.status || "pending"} manage-step-card-link" data-target="manage-live-results" role="button" tabindex="0">
|
|
<div class="manage-step-head"><strong>${t("events.manage_step_live")}</strong>${renderManageStatusBadgeView(manageStatuses?.live?.status || "pending")}</div>
|
|
<div class="manage-step-meta">${escapeHtml(manageStatuses?.live?.detail || "")}</div>
|
|
<p>${t("events.manage_step_live_hint")}</p>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="race-stage-grid mt-16">
|
|
<section class="panel" id="manage-setup-participants">
|
|
<div class="panel-header panel-header-with-pill">
|
|
<h3>${t("events.select_participants")}</h3>
|
|
<span class="pill">${selectedParticipantCount}</span>
|
|
</div>
|
|
<div class="panel-body">
|
|
<div class="actions">
|
|
<button id="selectAllParticipants" class="btn" type="button">${t("events.select_all_participants")}</button>
|
|
<button id="clearParticipants" class="btn btn-danger" type="button">${t("events.clear_participants")}</button>
|
|
</div>
|
|
<div class="check-grid mt-16">
|
|
${raceDrivers
|
|
.map((driver) => {
|
|
const checked = event.raceConfig.participantsConfigured
|
|
? (event.raceConfig.driverIds || []).includes(driver.id)
|
|
: true;
|
|
return `
|
|
<label class="check-card">
|
|
<input type="checkbox" class="race-participant" value="${driver.id}" ${checked ? "checked" : ""} />
|
|
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
|
|
</label>
|
|
`;
|
|
})
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" id="manage-setup-teams">
|
|
<div class="panel-header panel-header-with-pill">
|
|
<h3>${t("events.teams")}</h3>
|
|
<span class="pill">${raceTeams.length}</span>
|
|
</div>
|
|
<div class="panel-body">
|
|
<p class="hint">${t("events.team_race_intro")}</p>
|
|
<p class="hint">${t("events.team_steps")}</p>
|
|
<form id="teamForm" class="form-grid cols-4 team-create-form">
|
|
<input name="teamName" required placeholder="${t("events.team_name")}" />
|
|
<button class="btn btn-primary" type="submit">${t("events.add_team")}</button>
|
|
</form>
|
|
<p class="hint">${t("events.team_hint")}</p>
|
|
<div class="panel-row mt-16">
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("events.team_drivers")}</h3></div>
|
|
<div class="panel-body"><p class="hint">${t("events.team_form_drivers")}</p></div>
|
|
${teamDriverPool.fallback ? `<div class="panel-body"><p class="hint">${t("events.team_driver_fallback")}</p></div>` : ""}
|
|
<div class="panel-body check-grid">
|
|
${raceDrivers
|
|
.map(
|
|
(driver) => `
|
|
<label class="check-card">
|
|
<input type="checkbox" name="teamDriverIds" form="teamForm" value="${driver.id}" />
|
|
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
|
|
</label>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</section>
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("events.team_cars")}</h3></div>
|
|
<div class="panel-body"><p class="hint">${t("events.team_form_cars")}</p></div>
|
|
<div class="panel-body check-grid">
|
|
${state.cars
|
|
.map(
|
|
(car) => `
|
|
<label class="check-card">
|
|
<input type="checkbox" name="teamCarIds" form="teamForm" value="${car.id}" />
|
|
<span>${escapeHtml(car.name)} (${escapeHtml(car.transponder || "-")})</span>
|
|
</label>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<div class="mt-16">
|
|
${
|
|
raceTeams.length
|
|
? raceTeams
|
|
.map(
|
|
(team) => `
|
|
<article class="team-card">
|
|
<div>
|
|
<strong>${escapeHtml(team.name)}</strong>
|
|
<div class="hint">${t("events.team_drivers")}: ${escapeHtml(
|
|
team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") || "-"
|
|
)}</div>
|
|
<div class="hint">${t("events.team_cars")}: ${escapeHtml(
|
|
team.carIds
|
|
.map((carId) => {
|
|
const car = state.cars.find((item) => item.id === carId);
|
|
return car ? `${car.name} (${car.transponder || "-"})` : "";
|
|
})
|
|
.filter(Boolean)
|
|
.join(", ") || "-"
|
|
)}</div>
|
|
</div>
|
|
<div class="actions-inline">
|
|
<button id="team-edit-${team.id}" class="btn" type="button">${t("events.edit_team")}</button>
|
|
<button id="team-delete-${team.id}" class="btn btn-danger" type="button">${t("common.delete")}</button>
|
|
</div>
|
|
</article>
|
|
`
|
|
)
|
|
.join("")
|
|
: `<p>${t("events.no_teams")}</p>`
|
|
}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="panel mt-16" id="manage-format">
|
|
<div class="panel-header"><h3>${t("events.race_format")}</h3></div>
|
|
<div class="panel-body">
|
|
<p>${t("events.race_format_intro")}</p>
|
|
</div>
|
|
<div class="panel-body race-setup-shell">
|
|
<div class="race-setup-main">
|
|
<div class="race-setup-modebar">
|
|
<div>
|
|
<strong>${t("events.race_format")}</strong>
|
|
<div class="field-hint">${t("events.race_actions_hint")}</div>
|
|
</div>
|
|
<div class="actions-inline race-setup-toggle">
|
|
<button id="raceFormatBasicToggle" class="btn ${!raceFormatAdvanced ? "is-active" : ""}" type="button">${t("events.setup_mode_basic")}</button>
|
|
<button id="raceFormatAdvancedToggle" class="btn ${raceFormatAdvanced ? "is-active" : ""}" type="button">${t("events.setup_mode_advanced")}</button>
|
|
</div>
|
|
</div>
|
|
<form id="raceFormatForm" class="race-format-sections">
|
|
<section class="race-format-section">
|
|
<div class="panel-header"><h3>${t("events.practice_block")}</h3></div>
|
|
<div class="panel-body"><p class="hint">${t("events.practice_block_hint")}</p></div>
|
|
<div class="panel-body form-grid cols-2 race-format-grid">
|
|
${renderRaceFormatContextCardView(isEndurancePreset ? "events.context_endurance_title" : "events.context_standard_title", isEndurancePreset ? "events.context_endurance_hint" : "events.context_standard_hint")}
|
|
${renderRaceFormatFieldView(
|
|
"events.race_preset",
|
|
"events.race_preset_hint",
|
|
`<select name="presetId">
|
|
${racePresets
|
|
.map(
|
|
(preset) => `<option value="${preset.id}" ${event.raceConfig.presetId === preset.id ? "selected" : ""}>${escapeHtml(preset.label)}</option>`
|
|
)
|
|
.join("")}
|
|
</select>`
|
|
)}
|
|
${raceFormatAdvanced
|
|
? `
|
|
<div class="field-card race-preset-actions-card">
|
|
<span class="field-label">${t("events.preset_name")}</span>
|
|
<span class="field-hint">${t("events.race_preset_hint")}</span>
|
|
<input name="presetName" value="${escapeHtml(selectedPreset?.custom ? selectedPreset.label : "")}" placeholder="${t("events.preset_name")}" />
|
|
<div class="actions-inline">
|
|
<button class="btn" id="applyRacePreset" type="button">${t("events.apply_preset")}</button>
|
|
<button class="btn" id="saveRacePreset" type="button">${t("events.save_preset")}</button>
|
|
<button class="btn btn-danger" id="deleteRacePreset" type="button">${t("events.delete_preset")}</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
: `
|
|
<div class="field-card race-preset-actions-card race-preset-actions-card-compact">
|
|
<span class="field-label">${t("events.race_preset")}</span>
|
|
<span class="field-hint">${t("events.race_preset_hint")}</span>
|
|
<div class="actions-inline">
|
|
<button class="btn" id="applyRacePreset" type="button">${t("events.apply_preset")}</button>
|
|
</div>
|
|
</div>
|
|
`}
|
|
${showBasicQualifyingFields ? renderRaceFormatFieldView(
|
|
"events.qualifying_scoring",
|
|
"events.qualifying_scoring_hint",
|
|
`<select name="qualifyingScoring">
|
|
<option value="points" ${event.raceConfig.qualifyingScoring === "points" ? "selected" : ""}>${t("events.qualifying_scoring_points")}</option>
|
|
<option value="best" ${event.raceConfig.qualifyingScoring === "best" ? "selected" : ""}>${t("events.qualifying_scoring_best")}</option>
|
|
</select>`
|
|
) : ""}
|
|
${showBasicQualifyingFields ? renderRaceFormatFieldView(
|
|
"events.qualifying_rounds",
|
|
"events.qualifying_rounds_hint",
|
|
`<input type="number" min="1" name="qualifyingRounds" value="${event.raceConfig.qualifyingRounds}" />`
|
|
) : ""}
|
|
${showBasicQualifyingFields ? renderRaceFormatFieldView(
|
|
"events.cars_per_heat",
|
|
"events.cars_per_heat_hint",
|
|
`<input type="number" min="2" name="carsPerHeat" value="${event.raceConfig.carsPerHeat}" />`
|
|
) : ""}
|
|
${showBasicQualifyingFields ? renderRaceFormatFieldView(
|
|
"events.qual_duration",
|
|
"events.qual_duration_hint",
|
|
`<input type="number" min="1" name="qualDurationMin" value="${event.raceConfig.qualDurationMin}" />`
|
|
) : ""}
|
|
${showBasicQualifyingFields ? renderRaceFormatFieldView(
|
|
"events.qual_start_mode",
|
|
"events.qual_start_mode_hint",
|
|
`<select name="qualStartMode">
|
|
<option value="mass" ${event.raceConfig.qualStartMode === "mass" ? "selected" : ""}>${t("events.start_mode_mass")}</option>
|
|
<option value="position" ${event.raceConfig.qualStartMode === "position" ? "selected" : ""}>${t("events.start_mode_position")}</option>
|
|
<option value="staggered" ${event.raceConfig.qualStartMode === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
|
|
</select>`
|
|
) : ""}
|
|
${raceFormatAdvanced
|
|
? renderRaceFormatFieldView(
|
|
"events.qual_seed_laps",
|
|
"events.qual_seed_laps_hint",
|
|
`<input type="number" min="0" name="qualSeedLapCount" value="${event.raceConfig.qualSeedLapCount}" />`
|
|
)
|
|
: ""}
|
|
${raceFormatAdvanced
|
|
? renderRaceFormatFieldView(
|
|
"events.qual_seed_method",
|
|
"events.qual_seed_method_hint",
|
|
`<select name="qualSeedMethod">
|
|
<option value="best_sum" ${event.raceConfig.qualSeedMethod === "best_sum" ? "selected" : ""}>${t("events.seed_method_best_sum")}</option>
|
|
<option value="average" ${event.raceConfig.qualSeedMethod === "average" ? "selected" : ""}>${t("events.seed_method_average")}</option>
|
|
<option value="consecutive" ${event.raceConfig.qualSeedMethod === "consecutive" ? "selected" : ""}>${t("events.seed_method_consecutive")}</option>
|
|
</select>`
|
|
)
|
|
: ""}
|
|
${raceFormatAdvanced
|
|
? renderRaceFormatFieldView(
|
|
"events.counted_qual_rounds",
|
|
"events.counted_qual_rounds_hint",
|
|
`<input type="number" min="1" name="countedQualRounds" value="${event.raceConfig.countedQualRounds}" />`
|
|
)
|
|
: ""}
|
|
${raceFormatAdvanced
|
|
? renderRaceFormatFieldView(
|
|
"events.qual_points_table",
|
|
"events.qual_points_table_hint",
|
|
`<select name="qualifyingPointsTable">
|
|
<option value="rank_low" ${event.raceConfig.qualifyingPointsTable === "rank_low" ? "selected" : ""}>${t("events.qual_points_rank")}</option>
|
|
<option value="field_desc" ${event.raceConfig.qualifyingPointsTable === "field_desc" ? "selected" : ""}>${t("events.qual_points_desc")}</option>
|
|
<option value="ifmar" ${event.raceConfig.qualifyingPointsTable === "ifmar" ? "selected" : ""}>${t("events.qual_points_ifmar")}</option>
|
|
</select>`
|
|
)
|
|
: ""}
|
|
${raceFormatAdvanced
|
|
? renderRaceFormatFieldView(
|
|
"events.qual_tie_break",
|
|
"events.qual_tie_break_hint",
|
|
`<select name="qualifyingTieBreak">
|
|
<option value="rounds" ${event.raceConfig.qualifyingTieBreak === "rounds" ? "selected" : ""}>${t("events.qual_tie_break_rounds")}</option>
|
|
<option value="best_lap" ${event.raceConfig.qualifyingTieBreak === "best_lap" ? "selected" : ""}>${t("events.qual_tie_break_best_lap")}</option>
|
|
<option value="best_round" ${event.raceConfig.qualifyingTieBreak === "best_round" ? "selected" : ""}>${t("events.qual_tie_break_best_round")}</option>
|
|
</select>`
|
|
)
|
|
: ""}
|
|
</div>
|
|
</section>
|
|
<section class="race-format-section">
|
|
<div class="panel-header"><h3>${t("events.finals_block")}</h3></div>
|
|
<div class="panel-body"><p class="hint">${t("events.finals_block_hint")}</p></div>
|
|
<div class="panel-body form-grid cols-2 race-format-grid">
|
|
${renderRaceFormatContextCardView(isEndurancePreset ? "events.context_endurance_title" : "events.context_standard_title", isEndurancePreset ? "events.context_endurance_hint" : "events.context_standard_hint")}
|
|
${showBasicFinalFields ? renderRaceFormatFieldView(
|
|
"events.cars_per_final",
|
|
"events.cars_per_final_hint",
|
|
`<input type="number" min="2" name="carsPerFinal" value="${event.raceConfig.carsPerFinal}" />`
|
|
) : ""}
|
|
${showBasicFinalFields ? renderRaceFormatFieldView(
|
|
"events.final_legs",
|
|
"events.final_legs_hint",
|
|
`<input type="number" min="1" name="finalLegs" value="${event.raceConfig.finalLegs}" />`
|
|
) : ""}
|
|
${raceFormatAdvanced
|
|
? renderRaceFormatFieldView(
|
|
"events.counted_final_legs",
|
|
"events.counted_final_legs_hint",
|
|
`<input type="number" min="1" name="countedFinalLegs" value="${event.raceConfig.countedFinalLegs}" />`
|
|
)
|
|
: ""}
|
|
${showBasicFinalFields ? renderRaceFormatFieldView(
|
|
"events.final_duration",
|
|
"events.final_duration_hint",
|
|
`<input type="number" min="1" name="finalDurationMin" value="${event.raceConfig.finalDurationMin}" />`
|
|
) : ""}
|
|
${showBasicFinalFields ? renderRaceFormatFieldView(
|
|
"events.final_start_mode",
|
|
"events.final_start_mode_hint",
|
|
`<select name="finalStartMode">
|
|
<option value="mass" ${event.raceConfig.finalStartMode === "mass" ? "selected" : ""}>${t("events.start_mode_mass")}</option>
|
|
<option value="position" ${event.raceConfig.finalStartMode === "position" ? "selected" : ""}>${t("events.start_mode_position")}</option>
|
|
<option value="staggered" ${event.raceConfig.finalStartMode === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
|
|
</select>`
|
|
) : ""}
|
|
${showBasicFinalFields ? renderRaceFormatFieldView(
|
|
"events.bump_count",
|
|
"events.bump_count_hint",
|
|
`<input type="number" min="0" name="bumpCount" value="${event.raceConfig.bumpCount}" />`
|
|
) : ""}
|
|
${showBasicFinalFields ? renderRaceFormatFieldView(
|
|
"events.source_for_finals",
|
|
"events.finals_source_hint",
|
|
`<select name="finalsSource">
|
|
<option value="qualifying" ${event.raceConfig.finalsSource === "qualifying" ? "selected" : ""}>${t("events.finals_from_qualifying")}</option>
|
|
<option value="practice" ${event.raceConfig.finalsSource === "practice" ? "selected" : ""}>${t("events.finals_from_practice")}</option>
|
|
</select>`
|
|
) : ""}
|
|
${raceFormatAdvanced
|
|
? renderRaceFormatFieldView(
|
|
"events.reserve_bump_slots",
|
|
"events.reserve_bump_slots_hint",
|
|
`<label class="toggle"><input type="checkbox" name="reserveBumpSlots" ${event.raceConfig.reserveBumpSlots ? "checked" : ""} /><span>${t("events.reserve_bump_slots")}</span></label>`,
|
|
{ checkbox: true }
|
|
)
|
|
: ""}
|
|
</div>
|
|
</section>
|
|
<section class="race-format-section">
|
|
<div class="panel-header"><h3>${t("events.rules_block")}</h3></div>
|
|
<div class="panel-body"><p class="hint">${t("events.rules_block_hint")}</p></div>
|
|
<div class="panel-body form-grid cols-2 race-format-grid">
|
|
${renderRaceFormatContextCardView("events.context_rules_title", "events.context_rules_hint")}
|
|
${renderRaceFormatFieldView(
|
|
"events.follow_up_sec",
|
|
"events.follow_up_sec_hint",
|
|
`<input type="number" min="0" step="1" name="followUpSec" value="${event.raceConfig.followUpSec || 0}" />`
|
|
)}
|
|
${renderRaceFormatFieldView(
|
|
"events.min_lap_time",
|
|
"events.min_lap_time_hint",
|
|
`<input type="number" min="0" step="0.1" name="minLapSec" value="${((event.raceConfig.minLapMs || 0) / 1000).toFixed(1)}" />`
|
|
)}
|
|
${renderRaceFormatFieldView(
|
|
"events.max_lap_time",
|
|
"events.max_lap_time_hint",
|
|
`<input type="number" min="1" step="0.1" name="maxLapSec" value="${((event.raceConfig.maxLapMs || 60000) / 1000).toFixed(1)}" />`
|
|
)}
|
|
<div class="field-card race-format-note-card">
|
|
<span class="field-label">${t("events.actions")}</span>
|
|
<span class="field-hint">${t("events.race_driver_scope")}</span>
|
|
<span class="field-hint">${t("events.bump_reserved_note")}</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<div class="race-format-save-row">
|
|
<button class="btn btn-primary" type="submit">${t("events.save_race_format")}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<aside class="race-summary-card">
|
|
<div class="panel-header"><h3>${t("events.race_summary")}</h3></div>
|
|
<div class="panel-body">
|
|
<p class="hint">${t("events.race_summary_hint")}</p>
|
|
${raceSummaryWarnings.length ? `
|
|
<div class="race-summary-warnings">
|
|
<strong>${t("events.summary_warnings_title")}</strong>
|
|
<ul>
|
|
${raceSummaryWarnings.map((warning) => `<li><button class="summary-warning-link" type="button" data-target="${escapeHtml(warning.target)}">${escapeHtml(warning.message)}</button></li>`).join("")}
|
|
</ul>
|
|
</div>
|
|
` : ""}
|
|
<div class="race-summary-list">
|
|
${raceSummaryItems
|
|
.map(
|
|
(item) => `
|
|
<article class="race-summary-item">
|
|
<span>${escapeHtml(item.label)}</span>
|
|
<strong>${escapeHtml(item.value)}</strong>
|
|
</article>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
<div class="panel-body race-actions-panel" id="manage-generation">
|
|
<div class="panel-header panel-header-inline"><h3>${t("events.race_actions_title")}</h3></div>
|
|
<p class="hint">${t("events.race_actions_hint")}</p>
|
|
<div class="actions mt-16">
|
|
<button id="generateQualifying" class="btn" type="button">${t("events.generate_qualifying")}</button>
|
|
<button id="clearGeneratedQualifying" class="btn btn-danger" type="button">${t("events.clear_generated_qualifying")}</button>
|
|
<button id="reseedQualifying" class="btn" type="button">${t("events.reseed_qualifying")}</button>
|
|
<button id="generateFinals" class="btn btn-primary" type="button">${t("events.generate_finals")}</button>
|
|
<button id="clearGeneratedFinals" class="btn btn-danger" type="button">${t("events.clear_generated_finals")}</button>
|
|
<button id="applyBumps" class="btn" type="button">${t("events.apply_bumps")}</button>
|
|
</div>
|
|
<div class="actions mt-16">
|
|
<button id="exportRacePackage" class="btn" type="button">${t("events.export_race_package")}</button>
|
|
<input id="importRacePackage" type="file" accept="application/json" aria-label="${t("events.import_race_package")}" />
|
|
</div>
|
|
<p class="hint">${t("events.race_package_hint")}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16" id="manage-live-grid">
|
|
<div class="panel-header"><h3>${t("events.grid_editor")}</h3></div>
|
|
<div class="panel-body">
|
|
${renderGridEditor(selectedGridSession)}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16" id="manage-live-results">
|
|
<div class="panel-header"><h3>${t("events.practice_standings")}</h3></div>
|
|
<div class="panel-body">
|
|
${renderRaceStandingsTableView(buildPracticeStandings(event), t("events.no_practice_results"))}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("events.qualifying_standings")}</h3></div>
|
|
<div class="panel-body">
|
|
${renderRaceStandingsTableView(buildQualifyingStandings(event), t("events.no_qualifying_results"))}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("events.final_standings")}</h3></div>
|
|
<div class="panel-body">
|
|
${renderRaceStandingsTableView(buildFinalStandings(event), t("events.no_final_results"))}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("events.team_standings")}</h3></div>
|
|
<div class="panel-body">
|
|
<div class="actions">
|
|
<button id="printTeamResults" class="btn" type="button">${t("events.print_team_results")}</button>
|
|
<button id="pdfTeamResults" class="btn" type="button">${t("events.pdf_team_results")}</button>
|
|
</div>
|
|
<div class="mt-16">
|
|
${renderTeamRaceStandings(event)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("events.final_matrix")}</h3></div>
|
|
<div class="panel-body">
|
|
<div class="actions">
|
|
<button id="printStartlists" class="btn" type="button">${t("events.print_startlists")}</button>
|
|
<button id="printResults" class="btn" type="button">${t("events.print_results")}</button>
|
|
<button id="pdfStartlists" class="btn" type="button">${t("events.pdf_startlists")}</button>
|
|
<button id="pdfResults" class="btn" type="button">${t("events.pdf_results")}</button>
|
|
</div>
|
|
<div class="mt-16">
|
|
${renderFinalMatrix(event)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`
|
|
: ""
|
|
}
|
|
|
|
${
|
|
editingTeam
|
|
? `
|
|
<div class="modal-overlay" id="teamEditModalOverlay">
|
|
<div class="modal-card">
|
|
<div class="panel-header">
|
|
<h3>${t("events.edit_team")}</h3>
|
|
<button class="btn" id="teamEditCancel">${t("common.cancel")}</button>
|
|
</div>
|
|
<form id="teamEditForm" class="panel-body form-grid cols-2">
|
|
<input name="teamName" required value="${escapeHtml(editingTeam.name)}" placeholder="${t("events.team_name")}" />
|
|
<p class="form-error" id="teamEditError" hidden></p>
|
|
<div>
|
|
<h4>${t("events.team_drivers")}</h4>
|
|
<div class="check-grid">
|
|
${raceDrivers
|
|
.map(
|
|
(driver) => `
|
|
<label class="check-card">
|
|
<input type="checkbox" name="teamDriverIds" value="${driver.id}" ${editingTeam.driverIds.includes(driver.id) ? "checked" : ""} />
|
|
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
|
|
</label>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4>${t("events.team_cars")}</h4>
|
|
<div class="check-grid">
|
|
${state.cars
|
|
.map(
|
|
(car) => `
|
|
<label class="check-card">
|
|
<input type="checkbox" name="teamCarIds" value="${car.id}" ${editingTeam.carIds.includes(car.id) ? "checked" : ""} />
|
|
<span>${escapeHtml(car.name)} (${escapeHtml(car.transponder || "-")})</span>
|
|
</label>
|
|
`
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
<div class="actions-inline">
|
|
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
|
|
<button class="btn" id="teamEditCancelFooter" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
|
|
${
|
|
editingSession
|
|
? `
|
|
<div class="modal-overlay" id="sessionEditModalOverlay">
|
|
<div class="modal-card">
|
|
<div class="panel-header">
|
|
<h3>${t("events.edit_session")}</h3>
|
|
<button class="btn" id="sessionEditCancel">${t("common.cancel")}</button>
|
|
</div>
|
|
<form id="sessionEditForm" class="panel-body form-grid cols-5">
|
|
<div class="span-5"><p class="hint">${t("events.edit_session_help")}</p></div>
|
|
<label>
|
|
${t("events.session_name")}
|
|
<input name="name" required value="${escapeHtml(editingSession.name)}" placeholder="${t("events.session_name")}" />
|
|
</label>
|
|
<label>
|
|
${t("events.session_type_label")}
|
|
<select name="type">
|
|
${sessionTypeChoices.map(
|
|
(item) => `<option value="${item}" ${item === editingSession.type ? "selected" : ""}>${getSessionTypeLabel(item)}</option>`
|
|
).join("")}
|
|
</select>
|
|
<small>${t(sessionTypeHintKey)}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_duration_label")}
|
|
<input name="durationMin" required type="number" min="1" value="${editingSession.durationMin || 5}" />
|
|
<small>${t("events.session_duration_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_followup_label")}
|
|
<input name="followUpSec" type="number" min="0" step="1" value="${editingSession.followUpSec || 0}" />
|
|
<small>${t("events.session_followup_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_start_mode_label")}
|
|
<select name="startMode">
|
|
<option value="mass" ${normalizeStartMode(editingSession.startMode) === "mass" ? "selected" : ""}>${t("events.start_mode_mass")}</option>
|
|
<option value="position" ${normalizeStartMode(editingSession.startMode) === "position" ? "selected" : ""}>${t("events.start_mode_position")}</option>
|
|
<option value="staggered" ${normalizeStartMode(editingSession.startMode) === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
|
|
</select>
|
|
<small>${t("events.session_start_mode_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_seed_laps_label")}
|
|
<input name="seedBestLapCount" type="number" min="0" step="1" value="${editingSession.seedBestLapCount || 0}" />
|
|
<small>${t("events.session_seed_laps_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_seed_method_label")}
|
|
<select name="seedMethod">
|
|
<option value="best_sum" ${editingSession.seedMethod === "best_sum" ? "selected" : ""}>${t("events.seed_method_best_sum")}</option>
|
|
<option value="average" ${editingSession.seedMethod === "average" ? "selected" : ""}>${t("events.seed_method_average")}</option>
|
|
<option value="consecutive" ${editingSession.seedMethod === "consecutive" ? "selected" : ""}>${t("events.seed_method_consecutive")}</option>
|
|
</select>
|
|
<small>${t("events.session_seed_method_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_stagger_gap_label")}
|
|
<input name="staggerGapSec" type="number" min="0" step="1" value="${editingSession.staggerGapSec || 0}" />
|
|
<small>${t("events.session_stagger_gap_hint")}</small>
|
|
</label>
|
|
<label>
|
|
${t("events.session_max_cars_label")}
|
|
<input name="maxCars" type="number" min="1" value="${editingSession.maxCars || ""}" />
|
|
<small>${t("events.session_max_cars_hint")}</small>
|
|
</label>
|
|
<p class="form-error" id="sessionEditError" hidden></p>
|
|
<div class="actions-inline">
|
|
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
|
|
<button class="btn" id="sessionEditCancelFooter" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
`;
|
|
|
|
const bindManageJump = (node) => {
|
|
const triggerJump = () => {
|
|
const targetId = node.getAttribute("data-target") || "";
|
|
const target = targetId ? document.getElementById(targetId) : null;
|
|
if (target) {
|
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
}
|
|
};
|
|
node.addEventListener("click", triggerJump);
|
|
node.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
triggerJump();
|
|
}
|
|
});
|
|
};
|
|
|
|
eventManageArea.querySelectorAll(".summary-warning-link, .manage-step-card-link").forEach((node) => {
|
|
bindManageJump(node);
|
|
});
|
|
|
|
document.getElementById("eventBrandingForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
event.branding = normalizeBrandingConfig({
|
|
...event.branding,
|
|
brandName: String(form.get("brandName") || "").trim(),
|
|
brandTagline: String(form.get("brandTagline") || "").trim(),
|
|
pdfFooter: String(form.get("pdfFooter") || "").trim(),
|
|
pdfTheme: String(form.get("pdfTheme") || "").trim(),
|
|
});
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("eventLogoUpload")?.addEventListener("change", (eventInput) => {
|
|
const input = eventInput.currentTarget;
|
|
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
event.branding = normalizeBrandingConfig({
|
|
...event.branding,
|
|
logoDataUrl: typeof reader.result === "string" ? reader.result : "",
|
|
});
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
document.getElementById("eventLogoClear")?.addEventListener("click", () => {
|
|
event.branding = normalizeBrandingConfig({
|
|
...event.branding,
|
|
logoDataUrl: "",
|
|
});
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("sessionForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
state.sessions.push(normalizeSession({
|
|
id: uid("session"),
|
|
eventId,
|
|
name: String(form.get("name")).trim(),
|
|
type: String(form.get("type")),
|
|
durationMin: Number(form.get("durationMin")),
|
|
followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0),
|
|
startMode: String(form.get("startMode") || "mass"),
|
|
seedBestLapCount: Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0),
|
|
seedMethod: String(form.get("seedMethod") || "best_sum"),
|
|
staggerGapSec: Math.max(0, Number(form.get("staggerGapSec") || 0) || 0),
|
|
maxCars: Number(form.get("maxCars") || 0) || null,
|
|
mode: event.mode,
|
|
status: "ready",
|
|
startedAt: null,
|
|
endedAt: null,
|
|
finishedByTimer: false,
|
|
assignments: [],
|
|
}));
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
updateHeaderState();
|
|
});
|
|
|
|
sessions.forEach((s) => {
|
|
document.getElementById(`session-edit-${s.id}`)?.addEventListener("click", () => {
|
|
selectedSessionEditId = s.id;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById(`session-active-${s.id}`)?.addEventListener("click", () => {
|
|
state.activeSessionId = s.id;
|
|
saveState();
|
|
updateHeaderState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById(`session-delete-${s.id}`)?.addEventListener("click", () => {
|
|
state.sessions = state.sessions.filter((x) => x.id !== s.id);
|
|
delete state.resultsBySession[s.id];
|
|
if (state.activeSessionId === s.id) {
|
|
state.activeSessionId = null;
|
|
}
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
updateHeaderState();
|
|
});
|
|
|
|
document.getElementById(`session-grid-${s.id}`)?.addEventListener("click", () => {
|
|
ensureSessionDriverOrder(s);
|
|
selectedGridSessionId = s.id;
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById(`session-sheet-print-${s.id}`)?.addEventListener("click", () => {
|
|
openPrintWindow(`${getEventName(eventId)} - ${s.name}`, buildSessionHeatSheetHtml(s));
|
|
});
|
|
|
|
document.getElementById(`session-sheet-export-${s.id}`)?.addEventListener("click", () => {
|
|
exportSessionHeatSheet(s);
|
|
});
|
|
|
|
document.getElementById(`session-sheet-pdf-${s.id}`)?.addEventListener("click", async () => {
|
|
await exportSessionHeatSheetPdf(s);
|
|
});
|
|
});
|
|
|
|
document.getElementById("sessionEditCancel")?.addEventListener("click", () => {
|
|
selectedSessionEditId = null;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("sessionEditCancelFooter")?.addEventListener("click", () => {
|
|
selectedSessionEditId = null;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("sessionEditModalOverlay")?.addEventListener("click", (event) => {
|
|
if (event.target?.id === "sessionEditModalOverlay") {
|
|
selectedSessionEditId = null;
|
|
renderEventManager(eventId);
|
|
}
|
|
});
|
|
|
|
bindModalShell("sessionEditModalOverlay", () => {
|
|
selectedSessionEditId = null;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("sessionEditForm")?.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
if (!editingSession) {
|
|
return;
|
|
}
|
|
const form = new FormData(event.currentTarget);
|
|
const cleanedName = String(form.get("name") || "").trim();
|
|
const cleanedDuration = Number(form.get("durationMin") || editingSession.durationMin || 5) || 0;
|
|
if (!cleanedName) {
|
|
setFormError("sessionEditError", t("validation.required_name"));
|
|
return;
|
|
}
|
|
if (cleanedDuration < 1) {
|
|
setFormError("sessionEditError", t("validation.required_duration"));
|
|
return;
|
|
}
|
|
setFormError("sessionEditError", "");
|
|
editingSession.name = cleanedName;
|
|
editingSession.type = String(form.get("type") || editingSession.type);
|
|
editingSession.durationMin = Math.max(1, cleanedDuration);
|
|
editingSession.followUpSec = Math.max(0, Number(form.get("followUpSec") || 0) || 0);
|
|
editingSession.startMode = normalizeStartMode(String(form.get("startMode") || editingSession.startMode || "mass"));
|
|
editingSession.seedBestLapCount = Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0);
|
|
editingSession.seedMethod = ["best_sum", "average", "consecutive"].includes(String(form.get("seedMethod") || "").toLowerCase())
|
|
? String(form.get("seedMethod")).toLowerCase()
|
|
: "best_sum";
|
|
editingSession.staggerGapSec = Math.max(0, Number(form.get("staggerGapSec") || 0) || 0);
|
|
editingSession.maxCars = Number(form.get("maxCars") || 0) || null;
|
|
selectedSessionEditId = null;
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
if (event.mode === "track") {
|
|
document.getElementById("sponsorRoundsForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
const qualificationRounds = Number(form.get("qualificationRounds") || 0);
|
|
const heatRounds = Number(form.get("heatRounds") || 0);
|
|
const finalRounds = Number(form.get("finalRounds") || 0);
|
|
const durationMin = Number(form.get("roundDuration") || 5);
|
|
|
|
createSponsorRounds(eventId, {
|
|
qualificationRounds,
|
|
heatRounds,
|
|
finalRounds,
|
|
durationMin,
|
|
});
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("assignForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
const sessionId = String(form.get("sessionId"));
|
|
const session = state.sessions.find((x) => x.id === sessionId);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
|
|
const driverId = String(form.get("driverId"));
|
|
const carId = String(form.get("carId"));
|
|
const car = state.cars.find((x) => x.id === carId);
|
|
if (!car) {
|
|
return;
|
|
}
|
|
|
|
const duplicateCar = (session.assignments || []).find((a) => a.carId === carId);
|
|
if (duplicateCar) {
|
|
alert(t("events.duplicate_car"));
|
|
return;
|
|
}
|
|
|
|
const duplicateDriver = (session.assignments || []).find((a) => a.driverId === driverId);
|
|
if (duplicateDriver) {
|
|
alert(t("events.duplicate_driver"));
|
|
return;
|
|
}
|
|
|
|
const duplicateTp = (session.assignments || []).find((a) => {
|
|
const existingCar = state.cars.find((x) => x.id === a.carId);
|
|
return existingCar?.transponder && existingCar.transponder === car.transponder;
|
|
});
|
|
if (duplicateTp) {
|
|
alert(t("events.duplicate_tp"));
|
|
return;
|
|
}
|
|
|
|
session.assignments = session.assignments || [];
|
|
session.assignments.push({ id: uid("as"), driverId, carId });
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("autoAssignSession")?.addEventListener("click", () => {
|
|
const sessionId = getSelectedAssignmentSessionId();
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
autoAssignTrackSession(event, sessionId);
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("clearAssignSession")?.addEventListener("click", () => {
|
|
const sessionId = getSelectedAssignmentSessionId();
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
const session = state.sessions.find((x) => x.id === sessionId);
|
|
if (!session) {
|
|
return;
|
|
}
|
|
session.assignments = [];
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
renderAssignmentList(eventId);
|
|
}
|
|
|
|
if (event.mode === "race") {
|
|
const persistRaceParticipants = () => {
|
|
const selectedIds = Array.from(document.querySelectorAll(".race-participant:checked")).map((node) => node.value);
|
|
event.raceConfig.driverIds = selectedIds;
|
|
event.raceConfig.participantsConfigured = true;
|
|
saveState();
|
|
};
|
|
|
|
document.querySelectorAll(".race-participant").forEach((node) => {
|
|
node.addEventListener("change", persistRaceParticipants);
|
|
});
|
|
|
|
document.getElementById("selectAllParticipants")?.addEventListener("click", () => {
|
|
document.querySelectorAll(".race-participant").forEach((node) => {
|
|
node.checked = true;
|
|
});
|
|
persistRaceParticipants();
|
|
});
|
|
|
|
document.getElementById("clearParticipants")?.addEventListener("click", () => {
|
|
document.querySelectorAll(".race-participant").forEach((node) => {
|
|
node.checked = false;
|
|
});
|
|
persistRaceParticipants();
|
|
});
|
|
|
|
document.getElementById("teamForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
const name = String(form.get("teamName") || "").trim();
|
|
const driverIds = form.getAll("teamDriverIds").map(String).filter(Boolean);
|
|
const carIds = form.getAll("teamCarIds").map(String).filter(Boolean);
|
|
if (!name || (!driverIds.length && !carIds.length)) {
|
|
return;
|
|
}
|
|
const createdTeam = normalizeRaceTeam({ id: uid("team"), name, driverIds, carIds });
|
|
event.raceConfig.teams = [...getEventTeams(event), createdTeam];
|
|
selectedTeamEditId = createdTeam.id;
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
raceTeams.forEach((team) => {
|
|
document.getElementById(`team-edit-${team.id}`)?.addEventListener("click", () => {
|
|
selectedTeamEditId = team.id;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById(`team-delete-${team.id}`)?.addEventListener("click", () => {
|
|
event.raceConfig.teams = getEventTeams(event).filter((item) => item.id !== team.id);
|
|
if (selectedTeamEditId === team.id) {
|
|
selectedTeamEditId = null;
|
|
}
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
});
|
|
|
|
document.getElementById("teamEditCancel")?.addEventListener("click", () => {
|
|
selectedTeamEditId = null;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("teamEditCancelFooter")?.addEventListener("click", () => {
|
|
selectedTeamEditId = null;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("teamEditModalOverlay")?.addEventListener("click", (modalEvent) => {
|
|
if (modalEvent.target?.id === "teamEditModalOverlay") {
|
|
selectedTeamEditId = null;
|
|
renderEventManager(eventId);
|
|
}
|
|
});
|
|
|
|
bindModalShell("teamEditModalOverlay", () => {
|
|
selectedTeamEditId = null;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("teamEditForm")?.addEventListener("submit", (submitEvent) => {
|
|
submitEvent.preventDefault();
|
|
if (!editingTeam) {
|
|
return;
|
|
}
|
|
const form = new FormData(submitEvent.currentTarget);
|
|
const name = String(form.get("teamName") || "").trim();
|
|
const driverIds = form.getAll("teamDriverIds").map(String).filter(Boolean);
|
|
const carIds = form.getAll("teamCarIds").map(String).filter(Boolean);
|
|
if (!name) {
|
|
setFormError("teamEditError", t("validation.required_name"));
|
|
return;
|
|
}
|
|
if (!driverIds.length && !carIds.length) {
|
|
setFormError("teamEditError", t("validation.invalid_selection"));
|
|
return;
|
|
}
|
|
setFormError("teamEditError", "");
|
|
event.raceConfig.teams = getEventTeams(event).map((team) =>
|
|
team.id === editingTeam.id ? normalizeRaceTeam({ ...team, name, driverIds, carIds }) : team
|
|
);
|
|
selectedTeamEditId = null;
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("raceFormatBasicToggle")?.addEventListener("click", () => {
|
|
raceFormatAdvanced = false;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("raceFormatAdvancedToggle")?.addEventListener("click", () => {
|
|
raceFormatAdvanced = true;
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("raceFormatForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
event.raceConfig = buildRaceFormatConfigFromForm(form, event);
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("applyRacePreset")?.addEventListener("click", () => {
|
|
const formElement = document.getElementById("raceFormatForm");
|
|
if (!(formElement instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
const form = new FormData(formElement);
|
|
applyRaceFormatPreset(event, String(form.get("presetId") || "custom"));
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("saveRacePreset")?.addEventListener("click", () => {
|
|
const formElement = document.getElementById("raceFormatForm");
|
|
if (!(formElement instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
const form = new FormData(formElement);
|
|
const presetName = String(form.get("presetName") || "").trim();
|
|
if (!presetName) {
|
|
return;
|
|
}
|
|
const config = buildRaceFormatConfigFromForm(form, event);
|
|
const selectedPresetId = String(form.get("presetId") || "custom");
|
|
const existingCustomPreset = (state.settings.racePresets || []).find((preset) => preset.id === selectedPresetId);
|
|
const presetId = existingCustomPreset ? existingCustomPreset.id : uid("preset");
|
|
const storedPreset = normalizeStoredRacePreset({
|
|
id: presetId,
|
|
name: presetName,
|
|
values: {
|
|
qualifyingScoring: config.qualifyingScoring,
|
|
qualifyingRounds: config.qualifyingRounds,
|
|
carsPerHeat: config.carsPerHeat,
|
|
qualDurationMin: config.qualDurationMin,
|
|
qualStartMode: config.qualStartMode,
|
|
qualSeedLapCount: config.qualSeedLapCount,
|
|
qualSeedMethod: config.qualSeedMethod,
|
|
countedQualRounds: config.countedQualRounds,
|
|
qualifyingPointsTable: config.qualifyingPointsTable,
|
|
qualifyingTieBreak: config.qualifyingTieBreak,
|
|
carsPerFinal: config.carsPerFinal,
|
|
finalLegs: config.finalLegs,
|
|
countedFinalLegs: config.countedFinalLegs,
|
|
finalDurationMin: config.finalDurationMin,
|
|
finalStartMode: config.finalStartMode,
|
|
followUpSec: config.followUpSec,
|
|
minLapMs: config.minLapMs,
|
|
maxLapMs: config.maxLapMs,
|
|
bumpCount: config.bumpCount,
|
|
reserveBumpSlots: config.reserveBumpSlots,
|
|
finalsSource: config.finalsSource,
|
|
},
|
|
});
|
|
const otherPresets = (state.settings.racePresets || []).filter((preset) => preset.id !== presetId);
|
|
state.settings.racePresets = [...otherPresets, storedPreset];
|
|
event.raceConfig = { ...config, presetId };
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("deleteRacePreset")?.addEventListener("click", () => {
|
|
const formElement = document.getElementById("raceFormatForm");
|
|
if (!(formElement instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
const form = new FormData(formElement);
|
|
const presetId = String(form.get("presetId") || "custom");
|
|
if (!(state.settings.racePresets || []).some((preset) => preset.id === presetId)) {
|
|
return;
|
|
}
|
|
state.settings.racePresets = (state.settings.racePresets || []).filter((preset) => preset.id !== presetId);
|
|
event.raceConfig.presetId = "custom";
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("generateQualifying")?.addEventListener("click", () => {
|
|
const created = generateQualifyingForRace(event);
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
if (created > 0) {
|
|
alert(t("events.generated_qualifying"));
|
|
}
|
|
});
|
|
|
|
document.getElementById("clearGeneratedQualifying")?.addEventListener("click", () => {
|
|
clearGeneratedQualifying(event.id);
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("reseedQualifying")?.addEventListener("click", () => {
|
|
const result = reseedUpcomingQualifying(event);
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
const messages = [];
|
|
if (result.updated > 0) {
|
|
messages.push(t("events.reseed_done"));
|
|
} else {
|
|
messages.push(t("events.no_reseed_done"));
|
|
}
|
|
if (result.locked > 0) {
|
|
messages.push(t("events.reseed_locked", { count: result.locked }));
|
|
}
|
|
alert(messages.join("\n"));
|
|
});
|
|
|
|
document.getElementById("generateFinals")?.addEventListener("click", () => {
|
|
const created = generateFinalsForRace(event);
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
if (created > 0) {
|
|
alert(t("events.finals_generated"));
|
|
}
|
|
});
|
|
|
|
document.getElementById("clearGeneratedFinals")?.addEventListener("click", () => {
|
|
clearGeneratedFinals(event.id);
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("applyBumps")?.addEventListener("click", () => {
|
|
const applied = applyBumpsForRace(event);
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
alert(t(applied > 0 ? "events.bumps_applied" : "events.no_bumps_applied"));
|
|
});
|
|
|
|
document.getElementById("exportRacePackage")?.addEventListener("click", () => {
|
|
const payload = buildRacePackagePayload(eventId);
|
|
downloadJsonFile(`${sanitizeFilenameSegment(event.name)}_race_package.json`, payload);
|
|
});
|
|
|
|
document.getElementById("importRacePackage")?.addEventListener("change", (importEvent) => {
|
|
const input = importEvent.currentTarget;
|
|
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
|
if (!file) {
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
try {
|
|
const parsed = JSON.parse(String(reader.result || "{}"));
|
|
importRacePackagePayload(parsed);
|
|
} catch (error) {
|
|
alert(t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) }));
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
document.getElementById("printStartlists")?.addEventListener("click", () => {
|
|
openPrintWindow(`${event.name} - ${t("events.start_lists")}`, buildRaceStartListsHtml(event));
|
|
});
|
|
|
|
document.getElementById("printResults")?.addEventListener("click", () => {
|
|
openPrintWindow(`${event.name} - ${t("events.results_overview")}`, buildRaceResultsHtml(event));
|
|
});
|
|
|
|
document.getElementById("printTeamResults")?.addEventListener("click", () => {
|
|
openPrintWindow(`${event.name} - ${t("events.team_report")}`, buildTeamRaceResultsHtml(event));
|
|
});
|
|
|
|
document.getElementById("pdfStartlists")?.addEventListener("click", async () => {
|
|
await exportRaceStartListsPdf(event);
|
|
});
|
|
|
|
document.getElementById("pdfResults")?.addEventListener("click", async () => {
|
|
await exportRaceResultsPdf(event);
|
|
});
|
|
|
|
document.getElementById("pdfTeamResults")?.addEventListener("click", async () => {
|
|
await exportTeamRaceResultsPdf(event);
|
|
});
|
|
|
|
document.getElementById("gridResetOrder")?.addEventListener("click", () => {
|
|
if (!selectedGridSession) {
|
|
return;
|
|
}
|
|
selectedGridSession.driverIds = getSessionEntrants(selectedGridSession)
|
|
.map((driver) => driver.id)
|
|
.filter(Boolean);
|
|
selectedGridSession.manualGridIds = [...selectedGridSession.driverIds];
|
|
selectedGridSession.gridCustomized = false;
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
document.getElementById("gridToggleLock")?.addEventListener("click", () => {
|
|
if (!selectedGridSession) {
|
|
return;
|
|
}
|
|
if (!selectedGridSession.gridCustomized) {
|
|
selectedGridSession.manualGridIds = [...ensureSessionDriverOrder(selectedGridSession)];
|
|
selectedGridSession.gridCustomized = true;
|
|
} else {
|
|
selectedGridSession.manualGridIds = [...selectedGridSession.driverIds];
|
|
selectedGridSession.gridCustomized = false;
|
|
}
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
|
|
let dragIndex = null;
|
|
document.querySelectorAll("#gridDragList .drag-item").forEach((node) => {
|
|
node.addEventListener("dragstart", () => {
|
|
dragIndex = Number(node.dataset.index);
|
|
node.classList.add("drag-item-active");
|
|
});
|
|
node.addEventListener("dragend", () => {
|
|
dragIndex = null;
|
|
node.classList.remove("drag-item-active");
|
|
});
|
|
node.addEventListener("dragover", (dragEvent) => {
|
|
dragEvent.preventDefault();
|
|
node.classList.add("drag-item-over");
|
|
});
|
|
node.addEventListener("dragleave", () => {
|
|
node.classList.remove("drag-item-over");
|
|
});
|
|
node.addEventListener("drop", (dropEvent) => {
|
|
dropEvent.preventDefault();
|
|
node.classList.remove("drag-item-over");
|
|
if (!selectedGridSession || dragIndex === null) {
|
|
return;
|
|
}
|
|
const dropIndex = Number(node.dataset.index);
|
|
if (Number.isNaN(dropIndex) || dropIndex === dragIndex) {
|
|
return;
|
|
}
|
|
selectedGridSession.manualGridIds = reorderList(ensureSessionDriverOrder(selectedGridSession), dragIndex, dropIndex);
|
|
selectedGridSession.gridCustomized = true;
|
|
saveState();
|
|
renderEventManager(eventId);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderAssignmentList(eventId) {
|
|
const block = document.getElementById("assignmentList");
|
|
if (!block) {
|
|
return;
|
|
}
|
|
|
|
const sessions = getSessionsForEvent(eventId);
|
|
block.innerHTML = sessions
|
|
.map((s) => {
|
|
const items = (s.assignments || [])
|
|
.map((a) => {
|
|
const driver = state.drivers.find((d) => d.id === a.driverId);
|
|
const car = state.cars.find((c) => c.id === a.carId);
|
|
return `
|
|
<li>
|
|
${escapeHtml(driver?.name || t("common.unknown_driver"))} -> ${escapeHtml(car?.name || t("common.unknown_car"))} (${escapeHtml(
|
|
car?.transponder || "-"
|
|
)})
|
|
<button id="as-delete-${a.id}" class="btn btn-danger btn-mini">x</button>
|
|
</li>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
return `
|
|
<div class="assignment-group">
|
|
<h4>${escapeHtml(s.name)} (${escapeHtml(getSessionTypeLabel(s.type))})</h4>
|
|
<ul>${items || `<li>${t("events.no_assignments")}</li>`}</ul>
|
|
</div>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
sessions.forEach((s) => {
|
|
(s.assignments || []).forEach((a) => {
|
|
document.getElementById(`as-delete-${a.id}`)?.addEventListener("click", () => {
|
|
s.assignments = s.assignments.filter((x) => x.id !== a.id);
|
|
saveState();
|
|
renderAssignmentList(eventId);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderTiming() {
|
|
const active = getActiveSession();
|
|
const result = active ? ensureSessionResult(active.id) : null;
|
|
const leaderboard = active ? buildLeaderboard(active) : [];
|
|
const sessionTiming = active ? getSessionTiming(active) : null;
|
|
const clockLabel = active && sessionTiming?.followUpActive ? t("timing.follow_up") : active && sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining");
|
|
const clockValue = sessionTiming?.followUpActive
|
|
? formatCountdown(sessionTiming?.followUpRemainingMs ?? 0)
|
|
: sessionTiming?.untimed
|
|
? formatElapsedClock(sessionTiming?.elapsedMs ?? 0)
|
|
: formatCountdown(sessionTiming?.remainingMs ?? 0);
|
|
const showFinishedBanner = Boolean(active && active.status === "finished" && active.finishedByTimer);
|
|
const showFollowUpBanner = Boolean(active && sessionTiming?.followUpActive);
|
|
const selectedRow = leaderboard.find((row) => row.key === selectedLeaderboardKey) || null;
|
|
if (selectedLeaderboardKey && !selectedRow) {
|
|
selectedLeaderboardKey = null;
|
|
}
|
|
|
|
dom.view.innerHTML = `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("timing.decoder_connection")}</h3></div>
|
|
<div class="panel-body timing-top-grid">
|
|
<div class="timing-compact-card">
|
|
<label class="timing-compact-label" for="timingWsUrl">${t("settings.decoder")}</label>
|
|
<input id="timingWsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
|
|
<div class="actions">
|
|
<button id="timingConnect" class="btn btn-primary">${t("timing.connect")}</button>
|
|
<button id="timingDisconnect" class="btn">${t("timing.disconnect")}</button>
|
|
<button id="timingSimPass" class="btn">${t("timing.simulate")}</button>
|
|
</div>
|
|
</div>
|
|
<div class="timing-compact-card">
|
|
<span class="timing-compact-label">${t("timing.status")}</span>
|
|
<strong>${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")}</strong>
|
|
<small>${t("timing.last_message")}: ${state.decoder.lastMessageAt ? new Date(state.decoder.lastMessageAt).toLocaleString() : "-"}</small>
|
|
<p class="error">${escapeHtml(state.decoder.lastError || "")}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("timing.control")}</h3></div>
|
|
<div class="panel-body timing-top-grid">
|
|
<div class="timing-compact-card timing-compact-card-wide">
|
|
<label class="timing-compact-label" for="activeSessionSelect">${t("timing.select_session")}</label>
|
|
<select id="activeSessionSelect">
|
|
<option value="">${t("timing.select_session")}</option>
|
|
${state.sessions
|
|
.map(
|
|
(s) => `<option value="${s.id}" ${state.activeSessionId === s.id ? "selected" : ""}>${escapeHtml(
|
|
getEventName(s.eventId)
|
|
)} • ${escapeHtml(s.name)} • ${escapeHtml(getSessionTypeLabel(s.type))}</option>`
|
|
)
|
|
.join("")}
|
|
</select>
|
|
<div class="actions">
|
|
<button id="setActiveSession" class="btn">${t("timing.set_active")}</button>
|
|
<button id="startSession" class="btn btn-primary">${t("timing.start")}</button>
|
|
<button id="stopSession" class="btn">${t("timing.stop")}</button>
|
|
<button id="resetSession" class="btn btn-danger">${t("timing.reset")}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="panel-body timing-session-summary">
|
|
${
|
|
active
|
|
? `<div class="timing-session-head">
|
|
<strong>${escapeHtml(active.name)}</strong>
|
|
<span class="pill">${escapeHtml(getSessionTypeLabel(active.type))}</span>
|
|
<span class="pill">${escapeHtml(getEventName(active.eventId))}</span>
|
|
<span class="pill ${active.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(active.status))}</span>
|
|
</div>
|
|
<div class="timing-session-stats">
|
|
<article class="timing-session-stat"><span>${clockLabel}</span><strong>${clockValue}</strong></article>
|
|
<article class="timing-session-stat"><span>${t("timing.started")}</span><strong>${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}</strong></article>
|
|
<article class="timing-session-stat"><span>${t("table.start_mode")}</span><strong>${escapeHtml(getStartModeLabel(active.startMode))}</strong></article>
|
|
<article class="timing-session-stat"><span>${t("timing.seeding_mode")}</span><strong>${active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-"}</strong></article>
|
|
<article class="timing-session-stat"><span>${t("timing.total_passings")}</span><strong>${getVisiblePassings(result).length}</strong></article>
|
|
</div>
|
|
${
|
|
active.type === "free_practice"
|
|
? `<p class="hint">${t("events.free_practice_note")}</p>`
|
|
: active.type === "open_practice"
|
|
? `<p class="hint">${t("events.open_practice_note")}</p>`
|
|
: ""
|
|
}`
|
|
: `<p>${t("timing.no_active")}</p>`
|
|
}
|
|
${showFollowUpBanner ? `<p class="finish-banner">${t("timing.follow_up_active")}</p>` : ""}
|
|
${showFinishedBanner ? `<p class="finish-banner">${t("timing.race_finished")}</p>` : ""}
|
|
${active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : ""}
|
|
</div>
|
|
</section>
|
|
|
|
${active ? renderQuickAddPanel(active) : ""}
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("timing.speaker_panel")}</h3></div>
|
|
<div class="panel-body">
|
|
<p class="hint">${t("timing.speaker_panel_hint")}</p>
|
|
<div class="check-grid">
|
|
${renderSpeakerToggle("speakerPassingCueEnabled", "settings.speaker_passing_cue")}
|
|
${renderSpeakerToggle("speakerLeaderCueEnabled", "settings.speaker_leader_cue")}
|
|
${renderSpeakerToggle("speakerBestLapCueEnabled", "settings.speaker_bestlap_cue")}
|
|
${renderSpeakerToggle("speakerTop3CueEnabled", "settings.speaker_top3_cue")}
|
|
${renderSpeakerToggle("speakerSessionStartCueEnabled", "settings.speaker_start_cue")}
|
|
${renderSpeakerToggle("speakerFinishCueEnabled", "settings.speaker_finish_cue")}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("timing.leaderboard")}</h3></div>
|
|
<div class="panel-body">
|
|
${renderLeaderboard(leaderboard)}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("timing.recent_passings")}</h3></div>
|
|
<div class="panel-body">
|
|
${renderRecentPassings(active)}
|
|
</div>
|
|
</section>
|
|
|
|
${selectedRow && active ? renderLeaderboardModal(active, selectedRow) : ""}
|
|
`;
|
|
|
|
document.getElementById("timingConnect")?.addEventListener("click", () => {
|
|
const input = document.getElementById("timingWsUrl");
|
|
if (input && input.value.trim()) {
|
|
state.settings.wsUrl = input.value.trim();
|
|
saveState();
|
|
}
|
|
connectDecoder();
|
|
});
|
|
|
|
document.getElementById("timingDisconnect")?.addEventListener("click", disconnectDecoder);
|
|
|
|
leaderboard.forEach((row) => {
|
|
document.getElementById(`leaderboard-detail-${row.key}`)?.addEventListener("click", () => {
|
|
selectedLeaderboardKey = row.key;
|
|
renderView();
|
|
});
|
|
});
|
|
|
|
if (active && selectedRow) {
|
|
bindQuickAddActions(active, selectedRow.transponder, "leaderboardModal");
|
|
document.getElementById("corrLapPlus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { lapDelta: 1 });
|
|
renderView();
|
|
});
|
|
document.getElementById("corrLapMinus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { lapDelta: -1 });
|
|
renderView();
|
|
});
|
|
document.getElementById("corrSecPlus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 1000 });
|
|
renderView();
|
|
});
|
|
document.getElementById("corr5SecPlus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 5000 });
|
|
renderView();
|
|
});
|
|
document.getElementById("corrSecMinus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 });
|
|
renderView();
|
|
});
|
|
document.getElementById("corrInvalidateLast")?.addEventListener("click", () => {
|
|
invalidateCompetitorLastLap(active, selectedRow);
|
|
renderView();
|
|
});
|
|
document.getElementById("corrRestoreInvalid")?.addEventListener("click", () => {
|
|
restoreCompetitorLastInvalidLap(active, selectedRow);
|
|
renderView();
|
|
});
|
|
document.getElementById("corrReset")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { reset: true });
|
|
renderView();
|
|
});
|
|
}
|
|
|
|
if (active) {
|
|
ensureSessionResult(active.id)
|
|
.passings.slice(-20)
|
|
.reverse()
|
|
.forEach((passing, index) => {
|
|
bindQuickAddActions(active, passing.transponder, `recentPassing-${index}`);
|
|
});
|
|
}
|
|
|
|
document.getElementById("quickAddCancel")?.addEventListener("click", () => {
|
|
quickAddDraft = null;
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("quickAddForm")?.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
if (!active || !quickAddDraft) {
|
|
return;
|
|
}
|
|
const form = new FormData(event.currentTarget);
|
|
const name = String(form.get("name") || "").trim();
|
|
if (!name) {
|
|
return;
|
|
}
|
|
const transponder = String(form.get("transponder") || "").trim();
|
|
const brand = String(form.get("brand") || "").trim();
|
|
if (!transponder) {
|
|
return;
|
|
}
|
|
if (quickAddDraft.type === "driver") {
|
|
if (!state.drivers.some((item) => String(item.transponder || "").trim() === transponder)) {
|
|
state.drivers.push(
|
|
normalizeDriver({
|
|
id: uid("driver"),
|
|
name,
|
|
classId: String(form.get("classId") || getPreferredClassId(active)),
|
|
brand,
|
|
transponder,
|
|
})
|
|
);
|
|
}
|
|
} else if (!state.cars.some((item) => String(item.transponder || "").trim() === transponder)) {
|
|
state.cars.push(
|
|
normalizeCar({
|
|
id: uid("car"),
|
|
name,
|
|
brand,
|
|
transponder,
|
|
})
|
|
);
|
|
}
|
|
quickAddDraft = null;
|
|
saveState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("leaderboardModalClose")?.addEventListener("click", () => {
|
|
selectedLeaderboardKey = null;
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("leaderboardModalOverlay")?.addEventListener("click", (event) => {
|
|
if (event.target?.id === "leaderboardModalOverlay") {
|
|
selectedLeaderboardKey = null;
|
|
renderView();
|
|
}
|
|
});
|
|
|
|
document.getElementById("timingSimPass")?.addEventListener("click", () => {
|
|
const tp = prompt(t("timing.prompt_transponder"), "232323");
|
|
if (!tp) {
|
|
return;
|
|
}
|
|
processDecoderMessage({
|
|
msg: "PASSING",
|
|
transponder: tp,
|
|
rtc_time: new Date().toISOString(),
|
|
strength: 0,
|
|
resend: false,
|
|
loop_id: "sim",
|
|
});
|
|
});
|
|
|
|
document.getElementById("setActiveSession")?.addEventListener("click", () => {
|
|
const select = document.getElementById("activeSessionSelect");
|
|
if (!select || !select.value) {
|
|
return;
|
|
}
|
|
state.activeSessionId = select.value;
|
|
saveState();
|
|
updateHeaderState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("startSession")?.addEventListener("click", () => {
|
|
const session = getActiveSession();
|
|
if (!session) {
|
|
return;
|
|
}
|
|
ensureAudioContext();
|
|
|
|
if (session.mode === "track") {
|
|
const trackValidation = validateTrackSessionForStart(session);
|
|
if (!trackValidation.ok) {
|
|
alert(trackValidation.message);
|
|
return;
|
|
}
|
|
}
|
|
|
|
session.status = "running";
|
|
session.startedAt = Date.now();
|
|
session.endedAt = null;
|
|
session.finishedByTimer = false;
|
|
session.followUpStartedAt = null;
|
|
lastFinishAnnouncementSessionId = null;
|
|
lastOverlayLeaderKeyBySession[session.id] = null;
|
|
lastOverlayTop3BySession[session.id] = [];
|
|
overlayEvents = [];
|
|
ensureSessionResult(session.id);
|
|
pushOverlayEvent("start", `${session.name} • ${t("timing.start")}`);
|
|
saveState();
|
|
updateHeaderState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("stopSession")?.addEventListener("click", () => {
|
|
const session = getActiveSession();
|
|
if (!session) {
|
|
return;
|
|
}
|
|
session.status = "finished";
|
|
session.endedAt = Date.now();
|
|
session.finishedByTimer = false;
|
|
session.followUpStartedAt = null;
|
|
saveState();
|
|
updateHeaderState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("resetSession")?.addEventListener("click", () => {
|
|
const session = getActiveSession();
|
|
if (!session) {
|
|
return;
|
|
}
|
|
if (!confirm(t("timing.clear_confirm"))) {
|
|
return;
|
|
}
|
|
delete state.resultsBySession[session.id];
|
|
session.followUpStartedAt = null;
|
|
lastFinishAnnouncementSessionId = null;
|
|
delete lastOverlayLeaderKeyBySession[session.id];
|
|
delete lastOverlayTop3BySession[session.id];
|
|
overlayEvents = [];
|
|
saveState();
|
|
renderView();
|
|
});
|
|
|
|
document.querySelectorAll("[data-speaker-setting]").forEach((node) => {
|
|
node.addEventListener("change", (event) => {
|
|
const input = event.currentTarget;
|
|
if (!(input instanceof HTMLInputElement)) {
|
|
return;
|
|
}
|
|
state.settings[input.dataset.speakerSetting] = input.checked;
|
|
saveState();
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderJudging() {
|
|
const active = getActiveSession();
|
|
if (!active) {
|
|
dom.view.innerHTML = `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("judging.title")}</h3></div>
|
|
<div class="panel-body"><p>${t("judging.no_active_session")}</p></div>
|
|
</section>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const result = ensureSessionResult(active.id);
|
|
const leaderboard = buildLeaderboard(active);
|
|
const filteredRows = getJudgeFilteredRows(leaderboard, judgingCompetitorFilter);
|
|
if (selectedJudgeKey && !filteredRows.some((row) => row.key === selectedJudgeKey)) {
|
|
selectedJudgeKey = null;
|
|
}
|
|
if (!selectedJudgeKey && filteredRows.length) {
|
|
selectedJudgeKey = filteredRows[0].key;
|
|
}
|
|
const selectedRow = leaderboard.find((row) => row.key === selectedJudgeKey) || null;
|
|
const selectedPassings = selectedRow ? getCompetitorPassings(active, selectedRow, { includeInvalid: true }) : [];
|
|
const actionLog = getJudgeFilteredLog(Array.isArray(result.adjustments) ? result.adjustments.slice(-50).reverse() : [], judgingLogFilter);
|
|
const latestUndoable = (result.adjustments || []).slice().reverse().find((entry) => !entry.undoneAt && entry.undo);
|
|
|
|
dom.view.innerHTML = `
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<h3>${t("judging.title")}</h3>
|
|
<span class="pill pill-green">${escapeHtml(active.name)}</span>
|
|
</div>
|
|
<div class="panel-body">
|
|
<p>${t("judging.active_session")}: <strong>${escapeHtml(active.name)}</strong> • ${escapeHtml(getSessionTypeLabel(active.type))}</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel-row">
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<h3>${t("judging.select_competitor")}</h3>
|
|
<select id="judgingCompetitorFilter">
|
|
<option value="all" ${judgingCompetitorFilter === "all" ? "selected" : ""}>${t("judging.filter_all")}</option>
|
|
<option value="invalid" ${judgingCompetitorFilter === "invalid" ? "selected" : ""}>${t("judging.filter_invalid")}</option>
|
|
<option value="corrected" ${judgingCompetitorFilter === "corrected" ? "selected" : ""}>${t("judging.filter_corrected")}</option>
|
|
<option value="team" ${judgingCompetitorFilter === "team" ? "selected" : ""}>${t("judging.filter_team")}</option>
|
|
</select>
|
|
</div>
|
|
<div class="panel-body">
|
|
${renderTable(
|
|
[t("table.pos"), t("table.driver"), t("table.result"), t("table.best_lap"), ""],
|
|
filteredRows.map(
|
|
(row, index) => `
|
|
<tr class="${row.key === selectedJudgeKey ? "judge-selected-row" : ""}">
|
|
<td>${index + 1}</td>
|
|
<td>
|
|
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
|
|
<div class="table-subnote">${escapeHtml(row.subLabel || row.transponder || "-")}</div>
|
|
</td>
|
|
<td>${escapeHtml(row.resultDisplay || "-")}</td>
|
|
<td>${formatLap(row.bestLapMs)}</td>
|
|
<td><button class="btn btn-mini" id="judge-select-${row.key}">${t("timing.details")}</button></td>
|
|
</tr>
|
|
`
|
|
)
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("judging.manual_actions")}</h3></div>
|
|
<div class="panel-body">
|
|
${
|
|
selectedRow
|
|
? `
|
|
<p><strong>${escapeHtml(selectedRow.displayName || selectedRow.driverName)}</strong> • ${escapeHtml(selectedRow.subLabel || selectedRow.carName || "-")}</p>
|
|
<p>${t("table.laps")}: ${selectedRow.laps}</p>
|
|
<p>${t("table.best_lap")}: ${formatLap(selectedRow.bestLapMs)}</p>
|
|
<p>${t("table.last_lap")}: ${formatLap(selectedRow.lastLapMs)}</p>
|
|
<div class="actions">
|
|
<button class="btn" id="judgeLapPlus">${t("timing.penalty_add_lap")}</button>
|
|
<button class="btn" id="judgeLapMinus">${t("timing.penalty_remove_lap")}</button>
|
|
<button class="btn" id="judgeSecPlus">${t("timing.penalty_add_sec")}</button>
|
|
<button class="btn" id="judgeFivePlus">${t("timing.penalty_add_5sec")}</button>
|
|
<button class="btn" id="judgeSecMinus">${t("timing.penalty_remove_sec")}</button>
|
|
<button class="btn" id="judgeInvalidate">${t("timing.invalidate_last_lap")}</button>
|
|
<button class="btn" id="judgeRestore">${t("timing.restore_last_invalid")}</button>
|
|
<button class="btn btn-danger" id="judgeReset">${t("timing.penalty_reset")}</button>
|
|
</div>
|
|
<div class="mt-16">
|
|
<h4>${t("timing.lap_history")}</h4>
|
|
${
|
|
selectedPassings.length
|
|
? renderTable(
|
|
[t("table.lap"), t("table.last_lap"), t("table.status")],
|
|
selectedPassings.map(
|
|
(passing, index) => `
|
|
<tr class="${isCountedPassing(passing) ? "" : "passing-invalid"}">
|
|
<td>${index + 1}</td>
|
|
<td>${formatLap(passing.lapMs)}</td>
|
|
<td>${escapeHtml(getPassingValidationLabel(passing))}</td>
|
|
</tr>
|
|
`
|
|
)
|
|
)
|
|
: `<p>${t("timing.no_lap_history")}</p>`
|
|
}
|
|
</div>
|
|
`
|
|
: `<p>${t("judging.selected_none")}</p>`
|
|
}
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header">
|
|
<h3>${t("judging.action_log")}</h3>
|
|
<div class="actions-inline">
|
|
<select id="judgingLogFilter">
|
|
<option value="all" ${judgingLogFilter === "all" ? "selected" : ""}>${t("judging.filter_all")}</option>
|
|
<option value="corrections" ${judgingLogFilter === "corrections" ? "selected" : ""}>${t("judging.filter_log_corrections")}</option>
|
|
<option value="invalid" ${judgingLogFilter === "invalid" ? "selected" : ""}>${t("judging.filter_log_invalidations")}</option>
|
|
<option value="undo" ${judgingLogFilter === "undo" ? "selected" : ""}>${t("judging.filter_log_undo")}</option>
|
|
</select>
|
|
<button class="btn" id="judgingExportLog" type="button">${t("judging.export_log")}</button>
|
|
<button class="btn" id="judgingUndoLast" type="button">${t("judging.undo_last")}</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel-body">
|
|
${
|
|
actionLog.length
|
|
? renderTable(
|
|
[t("table.time"), t("table.driver"), t("events.actions"), t("table.status"), ""],
|
|
actionLog.map(
|
|
(entry) => `
|
|
<tr>
|
|
<td>${new Date(entry.ts).toLocaleTimeString()}</td>
|
|
<td>${escapeHtml(entry.displayName || "-")}</td>
|
|
<td>${escapeHtml(entry.action || "-")}</td>
|
|
<td>${escapeHtml(entry.detail || "-")}${entry.undoneAt ? ` • ${escapeHtml(t("judging.filter_log_undo"))}` : ""}</td>
|
|
<td>${entry.undo && !entry.undoneAt ? `<button class="btn btn-mini" id="judge-undo-${entry.id}" type="button">${t("judging.undo_action")}</button>` : ""}</td>
|
|
</tr>
|
|
`
|
|
)
|
|
)
|
|
: `<p>${t("judging.no_action_log")}</p>`
|
|
}
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
leaderboard.forEach((row) => {
|
|
document.getElementById(`judge-select-${row.key}`)?.addEventListener("click", () => {
|
|
selectedJudgeKey = row.key;
|
|
renderJudging();
|
|
});
|
|
});
|
|
|
|
document.getElementById("judgingCompetitorFilter")?.addEventListener("change", (event) => {
|
|
const input = event.currentTarget;
|
|
if (!(input instanceof HTMLSelectElement)) {
|
|
return;
|
|
}
|
|
judgingCompetitorFilter = input.value;
|
|
renderJudging();
|
|
});
|
|
|
|
document.getElementById("judgingLogFilter")?.addEventListener("change", (event) => {
|
|
const input = event.currentTarget;
|
|
if (!(input instanceof HTMLSelectElement)) {
|
|
return;
|
|
}
|
|
judgingLogFilter = input.value;
|
|
renderJudging();
|
|
});
|
|
|
|
document.getElementById("judgingExportLog")?.addEventListener("click", () => {
|
|
const rows = (result.adjustments || []).map((entry) => ({
|
|
time: new Date(entry.ts).toISOString(),
|
|
competitor: entry.displayName || "-",
|
|
action: entry.action || "-",
|
|
detail: entry.detail || "-",
|
|
category: entry.category || "-",
|
|
undoneAt: entry.undoneAt ? new Date(entry.undoneAt).toISOString() : "",
|
|
}));
|
|
const blob = new Blob([JSON.stringify({ session: active.name, items: rows }, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${active.name.replaceAll(/\s+/g, "_")}_judging_log.json`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
document.getElementById("judgingUndoLast")?.addEventListener("click", () => {
|
|
if (!latestUndoable) {
|
|
alert(t("judging.no_undo"));
|
|
return;
|
|
}
|
|
undoJudgingAdjustment(active, latestUndoable.id);
|
|
renderJudging();
|
|
});
|
|
|
|
actionLog.forEach((entry) => {
|
|
if (!entry.undo || entry.undoneAt) {
|
|
return;
|
|
}
|
|
document.getElementById(`judge-undo-${entry.id}`)?.addEventListener("click", () => {
|
|
undoJudgingAdjustment(active, entry.id);
|
|
renderJudging();
|
|
});
|
|
});
|
|
|
|
if (!selectedRow) {
|
|
return;
|
|
}
|
|
|
|
document.getElementById("judgeLapPlus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { lapDelta: 1 });
|
|
renderJudging();
|
|
});
|
|
document.getElementById("judgeLapMinus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { lapDelta: -1 });
|
|
renderJudging();
|
|
});
|
|
document.getElementById("judgeSecPlus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 1000 });
|
|
renderJudging();
|
|
});
|
|
document.getElementById("judgeFivePlus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 5000 });
|
|
renderJudging();
|
|
});
|
|
document.getElementById("judgeSecMinus")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 });
|
|
renderJudging();
|
|
});
|
|
document.getElementById("judgeInvalidate")?.addEventListener("click", () => {
|
|
invalidateCompetitorLastLap(active, selectedRow);
|
|
renderJudging();
|
|
});
|
|
document.getElementById("judgeRestore")?.addEventListener("click", () => {
|
|
restoreCompetitorLastInvalidLap(active, selectedRow);
|
|
renderJudging();
|
|
});
|
|
document.getElementById("judgeReset")?.addEventListener("click", () => {
|
|
applyCompetitorCorrection(active, selectedRow, { reset: true });
|
|
renderJudging();
|
|
});
|
|
}
|
|
|
|
function renderSpeakerToggle(settingKey, labelKey) {
|
|
return `
|
|
<label class="check-card">
|
|
<input type="checkbox" data-speaker-setting="${settingKey}" ${state.settings[settingKey] ? "checked" : ""} />
|
|
<span>${t(labelKey)}</span>
|
|
</label>
|
|
`;
|
|
}
|
|
|
|
function renderQuickAddPanel(session) {
|
|
if (!quickAddDraft || !quickAddDraft.transponder) {
|
|
return "";
|
|
}
|
|
const classOptions = state.classes
|
|
.map(
|
|
(item) => `<option value="${item.id}" ${item.id === (quickAddDraft.classId || getPreferredClassId(session)) ? "selected" : ""}>${escapeHtml(item.name)}</option>`
|
|
)
|
|
.join("");
|
|
const isDriver = quickAddDraft.type === "driver";
|
|
return `
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t(isDriver ? "timing.quick_add_driver_title" : "timing.quick_add_car_title")}</h3></div>
|
|
<form id="quickAddForm" class="panel-body form-grid cols-5">
|
|
<input name="transponder" value="${escapeHtml(quickAddDraft.transponder)}" readonly />
|
|
<input
|
|
name="name"
|
|
required
|
|
autofocus
|
|
placeholder="${t(isDriver ? "drivers.name_placeholder" : "cars.name_placeholder")}"
|
|
value="${escapeHtml(quickAddDraft.name || "")}"
|
|
/>
|
|
<input
|
|
name="brand"
|
|
placeholder="${t(isDriver ? "drivers.brand_placeholder" : "cars.brand_placeholder")}"
|
|
value="${escapeHtml(quickAddDraft.brand || "")}"
|
|
/>
|
|
${
|
|
isDriver
|
|
? `<select name="classId">${classOptions}</select>`
|
|
: `<div class="hint quick-add-spacer">${t("timing.quick_add_hint")}</div>`
|
|
}
|
|
<div class="actions-inline">
|
|
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
|
|
<button class="btn" id="quickAddCancel" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderGuidePanel(titleKey, itemKeys, extras = "") {
|
|
return `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t(titleKey)}</h3></div>
|
|
<div class="panel-body">
|
|
<ul class="guide-list">
|
|
${itemKeys.map((key) => `<li>${t(key)}</li>`).join("")}
|
|
</ul>
|
|
${extras}
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderGuideOverviewCard(titleKey, blurbKey) {
|
|
return `
|
|
<article class="guide-overview-card">
|
|
<strong>${t(titleKey)}</strong>
|
|
<p>${t(blurbKey)}</p>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function renderGuide() {
|
|
dom.view.innerHTML = `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("guide.title")}</h3></div>
|
|
<div class="panel-body">
|
|
<p>${t("guide.intro")}</p>
|
|
<div class="guide-overview-grid mt-16">
|
|
${renderGuideOverviewCard("guide.sponsor_title", "guide.card_sponsor_blurb")}
|
|
${renderGuideOverviewCard("guide.race_title", "guide.card_race_blurb")}
|
|
${renderGuideOverviewCard("guide.team_title", "guide.card_team_blurb")}
|
|
${renderGuideOverviewCard("guide.windows_title", "guide.card_decoder_blurb")}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="guide-section-grid mt-16">
|
|
${renderGuidePanel("guide.sponsor_title", ["guide.sponsor_1", "guide.sponsor_2", "guide.sponsor_3", "guide.sponsor_4", "guide.sponsor_5", "guide.sponsor_6"])}
|
|
${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6", "guide.race_wizard_7"])}
|
|
</div>
|
|
|
|
<div class="guide-section-grid mt-16">
|
|
${renderGuidePanel("guide.race_title", ["guide.race_1", "guide.race_2", "guide.race_3", "guide.race_4", "guide.race_4a", "guide.race_5", "guide.race_6", "guide.race_7", "guide.race_8", "guide.race_9", "guide.race_10"])}
|
|
${renderGuidePanel("guide.manage_steps_title", ["guide.manage_steps_1", "guide.manage_steps_2", "guide.manage_steps_3", "guide.manage_steps_4", "guide.manage_steps_5", "guide.manage_steps_6", "guide.manage_steps_7", "guide.manage_steps_8"])}
|
|
</div>
|
|
|
|
<div class="guide-section-grid mt-16">
|
|
${renderGuidePanel("guide.race_format_title", ["guide.race_format_0", "guide.race_format_1", "guide.race_format_2", "guide.race_format_3", "guide.race_format_4", "guide.race_format_5", "guide.race_format_6", "guide.race_format_7", "guide.race_format_8", "guide.race_format_9", "guide.race_format_10", "guide.race_format_11", "guide.race_format_11a", "guide.race_format_12", "guide.race_format_13", "guide.race_format_14"])}
|
|
${renderGuidePanel("guide.validation_title", ["guide.validation_1", "guide.validation_2", "guide.validation_3", "guide.validation_4", "guide.validation_5", "guide.validation_6", "guide.validation_7", "guide.validation_8"])}
|
|
</div>
|
|
|
|
<div class="guide-section-grid mt-16">
|
|
${renderGuidePanel("guide.free_practice_title", ["guide.free_practice_1", "guide.free_practice_2", "guide.free_practice_3"])}
|
|
${renderGuidePanel("guide.open_practice_title", ["guide.open_practice_1", "guide.open_practice_2", "guide.open_practice_3"])}
|
|
</div>
|
|
|
|
<div class="guide-section-grid mt-16">
|
|
${renderGuidePanel("guide.team_title", ["guide.team_1", "guide.team_2", "guide.team_3", "guide.team_4", "guide.team_5", "guide.team_6"])}
|
|
${renderGuidePanel("guide.qualifying_title", ["guide.qualifying_1", "guide.qualifying_2", "guide.qualifying_3", "guide.qualifying_4", "guide.qualifying_5"])}
|
|
</div>
|
|
|
|
<div class="guide-section-grid mt-16">
|
|
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3"])}
|
|
${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"])}
|
|
</div>
|
|
|
|
<div class="guide-section-grid mt-16">
|
|
${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"])}
|
|
${renderGuidePanel("guide.linux_title", ["guide.linux_1", "guide.linux_2", "guide.linux_3"])}
|
|
</div>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${t("guide.sqlite_title")}</h3></div>
|
|
<div class="panel-body">
|
|
<ul class="guide-list">
|
|
<li>${t("guide.sqlite_1")}</li>
|
|
<li>${t("guide.sqlite_2")}</li>
|
|
<li>${t("guide.sqlite_3")}</li>
|
|
<li>${t("guide.sqlite_4")}</li>
|
|
<li>${t("guide.sqlite_5")}</li>
|
|
</ul>
|
|
<p><a href="https://www.ammconverter.eu/docs/intro/quick-start/" target="_blank" rel="noreferrer">${t("guide.ammc_ref")}</a></p>
|
|
</div>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderOverlay() {
|
|
const active = getActiveSession();
|
|
const leaderboard = active ? buildLeaderboard(active).slice(0, 12) : [];
|
|
const result = active ? ensureSessionResult(active.id) : null;
|
|
const sessionTiming = active ? getSessionTiming(active) : null;
|
|
const overlayClock = sessionTiming?.followUpActive
|
|
? formatCountdown(sessionTiming?.followUpRemainingMs ?? 0)
|
|
: sessionTiming?.untimed
|
|
? formatElapsedClock(sessionTiming?.elapsedMs ?? 0)
|
|
: formatCountdown(sessionTiming?.remainingMs ?? 0);
|
|
const recent = active && result ? getVisiblePassings(result).slice(-8).reverse() : [];
|
|
const event = active ? state.events.find((item) => item.id === active.eventId) : null;
|
|
const branding = resolveEventBranding(event);
|
|
const practiceRows = event ? buildPracticeStandings(event) : [];
|
|
const qualifyingRows = event ? buildQualifyingStandings(event) : [];
|
|
const finalRows = event ? buildFinalStandings(event) : [];
|
|
const topRow = leaderboard[0] || null;
|
|
const fastestRow =
|
|
[...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
|
|
const modeLabel = getOverlayModeLabel(overlayViewMode, { t });
|
|
const overlayStatusLabel = sessionTiming?.followUpActive ? t("timing.follow_up_active") : active ? getStatusLabel(active.status) : "";
|
|
const obsConfig = overlayViewMode === "obs" ? getObsOverlayConfig() : null;
|
|
const rotatingPanels = buildOverlayPanels(active, recent, { t, overlayEvents, normalizeStartMode, renderPositionGrid });
|
|
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
|
|
|
|
const denseOverlay = overlayViewMode === "leaderboard" || overlayViewMode === "tv";
|
|
|
|
dom.view.innerHTML = `
|
|
${
|
|
overlayMode
|
|
? ""
|
|
: `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("overlay.title")}</h3></div>
|
|
<div class="panel-body">
|
|
<div class="actions">
|
|
<button id="overlayLaunchLeaderboard" class="btn" type="button">${t("timing.open_overlay")}</button>
|
|
<button id="overlayLaunchSpeaker" class="btn" type="button">${t("timing.open_speaker_overlay")}</button>
|
|
<button id="overlayLaunchResults" class="btn" type="button">${t("timing.open_results_overlay")}</button>
|
|
<button id="overlayLaunchTv" class="btn" type="button">${t("timing.open_tv_overlay")}</button>
|
|
<button id="overlayLaunchTeam" class="btn" type="button">${t("timing.open_team_overlay")}</button>
|
|
<button id="overlayLaunchObs" class="btn" type="button">${t("timing.open_obs_overlay")}</button>
|
|
<button id="overlayCopyObsUrl" class="btn" type="button">${t("overlay.obs_copy_url")}</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`
|
|
}
|
|
<section class="overlay-shell ${overlayViewMode === "tv" ? "overlay-shell-tv" : ""} ${overlayViewMode === "obs" ? "overlay-shell-obs" : ""} ${overlayViewMode === "obs" && obsConfig ? `overlay-shell-obs-theme-${obsConfig.theme}` : ""} ${overlayViewMode === "obs" && obsConfig ? `overlay-shell-obs-layout-${obsConfig.layout}` : ""} ${denseOverlay ? "overlay-shell-dense" : ""} ${publicOverlayMode ? "overlay-shell-public" : ""}">
|
|
${
|
|
active
|
|
? `
|
|
${
|
|
overlayViewMode === "obs"
|
|
? ""
|
|
: `<header class="overlay-header">
|
|
<div class="overlay-header-main">
|
|
${branding.logoDataUrl ? `<img class="overlay-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
|
|
<div class="overlay-header-copy">
|
|
<div class="overlay-kicker-row">
|
|
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
|
|
<span class="pill">${escapeHtml(getSessionTypeLabel(active.type))}</span>
|
|
${overlayViewMode !== "tv" ? `<span class="pill">${escapeHtml(getStartModeLabel(active.startMode))}</span>` : ""}
|
|
<span class="pill">${escapeHtml(modeLabel)}</span>
|
|
</div>
|
|
<h1>${escapeHtml(active.name)}</h1>
|
|
<p class="overlay-header-sub">${escapeHtml(branding.brandName || "JMK RB RaceController")}</p>
|
|
</div>
|
|
</div>
|
|
<div class="overlay-meta">
|
|
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
|
|
${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `<div class="overlay-clock">${overlayClock}</div>`}
|
|
<div class="overlay-status">${escapeHtml(overlayStatusLabel)}</div>
|
|
</div>
|
|
</header>`
|
|
}
|
|
|
|
${
|
|
overlayViewMode === "speaker"
|
|
? `
|
|
<section class="overlay-speaker">
|
|
<div class="overlay-speaker-main">
|
|
<div class="overlay-speaker-label">P1</div>
|
|
<h2>${escapeHtml(topRow?.displayName || topRow?.driverName || t("common.unknown_driver"))}</h2>
|
|
<p>${t("table.result")}: ${escapeHtml(topRow?.resultDisplay || "-")}</p>
|
|
<p>${t("table.best_lap")}: ${formatLap(topRow?.bestLapMs)}</p>
|
|
</div>
|
|
<div class="overlay-speaker-side">
|
|
<section class="overlay-side-card">
|
|
<h3>${t("overlay.last_passings")}</h3>
|
|
${
|
|
recent.length
|
|
? recent
|
|
.map(
|
|
(passing) => `
|
|
<div class="overlay-passing">
|
|
<strong>${escapeHtml(passing.displayName || passing.teamName || passing.driverName || t("common.unknown_driver"))}</strong>
|
|
<span>${formatLap(passing.lapMs)}</span>
|
|
</div>
|
|
`
|
|
)
|
|
.join("")
|
|
: `<p>${t("timing.no_passings")}</p>`
|
|
}
|
|
</section>
|
|
<section class="overlay-side-card">
|
|
<h3>${t("events.position_grid")}</h3>
|
|
${normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : `<p>${t("events.na")}</p>`}
|
|
</section>
|
|
<section class="overlay-side-card">
|
|
<h3>${t("overlay.event_markers")}</h3>
|
|
${
|
|
overlayEvents.length
|
|
? overlayEvents
|
|
.map(
|
|
(item) => `
|
|
<div class="overlay-passing">
|
|
<strong>${escapeHtml(item.label)}</strong>
|
|
<span>${new Date(item.ts).toLocaleTimeString()}</span>
|
|
</div>
|
|
`
|
|
)
|
|
.join("")
|
|
: `<p>${t("timing.no_passings")}</p>`
|
|
}
|
|
</section>
|
|
</div>
|
|
</section>
|
|
`
|
|
: overlayViewMode === "results"
|
|
? `
|
|
<section class="overlay-results">
|
|
<div class="overlay-side-card">
|
|
<h3>${t("events.practice_standings")}</h3>
|
|
${renderRaceStandingsTableView(practiceRows, t("events.no_practice_results"))}
|
|
</div>
|
|
<div class="overlay-side-card">
|
|
<h3>${t("events.qualifying_standings")}</h3>
|
|
${renderRaceStandingsTableView(qualifyingRows, t("events.no_qualifying_results"))}
|
|
</div>
|
|
<div class="overlay-side-card">
|
|
<h3>${t("events.final_standings")}</h3>
|
|
${renderRaceStandingsTableView(finalRows, t("events.no_final_results"))}
|
|
</div>
|
|
</section>
|
|
`
|
|
: overlayViewMode === "team"
|
|
? renderTeamOverlay(leaderboard, result, sessionTiming, { t, escapeHtml, formatLap, formatPredictedLapDelta, formatTeamActiveMemberLabel, getVisiblePassings, renderOverlayLeaderboard })
|
|
: overlayViewMode === "obs"
|
|
? renderObsOverlay(active, leaderboard, result, sessionTiming, branding, {
|
|
t,
|
|
escapeHtml,
|
|
formatLap,
|
|
getVisiblePassings,
|
|
getSessionTypeLabel,
|
|
getStartModeLabel,
|
|
getStatusLabel,
|
|
getEventName,
|
|
getObsOverlayConfig,
|
|
normalizeStartMode,
|
|
renderPositionGrid,
|
|
getSessionGridEntries,
|
|
formatTeamActiveMemberLabel,
|
|
renderOverlayLeaderboard,
|
|
})
|
|
: overlayViewMode === "tv"
|
|
? `
|
|
<section class="overlay-board overlay-board-tv">
|
|
<div class="overlay-table-wrap overlay-display-wrap overlay-display-wrap-dense">
|
|
<section class="overlay-fastest-banner overlay-fastest-banner-dense">
|
|
<div class="overlay-fastest-banner-copy">
|
|
<span>${t("overlay.fastest_lap")}</span>
|
|
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
|
|
</div>
|
|
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}</div>
|
|
<div class="overlay-fastest-meta">${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${getVisiblePassings(result).length || 0}</div>
|
|
</section>
|
|
<div class="overlay-leaderboard-card overlay-leaderboard-card-tv overlay-leaderboard-card-dense">
|
|
${renderOverlayLeaderboard(leaderboard, { t, escapeHtml, formatTeamActiveMemberLabel, formatLap, formatPredictedLapDelta })}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`
|
|
: `
|
|
<section class="overlay-board">
|
|
<div class="overlay-table-wrap overlay-display-wrap overlay-display-wrap-dense">
|
|
<section class="overlay-fastest-banner overlay-fastest-banner-dense">
|
|
<div class="overlay-fastest-banner-copy">
|
|
<span>${t("overlay.fastest_lap")}</span>
|
|
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
|
|
</div>
|
|
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}</div>
|
|
<div class="overlay-fastest-meta">${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${result?.passings.length || 0}</div>
|
|
</section>
|
|
<div class="overlay-leaderboard-card overlay-leaderboard-card-dense">
|
|
${renderOverlayLeaderboard(leaderboard, { t, escapeHtml, formatTeamActiveMemberLabel, formatLap, formatPredictedLapDelta })}
|
|
</div>
|
|
</div>
|
|
<aside class="overlay-side">
|
|
${activePanel ? renderOverlaySidePanel(activePanel, { t, escapeHtml }) : `<section class="overlay-side-card"><p>${t("timing.no_passings")}</p></section>`}
|
|
</aside>
|
|
</section>
|
|
`
|
|
}
|
|
`
|
|
: `
|
|
<section class="overlay-empty">
|
|
<h1>${t("overlay.title")}</h1>
|
|
<p>${t("overlay.no_active")}</p>
|
|
</section>
|
|
`
|
|
}
|
|
</section>
|
|
`;
|
|
|
|
document.getElementById("overlayFullscreen")?.addEventListener("click", async () => {
|
|
const target = document.documentElement;
|
|
if (!document.fullscreenElement) {
|
|
await target.requestFullscreen?.().catch(() => {});
|
|
return;
|
|
}
|
|
await document.exitFullscreen?.().catch(() => {});
|
|
});
|
|
document.getElementById("overlayLaunchLeaderboard")?.addEventListener("click", () => openOverlayWindow("leaderboard"));
|
|
document.getElementById("overlayLaunchSpeaker")?.addEventListener("click", () => openOverlayWindow("speaker"));
|
|
document.getElementById("overlayLaunchResults")?.addEventListener("click", () => openOverlayWindow("results"));
|
|
document.getElementById("overlayLaunchTv")?.addEventListener("click", () => openOverlayWindow("tv"));
|
|
document.getElementById("overlayLaunchTeam")?.addEventListener("click", () => openOverlayWindow("team"));
|
|
document.getElementById("overlayLaunchObs")?.addEventListener("click", () => openOverlayWindow("obs", { public: true }));
|
|
document.getElementById("overlayCopyObsUrl")?.addEventListener("click", async () => {
|
|
const url = buildOverlayUrl("obs", { public: true });
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(url).catch(() => {});
|
|
return;
|
|
}
|
|
window.prompt("Copy OBS URL", url);
|
|
});
|
|
}
|
|
|
|
function getQuickAddState(transponder) {
|
|
const normalized = String(transponder || "").trim();
|
|
const driver = state.drivers.find((item) => String(item.transponder || "").trim() === normalized) || null;
|
|
const car = state.cars.find((item) => String(item.transponder || "").trim() === normalized) || null;
|
|
return {
|
|
transponder: normalized,
|
|
hasDriver: Boolean(driver),
|
|
hasCar: Boolean(car),
|
|
};
|
|
}
|
|
|
|
function getPreferredClassId(session) {
|
|
const event = state.events.find((item) => item.id === session?.eventId);
|
|
if (event?.classId) {
|
|
return event.classId;
|
|
}
|
|
return state.classes[0]?.id || "";
|
|
}
|
|
|
|
function beginQuickAddDraft(session, type, transponder) {
|
|
const normalized = String(transponder || "").trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
if (type === "driver" && state.drivers.some((item) => String(item.transponder || "").trim() === normalized)) {
|
|
return;
|
|
}
|
|
if (type === "car" && state.cars.some((item) => String(item.transponder || "").trim() === normalized)) {
|
|
return;
|
|
}
|
|
quickAddDraft = {
|
|
type,
|
|
transponder: normalized,
|
|
classId: getPreferredClassId(session),
|
|
name: type === "driver" ? normalized : `Car ${normalized}`,
|
|
};
|
|
renderView();
|
|
}
|
|
|
|
function renderQuickAddActions(session, transponder, idPrefix) {
|
|
const quickState = getQuickAddState(transponder);
|
|
if (!quickState.transponder || (quickState.hasDriver && quickState.hasCar)) {
|
|
return "";
|
|
}
|
|
return `
|
|
<div class="actions-inline quick-add-actions">
|
|
${!quickState.hasDriver ? `<button class="btn btn-mini" id="${idPrefix}-add-driver">${t("timing.add_driver")}</button>` : ""}
|
|
${!quickState.hasCar ? `<button class="btn btn-mini" id="${idPrefix}-add-car">${t("timing.add_car")}</button>` : ""}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function bindQuickAddActions(session, transponder, idPrefix) {
|
|
document.getElementById(`${idPrefix}-add-driver`)?.addEventListener("click", () => {
|
|
beginQuickAddDraft(session, "driver", transponder);
|
|
});
|
|
document.getElementById(`${idPrefix}-add-car`)?.addEventListener("click", () => {
|
|
beginQuickAddDraft(session, "car", transponder);
|
|
});
|
|
}
|
|
|
|
function renderLeaderboardModal(session, row) {
|
|
const passings = getCompetitorPassings(session, row);
|
|
return `
|
|
<div class="modal-overlay" id="leaderboardModalOverlay">
|
|
<div class="modal-card">
|
|
<div class="panel-header">
|
|
<h3>${t("timing.detail_title")}</h3>
|
|
<button class="btn" id="leaderboardModalClose">${t("timing.close_details")}</button>
|
|
</div>
|
|
<div class="panel-body">
|
|
<p><strong>${escapeHtml(row.displayName || row.driverName)}</strong> • ${escapeHtml(row.subLabel || row.carName)}</p>
|
|
<p>${t("table.transponder")}: ${escapeHtml(row.transponder)}</p>
|
|
${renderQuickAddActions(session, row.transponder, "leaderboardModal")}
|
|
<p>${t("table.laps")}: ${row.laps}</p>
|
|
<p>${t("timing.total_time")}: ${escapeHtml(row.resultDisplay)}</p>
|
|
<p>${t("table.best_lap")}: ${formatLap(row.bestLapMs)}</p>
|
|
<p>${t("table.last_lap")}: ${formatLap(row.lastLapMs)}</p>
|
|
<p>${t("table.own_delta")}: ${escapeHtml(row.lapDelta || "-")}</p>
|
|
</div>
|
|
<div class="panel-body">
|
|
<h4>${t("timing.manual_corrections")}</h4>
|
|
<p>${t("timing.lap_adjustment")}: ${Number(row.manualLapAdjustment || 0) || 0}</p>
|
|
<p>${t("timing.time_penalty")}: ${(Number(row.manualTimeAdjustmentMs || 0) || 0) > 0 ? "+" : (Number(row.manualTimeAdjustmentMs || 0) || 0) < 0 ? "-" : ""}${formatLap(Math.abs(Number(row.manualTimeAdjustmentMs || 0) || 0))}</p>
|
|
<div class="actions-inline">
|
|
<button class="btn btn-mini" id="corrLapPlus" type="button">${t("timing.penalty_add_lap")}</button>
|
|
<button class="btn btn-mini" id="corrLapMinus" type="button">${t("timing.penalty_remove_lap")}</button>
|
|
<button class="btn btn-mini" id="corrSecPlus" type="button">${t("timing.penalty_add_sec")}</button>
|
|
<button class="btn btn-mini" id="corr5SecPlus" type="button">${t("timing.penalty_add_5sec")}</button>
|
|
<button class="btn btn-mini" id="corrSecMinus" type="button">${t("timing.penalty_remove_sec")}</button>
|
|
<button class="btn btn-mini" id="corrInvalidateLast" type="button">${t("timing.invalidate_last_lap")}</button>
|
|
<button class="btn btn-mini" id="corrRestoreInvalid" type="button">${t("timing.restore_last_invalid")}</button>
|
|
<button class="btn btn-danger btn-mini" id="corrReset" type="button">${t("timing.penalty_reset")}</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel-body">
|
|
<h4>${t("timing.lap_history")}</h4>
|
|
${
|
|
passings.length
|
|
? renderTable(
|
|
[t("table.lap"), t("table.time"), t("table.last_lap")],
|
|
passings.map(
|
|
(passing, index) => `
|
|
<tr>
|
|
<td>${index + 1}</td>
|
|
<td>${formatRaceClock(Math.max(0, passing.timestamp - (session.startedAt || passing.timestamp)))}</td>
|
|
<td>${formatLap(passing.lapMs)}</td>
|
|
</tr>
|
|
`
|
|
)
|
|
)
|
|
: `<p>${t("timing.no_lap_history")}</p>`
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderLeaderboard(rows) {
|
|
if (!rows.length) {
|
|
return `<p>${t("timing.no_laps")}</p>`;
|
|
}
|
|
|
|
return renderTable(
|
|
[
|
|
t("table.pos"),
|
|
t("table.driver"),
|
|
t("table.car"),
|
|
t("table.transponder"),
|
|
t("table.laps"),
|
|
t("table.result"),
|
|
t("table.last_lap"),
|
|
t("table.best_lap"),
|
|
t("table.leader_gap"),
|
|
t("table.ahead_gap"),
|
|
t("table.own_delta"),
|
|
"",
|
|
],
|
|
rows.map((row, idx) => {
|
|
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
|
|
return `
|
|
<tr class="${row.invalidPending ? "leaderboard-invalid" : ""}">
|
|
<td><span class="pos-pill ${posClass}">${idx + 1}</span></td>
|
|
<td>
|
|
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
|
|
${row.teamId ? `<div class="table-subnote">${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</div>` : ""}
|
|
${getManualCorrectionSummary(row) ? `<div class="table-subnote">${escapeHtml(getManualCorrectionSummary(row))}</div>` : ""}
|
|
${row.invalidPending ? `<div class="table-subnote table-subnote-warn">${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}</div>` : ""}
|
|
</td>
|
|
<td>${escapeHtml(row.subLabel || row.carName)}</td>
|
|
<td>${escapeHtml(row.transponder)}</td>
|
|
<td>${row.laps}</td>
|
|
<td>${escapeHtml(row.resultDisplay)}</td>
|
|
<td>${formatLap(row.lastLapMs)}</td>
|
|
<td class="best">${formatLap(row.bestLapMs)}</td>
|
|
<td>${escapeHtml(row.leaderGap || row.gap || "-")}</td>
|
|
<td>${escapeHtml(row.gapAhead || "-")}</td>
|
|
<td>${escapeHtml(row.lapDelta || "-")}</td>
|
|
<td><button id="leaderboard-detail-${row.key}" class="btn btn-mini">${t("timing.details")}</button></td>
|
|
</tr>
|
|
`;
|
|
})
|
|
);
|
|
}
|
|
|
|
function renderRecentPassings(session) {
|
|
if (!session) {
|
|
return `<p>${t("timing.no_session_selected")}</p>`;
|
|
}
|
|
const result = ensureSessionResult(session.id);
|
|
const items = result.passings.slice(-20).reverse();
|
|
if (!items.length) {
|
|
return `<p>${t("timing.no_passings")}</p>`;
|
|
}
|
|
|
|
return renderTable(
|
|
[t("table.time"), t("table.transponder"), t("table.driver"), t("table.car"), t("table.last_lap"), t("table.status"), ""],
|
|
items.map((p, index) => {
|
|
return `
|
|
<tr class="${isCountedPassing(p) ? "" : "passing-invalid"}">
|
|
<td>${new Date(p.timestamp).toLocaleTimeString()}</td>
|
|
<td>${escapeHtml(p.transponder)}</td>
|
|
<td>${escapeHtml(p.teamName ? `${p.teamName} • ${p.driverName || t("common.unknown_driver")}` : p.driverName || t("common.unknown_driver"))}</td>
|
|
<td>${escapeHtml(p.carName || p.subLabel || "-")}</td>
|
|
<td>${formatLap(p.lapMs)}</td>
|
|
<td>${escapeHtml(getPassingValidationLabel(p))}</td>
|
|
<td>${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)}</td>
|
|
</tr>
|
|
`;
|
|
})
|
|
);
|
|
}
|
|
|
|
|
|
function getSessionsForEvent(eventId) {
|
|
return state.sessions.filter((s) => s.eventId === eventId);
|
|
}
|
|
|
|
function getModeLabel(mode) {
|
|
return mode === "track" ? t("mode.track") : t("mode.race");
|
|
}
|
|
|
|
function normalizeStartMode(mode) {
|
|
return ["mass", "position", "staggered"].includes(String(mode || "").toLowerCase()) ? String(mode).toLowerCase() : "mass";
|
|
}
|
|
|
|
function getStartModeLabel(mode) {
|
|
return t(`events.start_mode_${normalizeStartMode(mode)}`);
|
|
}
|
|
|
|
function getClassName(classId) {
|
|
return state.classes.find((x) => x.id === classId)?.name || t("common.unknown");
|
|
}
|
|
|
|
function getEventName(eventId) {
|
|
return state.events.find((x) => x.id === eventId)?.name || t("common.unknown_event");
|
|
}
|
|
|
|
function getFreePracticeSessions(eventId) {
|
|
return getSessionsForEvent(eventId).filter((session) => session.type === "free_practice");
|
|
}
|
|
|
|
function connectDecoder() {
|
|
disconnectDecoder({ silent: true });
|
|
state.decoder.lastError = "";
|
|
saveState();
|
|
|
|
const url = state.settings.wsUrl;
|
|
try {
|
|
wsClient = new WebSocket(url);
|
|
} catch (error) {
|
|
state.decoder.lastError = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) });
|
|
saveState();
|
|
renderView();
|
|
return;
|
|
}
|
|
|
|
wsClient.onopen = () => {
|
|
state.decoder.connected = true;
|
|
state.decoder.lastError = "";
|
|
saveState();
|
|
updateConnectionBadge();
|
|
if (currentView === "timing" || currentView === "settings" || currentView === "overlay") {
|
|
renderView();
|
|
}
|
|
};
|
|
|
|
wsClient.onmessage = (event) => {
|
|
state.decoder.lastMessageAt = Date.now();
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(String(event.data));
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
if (Array.isArray(parsed)) {
|
|
parsed.forEach(processDecoderMessage);
|
|
} else {
|
|
processDecoderMessage(parsed);
|
|
}
|
|
|
|
saveState();
|
|
updateConnectionBadge();
|
|
if (currentView === "timing" || currentView === "dashboard" || currentView === "overlay") {
|
|
renderView();
|
|
}
|
|
};
|
|
|
|
wsClient.onclose = () => {
|
|
state.decoder.connected = false;
|
|
saveState();
|
|
updateConnectionBadge();
|
|
|
|
if (state.settings.autoReconnect) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = setTimeout(() => {
|
|
if (!state.decoder.connected) {
|
|
connectDecoder();
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
if (currentView === "timing" || currentView === "settings" || currentView === "overlay") {
|
|
renderView();
|
|
}
|
|
};
|
|
|
|
wsClient.onerror = () => {
|
|
state.decoder.lastError = t("error.decoder_connection");
|
|
saveState();
|
|
updateConnectionBadge();
|
|
if (currentView === "timing" || currentView === "settings" || currentView === "overlay") {
|
|
renderView();
|
|
}
|
|
};
|
|
}
|
|
|
|
function disconnectDecoder(options = {}) {
|
|
clearTimeout(reconnectTimer);
|
|
if (wsClient) {
|
|
wsClient.onopen = null;
|
|
wsClient.onmessage = null;
|
|
wsClient.onclose = null;
|
|
wsClient.onerror = null;
|
|
wsClient.close();
|
|
wsClient = null;
|
|
}
|
|
state.decoder.connected = false;
|
|
if (!options.silent) {
|
|
saveState();
|
|
}
|
|
updateConnectionBadge();
|
|
}
|
|
|
|
function processDecoderMessage(msg) {
|
|
if (!msg || typeof msg !== "object") {
|
|
return;
|
|
}
|
|
|
|
const type = String(msg.msg || msg.type || "").toUpperCase();
|
|
if (type !== "PASSING") {
|
|
return;
|
|
}
|
|
|
|
const session = getActiveSession();
|
|
if (!session || session.status !== "running") {
|
|
return;
|
|
}
|
|
|
|
const timestamp = parseRtcTime(msg.rtc_time) || Date.now();
|
|
const transponder = String(msg.transponder ?? msg.tran_code ?? "").replace("ID:", "").trim();
|
|
if (!transponder) {
|
|
return;
|
|
}
|
|
|
|
const result = ensureSessionResult(session.id);
|
|
const competitor = resolveCompetitor(session, transponder);
|
|
if (competitor.ignore) {
|
|
return;
|
|
}
|
|
const key = competitor.key;
|
|
|
|
if (!result.competitors[key]) {
|
|
result.competitors[key] = {
|
|
key,
|
|
teamId: competitor.teamId || null,
|
|
teamName: competitor.teamName || "",
|
|
driverId: competitor.driverId,
|
|
driverName: competitor.driverName,
|
|
displayName: competitor.displayName || competitor.driverName,
|
|
subLabel: competitor.subLabel || competitor.carName || "",
|
|
carId: competitor.carId,
|
|
carName: competitor.carName,
|
|
transponder,
|
|
laps: 0,
|
|
lastLapMs: null,
|
|
bestLapMs: null,
|
|
startTimestamp: null,
|
|
lastTimestamp: null,
|
|
};
|
|
}
|
|
|
|
const entry = result.competitors[key];
|
|
entry.teamId = competitor.teamId || entry.teamId || null;
|
|
entry.teamName = competitor.teamName || entry.teamName || "";
|
|
entry.displayName = competitor.displayName || entry.displayName || competitor.driverName;
|
|
entry.subLabel = competitor.subLabel || entry.subLabel || competitor.carName || "";
|
|
entry.driverId = competitor.driverId ?? entry.driverId;
|
|
entry.driverName = competitor.driverName || entry.driverName;
|
|
entry.carId = competitor.carId ?? entry.carId;
|
|
entry.carName = competitor.carName || entry.carName;
|
|
entry.transponder = transponder;
|
|
const startMode = normalizeStartMode(session.startMode);
|
|
|
|
if (startMode === "staggered" && !entry.startTimestamp) {
|
|
entry.startTimestamp = timestamp;
|
|
entry.lastTimestamp = timestamp;
|
|
announcePassing(entry);
|
|
saveState();
|
|
return;
|
|
}
|
|
|
|
if (!entry.startTimestamp) {
|
|
entry.startTimestamp = session.startedAt || timestamp;
|
|
}
|
|
|
|
const baseTs = entry.lastTimestamp || entry.startTimestamp || session.startedAt || timestamp;
|
|
const lapMs = Math.max(0, timestamp - baseTs);
|
|
const { minLapMs, maxLapMs } = getSessionLapWindow(session);
|
|
let validLap = true;
|
|
let invalidReason = "";
|
|
if (minLapMs > 0 && lapMs > 0 && lapMs < minLapMs) {
|
|
validLap = false;
|
|
invalidReason = "below_min";
|
|
} else if (Number.isFinite(maxLapMs) && maxLapMs > 0 && lapMs > maxLapMs) {
|
|
validLap = false;
|
|
invalidReason = "above_max";
|
|
}
|
|
|
|
const passing = {
|
|
timestamp,
|
|
transponder,
|
|
teamId: entry.teamId,
|
|
teamName: entry.teamName,
|
|
driverId: entry.driverId,
|
|
driverName: entry.driverName,
|
|
displayName: entry.displayName,
|
|
subLabel: entry.subLabel,
|
|
carId: entry.carId,
|
|
carName: entry.carName,
|
|
competitorKey: key,
|
|
lapMs,
|
|
validLap,
|
|
invalidReason,
|
|
strength: msg.strength,
|
|
loopId: String(msg.loop_id || ""),
|
|
resend: Boolean(msg.resend),
|
|
};
|
|
|
|
if (!validLap) {
|
|
result.passings.push(passing);
|
|
if (invalidReason === "above_max") {
|
|
entry.lastTimestamp = timestamp;
|
|
}
|
|
persistPassingToBackend(session.id, passing);
|
|
saveState();
|
|
return;
|
|
}
|
|
|
|
entry.laps += 1;
|
|
entry.lastLapMs = lapMs;
|
|
entry.lastTimestamp = timestamp;
|
|
|
|
if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) {
|
|
entry.bestLapMs = lapMs;
|
|
}
|
|
|
|
result.passings.push(passing);
|
|
persistPassingToBackend(session.id, passing);
|
|
pushOverlayEvent("passing", `${entry.displayName || entry.driverName} • ${formatLap(entry.lastLapMs)}`);
|
|
const leaderboard = buildLeaderboard(session);
|
|
const leader = leaderboard[0];
|
|
if (leader?.key && lastOverlayLeaderKeyBySession[session.id] !== leader.key) {
|
|
lastOverlayLeaderKeyBySession[session.id] = leader.key;
|
|
pushOverlayEvent("leader", `${leader.displayName || leader.driverName} • P1`);
|
|
}
|
|
if (entry.bestLapMs && Number.isFinite(entry.bestLapMs)) {
|
|
const bestKey = `${session.id}:${entry.key}`;
|
|
const previousBest = lastOverlayBestLapByKey[bestKey];
|
|
if (!previousBest || entry.bestLapMs < previousBest) {
|
|
lastOverlayBestLapByKey[bestKey] = entry.bestLapMs;
|
|
pushOverlayEvent("bestlap", `${entry.displayName || entry.driverName} • ${formatLap(entry.bestLapMs)}`);
|
|
}
|
|
}
|
|
const top3Keys = leaderboard.slice(0, 3).map((row) => row.key);
|
|
const previousTop3 = lastOverlayTop3BySession[session.id] || [];
|
|
if (top3Keys.join("|") !== previousTop3.join("|")) {
|
|
lastOverlayTop3BySession[session.id] = top3Keys;
|
|
if (previousTop3.length) {
|
|
pushOverlayEvent("top3", `${t("overlay.mode_leaderboard")} • Top 3 updated`);
|
|
}
|
|
}
|
|
announcePassing(entry);
|
|
}
|
|
|
|
function parseRtcTime(value) {
|
|
if (!value || typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const ts = Date.parse(value);
|
|
return Number.isFinite(ts) ? ts : null;
|
|
}
|
|
|
|
function resolveCompetitor(session, transponder) {
|
|
const sessionType = String(session?.type || "").toLowerCase();
|
|
const isOpenPractice = sessionType === "open_practice";
|
|
const isFreePractice = sessionType === "free_practice";
|
|
const isOpenMonitoringSession = isOpenPractice || isFreePractice;
|
|
const event = state.events.find((item) => item.id === session?.eventId) || null;
|
|
if (session.mode === "track") {
|
|
const matchingAssignments = (session.assignments || []).filter((a) => {
|
|
const car = state.cars.find((c) => c.id === a.carId);
|
|
return car?.transponder === transponder;
|
|
});
|
|
|
|
if (matchingAssignments.length > 1) {
|
|
return {
|
|
key: `track_ambiguous_${transponder}`,
|
|
driverId: null,
|
|
driverName: `Ambiguous TP ${transponder}`,
|
|
carId: null,
|
|
carName: t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
const assignment = matchingAssignments[0];
|
|
if (assignment) {
|
|
const driver = state.drivers.find((d) => d.id === assignment.driverId);
|
|
const car = state.cars.find((c) => c.id === assignment.carId);
|
|
return {
|
|
key: `track_${assignment.id}`,
|
|
driverId: driver?.id || null,
|
|
driverName: driver?.name || t("common.unknown_driver"),
|
|
carId: car?.id || null,
|
|
carName: car?.name || t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: `track_tp_${transponder}`,
|
|
driverId: null,
|
|
driverName: t("common.unassigned_driver"),
|
|
carId: null,
|
|
carName: t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
if (session.mode === "race" && sessionType === "team_race") {
|
|
const driver = state.drivers.find((d) => d.transponder === transponder) || null;
|
|
const car = state.cars.find((c) => c.transponder === transponder) || null;
|
|
const team = event ? findEventTeamForPassing(event, driver?.id || null, car?.id || null) : null;
|
|
if (!team) {
|
|
return {
|
|
key: `ignore_team_${transponder}`,
|
|
ignore: true,
|
|
};
|
|
}
|
|
|
|
const memberBits = [driver?.name || "", car?.name || ""].filter(Boolean);
|
|
return {
|
|
key: `team_${team.id}`,
|
|
teamId: team.id,
|
|
teamName: team.name,
|
|
displayName: team.name,
|
|
subLabel: memberBits.join(" • ") || transponder,
|
|
driverId: driver?.id || null,
|
|
driverName: driver?.name || team.name,
|
|
carId: car?.id || null,
|
|
carName: car?.name || t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
const driver = state.drivers.find((d) => d.transponder === transponder);
|
|
if (driver) {
|
|
if (!isOpenMonitoringSession && Array.isArray(session.driverIds) && session.driverIds.length && !session.driverIds.includes(driver.id)) {
|
|
return {
|
|
key: `ignore_${driver.id}`,
|
|
ignore: true,
|
|
};
|
|
}
|
|
return {
|
|
key: `driver_${driver.id}`,
|
|
driverId: driver.id,
|
|
driverName: driver.name,
|
|
displayName: driver.name,
|
|
subLabel: driver.transponder || "",
|
|
carId: null,
|
|
carName: t("common.driver_car"),
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: `driver_tp_${transponder}`,
|
|
driverId: null,
|
|
driverName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"),
|
|
displayName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"),
|
|
subLabel: transponder,
|
|
carId: null,
|
|
carName: t("common.unknown_car"),
|
|
};
|
|
}
|
|
|
|
function renderSessionsTable(sessions) {
|
|
if (!sessions.length) {
|
|
return `<p>${t("session.none_yet")}</p>`;
|
|
}
|
|
return renderTable(
|
|
[t("table.event"), t("table.session"), t("table.type"), t("table.status"), t("table.mode")],
|
|
sessions.map(
|
|
(s) => `
|
|
<tr>
|
|
<td>${escapeHtml(getEventName(s.eventId))}</td>
|
|
<td>${escapeHtml(s.name)}</td>
|
|
<td>${escapeHtml(getSessionTypeLabel(s.type))}</td>
|
|
<td>${escapeHtml(getStatusLabel(s.status))}</td>
|
|
<td>${getModeLabel(s.mode)}</td>
|
|
</tr>
|
|
`
|
|
)
|
|
);
|
|
}
|
|
|
|
function renderSimpleList(items, labelFn, idFn) {
|
|
if (!items.length) {
|
|
return `<p>${t("common.no_entries")}</p>`;
|
|
}
|
|
|
|
return `
|
|
<ul class="simple-list">
|
|
${items
|
|
.map(
|
|
(item) => `
|
|
<li>
|
|
<span>${labelFn(item)}</span>
|
|
<button class="btn btn-danger btn-mini" id="${idFn(item)}">${t("common.delete")}</button>
|
|
</li>
|
|
`
|
|
)
|
|
.join("")}
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
function renderTable(headers, rowHtml) {
|
|
return `
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
${headers.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rowHtml.join("") || `<tr><td colspan="${headers.length}">${t("common.no_rows")}</td></tr>`}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
}
|
|
|
|
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(`
|
|
<!doctype html>
|
|
<html lang="${escapeHtml(state.settings.language || DEFAULT_LANGUAGE)}">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>${escapeHtml(title)}</title>
|
|
<style>
|
|
@page { size: A4 portrait; margin: 14mm; }
|
|
body { font-family: Arial, sans-serif; padding: 0; color: #10131a; }
|
|
h1, h2, h3 { margin: 0 0 12px; }
|
|
h1 { font-size: 28px; }
|
|
h2 { font-size: 18px; border-bottom: 2px solid #10131a; padding-bottom: 4px; }
|
|
p { margin: 0 0 12px; color: #44506b; }
|
|
.print-header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; padding-bottom: 16px; border-bottom: 4px solid #10131a; }
|
|
.print-kicker { text-transform: uppercase; letter-spacing: 0.08em; color: #5b677f; font-size: 12px; }
|
|
.print-meta { text-align: right; font-size: 13px; }
|
|
.print-brand-block { display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
|
|
.print-brand-block p { margin: 0; }
|
|
.print-logo { max-width: 92px; max-height: 52px; object-fit: contain; }
|
|
.print-block { margin-top: 24px; break-inside: avoid; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
|
th, td { border: 1px solid #b9c2d4; padding: 8px; text-align: left; }
|
|
th { background: #eef2f8; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
tbody tr:nth-child(even) { background: #f8faff; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${bodyHtml}
|
|
</body>
|
|
</html>
|
|
`);
|
|
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);
|
|
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")],
|
|
rows.map((row, index) => [
|
|
String(index + 1),
|
|
row.displayName || row.driverName || "-",
|
|
String(row.laps || 0),
|
|
row.resultDisplay || "-",
|
|
formatLap(row.bestLapMs),
|
|
])
|
|
)
|
|
),
|
|
],
|
|
});
|
|
}
|
|
|
|
async function exportTeamRaceResultsPdf(event) {
|
|
const branding = resolveEventBranding(event);
|
|
const sections = [];
|
|
buildTeamRaceStandings(event).forEach(({ session, rows }) => {
|
|
sections.push(
|
|
buildPdfSection(
|
|
`${t("events.team_report")} • ${session.name}`,
|
|
[t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")],
|
|
rows.map((row, index) => [
|
|
String(index + 1),
|
|
row.displayName || row.driverName || "-",
|
|
String(row.laps || 0),
|
|
row.resultDisplay || "-",
|
|
formatLap(row.bestLapMs),
|
|
])
|
|
)
|
|
);
|
|
|
|
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),
|
|
])
|
|
)
|
|
);
|
|
});
|
|
});
|
|
|
|
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 `
|
|
<header class="print-header">
|
|
<div>
|
|
<p class="print-kicker">${escapeHtml(getClassName(event?.classId || ""))}</p>
|
|
<h1>${escapeHtml(event?.name || t("common.unknown_event"))}</h1>
|
|
<p>${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}</p>
|
|
</div>
|
|
<div class="print-meta">
|
|
${buildPrintBrandBlock(branding)}
|
|
</div>
|
|
</header>
|
|
<p>${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(session.startMode))} • ${t("table.duration")}: ${session.durationMin} min</p>
|
|
${
|
|
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 `
|
|
<tr>
|
|
<td>${entry.slot}</td>
|
|
<td>${escapeHtml(entry.name)}</td>
|
|
<td>${escapeHtml(driver?.brand || "-")}</td>
|
|
<td>${escapeHtml(entry.meta || "-")}</td>
|
|
</tr>
|
|
`;
|
|
})
|
|
)
|
|
: `<p>${t("common.no_entries")}</p>`
|
|
}
|
|
`;
|
|
}
|
|
|
|
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) });
|
|
}
|
|
}
|