Add follow-up timing, invalid lap handling, corrections and docs
This commit is contained in:
48
README.md
48
README.md
@@ -9,12 +9,14 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
||||
- UI-separering:
|
||||
- `Event` = sponsor-event med delade bilar/transpondrar
|
||||
- `Race Setup` = riktiga race med personlig transponder per förare
|
||||
- `Race Setup` innehåller nu även:
|
||||
- `Race Setup` innehåller nu även:
|
||||
- välj exakt vilka förare som är med i racet
|
||||
- practice-ranking
|
||||
- kval-ranking med `poäng` eller `bästa resultat`
|
||||
- inbyggd guide för hur man skapar race steg för steg
|
||||
- beskrivningar direkt i alla fält under `Raceformat`
|
||||
- `Follow-up tid` efter ordinarie racetid innan heatet stängs
|
||||
- `Min varvtid` och `Max varvtid` för att filtrera shortcuts/felträffar och styra bättre statistik/stintlogik
|
||||
- sessionstyp `Free Practice` för löpande varvtider utan seedning
|
||||
- auto-generering av kvalheat från practice-ranking eller klasslista
|
||||
- reseeding av kommande kvalheat från aktuell ranking
|
||||
@@ -59,6 +61,8 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
||||
- Sessioninställningar för `Mass start`, `Position start`, `Staggered`
|
||||
- `Timing` visar grid/startordning för aktiv `Position start`-session
|
||||
- leaderboard visar både `gap till ledaren`, `gap till bilen framför` och `eget delta` mot förra varvet
|
||||
- `Senaste passeringar` visar nu även ogiltiga varv med status `För kort varv` eller `Över maxvarv`
|
||||
- manuella korrigeringar i `Tidtagning -> Detaljer`: `+1/-1 varv`, `+1/+5/-1 sek`, `Nollställ korrigering`
|
||||
- Practice/Kval kan seedas på bästa `2` eller `3` varv i sessionsinställningar
|
||||
- Persistens:
|
||||
- Frontend state i browser (`localStorage`)
|
||||
@@ -66,9 +70,51 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
||||
- Inbyggd `Guide`-meny i appen med steg-för-steg för:
|
||||
- Sponsor-event (10 personer / 4 bilar)
|
||||
- Vanligt race
|
||||
- ogiltiga varv, follow-up och manuella korrigeringar
|
||||
- AMMC + npm setup på Windows och Linux
|
||||
- Språkval i UI: `SV` / `EN`
|
||||
|
||||
## Nya racefunktioner
|
||||
|
||||
### Follow-up time
|
||||
- Ställs i `Race Setup -> Hantera -> Raceformat`
|
||||
- Kan också sättas per session när du skapar eller redigerar en session
|
||||
- När ordinarie tid går ut går sessionen först in i `Follow-up aktiv`
|
||||
- När follow-up-tiden är slut stängs sessionen automatiskt
|
||||
- Genererade kval/finaler ärver follow-up från raceformatet
|
||||
|
||||
### Min / Max varvtid
|
||||
- Ställs i `Race Setup -> Hantera -> Raceformat`
|
||||
- `Min varvtid`:
|
||||
- varv snabbare än gränsen ignoreras som shortcut eller felträff
|
||||
- `Max varvtid`:
|
||||
- varv långsammare än gränsen räknas inte som giltigt varv
|
||||
- används också för att bryta stintar och förbättra statistik
|
||||
|
||||
Praktiskt exempel:
|
||||
- bana runt `16 sek/varv`
|
||||
- `Min varvtid = 11 sek`
|
||||
- `Max varvtid = 60 sek`
|
||||
|
||||
### Ogiltiga passeringar
|
||||
- `Tidtagning -> Senaste passeringar` visar nu både giltiga och ogiltiga passeringar
|
||||
- status som visas:
|
||||
- `För kort varv`
|
||||
- `Över maxvarv`
|
||||
- ogiltiga passeringar markeras visuellt i listan
|
||||
|
||||
### Manuella korrigeringar
|
||||
- öppna `Tidtagning`
|
||||
- klicka `Detaljer` på en förare / ett lag
|
||||
- där finns:
|
||||
- `+1 varv`
|
||||
- `-1 varv`
|
||||
- `+1 sek`
|
||||
- `+5 sek`
|
||||
- `-1 sek`
|
||||
- `Nollställ korrigering`
|
||||
- korrigeringarna uppdaterar leaderboard och resultat direkt
|
||||
|
||||
## Windows installation
|
||||
Kör i PowerShell i projektmappen.
|
||||
|
||||
|
||||
228
src/app.js
228
src/app.js
@@ -207,6 +207,8 @@ const TRANSLATIONS = {
|
||||
"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)",
|
||||
@@ -273,6 +275,8 @@ const TRANSLATIONS = {
|
||||
"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",
|
||||
@@ -295,6 +299,18 @@ const TRANSLATIONS = {
|
||||
"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.valid_passing": "Giltigt varv",
|
||||
"timing.invalid_short": "För kort varv",
|
||||
"timing.invalid_long": "Över maxvarv",
|
||||
"timing.total_time": "Total tid",
|
||||
"timing.clear_confirm": "Rensa all tiddata för denna session?",
|
||||
"timing.prompt_transponder": "Transponder",
|
||||
@@ -470,6 +486,9 @@ const TRANSLATIONS = {
|
||||
"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.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.",
|
||||
@@ -485,6 +504,12 @@ const TRANSLATIONS = {
|
||||
"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.",
|
||||
"overlay.title": "Overlay",
|
||||
"overlay.subtitle": "Extern leaderboard-skärm",
|
||||
"overlay.no_active": "Ingen aktiv session vald.",
|
||||
@@ -728,6 +753,8 @@ const TRANSLATIONS = {
|
||||
"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)",
|
||||
@@ -794,6 +821,8 @@ const TRANSLATIONS = {
|
||||
"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",
|
||||
@@ -816,6 +845,18 @@ const TRANSLATIONS = {
|
||||
"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.valid_passing": "Valid lap",
|
||||
"timing.invalid_short": "Short lap",
|
||||
"timing.invalid_long": "Over max lap",
|
||||
"timing.total_time": "Total time",
|
||||
"timing.clear_confirm": "Clear all timing data for this session?",
|
||||
"timing.prompt_transponder": "Transponder",
|
||||
@@ -991,6 +1032,9 @@ const TRANSLATIONS = {
|
||||
"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.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.",
|
||||
@@ -1006,6 +1050,12 @@ const TRANSLATIONS = {
|
||||
"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.",
|
||||
"overlay.title": "Overlay",
|
||||
"overlay.subtitle": "External leaderboard screen",
|
||||
"overlay.no_active": "No active session selected.",
|
||||
@@ -1546,6 +1596,8 @@ function normalizeSession(session) {
|
||||
startMode: session?.startMode || "mass",
|
||||
staggerGapSec: Number(session?.staggerGapSec || 5) || 5,
|
||||
seedBestLapCount: Math.max(0, Number(session?.seedBestLapCount || 0) || 0),
|
||||
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),
|
||||
@@ -1580,6 +1632,7 @@ function normalizeEvent(event) {
|
||||
countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1),
|
||||
finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5),
|
||||
finalStartMode: normalizeStartMode(event?.raceConfig?.finalStartMode || "position"),
|
||||
followUpSec: Math.max(0, Number(event?.raceConfig?.followUpSec || 0) || 0),
|
||||
minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0),
|
||||
maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000),
|
||||
bumpCount: Math.max(0, Number(event?.raceConfig?.bumpCount || 0) || 0),
|
||||
@@ -2056,9 +2109,21 @@ function handleSessionTimerTick() {
|
||||
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;
|
||||
@@ -2833,6 +2898,7 @@ function renderEventManager(eventId) {
|
||||
${SESSION_TYPES.map((s) => `<option value="${s}">${getSessionTypeLabel(s)}</option>`).join("")}
|
||||
</select>
|
||||
<input required type="number" min="1" name="durationMin" placeholder="${t("events.duration_placeholder")}" />
|
||||
<input type="number" min="0" step="1" name="followUpSec" placeholder="${t("events.follow_up_sec")}" value="${event.mode === "race" ? event.raceConfig.followUpSec || 0 : 0}" />
|
||||
<select name="startMode">
|
||||
<option value="mass">${t("events.start_mode_mass")}</option>
|
||||
<option value="position">${t("events.start_mode_position")}</option>
|
||||
@@ -3139,6 +3205,11 @@ function renderEventManager(eventId) {
|
||||
<option value="staggered" ${event.raceConfig.finalStartMode === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
|
||||
</select>`
|
||||
)}
|
||||
${renderRaceFormatField(
|
||||
"events.follow_up_sec",
|
||||
"events.follow_up_sec_hint",
|
||||
`<input type="number" min="0" step="1" name="followUpSec" value="${event.raceConfig.followUpSec || 0}" />`
|
||||
)}
|
||||
${renderRaceFormatField(
|
||||
"events.min_lap_time",
|
||||
"events.min_lap_time_hint",
|
||||
@@ -3313,6 +3384,7 @@ function renderEventManager(eventId) {
|
||||
).join("")}
|
||||
</select>
|
||||
<input name="durationMin" required type="number" min="1" value="${editingSession.durationMin || 5}" />
|
||||
<input name="followUpSec" type="number" min="0" step="1" value="${editingSession.followUpSec || 0}" />
|
||||
<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>
|
||||
@@ -3383,6 +3455,7 @@ function renderEventManager(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),
|
||||
staggerGapSec: Math.max(0, Number(form.get("staggerGapSec") || 0) || 0),
|
||||
@@ -3485,6 +3558,7 @@ function renderEventManager(eventId) {
|
||||
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.staggerGapSec = Math.max(0, Number(form.get("staggerGapSec") || 0) || 0);
|
||||
@@ -3703,6 +3777,7 @@ function renderEventManager(eventId) {
|
||||
countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1),
|
||||
finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5),
|
||||
finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")),
|
||||
followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0),
|
||||
minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)),
|
||||
maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)),
|
||||
bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0),
|
||||
@@ -3906,9 +3981,14 @@ function renderTiming() {
|
||||
const result = active ? ensureSessionResult(active.id) : null;
|
||||
const leaderboard = active ? buildLeaderboard(active) : [];
|
||||
const sessionTiming = active ? getSessionTiming(active) : null;
|
||||
const clockLabel = active && sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining");
|
||||
const clockValue = sessionTiming?.untimed ? formatElapsedClock(sessionTiming?.elapsedMs ?? 0) : formatCountdown(sessionTiming?.remainingMs ?? 0);
|
||||
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;
|
||||
@@ -3994,6 +4074,7 @@ function renderTiming() {
|
||||
}`
|
||||
: `<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>
|
||||
@@ -4053,6 +4134,30 @@ function renderTiming() {
|
||||
|
||||
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("corrReset")?.addEventListener("click", () => {
|
||||
applyCompetitorCorrection(active, selectedRow, { reset: true });
|
||||
renderView();
|
||||
});
|
||||
}
|
||||
|
||||
if (active) {
|
||||
@@ -4161,6 +4266,7 @@ function renderTiming() {
|
||||
session.startedAt = Date.now();
|
||||
session.endedAt = null;
|
||||
session.finishedByTimer = false;
|
||||
session.followUpStartedAt = null;
|
||||
lastFinishAnnouncementSessionId = null;
|
||||
lastOverlayLeaderKeyBySession[session.id] = null;
|
||||
lastOverlayTop3BySession[session.id] = [];
|
||||
@@ -4180,6 +4286,7 @@ function renderTiming() {
|
||||
session.status = "finished";
|
||||
session.endedAt = Date.now();
|
||||
session.finishedByTimer = false;
|
||||
session.followUpStartedAt = null;
|
||||
saveState();
|
||||
updateHeaderState();
|
||||
renderView();
|
||||
@@ -4194,6 +4301,7 @@ function renderTiming() {
|
||||
return;
|
||||
}
|
||||
delete state.resultsBySession[session.id];
|
||||
session.followUpStartedAt = null;
|
||||
lastFinishAnnouncementSessionId = null;
|
||||
delete lastOverlayLeaderKeyBySession[session.id];
|
||||
delete lastOverlayTop3BySession[session.id];
|
||||
@@ -4316,6 +4424,9 @@ function renderGuide() {
|
||||
<li>${t("guide.race_format_5")}</li>
|
||||
<li>${t("guide.race_format_6")}</li>
|
||||
<li>${t("guide.race_format_7")}</li>
|
||||
<li>${t("guide.race_format_8")}</li>
|
||||
<li>${t("guide.race_format_9")}</li>
|
||||
<li>${t("guide.race_format_10")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -4356,6 +4467,19 @@ function renderGuide() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("guide.validation_title")}</h3></div>
|
||||
<div class="panel-body">
|
||||
<ul>
|
||||
<li>${t("guide.validation_1")}</li>
|
||||
<li>${t("guide.validation_2")}</li>
|
||||
<li>${t("guide.validation_3")}</li>
|
||||
<li>${t("guide.validation_4")}</li>
|
||||
<li>${t("guide.validation_5")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("guide.host_title")}</h3></div>
|
||||
<div class="panel-body">
|
||||
@@ -4411,7 +4535,9 @@ function renderOverlay() {
|
||||
const leaderboard = active ? buildLeaderboard(active).slice(0, 12) : [];
|
||||
const result = active ? ensureSessionResult(active.id) : null;
|
||||
const sessionTiming = active ? getSessionTiming(active) : null;
|
||||
const overlayClock = sessionTiming?.untimed
|
||||
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() : [];
|
||||
@@ -4424,6 +4550,7 @@ function renderOverlay() {
|
||||
const fastestRow =
|
||||
[...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
|
||||
const modeLabel = getOverlayModeLabel(overlayViewMode);
|
||||
const overlayStatusLabel = sessionTiming?.followUpActive ? t("timing.follow_up_active") : active ? getStatusLabel(active.status) : "";
|
||||
const rotatingPanels = buildOverlayPanels(active, recent);
|
||||
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
|
||||
|
||||
@@ -4451,7 +4578,7 @@ function renderOverlay() {
|
||||
<div class="overlay-meta">
|
||||
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
|
||||
<div class="overlay-clock">${overlayClock}</div>
|
||||
<div class="overlay-status">${escapeHtml(getStatusLabel(active.status))}</div>
|
||||
<div class="overlay-status">${escapeHtml(overlayStatusLabel)}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -4718,6 +4845,19 @@ function renderLeaderboardModal(session, row) {
|
||||
<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-danger btn-mini" id="corrReset" type="button">${t("timing.penalty_reset")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<h4>${t("timing.lap_history")}</h4>
|
||||
${
|
||||
@@ -4770,6 +4910,7 @@ function renderLeaderboard(rows) {
|
||||
<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>` : ""}
|
||||
</td>
|
||||
<td>${escapeHtml(row.subLabel || row.carName)}</td>
|
||||
<td>${escapeHtml(row.transponder)}</td>
|
||||
@@ -4901,22 +5042,22 @@ function renderRecentPassings(session) {
|
||||
return `<p>${t("timing.no_session_selected")}</p>`;
|
||||
}
|
||||
const result = ensureSessionResult(session.id);
|
||||
const items = getVisiblePassings(result).slice(-20).reverse();
|
||||
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.loop"), t("table.strength"), ""],
|
||||
[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>
|
||||
<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>${escapeHtml(p.loopId || "-")}</td>
|
||||
<td>${p.strength ?? "-"}</td>
|
||||
<td>${formatLap(p.lapMs)}</td>
|
||||
<td>${escapeHtml(getPassingValidationLabel(p))}</td>
|
||||
<td>${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)}</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -5297,15 +5438,56 @@ function getVisiblePassings(result) {
|
||||
return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : [];
|
||||
}
|
||||
|
||||
function getPassingValidationLabel(passing) {
|
||||
if (passing?.validLap === false) {
|
||||
return passing.invalidReason === "below_min" ? t("timing.invalid_short") : 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;
|
||||
const bits = [];
|
||||
if (laps) {
|
||||
bits.push(`${laps > 0 ? "+" : ""}${laps}L`);
|
||||
}
|
||||
if (timeMs) {
|
||||
bits.push(`${timeMs > 0 ? "+" : "-"}${formatLap(Math.abs(timeMs))}`);
|
||||
}
|
||||
return bits.join(" • ");
|
||||
}
|
||||
|
||||
function applyCompetitorCorrection(session, row, options = {}) {
|
||||
const entry = ensureSessionResult(session.id).competitors[row.key];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (options.reset) {
|
||||
entry.manualLapAdjustment = 0;
|
||||
entry.manualTimeAdjustmentMs = 0;
|
||||
} else {
|
||||
entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) + (Number(options.lapDelta || 0) || 0);
|
||||
entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) + (Number(options.timeMsDelta || 0) || 0);
|
||||
}
|
||||
saveState();
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5686,15 +5868,20 @@ function buildLeaderboard(session) {
|
||||
const isRollingPractice = isFreePractice || isOpenPractice;
|
||||
const nowTs = Date.now();
|
||||
const rows = Object.values(result.competitors).map((row) => {
|
||||
const totalElapsedMs = getCompetitorElapsedMs(session, row);
|
||||
const distanceToTargetMs = Math.abs(targetMs - totalElapsedMs);
|
||||
const seedMetric = getCompetitorSeedMetric(session, row);
|
||||
const passings = getCompetitorPassings(session, row);
|
||||
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 lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
|
||||
const lapDeltaMs =
|
||||
lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null;
|
||||
const predictionBaseMs =
|
||||
@@ -5714,10 +5901,13 @@ function buildLeaderboard(session) {
|
||||
: "late";
|
||||
return {
|
||||
...row,
|
||||
laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment),
|
||||
lastLapMs,
|
||||
bestLapMs,
|
||||
lastTimestamp: lastPassingTs || row.lastTimestamp,
|
||||
totalElapsedMs,
|
||||
manualLapAdjustment,
|
||||
manualTimeAdjustmentMs,
|
||||
distanceToTargetMs,
|
||||
seedMetric,
|
||||
previousLapMs,
|
||||
@@ -5727,16 +5917,16 @@ function buildLeaderboard(session) {
|
||||
predictionTone,
|
||||
comparisonMs:
|
||||
isRollingPractice
|
||||
? row.bestLapMs || row.lastLapMs || Number.MAX_SAFE_INTEGER
|
||||
? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER
|
||||
: useSeedRanking && seedMetric
|
||||
? seedMetric.totalMs
|
||||
: totalElapsedMs,
|
||||
resultDisplay:
|
||||
isRollingPractice
|
||||
? formatLap(row.bestLapMs || row.lastLapMs)
|
||||
? formatLap(bestLapMs || lastLapMs)
|
||||
: useSeedRanking && seedMetric
|
||||
? `${seedMetric.lapCount}/${formatRaceClock(seedMetric.totalMs)}`
|
||||
: `${row.laps}/${formatRaceClock(totalElapsedMs)}`,
|
||||
: `${Math.max(0, Number(row.laps || 0) + manualLapAdjustment)}/${formatRaceClock(totalElapsedMs)}`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6523,6 +6713,7 @@ function generateQualifyingForRace(event) {
|
||||
const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
|
||||
const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5);
|
||||
const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered");
|
||||
const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0);
|
||||
const heats = chunkArray(fallbackRows, carsPerHeat);
|
||||
let created = 0;
|
||||
|
||||
@@ -6543,6 +6734,8 @@ function generateQualifyingForRace(event) {
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
finishedByTimer: false,
|
||||
followUpSec,
|
||||
followUpStartedAt: null,
|
||||
startMode: qualStartMode,
|
||||
seedBestLapCount: 2,
|
||||
staggerGapSec: 3,
|
||||
@@ -6626,6 +6819,7 @@ function generateFinalsForRace(event) {
|
||||
const carsPerFinal = Math.max(2, Number(event.raceConfig?.carsPerFinal || 8) || 8);
|
||||
const finalDurationMin = Math.max(1, Number(event.raceConfig?.finalDurationMin || 5) || 5);
|
||||
const finalStartMode = normalizeStartMode(event.raceConfig?.finalStartMode || "position");
|
||||
const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0);
|
||||
const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0);
|
||||
const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0);
|
||||
const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal;
|
||||
@@ -6649,6 +6843,8 @@ function generateFinalsForRace(event) {
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
finishedByTimer: false,
|
||||
followUpSec,
|
||||
followUpStartedAt: null,
|
||||
startMode: finalStartMode,
|
||||
seedBestLapCount: 0,
|
||||
staggerGapSec: 0,
|
||||
|
||||
@@ -395,6 +395,11 @@ select:focus {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.data-table tr.passing-invalid td {
|
||||
background: rgba(225, 6, 0, 0.08);
|
||||
color: #ffd0d0;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: #bdc8e3;
|
||||
font-size: 0.82rem;
|
||||
@@ -857,6 +862,7 @@ select:focus {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.56rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.overlay-board {
|
||||
|
||||
Reference in New Issue
Block a user