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:
|
- UI-separering:
|
||||||
- `Event` = sponsor-event med delade bilar/transpondrar
|
- `Event` = sponsor-event med delade bilar/transpondrar
|
||||||
- `Race Setup` = riktiga race med personlig transponder per förare
|
- `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
|
- välj exakt vilka förare som är med i racet
|
||||||
- practice-ranking
|
- practice-ranking
|
||||||
- kval-ranking med `poäng` eller `bästa resultat`
|
- kval-ranking med `poäng` eller `bästa resultat`
|
||||||
- inbyggd guide för hur man skapar race steg för steg
|
- inbyggd guide för hur man skapar race steg för steg
|
||||||
- beskrivningar direkt i alla fält under `Raceformat`
|
- 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
|
- sessionstyp `Free Practice` för löpande varvtider utan seedning
|
||||||
- auto-generering av kvalheat från practice-ranking eller klasslista
|
- auto-generering av kvalheat från practice-ranking eller klasslista
|
||||||
- reseeding av kommande kvalheat från aktuell ranking
|
- 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`
|
- Sessioninställningar för `Mass start`, `Position start`, `Staggered`
|
||||||
- `Timing` visar grid/startordning för aktiv `Position start`-session
|
- `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
|
- 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
|
- Practice/Kval kan seedas på bästa `2` eller `3` varv i sessionsinställningar
|
||||||
- Persistens:
|
- Persistens:
|
||||||
- Frontend state i browser (`localStorage`)
|
- 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:
|
- Inbyggd `Guide`-meny i appen med steg-för-steg för:
|
||||||
- Sponsor-event (10 personer / 4 bilar)
|
- Sponsor-event (10 personer / 4 bilar)
|
||||||
- Vanligt race
|
- Vanligt race
|
||||||
|
- ogiltiga varv, follow-up och manuella korrigeringar
|
||||||
- AMMC + npm setup på Windows och Linux
|
- AMMC + npm setup på Windows och Linux
|
||||||
- Språkval i UI: `SV` / `EN`
|
- 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
|
## Windows installation
|
||||||
Kör i PowerShell i projektmappen.
|
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_qualifying": "Kval-ranking",
|
||||||
"events.finals_from_practice": "Practice-ranking",
|
"events.finals_from_practice": "Practice-ranking",
|
||||||
"events.finals_source_hint": "Välj om finalerna ska seedas från practice eller kval.",
|
"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": "Min varvtid (sek)",
|
||||||
"events.min_lap_time_hint": "Varv snabbare än detta ignoreras som shortcut eller felträff.",
|
"events.min_lap_time_hint": "Varv snabbare än detta ignoreras som shortcut eller felträff.",
|
||||||
"events.max_lap_time": "Max varvtid (sek)",
|
"events.max_lap_time": "Max varvtid (sek)",
|
||||||
@@ -273,6 +275,8 @@ const TRANSLATIONS = {
|
|||||||
"timing.remaining": "Nedräkning",
|
"timing.remaining": "Nedräkning",
|
||||||
"timing.elapsed": "Körtid",
|
"timing.elapsed": "Körtid",
|
||||||
"timing.race_finished": "Race is finished",
|
"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.no_active": "Ingen aktiv session vald.",
|
||||||
"timing.leaderboard": "Live leaderboard",
|
"timing.leaderboard": "Live leaderboard",
|
||||||
"timing.recent_passings": "Senaste passeringar",
|
"timing.recent_passings": "Senaste passeringar",
|
||||||
@@ -295,6 +299,18 @@ const TRANSLATIONS = {
|
|||||||
"timing.detail_title": "Leaderboard-detaljer",
|
"timing.detail_title": "Leaderboard-detaljer",
|
||||||
"timing.lap_history": "Varvhistorik",
|
"timing.lap_history": "Varvhistorik",
|
||||||
"timing.no_lap_history": "Inga varv att visa.",
|
"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.total_time": "Total tid",
|
||||||
"timing.clear_confirm": "Rensa all tiddata för denna session?",
|
"timing.clear_confirm": "Rensa all tiddata för denna session?",
|
||||||
"timing.prompt_transponder": "Transponder",
|
"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_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_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_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_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_1": "Använd sessionstypen fri träning när du bara vill visa löpande varvtider.",
|
||||||
"guide.free_practice_2": "Free Practice påverkar inte seedning till kval eller finaler.",
|
"guide.free_practice_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_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_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.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.title": "Overlay",
|
||||||
"overlay.subtitle": "Extern leaderboard-skärm",
|
"overlay.subtitle": "Extern leaderboard-skärm",
|
||||||
"overlay.no_active": "Ingen aktiv session vald.",
|
"overlay.no_active": "Ingen aktiv session vald.",
|
||||||
@@ -728,6 +753,8 @@ const TRANSLATIONS = {
|
|||||||
"events.finals_from_qualifying": "Qualifying standings",
|
"events.finals_from_qualifying": "Qualifying standings",
|
||||||
"events.finals_from_practice": "Practice standings",
|
"events.finals_from_practice": "Practice standings",
|
||||||
"events.finals_source_hint": "Choose whether finals should be seeded from practice or qualifying.",
|
"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": "Min lap time (sec)",
|
||||||
"events.min_lap_time_hint": "Laps faster than this are ignored as shortcuts or false hits.",
|
"events.min_lap_time_hint": "Laps faster than this are ignored as shortcuts or false hits.",
|
||||||
"events.max_lap_time": "Max lap time (sec)",
|
"events.max_lap_time": "Max lap time (sec)",
|
||||||
@@ -794,6 +821,8 @@ const TRANSLATIONS = {
|
|||||||
"timing.remaining": "Countdown",
|
"timing.remaining": "Countdown",
|
||||||
"timing.elapsed": "Elapsed",
|
"timing.elapsed": "Elapsed",
|
||||||
"timing.race_finished": "Race is finished",
|
"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.no_active": "No active session selected.",
|
||||||
"timing.leaderboard": "Live Leaderboard",
|
"timing.leaderboard": "Live Leaderboard",
|
||||||
"timing.recent_passings": "Recent Passings",
|
"timing.recent_passings": "Recent Passings",
|
||||||
@@ -816,6 +845,18 @@ const TRANSLATIONS = {
|
|||||||
"timing.detail_title": "Leaderboard details",
|
"timing.detail_title": "Leaderboard details",
|
||||||
"timing.lap_history": "Lap history",
|
"timing.lap_history": "Lap history",
|
||||||
"timing.no_lap_history": "No laps to show.",
|
"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.total_time": "Total time",
|
||||||
"timing.clear_confirm": "Clear all timing data for this session?",
|
"timing.clear_confirm": "Clear all timing data for this session?",
|
||||||
"timing.prompt_transponder": "Transponder",
|
"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_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_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_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_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_1": "Use the free practice session type when you only want to show live lap times.",
|
||||||
"guide.free_practice_2": "Free Practice does not affect seeding for qualifying or finals.",
|
"guide.free_practice_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_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_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.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.title": "Overlay",
|
||||||
"overlay.subtitle": "External leaderboard screen",
|
"overlay.subtitle": "External leaderboard screen",
|
||||||
"overlay.no_active": "No active session selected.",
|
"overlay.no_active": "No active session selected.",
|
||||||
@@ -1546,6 +1596,8 @@ function normalizeSession(session) {
|
|||||||
startMode: session?.startMode || "mass",
|
startMode: session?.startMode || "mass",
|
||||||
staggerGapSec: Number(session?.staggerGapSec || 5) || 5,
|
staggerGapSec: Number(session?.staggerGapSec || 5) || 5,
|
||||||
seedBestLapCount: Math.max(0, Number(session?.seedBestLapCount || 0) || 0),
|
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 : [],
|
driverIds: Array.isArray(session?.driverIds) ? session.driverIds : [],
|
||||||
manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds : [],
|
manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds : [],
|
||||||
gridCustomized: Boolean(session?.gridCustomized),
|
gridCustomized: Boolean(session?.gridCustomized),
|
||||||
@@ -1580,6 +1632,7 @@ function normalizeEvent(event) {
|
|||||||
countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1),
|
countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1),
|
||||||
finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5),
|
finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5),
|
||||||
finalStartMode: normalizeStartMode(event?.raceConfig?.finalStartMode || "position"),
|
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),
|
minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0),
|
||||||
maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000),
|
maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000),
|
||||||
bumpCount: Math.max(0, Number(event?.raceConfig?.bumpCount || 0) || 0),
|
bumpCount: Math.max(0, Number(event?.raceConfig?.bumpCount || 0) || 0),
|
||||||
@@ -2056,9 +2109,21 @@ function handleSessionTimerTick() {
|
|||||||
return { changed: false };
|
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.status = "finished";
|
||||||
active.endedAt = Date.now();
|
active.endedAt = Date.now();
|
||||||
active.finishedByTimer = true;
|
active.finishedByTimer = true;
|
||||||
|
active.followUpStartedAt = null;
|
||||||
if (lastFinishAnnouncementSessionId !== active.id) {
|
if (lastFinishAnnouncementSessionId !== active.id) {
|
||||||
announceRaceFinished();
|
announceRaceFinished();
|
||||||
lastFinishAnnouncementSessionId = active.id;
|
lastFinishAnnouncementSessionId = active.id;
|
||||||
@@ -2833,6 +2898,7 @@ function renderEventManager(eventId) {
|
|||||||
${SESSION_TYPES.map((s) => `<option value="${s}">${getSessionTypeLabel(s)}</option>`).join("")}
|
${SESSION_TYPES.map((s) => `<option value="${s}">${getSessionTypeLabel(s)}</option>`).join("")}
|
||||||
</select>
|
</select>
|
||||||
<input required type="number" min="1" name="durationMin" placeholder="${t("events.duration_placeholder")}" />
|
<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">
|
<select name="startMode">
|
||||||
<option value="mass">${t("events.start_mode_mass")}</option>
|
<option value="mass">${t("events.start_mode_mass")}</option>
|
||||||
<option value="position">${t("events.start_mode_position")}</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>
|
<option value="staggered" ${event.raceConfig.finalStartMode === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
|
||||||
</select>`
|
</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(
|
${renderRaceFormatField(
|
||||||
"events.min_lap_time",
|
"events.min_lap_time",
|
||||||
"events.min_lap_time_hint",
|
"events.min_lap_time_hint",
|
||||||
@@ -3313,6 +3384,7 @@ function renderEventManager(eventId) {
|
|||||||
).join("")}
|
).join("")}
|
||||||
</select>
|
</select>
|
||||||
<input name="durationMin" required type="number" min="1" value="${editingSession.durationMin || 5}" />
|
<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">
|
<select name="startMode">
|
||||||
<option value="mass" ${normalizeStartMode(editingSession.startMode) === "mass" ? "selected" : ""}>${t("events.start_mode_mass")}</option>
|
<option value="mass" ${normalizeStartMode(editingSession.startMode) === "mass" ? "selected" : ""}>${t("events.start_mode_mass")}</option>
|
||||||
<option value="position" ${normalizeStartMode(editingSession.startMode) === "position" ? "selected" : ""}>${t("events.start_mode_position")}</option>
|
<option value="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(),
|
name: String(form.get("name")).trim(),
|
||||||
type: String(form.get("type")),
|
type: String(form.get("type")),
|
||||||
durationMin: Number(form.get("durationMin")),
|
durationMin: Number(form.get("durationMin")),
|
||||||
|
followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0),
|
||||||
startMode: String(form.get("startMode") || "mass"),
|
startMode: String(form.get("startMode") || "mass"),
|
||||||
seedBestLapCount: Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0),
|
seedBestLapCount: Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0),
|
||||||
staggerGapSec: Math.max(0, Number(form.get("staggerGapSec") || 0) || 0),
|
staggerGapSec: Math.max(0, Number(form.get("staggerGapSec") || 0) || 0),
|
||||||
@@ -3485,6 +3558,7 @@ function renderEventManager(eventId) {
|
|||||||
editingSession.name = cleanedName;
|
editingSession.name = cleanedName;
|
||||||
editingSession.type = String(form.get("type") || editingSession.type);
|
editingSession.type = String(form.get("type") || editingSession.type);
|
||||||
editingSession.durationMin = Math.max(1, cleanedDuration);
|
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.startMode = normalizeStartMode(String(form.get("startMode") || editingSession.startMode || "mass"));
|
||||||
editingSession.seedBestLapCount = Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0);
|
editingSession.seedBestLapCount = Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0);
|
||||||
editingSession.staggerGapSec = Math.max(0, Number(form.get("staggerGapSec") || 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),
|
countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1),
|
||||||
finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5),
|
finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5),
|
||||||
finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")),
|
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)),
|
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)),
|
maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)),
|
||||||
bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0),
|
bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0),
|
||||||
@@ -3906,9 +3981,14 @@ function renderTiming() {
|
|||||||
const result = active ? ensureSessionResult(active.id) : null;
|
const result = active ? ensureSessionResult(active.id) : null;
|
||||||
const leaderboard = active ? buildLeaderboard(active) : [];
|
const leaderboard = active ? buildLeaderboard(active) : [];
|
||||||
const sessionTiming = active ? getSessionTiming(active) : null;
|
const sessionTiming = active ? getSessionTiming(active) : null;
|
||||||
const clockLabel = active && sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining");
|
const clockLabel = active && sessionTiming?.followUpActive ? t("timing.follow_up") : active && sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining");
|
||||||
const clockValue = sessionTiming?.untimed ? formatElapsedClock(sessionTiming?.elapsedMs ?? 0) : formatCountdown(sessionTiming?.remainingMs ?? 0);
|
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 showFinishedBanner = Boolean(active && active.status === "finished" && active.finishedByTimer);
|
||||||
|
const showFollowUpBanner = Boolean(active && sessionTiming?.followUpActive);
|
||||||
const selectedRow = leaderboard.find((row) => row.key === selectedLeaderboardKey) || null;
|
const selectedRow = leaderboard.find((row) => row.key === selectedLeaderboardKey) || null;
|
||||||
if (selectedLeaderboardKey && !selectedRow) {
|
if (selectedLeaderboardKey && !selectedRow) {
|
||||||
selectedLeaderboardKey = null;
|
selectedLeaderboardKey = null;
|
||||||
@@ -3994,6 +4074,7 @@ function renderTiming() {
|
|||||||
}`
|
}`
|
||||||
: `<p>${t("timing.no_active")}</p>`
|
: `<p>${t("timing.no_active")}</p>`
|
||||||
}
|
}
|
||||||
|
${showFollowUpBanner ? `<p class="finish-banner">${t("timing.follow_up_active")}</p>` : ""}
|
||||||
${showFinishedBanner ? `<p class="finish-banner">${t("timing.race_finished")}</p>` : ""}
|
${showFinishedBanner ? `<p class="finish-banner">${t("timing.race_finished")}</p>` : ""}
|
||||||
${active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : ""}
|
${active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : ""}
|
||||||
</div>
|
</div>
|
||||||
@@ -4053,6 +4134,30 @@ function renderTiming() {
|
|||||||
|
|
||||||
if (active && selectedRow) {
|
if (active && selectedRow) {
|
||||||
bindQuickAddActions(active, selectedRow.transponder, "leaderboardModal");
|
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) {
|
if (active) {
|
||||||
@@ -4161,6 +4266,7 @@ function renderTiming() {
|
|||||||
session.startedAt = Date.now();
|
session.startedAt = Date.now();
|
||||||
session.endedAt = null;
|
session.endedAt = null;
|
||||||
session.finishedByTimer = false;
|
session.finishedByTimer = false;
|
||||||
|
session.followUpStartedAt = null;
|
||||||
lastFinishAnnouncementSessionId = null;
|
lastFinishAnnouncementSessionId = null;
|
||||||
lastOverlayLeaderKeyBySession[session.id] = null;
|
lastOverlayLeaderKeyBySession[session.id] = null;
|
||||||
lastOverlayTop3BySession[session.id] = [];
|
lastOverlayTop3BySession[session.id] = [];
|
||||||
@@ -4180,6 +4286,7 @@ function renderTiming() {
|
|||||||
session.status = "finished";
|
session.status = "finished";
|
||||||
session.endedAt = Date.now();
|
session.endedAt = Date.now();
|
||||||
session.finishedByTimer = false;
|
session.finishedByTimer = false;
|
||||||
|
session.followUpStartedAt = null;
|
||||||
saveState();
|
saveState();
|
||||||
updateHeaderState();
|
updateHeaderState();
|
||||||
renderView();
|
renderView();
|
||||||
@@ -4194,6 +4301,7 @@ function renderTiming() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
delete state.resultsBySession[session.id];
|
delete state.resultsBySession[session.id];
|
||||||
|
session.followUpStartedAt = null;
|
||||||
lastFinishAnnouncementSessionId = null;
|
lastFinishAnnouncementSessionId = null;
|
||||||
delete lastOverlayLeaderKeyBySession[session.id];
|
delete lastOverlayLeaderKeyBySession[session.id];
|
||||||
delete lastOverlayTop3BySession[session.id];
|
delete lastOverlayTop3BySession[session.id];
|
||||||
@@ -4316,6 +4424,9 @@ function renderGuide() {
|
|||||||
<li>${t("guide.race_format_5")}</li>
|
<li>${t("guide.race_format_5")}</li>
|
||||||
<li>${t("guide.race_format_6")}</li>
|
<li>${t("guide.race_format_6")}</li>
|
||||||
<li>${t("guide.race_format_7")}</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -4356,6 +4467,19 @@ function renderGuide() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="panel mt-16">
|
||||||
<div class="panel-header"><h3>${t("guide.host_title")}</h3></div>
|
<div class="panel-header"><h3>${t("guide.host_title")}</h3></div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
@@ -4411,7 +4535,9 @@ function renderOverlay() {
|
|||||||
const leaderboard = active ? buildLeaderboard(active).slice(0, 12) : [];
|
const leaderboard = active ? buildLeaderboard(active).slice(0, 12) : [];
|
||||||
const result = active ? ensureSessionResult(active.id) : null;
|
const result = active ? ensureSessionResult(active.id) : null;
|
||||||
const sessionTiming = active ? getSessionTiming(active) : 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)
|
? formatElapsedClock(sessionTiming?.elapsedMs ?? 0)
|
||||||
: formatCountdown(sessionTiming?.remainingMs ?? 0);
|
: formatCountdown(sessionTiming?.remainingMs ?? 0);
|
||||||
const recent = active && result ? getVisiblePassings(result).slice(-8).reverse() : [];
|
const recent = active && result ? getVisiblePassings(result).slice(-8).reverse() : [];
|
||||||
@@ -4424,6 +4550,7 @@ function renderOverlay() {
|
|||||||
const fastestRow =
|
const fastestRow =
|
||||||
[...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
|
[...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
|
||||||
const modeLabel = getOverlayModeLabel(overlayViewMode);
|
const modeLabel = getOverlayModeLabel(overlayViewMode);
|
||||||
|
const overlayStatusLabel = sessionTiming?.followUpActive ? t("timing.follow_up_active") : active ? getStatusLabel(active.status) : "";
|
||||||
const rotatingPanels = buildOverlayPanels(active, recent);
|
const rotatingPanels = buildOverlayPanels(active, recent);
|
||||||
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
|
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
|
||||||
|
|
||||||
@@ -4451,7 +4578,7 @@ function renderOverlay() {
|
|||||||
<div class="overlay-meta">
|
<div class="overlay-meta">
|
||||||
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
|
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
|
||||||
<div class="overlay-clock">${overlayClock}</div>
|
<div class="overlay-clock">${overlayClock}</div>
|
||||||
<div class="overlay-status">${escapeHtml(getStatusLabel(active.status))}</div>
|
<div class="overlay-status">${escapeHtml(overlayStatusLabel)}</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -4718,6 +4845,19 @@ function renderLeaderboardModal(session, row) {
|
|||||||
<p>${t("table.last_lap")}: ${formatLap(row.lastLapMs)}</p>
|
<p>${t("table.last_lap")}: ${formatLap(row.lastLapMs)}</p>
|
||||||
<p>${t("table.own_delta")}: ${escapeHtml(row.lapDelta || "-")}</p>
|
<p>${t("table.own_delta")}: ${escapeHtml(row.lapDelta || "-")}</p>
|
||||||
</div>
|
</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">
|
<div class="panel-body">
|
||||||
<h4>${t("timing.lap_history")}</h4>
|
<h4>${t("timing.lap_history")}</h4>
|
||||||
${
|
${
|
||||||
@@ -4770,6 +4910,7 @@ function renderLeaderboard(rows) {
|
|||||||
<td>
|
<td>
|
||||||
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
|
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
|
||||||
${row.teamId ? `<div class="table-subnote">${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</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>
|
||||||
<td>${escapeHtml(row.subLabel || row.carName)}</td>
|
<td>${escapeHtml(row.subLabel || row.carName)}</td>
|
||||||
<td>${escapeHtml(row.transponder)}</td>
|
<td>${escapeHtml(row.transponder)}</td>
|
||||||
@@ -4901,22 +5042,22 @@ function renderRecentPassings(session) {
|
|||||||
return `<p>${t("timing.no_session_selected")}</p>`;
|
return `<p>${t("timing.no_session_selected")}</p>`;
|
||||||
}
|
}
|
||||||
const result = ensureSessionResult(session.id);
|
const result = ensureSessionResult(session.id);
|
||||||
const items = getVisiblePassings(result).slice(-20).reverse();
|
const items = result.passings.slice(-20).reverse();
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return `<p>${t("timing.no_passings")}</p>`;
|
return `<p>${t("timing.no_passings")}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderTable(
|
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) => {
|
items.map((p, index) => {
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr class="${isCountedPassing(p) ? "" : "passing-invalid"}">
|
||||||
<td>${new Date(p.timestamp).toLocaleTimeString()}</td>
|
<td>${new Date(p.timestamp).toLocaleTimeString()}</td>
|
||||||
<td>${escapeHtml(p.transponder)}</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.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.carName || p.subLabel || "-")}</td>
|
||||||
<td>${escapeHtml(p.loopId || "-")}</td>
|
<td>${formatLap(p.lapMs)}</td>
|
||||||
<td>${p.strength ?? "-"}</td>
|
<td>${escapeHtml(getPassingValidationLabel(p))}</td>
|
||||||
<td>${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)}</td>
|
<td>${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@@ -5297,15 +5438,56 @@ function getVisiblePassings(result) {
|
|||||||
return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : [];
|
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()) {
|
function getSessionTiming(session, nowTs = Date.now()) {
|
||||||
const targetMs = getSessionTargetMs(session);
|
const targetMs = getSessionTargetMs(session);
|
||||||
const startedAt = Number(session?.startedAt || 0);
|
const startedAt = Number(session?.startedAt || 0);
|
||||||
const elapsedMs = startedAt ? Math.max(0, nowTs - 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 {
|
return {
|
||||||
targetMs,
|
targetMs,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
remainingMs: targetMs === null ? null : Math.max(0, targetMs - elapsedMs),
|
remainingMs: targetMs === null ? null : Math.max(0, targetMs - elapsedMs),
|
||||||
untimed: targetMs === null,
|
untimed: targetMs === null,
|
||||||
|
followUpActive,
|
||||||
|
followUpRemainingMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5686,15 +5868,20 @@ function buildLeaderboard(session) {
|
|||||||
const isRollingPractice = isFreePractice || isOpenPractice;
|
const isRollingPractice = isFreePractice || isOpenPractice;
|
||||||
const nowTs = Date.now();
|
const nowTs = Date.now();
|
||||||
const rows = Object.values(result.competitors).map((row) => {
|
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 passings = getCompetitorPassings(session, row);
|
||||||
const latestPassing = passings.length ? passings[passings.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 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 lastLapMs = latestPassing ? Number(latestPassing.lapMs || 0) : Number(row.lastLapMs || 0) || 0;
|
||||||
const bestLapMs = Number(row.bestLapMs || 0) || 0;
|
const bestLapMs = Number(row.bestLapMs || 0) || 0;
|
||||||
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
|
|
||||||
const lapDeltaMs =
|
const lapDeltaMs =
|
||||||
lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null;
|
lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null;
|
||||||
const predictionBaseMs =
|
const predictionBaseMs =
|
||||||
@@ -5714,10 +5901,13 @@ function buildLeaderboard(session) {
|
|||||||
: "late";
|
: "late";
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
|
laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment),
|
||||||
lastLapMs,
|
lastLapMs,
|
||||||
bestLapMs,
|
bestLapMs,
|
||||||
lastTimestamp: lastPassingTs || row.lastTimestamp,
|
lastTimestamp: lastPassingTs || row.lastTimestamp,
|
||||||
totalElapsedMs,
|
totalElapsedMs,
|
||||||
|
manualLapAdjustment,
|
||||||
|
manualTimeAdjustmentMs,
|
||||||
distanceToTargetMs,
|
distanceToTargetMs,
|
||||||
seedMetric,
|
seedMetric,
|
||||||
previousLapMs,
|
previousLapMs,
|
||||||
@@ -5727,16 +5917,16 @@ function buildLeaderboard(session) {
|
|||||||
predictionTone,
|
predictionTone,
|
||||||
comparisonMs:
|
comparisonMs:
|
||||||
isRollingPractice
|
isRollingPractice
|
||||||
? row.bestLapMs || row.lastLapMs || Number.MAX_SAFE_INTEGER
|
? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER
|
||||||
: useSeedRanking && seedMetric
|
: useSeedRanking && seedMetric
|
||||||
? seedMetric.totalMs
|
? seedMetric.totalMs
|
||||||
: totalElapsedMs,
|
: totalElapsedMs,
|
||||||
resultDisplay:
|
resultDisplay:
|
||||||
isRollingPractice
|
isRollingPractice
|
||||||
? formatLap(row.bestLapMs || row.lastLapMs)
|
? formatLap(bestLapMs || lastLapMs)
|
||||||
: useSeedRanking && seedMetric
|
: useSeedRanking && seedMetric
|
||||||
? `${seedMetric.lapCount}/${formatRaceClock(seedMetric.totalMs)}`
|
? `${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 carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
|
||||||
const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5);
|
const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5);
|
||||||
const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered");
|
const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered");
|
||||||
|
const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0);
|
||||||
const heats = chunkArray(fallbackRows, carsPerHeat);
|
const heats = chunkArray(fallbackRows, carsPerHeat);
|
||||||
let created = 0;
|
let created = 0;
|
||||||
|
|
||||||
@@ -6543,6 +6734,8 @@ function generateQualifyingForRace(event) {
|
|||||||
startedAt: null,
|
startedAt: null,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
finishedByTimer: false,
|
finishedByTimer: false,
|
||||||
|
followUpSec,
|
||||||
|
followUpStartedAt: null,
|
||||||
startMode: qualStartMode,
|
startMode: qualStartMode,
|
||||||
seedBestLapCount: 2,
|
seedBestLapCount: 2,
|
||||||
staggerGapSec: 3,
|
staggerGapSec: 3,
|
||||||
@@ -6626,6 +6819,7 @@ function generateFinalsForRace(event) {
|
|||||||
const carsPerFinal = Math.max(2, Number(event.raceConfig?.carsPerFinal || 8) || 8);
|
const carsPerFinal = Math.max(2, Number(event.raceConfig?.carsPerFinal || 8) || 8);
|
||||||
const finalDurationMin = Math.max(1, Number(event.raceConfig?.finalDurationMin || 5) || 5);
|
const finalDurationMin = Math.max(1, Number(event.raceConfig?.finalDurationMin || 5) || 5);
|
||||||
const finalStartMode = normalizeStartMode(event.raceConfig?.finalStartMode || "position");
|
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 bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0);
|
||||||
const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0);
|
const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0);
|
||||||
const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal;
|
const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal;
|
||||||
@@ -6649,6 +6843,8 @@ function generateFinalsForRace(event) {
|
|||||||
startedAt: null,
|
startedAt: null,
|
||||||
endedAt: null,
|
endedAt: null,
|
||||||
finishedByTimer: false,
|
finishedByTimer: false,
|
||||||
|
followUpSec,
|
||||||
|
followUpStartedAt: null,
|
||||||
startMode: finalStartMode,
|
startMode: finalStartMode,
|
||||||
seedBestLapCount: 0,
|
seedBestLapCount: 0,
|
||||||
staggerGapSec: 0,
|
staggerGapSec: 0,
|
||||||
|
|||||||
@@ -395,6 +395,11 @@ select:focus {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-table tr.passing-invalid td {
|
||||||
|
background: rgba(225, 6, 0, 0.08);
|
||||||
|
color: #ffd0d0;
|
||||||
|
}
|
||||||
|
|
||||||
.data-table th {
|
.data-table th {
|
||||||
color: #bdc8e3;
|
color: #bdc8e3;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
@@ -857,6 +862,7 @@ select:focus {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
font-size: 0.56rem;
|
font-size: 0.56rem;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-board {
|
.overlay-board {
|
||||||
|
|||||||
Reference in New Issue
Block a user