diff --git a/README.md b/README.md index 14a5d4e..d7c2b02 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he - `Min varvtid` och `Max varvtid` för att filtrera shortcuts/felträffar och styra bättre statistik/stintlogik - seedmetoder för practice/kval: bästa N varv som summa, snitt eller konsekutiva varv - valbar kval-poängtabell och tie-break-regler + - presets i `Raceformat` för kort teknisk bana, klubbrace, IFMAR-liknande upplägg och endurance - schemaavvikelse på `Översikt` mellan planerad och faktisk körtid - sessionstyp `Free Practice` för löpande varvtider utan seedning - auto-generering av kvalheat från practice-ranking eller klasslista @@ -139,6 +140,17 @@ Praktiskt exempel: - `Jämför räknade rundor` - `Bästa enskilda varv` - `Bästa runda / heatresultat` +- kvalrankingen visar även en underrad med aktiv tie-break-information i tabellen + +### Raceformat presets +- `Race Setup -> Hantera -> Raceformat` +- välj ett preset och klicka `Applicera preset` +- tillgängliga presets: + - `Kort teknisk bana 16s` + - `Klubbrace kval + final` + - `IFMAR-stil kval/final` + - `Endurance / lagrace` +- efter applicering kan alla fält fortfarande justeras manuellt och sparas som vanligt ### Schemaavvikelse på Översikt - `Översikt` visar nu om dagen ligger före eller efter schema @@ -149,6 +161,10 @@ Praktiskt exempel: - verklig tid från start till stopp - eller pågående körtid om sessionen fortfarande kör +### Invalid-lap markering i leaderboard +- om senaste mottagna passing för en förare/ett lag var ogiltig markeras raden även i leaderboard och overlay +- det gör det lättare att se felträffar utan att behöva stå i `Senaste passeringar` + ## Windows installation Kör i PowerShell i projektmappen. diff --git a/src/app.js b/src/app.js index 77f14ae..4da28ab 100644 --- a/src/app.js +++ b/src/app.js @@ -173,6 +173,17 @@ const TRANSLATIONS = { "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.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.invalid_recent": "Senaste träff ogiltig", "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", @@ -514,6 +525,8 @@ const TRANSLATIONS = { "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_12": "Du kan applicera preset och sedan justera enskilda fält manuellt innan du sparar raceformatet.", "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.", @@ -754,6 +767,17 @@ const TRANSLATIONS = { "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.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.invalid_recent": "Latest hit invalid", "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", @@ -1095,6 +1119,8 @@ const TRANSLATIONS = { "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_12": "You can apply a preset and then adjust individual fields manually before saving the race format.", "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.", @@ -1689,11 +1715,133 @@ function normalizeRaceTeam(team) { }; } +function getRaceFormatPresets() { + return [ + { + id: "custom", + label: t("events.preset_custom"), + values: {}, + }, + { + id: "short_technical", + label: t("events.preset_short_technical"), + values: { + qualifyingScoring: "points", + qualifyingRounds: 3, + carsPerHeat: 6, + qualDurationMin: 5, + qualStartMode: "staggered", + qualSeedLapCount: 3, + qualSeedMethod: "best_sum", + countedQualRounds: 2, + qualifyingPointsTable: "rank_low", + qualifyingTieBreak: "best_lap", + carsPerFinal: 8, + finalLegs: 3, + countedFinalLegs: 2, + finalDurationMin: 5, + finalStartMode: "position", + followUpSec: 10, + minLapMs: 11000, + maxLapMs: 60000, + bumpCount: 0, + }, + }, + { + id: "club_qualifying", + label: t("events.preset_club_qualifying"), + values: { + qualifyingScoring: "points", + qualifyingRounds: 4, + carsPerHeat: 8, + qualDurationMin: 5, + qualStartMode: "staggered", + qualSeedLapCount: 3, + qualSeedMethod: "best_sum", + countedQualRounds: 2, + qualifyingPointsTable: "rank_low", + qualifyingTieBreak: "rounds", + carsPerFinal: 8, + finalLegs: 3, + countedFinalLegs: 2, + finalDurationMin: 5, + finalStartMode: "position", + followUpSec: 15, + minLapMs: 12000, + maxLapMs: 60000, + bumpCount: 0, + }, + }, + { + id: "ifmar", + label: t("events.preset_ifmar"), + values: { + qualifyingScoring: "points", + qualifyingRounds: 5, + carsPerHeat: 10, + qualDurationMin: 5, + qualStartMode: "staggered", + qualSeedLapCount: 3, + qualSeedMethod: "best_sum", + countedQualRounds: 3, + qualifyingPointsTable: "ifmar", + qualifyingTieBreak: "best_lap", + carsPerFinal: 10, + finalLegs: 3, + countedFinalLegs: 2, + finalDurationMin: 5, + finalStartMode: "position", + followUpSec: 15, + minLapMs: 12000, + maxLapMs: 70000, + bumpCount: 0, + }, + }, + { + id: "endurance", + label: t("events.preset_endurance"), + values: { + qualifyingScoring: "best", + qualifyingRounds: 1, + carsPerHeat: 12, + qualDurationMin: 10, + qualStartMode: "mass", + qualSeedLapCount: 0, + qualSeedMethod: "best_sum", + countedQualRounds: 1, + qualifyingPointsTable: "rank_low", + qualifyingTieBreak: "best_round", + carsPerFinal: 12, + finalLegs: 1, + countedFinalLegs: 1, + finalDurationMin: 240, + finalStartMode: "mass", + followUpSec: 60, + minLapMs: 10000, + maxLapMs: 120000, + bumpCount: 0, + }, + }, + ]; +} + +function applyRaceFormatPreset(event, presetId) { + const preset = getRaceFormatPresets().find((item) => item.id === presetId); + if (!preset || preset.id === "custom") { + event.raceConfig.presetId = "custom"; + return; + } + Object.assign(event.raceConfig, preset.values, { presetId: preset.id }); +} + function normalizeEvent(event) { return { ...event, branding: normalizeBrandingConfig(event?.branding), raceConfig: { + presetId: getRaceFormatPresets().some((preset) => preset.id === String(event?.raceConfig?.presetId || "")) + ? String(event.raceConfig.presetId) + : "custom", qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points", qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3), carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8), @@ -3001,6 +3149,7 @@ function renderEventManager(eventId) { .join(""); const branding = normalizeBrandingConfig(event.branding); const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null; + const racePresets = getRaceFormatPresets(); const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : []; if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) { selectedGridSessionId = ""; @@ -3269,6 +3418,22 @@ function renderEventManager(eventId) {

${t("events.race_format_intro")}

+ ${renderRaceFormatField( + "events.race_preset", + "events.race_preset_hint", + `` + )} +
+   + ${t("events.race_preset_hint")} + +
${renderRaceFormatField( "events.qualifying_scoring", "events.qualifying_scoring_hint", @@ -3937,6 +4102,9 @@ function renderEventManager(eventId) { e.preventDefault(); const form = new FormData(e.currentTarget); event.raceConfig = { + presetId: getRaceFormatPresets().some((preset) => preset.id === String(form.get("presetId") || "")) + ? String(form.get("presetId")) + : "custom", qualifyingScoring: String(form.get("qualifyingScoring") || "points") === "best" ? "best" : "points", qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3), carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8), @@ -3972,6 +4140,17 @@ function renderEventManager(eventId) { 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("generateQualifying")?.addEventListener("click", () => { const created = generateQualifyingForRace(event); saveState(); @@ -4608,6 +4787,8 @@ function renderGuide() {
  • ${t("guide.race_format_8")}
  • ${t("guide.race_format_9")}
  • ${t("guide.race_format_10")}
  • +
  • ${t("guide.race_format_11")}
  • +
  • ${t("guide.race_format_12")}
  • @@ -5110,12 +5291,13 @@ function renderLeaderboard(rows) { rows.map((row, idx) => { const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : ""; return ` - + ${idx + 1}
    ${escapeHtml(row.displayName || row.driverName)}
    ${row.teamId ? `
    ${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
    ` : ""} ${getManualCorrectionSummary(row) ? `
    ${escapeHtml(getManualCorrectionSummary(row))}
    ` : ""} + ${row.invalidPending ? `
    ${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}
    ` : ""} ${escapeHtml(row.subLabel || row.carName)} ${escapeHtml(row.transponder)} @@ -5144,13 +5326,14 @@ function renderOverlayLeaderboard(rows) { .map((row, idx) => { const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : ""; return ` -
    +
    ${idx + 1}
    ${escapeHtml(row.displayName || row.driverName)} ${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")} + ${row.invalidPending ? `${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}` : ""}
    @@ -6073,7 +6256,9 @@ function buildLeaderboard(session) { const isRollingPractice = isFreePractice || isOpenPractice; const nowTs = Date.now(); const rows = Object.values(result.competitors).map((row) => { - const passings = getCompetitorPassings(session, 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 @@ -6104,6 +6289,7 @@ function buildLeaderboard(session) { : predictedProgress <= 1 ? "warn" : "late"; + const invalidPending = latestAnyPassing?.validLap === false; return { ...row, laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment), @@ -6120,6 +6306,9 @@ function buildLeaderboard(session) { predictedRemainingMs, predictedProgress, predictionTone, + invalidPending, + invalidLabel: invalidPending ? getPassingValidationLabel(latestAnyPassing) : "", + invalidLapMs: invalidPending ? Number(latestAnyPassing?.lapMs || 0) || 0 : 0, comparisonMs: isRollingPractice ? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER @@ -6404,6 +6593,16 @@ function compareNumberSet(left, right, highWins = false) { 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 getCompetitorElapsedMs(session, row) { const startTs = Number(row?.startTimestamp || session?.startedAt || 0); if (!startTs || !row?.lastTimestamp) { @@ -6579,6 +6778,10 @@ function buildQualifyingStandings(event) { 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; @@ -6602,6 +6805,7 @@ function buildQualifyingStandings(event) { totalScore, bestRank, bestRoundMetric, + bestRoundDisplay: entry.bestRoundDisplay || formatLap(bestRoundMetric), bestSingleLapMs, score: scoringMode === "points" @@ -6646,6 +6850,7 @@ function buildQualifyingStandings(event) { return rows.map((row, index) => ({ ...row, rank: index + 1, + scoreNote: buildQualifyingTieBreakNote(row, tieBreak), })); } @@ -6661,7 +6866,10 @@ function renderRaceStandingsTable(rows, emptyLabel) { ${row.rank} ${escapeHtml(row.driverName || t("common.unknown_driver"))} - ${escapeHtml(row.score || "-")} + +
    ${escapeHtml(row.score || "-")}
    + ${row.scoreNote ? `
    ${escapeHtml(row.scoreNote)}
    ` : ""} + ` ) @@ -7662,7 +7870,11 @@ async function exportRaceResultsPdf(event) { buildPdfSection( t("events.qualifying_standings"), [t("table.pos"), t("table.driver"), t("table.score")], - buildQualifyingStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"]) + buildQualifyingStandings(event).map((row) => [ + String(row.rank), + row.driverName || "-", + row.scoreNote ? `${row.score || "-"} | ${row.scoreNote}` : row.score || "-", + ]) ), buildPdfSection( t("events.final_standings"), diff --git a/src/styles.css b/src/styles.css index 8d3a538..95113ad 100644 --- a/src/styles.css +++ b/src/styles.css @@ -644,6 +644,14 @@ select:focus { font-size: 0.8rem; } +.table-subnote-warn { + color: #ffb7a9; +} + +.data-table tr.leaderboard-invalid td { + background: rgba(225, 106, 0, 0.06); +} + .grid-editor-toolbar { display: flex; justify-content: space-between; @@ -1178,6 +1186,11 @@ select:focus { background: linear-gradient(135deg, rgba(225, 6, 0, 0.12), rgba(255, 255, 255, 0.03)); } +.overlay-race-row-invalid { + border-color: rgba(245, 166, 35, 0.4); + box-shadow: inset 0 0 0 1px rgba(245, 166, 35, 0.08); +} + .overlay-race-driver strong, .overlay-race-metric strong, .overlay-race-best strong { @@ -1198,6 +1211,15 @@ select:focus { letter-spacing: 0.06em; } +.overlay-invalid-note { + display: block; + margin-top: 2px; + color: #ffbe98; + font-size: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + .overlay-prediction { margin-top: 2px; }