diff --git a/src/app.js b/src/app.js index 6980959..aab5dab 100644 --- a/src/app.js +++ b/src/app.js @@ -6,6 +6,8 @@ import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, 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"; + 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 }); @@ -42,6 +44,39 @@ const reseedUpcomingQualifying = (event) => reseedUpcomingQualifyingLogic(event, 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, 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 NAV_ITEMS = [ { id: "dashboard", titleKey: "nav.dashboard", subtitleKey: "nav.dashboard_sub" }, { id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub" }, @@ -6851,12 +6886,6 @@ function getModeLabel(mode) { return mode === "track" ? t("mode.track") : t("mode.race"); } -function getSessionTypeLabel(type) { - const key = `session.${String(type || "").toLowerCase()}`; - const translated = t(key); - return translated === key ? String(type || "") : translated; -} - function normalizeStartMode(mode) { return ["mass", "position", "staggered"].includes(String(mode || "").toLowerCase()) ? String(mode).toLowerCase() : "mass"; } @@ -6865,12 +6894,6 @@ function getStartModeLabel(mode) { return t(`events.start_mode_${normalizeStartMode(mode)}`); } -function getStatusLabel(status) { - const key = `status.${String(status || "").toLowerCase()}`; - const translated = t(key); - return translated === key ? String(status || "") : translated; -} - function getClassName(classId) { return state.classes.find((x) => x.id === classId)?.name || t("common.unknown"); } @@ -6879,53 +6902,6 @@ function getEventName(eventId) { return state.events.find((x) => x.id === eventId)?.name || t("common.unknown_event"); } -function isUntimedSession(session) { - return String(session?.type || "").toLowerCase() === "open_practice"; -} - -function getActiveSession() { - return state.sessions.find((s) => s.id === state.activeSessionId) || null; -} - -function getSessionTargetMs(session) { - if (isUntimedSession(session)) { - return null; - } - return Math.max(1, Number(session?.durationMin || 0)) * 60 * 1000; -} - -function getSessionLapWindow(session) { - const event = state.events.find((item) => item.id === session?.eventId); - if (event?.mode !== "race") { - return { minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY }; - } - const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0); - const configuredMaxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000); - const maxLapMs = configuredMaxLapMs > 0 ? Math.max(configuredMaxLapMs, minLapMs || 0) : Number.POSITIVE_INFINITY; - return { minLapMs, maxLapMs }; -} - -function isCountedPassing(passing) { - return passing?.validLap !== false; -} - -function getVisiblePassings(result) { - return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : []; -} - -function getPassingValidationLabel(passing) { - if (passing?.validLap === false) { - if (passing.invalidReason === "below_min") { - return t("timing.invalid_short"); - } - if (passing.invalidReason === "manual_invalid") { - return t("timing.invalid_manual"); - } - return t("timing.invalid_long"); - } - return t("timing.valid_passing"); -} - function getManualCorrectionSummary(row) { const laps = Number(row?.manualLapAdjustment || 0) || 0; const timeMs = Number(row?.manualTimeAdjustmentMs || 0) || 0; @@ -7149,38 +7125,6 @@ function getJudgeFilteredLog(adjustments, filterValue) { return adjustments; } -function getSessionTiming(session, nowTs = Date.now()) { - const targetMs = getSessionTargetMs(session); - const startedAt = Number(session?.startedAt || 0); - const elapsedMs = startedAt ? Math.max(0, nowTs - startedAt) : 0; - const followUpMs = Math.max(0, Number(session?.followUpSec || 0) || 0) * 1000; - const followUpStartedAt = Number(session?.followUpStartedAt || 0) || 0; - const followUpActive = Boolean(followUpStartedAt && followUpMs > 0); - const followUpRemainingMs = followUpActive ? Math.max(0, followUpMs - Math.max(0, nowTs - followUpStartedAt)) : 0; - return { - targetMs, - elapsedMs, - remainingMs: targetMs === null ? null : Math.max(0, targetMs - elapsedMs), - untimed: targetMs === null, - followUpActive, - followUpRemainingMs, - }; -} - -function ensureSessionResult(sessionId) { - if (!state.resultsBySession[sessionId]) { - state.resultsBySession[sessionId] = { - passings: [], - competitors: {}, - adjustments: [], - }; - } - if (!Array.isArray(state.resultsBySession[sessionId].adjustments)) { - state.resultsBySession[sessionId].adjustments = []; - } - return state.resultsBySession[sessionId]; -} - function getFreePracticeSessions(eventId) { return getSessionsForEvent(eventId).filter((session) => session.type === "free_practice"); } @@ -7537,190 +7481,6 @@ function resolveCompetitor(session, transponder) { }; } -function buildLeaderboard(session) { - const result = ensureSessionResult(session.id); - const sessionType = String(session.type || "").toLowerCase(); - const targetMs = getSessionTargetMs(session); - const useTargetTieBreak = session.status === "finished"; - const useSeedRanking = ["practice", "qualification"].includes(sessionType) && Number(session.seedBestLapCount || 0) > 0; - const isFreePractice = sessionType === "free_practice"; - const isOpenPractice = sessionType === "open_practice"; - const isRollingPractice = isFreePractice || isOpenPractice; - const { maxLapMs } = getSessionLapWindow(session); - const nowTs = Date.now(); - const rows = Object.values(result.competitors).map((row) => { - const allPassings = getCompetitorPassings(session, row, { includeInvalid: true }); - const passings = allPassings.filter((passing) => isCountedPassing(passing)); - const latestAnyPassing = allPassings.length ? allPassings[allPassings.length - 1] : null; - const latestPassing = passings.length ? passings[passings.length - 1] : null; - const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0; - const rawElapsedMs = lastPassingTs - ? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs)) - : getCompetitorElapsedMs(session, row); - const manualLapAdjustment = Number(row.manualLapAdjustment || 0) || 0; - const manualTimeAdjustmentMs = Number(row.manualTimeAdjustmentMs || 0) || 0; - const totalElapsedMs = Math.max(0, rawElapsedMs + manualTimeAdjustmentMs); - const distanceToTargetMs = Math.abs(targetMs - totalElapsedMs); - const seedMetric = getCompetitorSeedMetric(session, row); - const previousLapMs = passings.length >= 2 ? Number(passings[passings.length - 2].lapMs || 0) : null; - const lastLapMs = latestPassing ? Number(latestPassing.lapMs || 0) : Number(row.lastLapMs || 0) || 0; - const bestLapMs = Number(row.bestLapMs || 0) || 0; - const lapDeltaMs = - lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null; - const predictionBaseMs = - lastLapMs > 0 - ? lastLapMs - : bestLapMs > 0 - ? bestLapMs - : null; - const currentLapElapsedMs = lastPassingTs ? Math.max(0, nowTs - lastPassingTs) : 0; - const predictionStaleMs = predictionBaseMs - ? Math.max( - predictionBaseMs * 2, - Number.isFinite(maxLapMs) && maxLapMs > 0 ? maxLapMs : 0, - 15000 - ) - : 0; - const predictionActive = Boolean(predictionBaseMs && lastPassingTs && currentLapElapsedMs <= predictionStaleMs); - const predictedRemainingMs = predictionActive ? predictionBaseMs - currentLapElapsedMs : null; - const predictedProgress = predictionActive ? currentLapElapsedMs / predictionBaseMs : 0; - const predictionTone = - !predictionActive || predictedProgress <= 0.85 - ? "good" - : predictedProgress <= 1 - ? "warn" - : "late"; - const invalidPending = latestAnyPassing?.validLap === false; - return { - ...row, - laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment), - lastLapMs, - bestLapMs, - lastTimestamp: lastPassingTs || row.lastTimestamp, - totalElapsedMs, - manualLapAdjustment, - manualTimeAdjustmentMs, - distanceToTargetMs, - seedMetric, - previousLapMs, - lapDeltaMs, - predictedRemainingMs, - predictedProgress, - predictionTone, - invalidPending, - invalidLabel: invalidPending ? getPassingValidationLabel(latestAnyPassing) : "", - invalidLapMs: invalidPending ? Number(latestAnyPassing?.lapMs || 0) || 0 : 0, - comparisonMs: - isRollingPractice - ? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER - : useSeedRanking && seedMetric - ? seedMetric.comparableMs - : totalElapsedMs, - resultDisplay: - isRollingPractice - ? formatLap(bestLapMs || lastLapMs) - : useSeedRanking && seedMetric - ? formatSeedMetric(seedMetric) - : `${Math.max(0, Number(row.laps || 0) + manualLapAdjustment)}/${formatRaceClock(totalElapsedMs)}`, - }; - }); - - rows.sort((a, b) => { - if (isRollingPractice) { - if (a.comparisonMs !== b.comparisonMs) { - return a.comparisonMs - b.comparisonMs; - } - if (b.laps !== a.laps) { - return b.laps - a.laps; - } - return (b.lastTimestamp || 0) - (a.lastTimestamp || 0); - } - if (useSeedRanking) { - if (a.seedMetric && b.seedMetric && a.seedMetric.comparableMs !== b.seedMetric.comparableMs) { - return a.seedMetric.comparableMs - b.seedMetric.comparableMs; - } - if (a.seedMetric && !b.seedMetric) { - return -1; - } - if (!a.seedMetric && b.seedMetric) { - return 1; - } - if (b.laps !== a.laps) { - return b.laps - a.laps; - } - return a.totalElapsedMs - b.totalElapsedMs; - } - - if (b.laps !== a.laps) { - return b.laps - a.laps; - } - if (useTargetTieBreak && a.distanceToTargetMs !== b.distanceToTargetMs) { - return a.distanceToTargetMs - b.distanceToTargetMs; - } - return a.totalElapsedMs - b.totalElapsedMs; - }); - - const leader = rows[0]; - return rows.map((row, index) => { - if (!leader) { - return { ...row, gap: "-", leaderGap: "-", gapAhead: "-", lapDelta: "-" }; - } - return { - ...row, - gap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }), - leaderGap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }), - gapAhead: formatLeaderboardGap(row, rows[index - 1], { - useSeedRanking, - useTargetTieBreak, - isFreePractice: isRollingPractice, - selfLabel: t("status.leader"), - }), - lapDelta: formatLapDelta(row.lapDeltaMs), - }; - }); -} - -function formatLapDelta(deltaMs) { - if (!deltaMs && deltaMs !== 0) { - return "-"; - } - const sign = deltaMs <= 0 ? "-" : "+"; - return `${sign}${(Math.abs(deltaMs) / 1000).toFixed(3)}s`; -} - -function formatLeaderboardGap(row, referenceRow, options = {}) { - if (!referenceRow) { - return "-"; - } - if (row.key === referenceRow.key) { - if (options.isFreePractice) { - return t("status.free_practice"); - } - return options.selfLabel || (options.useSeedRanking ? t("status.seeded") : t("status.leader")); - } - if (options.isFreePractice) { - if (Number.isFinite(row.comparisonMs) && Number.isFinite(referenceRow.comparisonMs)) { - return `+${((row.comparisonMs - referenceRow.comparisonMs) / 1000).toFixed(3)}s`; - } - return "-"; - } - if (options.useSeedRanking) { - if (referenceRow.seedMetric && row.seedMetric) { - const seedGap = Math.max(0, row.seedMetric.comparableMs - referenceRow.seedMetric.comparableMs); - return `+${(seedGap / 1000).toFixed(3)}s`; - } - return `+${Math.max(0, (referenceRow.laps || 0) - (row.laps || 0))}L`; - } - const lapDiff = (referenceRow.laps || 0) - (row.laps || 0); - if (lapDiff > 0) { - return `+${lapDiff}L`; - } - const referenceValue = options.useTargetTieBreak ? referenceRow.distanceToTargetMs : referenceRow.totalElapsedMs; - const rowValue = options.useTargetTieBreak ? row.distanceToTargetMs : row.totalElapsedMs; - const timeGap = Math.max(0, rowValue - referenceValue); - return `+${(timeGap / 1000).toFixed(3)}s`; -} - function renderSessionsTable(sessions) { if (!sessions.length) { return `

${t("session.none_yet")}

`; @@ -7883,352 +7643,6 @@ function formatSeedMetric(metric) { return `${metric.lapCount}/${formatRaceClock(metric.totalMs)}`; } -function getQualifyingPointsValue(place, fieldSize, tableType) { - const normalized = ["rank_low", "field_desc", "ifmar"].includes(String(tableType || "").toLowerCase()) - ? String(tableType).toLowerCase() - : "rank_low"; - if (normalized === "field_desc") { - return Math.max(1, Number(fieldSize || 0) - place + 1); - } - if (normalized === "ifmar") { - const scale = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; - return scale[place - 1] ?? 0; - } - return place; -} - -function isHighPointsTable(tableType) { - return ["field_desc", "ifmar"].includes(String(tableType || "").toLowerCase()); -} - -function compareNumberSet(left, right, highWins = false) { - for (let i = 0; i < Math.max(left.length, right.length); i += 1) { - const leftValue = left[i] ?? (highWins ? -Infinity : Infinity); - const rightValue = right[i] ?? (highWins ? -Infinity : Infinity); - if (leftValue !== rightValue) { - return highWins ? rightValue - leftValue : leftValue - rightValue; - } - } - return 0; -} - -function buildQualifyingTieBreakNote(row, tieBreak) { - if (tieBreak === "best_lap") { - return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_lap")} • ${formatLap(row.bestSingleLapMs)}`; - } - if (tieBreak === "best_round") { - return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_round")} • ${row.bestRoundDisplay || formatLap(row.bestRoundMetric)}`; - } - return `${t("events.tie_break_note")}: ${t("events.counted_rounds_label")} • ${(row.ranks || []).join(" / ") || "-"}`; -} - -function hasQualifyingPrimaryTie(left, right, scoringMode) { - if (!left || !right) { - return false; - } - if (scoringMode === "points") { - return left.totalScore === right.totalScore; - } - return left.bestRank === right.bestRank; -} - -function getCompetitorElapsedMs(session, row) { - const startTs = Number(row?.startTimestamp || session?.startedAt || 0); - if (!startTs || !row?.lastTimestamp) { - return 0; - } - return Math.max(0, row.lastTimestamp - startTs); -} - -function getCompetitorPassings(session, row, options = {}) { - const result = ensureSessionResult(session.id); - return result.passings - .filter((passing) => { - if (!options.includeInvalid && !isCountedPassing(passing)) { - return false; - } - if (passing.competitorKey) { - return passing.competitorKey === row.key; - } - return ( - String(passing.transponder || "") === String(row.transponder || "") && - String(passing.driverId || "") === String(row.driverId || "") && - String(passing.carId || "") === String(row.carId || "") - ); - }) - .sort((a, b) => a.timestamp - b.timestamp); -} - -function getCompetitorSeedMetric(session, row) { - const lapCount = Math.max(0, Number(session?.seedBestLapCount || 0) || 0); - if (lapCount <= 0) { - return null; - } - - const method = ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase()) - ? String(session.seedMethod).toLowerCase() - : "best_sum"; - const laps = getCompetitorPassings(session, row) - .map((passing) => Number(passing.lapMs || 0)) - .filter((lapMs) => lapMs > 500); - - if (laps.length < lapCount) { - return null; - } - - let selected = []; - if (method === "consecutive") { - let bestWindow = null; - for (let index = 0; index <= laps.length - lapCount; index += 1) { - const window = laps.slice(index, index + lapCount); - const totalMs = window.reduce((sum, lapMs) => sum + lapMs, 0); - if (!bestWindow || totalMs < bestWindow.totalMs) { - bestWindow = { laps: window, totalMs }; - } - } - if (!bestWindow) { - return null; - } - selected = bestWindow.laps; - } else { - selected = [...laps].sort((a, b) => a - b).slice(0, lapCount); - } - const totalMs = selected.reduce((sum, lapMs) => sum + lapMs, 0); - const averageMs = totalMs / lapCount; - return { - lapCount, - method, - totalMs, - averageMs, - comparableMs: method === "average" ? averageMs : totalMs, - laps: selected, - }; -} - -function getSessionEntrants(session) { - const event = state.events.find((item) => item.id === session.eventId); - const eventDrivers = event ? getEventDrivers(event) : state.drivers; - if (!Array.isArray(session.driverIds) || !session.driverIds.length) { - return eventDrivers; - } - return eventDrivers.filter((driver) => session.driverIds.includes(driver.id)); -} - -function buildPracticeStandings(event) { - const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "practice"); - const competitorMap = new Map(); - - sessions.forEach((session) => { - buildLeaderboard(session).forEach((row) => { - const key = row.driverId || row.key; - const seedMetric = getCompetitorSeedMetric(session, row); - const comparableMs = seedMetric?.comparableMs ?? row.bestLapMs ?? row.totalElapsedMs; - const current = competitorMap.get(key); - if (!current || comparableMs < current.comparableMs) { - competitorMap.set(key, { - key, - driverId: row.driverId, - driverName: row.driverName, - comparableMs, - resultDisplay: seedMetric ? formatSeedMetric(seedMetric) : `${row.laps}/${formatRaceClock(row.totalElapsedMs)}`, - sourceSessionName: session.name, - }); - } - }); - }); - - return [...competitorMap.values()].sort((a, b) => a.comparableMs - b.comparableMs).map((row, index) => ({ - ...row, - rank: index + 1, - score: row.resultDisplay, - })); -} - -function buildQualifyingStandings(event) { - const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "qualification"); - const scoringMode = event.raceConfig?.qualifyingScoring || "points"; - const countedRounds = Math.max(1, Number(event.raceConfig?.countedQualRounds || 1) || 1); - const pointsTable = event.raceConfig?.qualifyingPointsTable || "rank_low"; - const highPointsWin = isHighPointsTable(pointsTable); - const tieBreak = event.raceConfig?.qualifyingTieBreak || "rounds"; - const competitorMap = new Map(); - - sessions.forEach((session) => { - const rows = buildLeaderboard(session); - const entrantCount = Math.max(rows.length, getSessionEntrants(session).length || 0); - rows.forEach((row, index) => { - const key = row.driverId || row.key; - if (!competitorMap.has(key)) { - competitorMap.set(key, { - key, - driverId: row.driverId, - driverName: row.driverName, - points: [], - ranks: [], - roundMetrics: [], - bestLaps: [], - }); - } - const entry = competitorMap.get(key); - entry.points.push(getQualifyingPointsValue(index + 1, entrantCount, pointsTable)); - entry.ranks.push(index + 1); - entry.roundMetrics.push(row.comparisonMs); - if (Number.isFinite(row.bestLapMs)) { - entry.bestLaps.push(row.bestLapMs); - } - if (!entry.bestRoundDisplay || row.comparisonMs < (entry.bestRoundMetricValue ?? Number.MAX_SAFE_INTEGER)) { - entry.bestRoundMetricValue = row.comparisonMs; - entry.bestRoundDisplay = row.resultDisplay; - } - entry.bestResultDisplay = row.resultDisplay; - entry.lastSessionName = session.name; - entry.sessionCount = (entry.sessionCount || 0) + 1; - entry.maxFieldSize = entrantCount; - }); - }); - - const rows = [...competitorMap.values()].map((entry) => { - const sortedPoints = [...entry.points].sort((a, b) => (highPointsWin ? b - a : a - b)); - const counted = sortedPoints.slice(0, countedRounds); - const totalScore = counted.reduce((sum, value) => sum + value, 0); - const bestRank = Math.min(...entry.ranks); - const bestRoundMetric = Math.min(...entry.roundMetrics); - const bestSingleLapMs = entry.bestLaps.length ? Math.min(...entry.bestLaps) : Number.MAX_SAFE_INTEGER; - return { - key: entry.key, - driverId: entry.driverId, - driverName: entry.driverName, - ranks: [...entry.ranks].sort((a, b) => a - b), - countedPoints: counted, - totalScore, - bestRank, - bestRoundMetric, - bestRoundDisplay: entry.bestRoundDisplay || formatLap(bestRoundMetric), - bestSingleLapMs, - score: - scoringMode === "points" - ? `${totalScore} (${counted.join("+")})` - : `${bestRank} / ${formatRaceClock(bestRoundMetric)}`, - }; - }); - - rows.sort((a, b) => { - if (scoringMode === "points") { - if (a.totalScore !== b.totalScore) { - return highPointsWin ? b.totalScore - a.totalScore : a.totalScore - b.totalScore; - } - if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) { - return a.bestSingleLapMs - b.bestSingleLapMs; - } - if (tieBreak === "best_round" && a.bestRoundMetric !== b.bestRoundMetric) { - return a.bestRoundMetric - b.bestRoundMetric; - } - const pointDiff = compareNumberSet(a.countedPoints, b.countedPoints, highPointsWin); - if (pointDiff !== 0) { - return pointDiff; - } - return a.bestRoundMetric - b.bestRoundMetric; - } - - if (a.bestRank !== b.bestRank) { - return a.bestRank - b.bestRank; - } - if (tieBreak === "rounds") { - const rankDiff = compareNumberSet(a.ranks, b.ranks, false); - if (rankDiff !== 0) { - return rankDiff; - } - } - if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) { - return a.bestSingleLapMs - b.bestSingleLapMs; - } - return a.bestRoundMetric - b.bestRoundMetric; - }); - - rows.forEach((row, index) => { - row.tieBreakWonAgainst = ""; - row.tieBreakLostAgainst = ""; - if (index === 0) { - return; - } - const previous = rows[index - 1]; - if (!hasQualifyingPrimaryTie(previous, row, scoringMode)) { - return; - } - previous.tieBreakWonAgainst = previous.tieBreakWonAgainst || row.driverName || t("common.unknown_driver"); - row.tieBreakLostAgainst = row.tieBreakLostAgainst || previous.driverName || t("common.unknown_driver"); - }); - - return rows.map((row, index) => ({ - ...row, - rank: index + 1, - scoreNote: [ - buildQualifyingTieBreakNote(row, tieBreak), - row.tieBreakWonAgainst ? `${t("events.tie_break_won")}: ${row.tieBreakWonAgainst}` : "", - row.tieBreakLostAgainst ? `${t("events.tie_break_lost")}: ${row.tieBreakLostAgainst}` : "", - ] - .filter(Boolean) - .join(" • "), - })); -} - -function formatTeamActiveMemberLabel(rowOrPassing) { - if (!rowOrPassing) { - return "-"; - } - const parts = [rowOrPassing.driverName || "", rowOrPassing.carName || ""].filter(Boolean); - return parts.join(" • ") || rowOrPassing.subLabel || "-"; -} - -function buildTeamRaceStandings(event) { - return getSessionsForEvent(event.id) - .filter((session) => session.type === "team_race") - .sort((left, right) => getSessionSortWeight(left) - getSessionSortWeight(right) || String(left.name).localeCompare(String(right.name))) - .map((session) => ({ - session, - rows: buildLeaderboard(session), - })); -} - -function buildTeamStintLog(session, row) { - const passings = getCompetitorPassings(session, row); - if (!passings.length) { - return []; - } - - const { maxLapMs } = getSessionLapWindow(session); - const stintGapMs = Number.isFinite(maxLapMs) ? maxLapMs : Number.POSITIVE_INFINITY; - const stints = []; - let current = null; - - passings.forEach((passing) => { - const memberLabel = formatTeamActiveMemberLabel(passing); - const memberKey = `${passing.driverId || passing.driverName || "-"}|${passing.carId || passing.carName || "-"}`; - const gapBreak = current && Number.isFinite(stintGapMs) && Math.max(0, passing.timestamp - current.endTs) > stintGapMs; - if (!current || current.memberKey !== memberKey || gapBreak) { - current = { - memberKey, - memberLabel, - driverName: passing.driverName || "-", - carName: passing.carName || "-", - startTs: passing.timestamp, - endTs: passing.timestamp, - laps: 1, - }; - stints.push(current); - return; - } - current.endTs = passing.timestamp; - current.laps += 1; - }); - - return stints.map((stint, index) => ({ - ...stint, - index: index + 1, - durationMs: Math.max(0, stint.endTs - stint.startTs), - })); -} - function renderTeamStintLog(session, rows) { if (!rows.length) { return `

${t("events.no_team_results")}

`; @@ -8333,61 +7747,6 @@ function getDriverDisplayById(driverId) { return driver.transponder ? `${driver.name} (${driver.transponder})` : driver.name; } -function getSessionGridEntries(session) { - if (!session) { - return []; - } - - if (session.mode === "track") { - return (session.assignments || []).map((assignment, index) => { - const driver = state.drivers.find((item) => item.id === assignment.driverId); - const car = state.cars.find((item) => item.id === assignment.carId); - return { - slot: index + 1, - name: driver?.name || t("common.unknown_driver"), - meta: car ? `${car.name} (${car.transponder || "-"})` : t("common.unknown_car"), - }; - }); - } - - if (session.type === "team_race") { - const event = state.events.find((item) => item.id === session.eventId); - return getEventTeams(event).map((team, index) => ({ - slot: index + 1, - name: team.name, - meta: - team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") || - team.carIds - .map((carId) => { - const car = state.cars.find((item) => item.id === carId); - return car ? `${car.name} (${car.transponder || "-"})` : ""; - }) - .filter(Boolean) - .join(", ") || - "-", - })); - } - - return getSessionGridOrder(session).map((driverId, index) => { - const driver = state.drivers.find((item) => item.id === driverId); - return { - slot: index + 1, - name: driver?.name || t("common.unknown_driver"), - meta: driver?.transponder || "-", - }; - }); -} - -function getSessionGridOrder(session) { - if (!session) { - return []; - } - if (Array.isArray(session.manualGridIds) && session.manualGridIds.length) { - return session.manualGridIds; - } - return Array.isArray(session.driverIds) ? session.driverIds : []; -} - function renderPositionGrid(session) { const entries = getSessionGridEntries(session); if (!entries.length) { @@ -8417,21 +7776,6 @@ function renderPositionGrid(session) { `; } -function ensureSessionDriverOrder(session) { - if (!session || session.mode !== "race") { - return []; - } - if (!Array.isArray(session.driverIds) || !session.driverIds.length) { - session.driverIds = getSessionEntrants(session) - .map((driver) => driver.id) - .filter(Boolean); - } - if (!Array.isArray(session.manualGridIds) || !session.manualGridIds.length) { - session.manualGridIds = [...session.driverIds]; - } - return session.manualGridIds; -} - function renderGridEditor(session) { if (!session) { return `

${t("events.grid_empty")}

`; @@ -8493,87 +7837,6 @@ function clearGeneratedFinals(eventId) { }); } -function buildFinalStandings(event) { - const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "final"); - const groupedByMain = new Map(); - const countedFinalLegs = Math.max(1, Number(event.raceConfig?.countedFinalLegs || 1) || 1); - - sessions.forEach((session) => { - const rows = buildLeaderboard(session); - const mainMatch = String(session.name || "").match(/^([A-Z])/i); - const mainKey = mainMatch ? mainMatch[1].toUpperCase() : "A"; - if (!groupedByMain.has(mainKey)) { - groupedByMain.set(mainKey, new Map()); - } - const competitorMap = groupedByMain.get(mainKey); - rows.forEach((row, index) => { - const key = row.driverId || row.key; - if (!competitorMap.has(key)) { - competitorMap.set(key, { - key, - mainKey, - driverId: row.driverId, - driverName: row.driverName, - legRanks: [], - bestElapsedMs: [], - }); - } - const entry = competitorMap.get(key); - entry.legRanks.push(index + 1); - entry.bestElapsedMs.push(row.totalElapsedMs); - }); - }); - - const mains = [...groupedByMain.entries()].sort((a, b) => a[0].localeCompare(b[0])); - const rows = []; - mains.forEach(([mainKey, competitorMap], mainIndex) => { - const mainRows = [...competitorMap.values()].map((entry) => { - const sortedRanks = [...entry.legRanks].sort((a, b) => a - b); - const countedRanks = sortedRanks.slice(0, countedFinalLegs); - return { - mainKey, - driverId: entry.driverId, - driverName: entry.driverName, - finalScore: countedRanks.reduce((sum, value) => sum + value, 0), - legRanks: sortedRanks, - bestElapsedMs: Math.min(...entry.bestElapsedMs), - }; - }); - - mainRows.sort((a, b) => { - if (a.finalScore !== b.finalScore) { - return a.finalScore - b.finalScore; - } - for (let i = 0; i < Math.max(a.legRanks.length, b.legRanks.length); i += 1) { - const left = a.legRanks[i] ?? 999; - const right = b.legRanks[i] ?? 999; - if (left !== right) { - return left - right; - } - } - return a.bestElapsedMs - b.bestElapsedMs; - }); - - mainRows.forEach((row, rowIndex) => { - rows.push({ - rank: `${mainKey}${rowIndex + 1}`, - driverId: row.driverId, - driverName: row.driverName, - score: `${row.finalScore} (${row.legRanks.join("+")})`, - orderingGroup: mainIndex, - orderingIndex: rowIndex, - }); - }); - }); - - return rows.sort((a, b) => { - if (a.orderingGroup !== b.orderingGroup) { - return a.orderingGroup - b.orderingGroup; - } - return a.orderingIndex - b.orderingIndex; - }); -} - function getFinalMainLayouts(event) { const finals = getSessionsForEvent(event.id) .filter((session) => session.type === "final") diff --git a/src/timing_logic.js b/src/timing_logic.js new file mode 100644 index 0000000..54ceb4f --- /dev/null +++ b/src/timing_logic.js @@ -0,0 +1,835 @@ +export function getSessionTypeLabel(type, { t }) { + const key = `session.${String(type || "").toLowerCase()}`; + const translated = t(key); + return translated === key ? String(type || "") : translated; +} + + + +export function getStatusLabel(status, { t }) { + const key = `status.${String(status || "").toLowerCase()}`; + const translated = t(key); + return translated === key ? String(status || "") : translated; +} + + + +export function isUntimedSession(session) { + return String(session?.type || "").toLowerCase() === "open_practice"; +} + + + +export function getActiveSession({ state }) { + return state.sessions.find((s) => s.id === state.activeSessionId) || null; +} + + + +export function getSessionTargetMs(session, { isUntimedSession }) { + if (isUntimedSession(session)) { + return null; + } + return Math.max(1, Number(session?.durationMin || 0)) * 60 * 1000; +} + + + +export function getSessionLapWindow(session, { state }) { + const event = state.events.find((item) => item.id === session?.eventId); + if (event?.mode !== "race") { + return { minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY }; + } + const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0); + const configuredMaxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000); + const maxLapMs = configuredMaxLapMs > 0 ? Math.max(configuredMaxLapMs, minLapMs || 0) : Number.POSITIVE_INFINITY; + return { minLapMs, maxLapMs }; +} + + + +export function isCountedPassing(passing) { + return passing?.validLap !== false; +} + + + +export function getVisiblePassings(result, { isCountedPassing }) { + return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : []; +} + + + +export function getPassingValidationLabel(passing, { t }) { + if (passing?.validLap === false) { + if (passing.invalidReason === "below_min") { + return t("timing.invalid_short"); + } + if (passing.invalidReason === "manual_invalid") { + return t("timing.invalid_manual"); + } + return t("timing.invalid_long"); + } + return t("timing.valid_passing"); +} + + + +export function getSessionTiming(session, nowTs = Date.now(), { getSessionTargetMs }) { + const targetMs = getSessionTargetMs(session); + const startedAt = Number(session?.startedAt || 0); + const elapsedMs = startedAt ? Math.max(0, nowTs - startedAt) : 0; + const followUpMs = Math.max(0, Number(session?.followUpSec || 0) || 0) * 1000; + const followUpStartedAt = Number(session?.followUpStartedAt || 0) || 0; + const followUpActive = Boolean(followUpStartedAt && followUpMs > 0); + const followUpRemainingMs = followUpActive ? Math.max(0, followUpMs - Math.max(0, nowTs - followUpStartedAt)) : 0; + return { + targetMs, + elapsedMs, + remainingMs: targetMs === null ? null : Math.max(0, targetMs - elapsedMs), + untimed: targetMs === null, + followUpActive, + followUpRemainingMs, + }; +} + + + +export function ensureSessionResult(sessionId, { state }) { + if (!state.resultsBySession[sessionId]) { + state.resultsBySession[sessionId] = { + passings: [], + competitors: {}, + adjustments: [], + }; + } + if (!Array.isArray(state.resultsBySession[sessionId].adjustments)) { + state.resultsBySession[sessionId].adjustments = []; + } + return state.resultsBySession[sessionId]; +} + + + +export function buildLeaderboard(session, { ensureSessionResult, getSessionTargetMs, getCompetitorPassings, isCountedPassing, getCompetitorElapsedMs, getCompetitorSeedMetric, getSessionLapWindow, formatSeedMetric, formatRaceClock, formatLeaderboardGap, formatLapDelta, t }) { + const result = ensureSessionResult(session.id); + const sessionType = String(session.type || "").toLowerCase(); + const targetMs = getSessionTargetMs(session); + const useTargetTieBreak = session.status === "finished"; + const useSeedRanking = ["practice", "qualification"].includes(sessionType) && Number(session.seedBestLapCount || 0) > 0; + const isFreePractice = sessionType === "free_practice"; + const isOpenPractice = sessionType === "open_practice"; + const isRollingPractice = isFreePractice || isOpenPractice; + const { maxLapMs } = getSessionLapWindow(session); + const nowTs = Date.now(); + const rows = Object.values(result.competitors).map((row) => { + const allPassings = getCompetitorPassings(session, row, { includeInvalid: true }); + const passings = allPassings.filter((passing) => isCountedPassing(passing)); + const latestAnyPassing = allPassings.length ? allPassings[allPassings.length - 1] : null; + const latestPassing = passings.length ? passings[passings.length - 1] : null; + const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0; + const rawElapsedMs = lastPassingTs + ? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs)) + : getCompetitorElapsedMs(session, row); + const manualLapAdjustment = Number(row.manualLapAdjustment || 0) || 0; + const manualTimeAdjustmentMs = Number(row.manualTimeAdjustmentMs || 0) || 0; + const totalElapsedMs = Math.max(0, rawElapsedMs + manualTimeAdjustmentMs); + const distanceToTargetMs = Math.abs(targetMs - totalElapsedMs); + const seedMetric = getCompetitorSeedMetric(session, row); + const previousLapMs = passings.length >= 2 ? Number(passings[passings.length - 2].lapMs || 0) : null; + const lastLapMs = latestPassing ? Number(latestPassing.lapMs || 0) : Number(row.lastLapMs || 0) || 0; + const bestLapMs = Number(row.bestLapMs || 0) || 0; + const lapDeltaMs = + lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null; + const predictionBaseMs = + lastLapMs > 0 + ? lastLapMs + : bestLapMs > 0 + ? bestLapMs + : null; + const currentLapElapsedMs = lastPassingTs ? Math.max(0, nowTs - lastPassingTs) : 0; + const predictionStaleMs = predictionBaseMs + ? Math.max( + predictionBaseMs * 2, + Number.isFinite(maxLapMs) && maxLapMs > 0 ? maxLapMs : 0, + 15000 + ) + : 0; + const predictionActive = Boolean(predictionBaseMs && lastPassingTs && currentLapElapsedMs <= predictionStaleMs); + const predictedRemainingMs = predictionActive ? predictionBaseMs - currentLapElapsedMs : null; + const predictedProgress = predictionActive ? currentLapElapsedMs / predictionBaseMs : 0; + const predictionTone = + !predictionActive || predictedProgress <= 0.85 + ? "good" + : predictedProgress <= 1 + ? "warn" + : "late"; + const invalidPending = latestAnyPassing?.validLap === false; + return { + ...row, + laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment), + lastLapMs, + bestLapMs, + lastTimestamp: lastPassingTs || row.lastTimestamp, + totalElapsedMs, + manualLapAdjustment, + manualTimeAdjustmentMs, + distanceToTargetMs, + seedMetric, + previousLapMs, + lapDeltaMs, + predictedRemainingMs, + predictedProgress, + predictionTone, + invalidPending, + invalidLabel: invalidPending ? getPassingValidationLabel(latestAnyPassing) : "", + invalidLapMs: invalidPending ? Number(latestAnyPassing?.lapMs || 0) || 0 : 0, + comparisonMs: + isRollingPractice + ? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER + : useSeedRanking && seedMetric + ? seedMetric.comparableMs + : totalElapsedMs, + resultDisplay: + isRollingPractice + ? formatLap(bestLapMs || lastLapMs) + : useSeedRanking && seedMetric + ? formatSeedMetric(seedMetric) + : `${Math.max(0, Number(row.laps || 0) + manualLapAdjustment)}/${formatRaceClock(totalElapsedMs)}`, + }; + }); + + rows.sort((a, b) => { + if (isRollingPractice) { + if (a.comparisonMs !== b.comparisonMs) { + return a.comparisonMs - b.comparisonMs; + } + if (b.laps !== a.laps) { + return b.laps - a.laps; + } + return (b.lastTimestamp || 0) - (a.lastTimestamp || 0); + } + if (useSeedRanking) { + if (a.seedMetric && b.seedMetric && a.seedMetric.comparableMs !== b.seedMetric.comparableMs) { + return a.seedMetric.comparableMs - b.seedMetric.comparableMs; + } + if (a.seedMetric && !b.seedMetric) { + return -1; + } + if (!a.seedMetric && b.seedMetric) { + return 1; + } + if (b.laps !== a.laps) { + return b.laps - a.laps; + } + return a.totalElapsedMs - b.totalElapsedMs; + } + + if (b.laps !== a.laps) { + return b.laps - a.laps; + } + if (useTargetTieBreak && a.distanceToTargetMs !== b.distanceToTargetMs) { + return a.distanceToTargetMs - b.distanceToTargetMs; + } + return a.totalElapsedMs - b.totalElapsedMs; + }); + + const leader = rows[0]; + return rows.map((row, index) => { + if (!leader) { + return { ...row, gap: "-", leaderGap: "-", gapAhead: "-", lapDelta: "-" }; + } + return { + ...row, + gap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }), + leaderGap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }), + gapAhead: formatLeaderboardGap(row, rows[index - 1], { + useSeedRanking, + useTargetTieBreak, + isFreePractice: isRollingPractice, + selfLabel: t("status.leader"), + }), + lapDelta: formatLapDelta(row.lapDeltaMs), + }; + }); +} + + + +export function formatLapDelta(deltaMs, { formatLap }) { + if (!deltaMs && deltaMs !== 0) { + return "-"; + } + const sign = deltaMs <= 0 ? "-" : "+"; + return `${sign}${(Math.abs(deltaMs) / 1000).toFixed(3)}s`; +} + + + +export function formatLeaderboardGap(row, referenceRow, options = {}, { t }) { + if (!referenceRow) { + return "-"; + } + if (row.key === referenceRow.key) { + if (options.isFreePractice) { + return t("status.free_practice"); + } + return options.selfLabel || (options.useSeedRanking ? t("status.seeded") : t("status.leader")); + } + if (options.isFreePractice) { + if (Number.isFinite(row.comparisonMs) && Number.isFinite(referenceRow.comparisonMs)) { + return `+${((row.comparisonMs - referenceRow.comparisonMs) / 1000).toFixed(3)}s`; + } + return "-"; + } + if (options.useSeedRanking) { + if (referenceRow.seedMetric && row.seedMetric) { + const seedGap = Math.max(0, row.seedMetric.comparableMs - referenceRow.seedMetric.comparableMs); + return `+${(seedGap / 1000).toFixed(3)}s`; + } + return `+${Math.max(0, (referenceRow.laps || 0) - (row.laps || 0))}L`; + } + const lapDiff = (referenceRow.laps || 0) - (row.laps || 0); + if (lapDiff > 0) { + return `+${lapDiff}L`; + } + const referenceValue = options.useTargetTieBreak ? referenceRow.distanceToTargetMs : referenceRow.totalElapsedMs; + const rowValue = options.useTargetTieBreak ? row.distanceToTargetMs : row.totalElapsedMs; + const timeGap = Math.max(0, rowValue - referenceValue); + return `+${(timeGap / 1000).toFixed(3)}s`; +} + + + +export function getCompetitorElapsedMs(session, row) { + const startTs = Number(row?.startTimestamp || session?.startedAt || 0); + if (!startTs || !row?.lastTimestamp) { + return 0; + } + return Math.max(0, row.lastTimestamp - startTs); +} + + + +export function getCompetitorPassings(session, row, options = {}, { ensureSessionResult, isCountedPassing }) { + const result = ensureSessionResult(session.id); + return result.passings + .filter((passing) => { + if (!options.includeInvalid && !isCountedPassing(passing)) { + return false; + } + if (passing.competitorKey) { + return passing.competitorKey === row.key; + } + return ( + String(passing.transponder || "") === String(row.transponder || "") && + String(passing.driverId || "") === String(row.driverId || "") && + String(passing.carId || "") === String(row.carId || "") + ); + }) + .sort((a, b) => a.timestamp - b.timestamp); +} + + + +export function getCompetitorSeedMetric(session, row, { getCompetitorPassings }) { + const lapCount = Math.max(0, Number(session?.seedBestLapCount || 0) || 0); + if (lapCount <= 0) { + return null; + } + + const method = ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase()) + ? String(session.seedMethod).toLowerCase() + : "best_sum"; + const laps = getCompetitorPassings(session, row) + .map((passing) => Number(passing.lapMs || 0)) + .filter((lapMs) => lapMs > 500); + + if (laps.length < lapCount) { + return null; + } + + let selected = []; + if (method === "consecutive") { + let bestWindow = null; + for (let index = 0; index <= laps.length - lapCount; index += 1) { + const window = laps.slice(index, index + lapCount); + const totalMs = window.reduce((sum, lapMs) => sum + lapMs, 0); + if (!bestWindow || totalMs < bestWindow.totalMs) { + bestWindow = { laps: window, totalMs }; + } + } + if (!bestWindow) { + return null; + } + selected = bestWindow.laps; + } else { + selected = [...laps].sort((a, b) => a - b).slice(0, lapCount); + } + const totalMs = selected.reduce((sum, lapMs) => sum + lapMs, 0); + const averageMs = totalMs / lapCount; + return { + lapCount, + method, + totalMs, + averageMs, + comparableMs: method === "average" ? averageMs : totalMs, + laps: selected, + }; +} + + + +export function getSessionEntrants(session, { state, getEventDrivers }) { + const event = state.events.find((item) => item.id === session.eventId); + const eventDrivers = event ? getEventDrivers(event) : state.drivers; + if (!Array.isArray(session.driverIds) || !session.driverIds.length) { + return eventDrivers; + } + return eventDrivers.filter((driver) => session.driverIds.includes(driver.id)); +} + + + +export function buildPracticeStandings(event, { getSessionsForEvent, buildLeaderboard, getCompetitorSeedMetric, formatSeedMetric, formatRaceClock }) { + const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "practice"); + const competitorMap = new Map(); + + sessions.forEach((session) => { + buildLeaderboard(session).forEach((row) => { + const key = row.driverId || row.key; + const seedMetric = getCompetitorSeedMetric(session, row); + const comparableMs = seedMetric?.comparableMs ?? row.bestLapMs ?? row.totalElapsedMs; + const current = competitorMap.get(key); + if (!current || comparableMs < current.comparableMs) { + competitorMap.set(key, { + key, + driverId: row.driverId, + driverName: row.driverName, + comparableMs, + resultDisplay: seedMetric ? formatSeedMetric(seedMetric) : `${row.laps}/${formatRaceClock(row.totalElapsedMs)}`, + sourceSessionName: session.name, + }); + } + }); + }); + + return [...competitorMap.values()].sort((a, b) => a.comparableMs - b.comparableMs).map((row, index) => ({ + ...row, + rank: index + 1, + score: row.resultDisplay, + })); +} + + + +export function getQualifyingPointsValue(place, fieldSize, tableType) { + const normalized = ["rank_low", "field_desc", "ifmar"].includes(String(tableType || "").toLowerCase()) + ? String(tableType).toLowerCase() + : "rank_low"; + if (normalized === "field_desc") { + return Math.max(1, Number(fieldSize || 0) - place + 1); + } + if (normalized === "ifmar") { + const scale = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; + return scale[place - 1] ?? 0; + } + return place; +} + + + +export function isHighPointsTable(tableType) { + return ["field_desc", "ifmar"].includes(String(tableType || "").toLowerCase()); +} + + + +export function compareNumberSet(left, right, highWins = false) { + for (let i = 0; i < Math.max(left.length, right.length); i += 1) { + const leftValue = left[i] ?? (highWins ? -Infinity : Infinity); + const rightValue = right[i] ?? (highWins ? -Infinity : Infinity); + if (leftValue !== rightValue) { + return highWins ? rightValue - leftValue : leftValue - rightValue; + } + } + return 0; +} + + + +export function buildQualifyingTieBreakNote(row, tieBreak, { t, formatLap }) { + if (tieBreak === "best_lap") { + return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_lap")} • ${formatLap(row.bestSingleLapMs)}`; + } + if (tieBreak === "best_round") { + return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_round")} • ${row.bestRoundDisplay || formatLap(row.bestRoundMetric)}`; + } + return `${t("events.tie_break_note")}: ${t("events.counted_rounds_label")} • ${(row.ranks || []).join(" / ") || "-"}`; +} + + + +export function hasQualifyingPrimaryTie(left, right, scoringMode) { + if (!left || !right) { + return false; + } + if (scoringMode === "points") { + return left.totalScore === right.totalScore; + } + return left.bestRank === right.bestRank; +} + + + +export function buildQualifyingStandings(event, { getSessionsForEvent, buildLeaderboard, getSessionEntrants, isHighPointsTable, getQualifyingPointsValue, compareNumberSet, buildQualifyingTieBreakNote, formatLap, formatRaceClock, t, hasQualifyingPrimaryTie }) { + const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "qualification"); + const scoringMode = event.raceConfig?.qualifyingScoring || "points"; + const countedRounds = Math.max(1, Number(event.raceConfig?.countedQualRounds || 1) || 1); + const pointsTable = event.raceConfig?.qualifyingPointsTable || "rank_low"; + const highPointsWin = isHighPointsTable(pointsTable); + const tieBreak = event.raceConfig?.qualifyingTieBreak || "rounds"; + const competitorMap = new Map(); + + sessions.forEach((session) => { + const rows = buildLeaderboard(session); + const entrantCount = Math.max(rows.length, getSessionEntrants(session).length || 0); + rows.forEach((row, index) => { + const key = row.driverId || row.key; + if (!competitorMap.has(key)) { + competitorMap.set(key, { + key, + driverId: row.driverId, + driverName: row.driverName, + points: [], + ranks: [], + roundMetrics: [], + bestLaps: [], + }); + } + const entry = competitorMap.get(key); + entry.points.push(getQualifyingPointsValue(index + 1, entrantCount, pointsTable)); + entry.ranks.push(index + 1); + entry.roundMetrics.push(row.comparisonMs); + if (Number.isFinite(row.bestLapMs)) { + entry.bestLaps.push(row.bestLapMs); + } + if (!entry.bestRoundDisplay || row.comparisonMs < (entry.bestRoundMetricValue ?? Number.MAX_SAFE_INTEGER)) { + entry.bestRoundMetricValue = row.comparisonMs; + entry.bestRoundDisplay = row.resultDisplay; + } + entry.bestResultDisplay = row.resultDisplay; + entry.lastSessionName = session.name; + entry.sessionCount = (entry.sessionCount || 0) + 1; + entry.maxFieldSize = entrantCount; + }); + }); + + const rows = [...competitorMap.values()].map((entry) => { + const sortedPoints = [...entry.points].sort((a, b) => (highPointsWin ? b - a : a - b)); + const counted = sortedPoints.slice(0, countedRounds); + const totalScore = counted.reduce((sum, value) => sum + value, 0); + const bestRank = Math.min(...entry.ranks); + const bestRoundMetric = Math.min(...entry.roundMetrics); + const bestSingleLapMs = entry.bestLaps.length ? Math.min(...entry.bestLaps) : Number.MAX_SAFE_INTEGER; + return { + key: entry.key, + driverId: entry.driverId, + driverName: entry.driverName, + ranks: [...entry.ranks].sort((a, b) => a - b), + countedPoints: counted, + totalScore, + bestRank, + bestRoundMetric, + bestRoundDisplay: entry.bestRoundDisplay || formatLap(bestRoundMetric), + bestSingleLapMs, + score: + scoringMode === "points" + ? `${totalScore} (${counted.join("+")})` + : `${bestRank} / ${formatRaceClock(bestRoundMetric)}`, + }; + }); + + rows.sort((a, b) => { + if (scoringMode === "points") { + if (a.totalScore !== b.totalScore) { + return highPointsWin ? b.totalScore - a.totalScore : a.totalScore - b.totalScore; + } + if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) { + return a.bestSingleLapMs - b.bestSingleLapMs; + } + if (tieBreak === "best_round" && a.bestRoundMetric !== b.bestRoundMetric) { + return a.bestRoundMetric - b.bestRoundMetric; + } + const pointDiff = compareNumberSet(a.countedPoints, b.countedPoints, highPointsWin); + if (pointDiff !== 0) { + return pointDiff; + } + return a.bestRoundMetric - b.bestRoundMetric; + } + + if (a.bestRank !== b.bestRank) { + return a.bestRank - b.bestRank; + } + if (tieBreak === "rounds") { + const rankDiff = compareNumberSet(a.ranks, b.ranks, false); + if (rankDiff !== 0) { + return rankDiff; + } + } + if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) { + return a.bestSingleLapMs - b.bestSingleLapMs; + } + return a.bestRoundMetric - b.bestRoundMetric; + }); + + rows.forEach((row, index) => { + row.tieBreakWonAgainst = ""; + row.tieBreakLostAgainst = ""; + if (index === 0) { + return; + } + const previous = rows[index - 1]; + if (!hasQualifyingPrimaryTie(previous, row, scoringMode)) { + return; + } + previous.tieBreakWonAgainst = previous.tieBreakWonAgainst || row.driverName || t("common.unknown_driver"); + row.tieBreakLostAgainst = row.tieBreakLostAgainst || previous.driverName || t("common.unknown_driver"); + }); + + return rows.map((row, index) => ({ + ...row, + rank: index + 1, + scoreNote: [ + buildQualifyingTieBreakNote(row, tieBreak), + row.tieBreakWonAgainst ? `${t("events.tie_break_won")}: ${row.tieBreakWonAgainst}` : "", + row.tieBreakLostAgainst ? `${t("events.tie_break_lost")}: ${row.tieBreakLostAgainst}` : "", + ] + .filter(Boolean) + .join(" • "), + })); +} + + + +export function formatTeamActiveMemberLabel(rowOrPassing) { + if (!rowOrPassing) { + return "-"; + } + const parts = [rowOrPassing.driverName || "", rowOrPassing.carName || ""].filter(Boolean); + return parts.join(" • ") || rowOrPassing.subLabel || "-"; +} + + + +export function buildTeamRaceStandings(event, { getSessionsForEvent, buildLeaderboard, getSessionSortWeight }) { + return getSessionsForEvent(event.id) + .filter((session) => session.type === "team_race") + .sort((left, right) => getSessionSortWeight(left) - getSessionSortWeight(right) || String(left.name).localeCompare(String(right.name))) + .map((session) => ({ + session, + rows: buildLeaderboard(session), + })); +} + + + +export function buildTeamStintLog(session, row, { getCompetitorPassings, getSessionLapWindow, formatTeamActiveMemberLabel }) { + const passings = getCompetitorPassings(session, row); + if (!passings.length) { + return []; + } + + const { maxLapMs } = getSessionLapWindow(session); + const stintGapMs = Number.isFinite(maxLapMs) ? maxLapMs : Number.POSITIVE_INFINITY; + const stints = []; + let current = null; + + passings.forEach((passing) => { + const memberLabel = formatTeamActiveMemberLabel(passing); + const memberKey = `${passing.driverId || passing.driverName || "-"}|${passing.carId || passing.carName || "-"}`; + const gapBreak = current && Number.isFinite(stintGapMs) && Math.max(0, passing.timestamp - current.endTs) > stintGapMs; + if (!current || current.memberKey !== memberKey || gapBreak) { + current = { + memberKey, + memberLabel, + driverName: passing.driverName || "-", + carName: passing.carName || "-", + startTs: passing.timestamp, + endTs: passing.timestamp, + laps: 1, + }; + stints.push(current); + return; + } + current.endTs = passing.timestamp; + current.laps += 1; + }); + + return stints.map((stint, index) => ({ + ...stint, + index: index + 1, + durationMs: Math.max(0, stint.endTs - stint.startTs), + })); +} + + + +export function getSessionGridEntries(session, { state, t, getEventTeams, getDriverDisplayById, getSessionGridOrder }) { + if (!session) { + return []; + } + + if (session.mode === "track") { + return (session.assignments || []).map((assignment, index) => { + const driver = state.drivers.find((item) => item.id === assignment.driverId); + const car = state.cars.find((item) => item.id === assignment.carId); + return { + slot: index + 1, + name: driver?.name || t("common.unknown_driver"), + meta: car ? `${car.name} (${car.transponder || "-"})` : t("common.unknown_car"), + }; + }); + } + + if (session.type === "team_race") { + const event = state.events.find((item) => item.id === session.eventId); + return getEventTeams(event).map((team, index) => ({ + slot: index + 1, + name: team.name, + meta: + team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") || + team.carIds + .map((carId) => { + const car = state.cars.find((item) => item.id === carId); + return car ? `${car.name} (${car.transponder || "-"})` : ""; + }) + .filter(Boolean) + .join(", ") || + "-", + })); + } + + return getSessionGridOrder(session).map((driverId, index) => { + const driver = state.drivers.find((item) => item.id === driverId); + return { + slot: index + 1, + name: driver?.name || t("common.unknown_driver"), + meta: driver?.transponder || "-", + }; + }); +} + + + +export function getSessionGridOrder(session) { + if (!session) { + return []; + } + if (Array.isArray(session.manualGridIds) && session.manualGridIds.length) { + return session.manualGridIds; + } + return Array.isArray(session.driverIds) ? session.driverIds : []; +} + + + +export function ensureSessionDriverOrder(session, { getSessionEntrants }) { + if (!session || session.mode !== "race") { + return []; + } + if (!Array.isArray(session.driverIds) || !session.driverIds.length) { + session.driverIds = getSessionEntrants(session) + .map((driver) => driver.id) + .filter(Boolean); + } + if (!Array.isArray(session.manualGridIds) || !session.manualGridIds.length) { + session.manualGridIds = [...session.driverIds]; + } + return session.manualGridIds; +} + + + +export function buildFinalStandings(event, { getSessionsForEvent, buildLeaderboard }) { + const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "final"); + const groupedByMain = new Map(); + const countedFinalLegs = Math.max(1, Number(event.raceConfig?.countedFinalLegs || 1) || 1); + + sessions.forEach((session) => { + const rows = buildLeaderboard(session); + const mainMatch = String(session.name || "").match(/^([A-Z])/i); + const mainKey = mainMatch ? mainMatch[1].toUpperCase() : "A"; + if (!groupedByMain.has(mainKey)) { + groupedByMain.set(mainKey, new Map()); + } + const competitorMap = groupedByMain.get(mainKey); + rows.forEach((row, index) => { + const key = row.driverId || row.key; + if (!competitorMap.has(key)) { + competitorMap.set(key, { + key, + mainKey, + driverId: row.driverId, + driverName: row.driverName, + legRanks: [], + bestElapsedMs: [], + }); + } + const entry = competitorMap.get(key); + entry.legRanks.push(index + 1); + entry.bestElapsedMs.push(row.totalElapsedMs); + }); + }); + + const mains = [...groupedByMain.entries()].sort((a, b) => a[0].localeCompare(b[0])); + const rows = []; + mains.forEach(([mainKey, competitorMap], mainIndex) => { + const mainRows = [...competitorMap.values()].map((entry) => { + const sortedRanks = [...entry.legRanks].sort((a, b) => a - b); + const countedRanks = sortedRanks.slice(0, countedFinalLegs); + return { + mainKey, + driverId: entry.driverId, + driverName: entry.driverName, + finalScore: countedRanks.reduce((sum, value) => sum + value, 0), + legRanks: sortedRanks, + bestElapsedMs: Math.min(...entry.bestElapsedMs), + }; + }); + + mainRows.sort((a, b) => { + if (a.finalScore !== b.finalScore) { + return a.finalScore - b.finalScore; + } + for (let i = 0; i < Math.max(a.legRanks.length, b.legRanks.length); i += 1) { + const left = a.legRanks[i] ?? 999; + const right = b.legRanks[i] ?? 999; + if (left !== right) { + return left - right; + } + } + return a.bestElapsedMs - b.bestElapsedMs; + }); + + mainRows.forEach((row, rowIndex) => { + rows.push({ + rank: `${mainKey}${rowIndex + 1}`, + driverId: row.driverId, + driverName: row.driverName, + score: `${row.finalScore} (${row.legRanks.join("+")})`, + orderingGroup: mainIndex, + orderingIndex: rowIndex, + }); + }); + }); + + return rows.sort((a, b) => { + if (a.orderingGroup !== b.orderingGroup) { + return a.orderingGroup - b.orderingGroup; + } + return a.orderingIndex - b.orderingIndex; + }); +} + +