fix lag tävling och TV overlay
This commit is contained in:
353
src/app.js
353
src/app.js
@@ -11,7 +11,7 @@ const NAV_ITEMS = [
|
||||
{ id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub" },
|
||||
];
|
||||
|
||||
const SESSION_TYPES = ["open_practice", "free_practice", "practice", "qualification", "heat", "final"];
|
||||
const SESSION_TYPES = ["open_practice", "free_practice", "practice", "qualification", "heat", "final", "team_race"];
|
||||
const STORAGE_KEY = "rc_timing_control_v1";
|
||||
const DEFAULT_LANGUAGE = "sv";
|
||||
|
||||
@@ -209,6 +209,17 @@ const TRANSLATIONS = {
|
||||
"events.finals_source_hint": "Välj om finalerna ska seedas från practice eller kval.",
|
||||
"events.race_driver_scope": "Race i denna klass använder alla förare i vald klass om sessionen inte har egen deltagarlista.",
|
||||
"events.reserve_bump_slots_hint": "Reserverar tomma platser i högre finaler så bumpade förare kan flyttas in utan att skriva över seedade platser.",
|
||||
"events.team_race": "Lagrace",
|
||||
"events.team_race_intro": "Skapa lag för långlopp. Alla passeringar från lagets förare eller bilar summeras till lagets totalvarv i Team Race-sessioner.",
|
||||
"events.team_name": "Lagnamn",
|
||||
"events.add_team": "Lägg till lag",
|
||||
"events.teams": "Lag",
|
||||
"events.team_drivers": "Lagförare",
|
||||
"events.team_cars": "Lagbilar",
|
||||
"events.team_hint": "Välj minst en förare eller bil per lag. Team Race-sessioner summerar lagets totala varv under hela körtiden, t.ex. 4 timmar.",
|
||||
"events.no_teams": "Inga lag skapade ännu.",
|
||||
"events.team_standings": "Lagställning",
|
||||
"events.no_team_results": "Inga teamresultat ännu.",
|
||||
"events.add_session": "Lägg till session",
|
||||
"events.set_active": "Sätt aktiv",
|
||||
"events.assignments": "Tilldelningar",
|
||||
@@ -397,6 +408,7 @@ const TRANSLATIONS = {
|
||||
"session.qualification": "kval",
|
||||
"session.heat": "heat",
|
||||
"session.final": "final",
|
||||
"session.team_race": "lagrace",
|
||||
"validation.no_assignments": "Ingen förar-/biltilldelning i denna session.",
|
||||
"validation.missing_tp": "En eller flera tilldelade bilar saknar transponder-ID.",
|
||||
"validation.duplicate_tp": "Dubblett-transponder i session: {ids}.",
|
||||
@@ -452,6 +464,12 @@ const TRANSLATIONS = {
|
||||
"guide.open_practice_1": "Använd Open Practice när du vill att systemet bara ska lista alla transpondrar som kommer in.",
|
||||
"guide.open_practice_2": "Om transpondern inte matchar en registrerad förare visas transpondernumret som namn.",
|
||||
"guide.open_practice_3": "Open Practice påverkar inte seedning, kval eller finaler.",
|
||||
"guide.team_title": "Lagrace / Endurance",
|
||||
"guide.team_1": "Gå till Race Setup och skapa ett race i rätt klass.",
|
||||
"guide.team_2": "Öppna Hantera och skapa lag under sektionen Lag.",
|
||||
"guide.team_3": "Koppla förare och/eller bilar till varje lag. Minst en av dem krävs.",
|
||||
"guide.team_4": "Skapa en session med typ Team Race och sätt tiden, t.ex. 240 minuter för 4 timmar.",
|
||||
"guide.team_5": "Starta sessionen i Tidtagning. Alla passeringar från lagets medlemmar summeras till lagets totalvarv.",
|
||||
"overlay.title": "Overlay",
|
||||
"overlay.subtitle": "Extern leaderboard-skärm",
|
||||
"overlay.no_active": "Ingen aktiv session vald.",
|
||||
@@ -693,6 +711,17 @@ const TRANSLATIONS = {
|
||||
"events.finals_source_hint": "Choose whether finals should be seeded from practice or qualifying.",
|
||||
"events.race_driver_scope": "Race mode uses all drivers in the event class unless a session has its own participant list.",
|
||||
"events.reserve_bump_slots_hint": "Reserve empty slots in higher finals so bumped drivers can be inserted without overwriting seeded spots.",
|
||||
"events.team_race": "Team Race",
|
||||
"events.team_race_intro": "Create endurance teams. All passings from the team's drivers or cars are added to the team's total laps in Team Race sessions.",
|
||||
"events.team_name": "Team name",
|
||||
"events.add_team": "Add team",
|
||||
"events.teams": "Teams",
|
||||
"events.team_drivers": "Team drivers",
|
||||
"events.team_cars": "Team cars",
|
||||
"events.team_hint": "Select at least one driver or car per team. Team Race sessions sum the team's total laps across the whole race duration, for example 4 hours.",
|
||||
"events.no_teams": "No teams created yet.",
|
||||
"events.team_standings": "Team standings",
|
||||
"events.no_team_results": "No team results yet.",
|
||||
"events.add_session": "Add Session",
|
||||
"events.set_active": "Set Active",
|
||||
"events.assignments": "Assignments",
|
||||
@@ -881,6 +910,7 @@ const TRANSLATIONS = {
|
||||
"session.qualification": "qualification",
|
||||
"session.heat": "heat",
|
||||
"session.final": "final",
|
||||
"session.team_race": "team race",
|
||||
"validation.no_assignments": "No driver/car assignments in this session.",
|
||||
"validation.missing_tp": "One or more assigned cars are missing transponder ID.",
|
||||
"validation.duplicate_tp": "Duplicate transponder(s) in session: {ids}.",
|
||||
@@ -936,6 +966,12 @@ const TRANSLATIONS = {
|
||||
"guide.open_practice_1": "Use Open Practice when you want the system to simply list every transponder that comes in.",
|
||||
"guide.open_practice_2": "If the transponder does not match a registered driver, the transponder number is shown as the name.",
|
||||
"guide.open_practice_3": "Open Practice does not affect seeding, qualifying or finals.",
|
||||
"guide.team_title": "Team Race / Endurance",
|
||||
"guide.team_1": "Go to Race Setup and create a race in the correct class.",
|
||||
"guide.team_2": "Open Manage and create teams in the Teams section.",
|
||||
"guide.team_3": "Assign drivers and/or cars to each team. At least one is required.",
|
||||
"guide.team_4": "Create a session with type Team Race and set the time, for example 240 minutes for 4 hours.",
|
||||
"guide.team_5": "Start the session in Timing. All passings from the team's members are added to the team's total laps.",
|
||||
"overlay.title": "Overlay",
|
||||
"overlay.subtitle": "External leaderboard screen",
|
||||
"overlay.no_active": "No active session selected.",
|
||||
@@ -1478,6 +1514,15 @@ function normalizeSession(session) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRaceTeam(team) {
|
||||
return {
|
||||
id: String(team?.id || uid("team")),
|
||||
name: String(team?.name || "").trim(),
|
||||
driverIds: Array.isArray(team?.driverIds) ? team.driverIds.filter(Boolean) : [],
|
||||
carIds: Array.isArray(team?.carIds) ? team.carIds.filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(event) {
|
||||
return {
|
||||
...event,
|
||||
@@ -1499,6 +1544,7 @@ function normalizeEvent(event) {
|
||||
driverIds: Array.isArray(event?.raceConfig?.driverIds) ? event.raceConfig.driverIds : [],
|
||||
participantsConfigured: Boolean(event?.raceConfig?.participantsConfigured),
|
||||
finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying",
|
||||
teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1924,7 +1970,7 @@ function announcePassing(entry) {
|
||||
return;
|
||||
}
|
||||
if (state.settings.passingSoundMode === "name") {
|
||||
speakText(entry?.driverName || t("common.unknown_driver"));
|
||||
speakText(entry?.displayName || entry?.driverName || t("common.unknown_driver"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2702,6 +2748,7 @@ function renderEventManager(eventId) {
|
||||
.map((d) => `<option value="${d.id}">${escapeHtml(d.name)}</option>`)
|
||||
.join("");
|
||||
const raceDrivers = event.mode === "race" ? state.drivers.filter((driver) => !event.classId || driver.classId === event.classId) : [];
|
||||
const raceTeams = event.mode === "race" ? getEventTeams(event) : [];
|
||||
const carOptions = state.cars
|
||||
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)} (${escapeHtml(c.transponder)})</option>`)
|
||||
.join("");
|
||||
@@ -2747,7 +2794,12 @@ function renderEventManager(eventId) {
|
||||
${renderTable(
|
||||
[t("table.session"), t("table.type"), t("table.duration"), t("table.start_mode"), t("table.seeding"), t("table.status"), t(event.mode === "track" ? "events.assignments" : "events.participants"), t("events.actions")],
|
||||
sessions.map((s) => {
|
||||
const assignCount = event.mode === "track" ? (s.assignments || []).length : getSessionEntrants(s).length;
|
||||
const assignCount =
|
||||
event.mode === "track"
|
||||
? (s.assignments || []).length
|
||||
: s.type === "team_race"
|
||||
? raceTeams.length
|
||||
: getSessionEntrants(s).length;
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(s.name)}</td>
|
||||
@@ -2877,6 +2929,80 @@ function renderEventManager(eventId) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("events.teams")}</h3></div>
|
||||
<div class="panel-body">
|
||||
<p class="hint">${t("events.team_race_intro")}</p>
|
||||
<form id="teamForm" class="form-grid cols-4">
|
||||
<input name="teamName" required placeholder="${t("events.team_name")}" />
|
||||
<button class="btn btn-primary" type="submit">${t("events.add_team")}</button>
|
||||
</form>
|
||||
<p class="hint">${t("events.team_hint")}</p>
|
||||
<div class="panel-row mt-16">
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("events.team_drivers")}</h3></div>
|
||||
<div class="panel-body check-grid">
|
||||
${raceDrivers
|
||||
.map(
|
||||
(driver) => `
|
||||
<label class="check-card">
|
||||
<input type="checkbox" name="teamDriverIds" form="teamForm" value="${driver.id}" />
|
||||
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("events.team_cars")}</h3></div>
|
||||
<div class="panel-body check-grid">
|
||||
${state.cars
|
||||
.map(
|
||||
(car) => `
|
||||
<label class="check-card">
|
||||
<input type="checkbox" name="teamCarIds" form="teamForm" value="${car.id}" />
|
||||
<span>${escapeHtml(car.name)} (${escapeHtml(car.transponder || "-")})</span>
|
||||
</label>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="mt-16">
|
||||
${
|
||||
raceTeams.length
|
||||
? raceTeams
|
||||
.map(
|
||||
(team) => `
|
||||
<article class="team-card">
|
||||
<div>
|
||||
<strong>${escapeHtml(team.name)}</strong>
|
||||
<div class="hint">${t("events.team_drivers")}: ${escapeHtml(
|
||||
team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") || "-"
|
||||
)}</div>
|
||||
<div class="hint">${t("events.team_cars")}: ${escapeHtml(
|
||||
team.carIds
|
||||
.map((carId) => {
|
||||
const car = state.cars.find((item) => item.id === carId);
|
||||
return car ? `${car.name} (${car.transponder || "-"})` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ") || "-"
|
||||
)}</div>
|
||||
</div>
|
||||
<button id="team-delete-${team.id}" class="btn btn-danger" type="button">${t("common.delete")}</button>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.join("")
|
||||
: `<p>${t("events.no_teams")}</p>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("events.race_format")}</h3></div>
|
||||
<div class="panel-body">
|
||||
@@ -3012,6 +3138,13 @@ function renderEventManager(eventId) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("events.team_standings")}</h3></div>
|
||||
<div class="panel-body">
|
||||
${renderTeamRaceStandings(event)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("events.final_matrix")}</h3></div>
|
||||
<div class="panel-body">
|
||||
@@ -3342,6 +3475,28 @@ function renderEventManager(eventId) {
|
||||
persistRaceParticipants();
|
||||
});
|
||||
|
||||
document.getElementById("teamForm")?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
const name = String(form.get("teamName") || "").trim();
|
||||
const driverIds = form.getAll("teamDriverIds").map(String).filter(Boolean);
|
||||
const carIds = form.getAll("teamCarIds").map(String).filter(Boolean);
|
||||
if (!name || (!driverIds.length && !carIds.length)) {
|
||||
return;
|
||||
}
|
||||
event.raceConfig.teams = [...getEventTeams(event), normalizeRaceTeam({ id: uid("team"), name, driverIds, carIds })];
|
||||
saveState();
|
||||
renderEventManager(eventId);
|
||||
});
|
||||
|
||||
raceTeams.forEach((team) => {
|
||||
document.getElementById(`team-delete-${team.id}`)?.addEventListener("click", () => {
|
||||
event.raceConfig.teams = getEventTeams(event).filter((item) => item.id !== team.id);
|
||||
saveState();
|
||||
renderEventManager(eventId);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("raceFormatForm")?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
@@ -3362,6 +3517,7 @@ function renderEventManager(eventId) {
|
||||
driverIds: event.raceConfig.driverIds || [],
|
||||
participantsConfigured: event.raceConfig.participantsConfigured !== false,
|
||||
finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying",
|
||||
teams: getEventTeams(event),
|
||||
};
|
||||
saveState();
|
||||
renderEventManager(eventId);
|
||||
@@ -3983,6 +4139,19 @@ function renderGuide() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("guide.team_title")}</h3></div>
|
||||
<div class="panel-body">
|
||||
<ul>
|
||||
<li>${t("guide.team_1")}</li>
|
||||
<li>${t("guide.team_2")}</li>
|
||||
<li>${t("guide.team_3")}</li>
|
||||
<li>${t("guide.team_4")}</li>
|
||||
<li>${t("guide.team_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">
|
||||
@@ -4055,7 +4224,7 @@ function renderOverlay() {
|
||||
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
|
||||
|
||||
dom.view.innerHTML = `
|
||||
<section class="overlay-shell">
|
||||
<section class="overlay-shell ${overlayViewMode === "tv" ? "overlay-shell-tv" : ""}">
|
||||
${
|
||||
active
|
||||
? `
|
||||
@@ -4086,7 +4255,7 @@ function renderOverlay() {
|
||||
<section class="overlay-speaker">
|
||||
<div class="overlay-speaker-main">
|
||||
<div class="overlay-speaker-label">P1</div>
|
||||
<h2>${escapeHtml(topRow?.driverName || t("common.unknown_driver"))}</h2>
|
||||
<h2>${escapeHtml(topRow?.displayName || topRow?.driverName || t("common.unknown_driver"))}</h2>
|
||||
<p>${t("table.result")}: ${escapeHtml(topRow?.resultDisplay || "-")}</p>
|
||||
<p>${t("table.best_lap")}: ${formatLap(topRow?.bestLapMs)}</p>
|
||||
</div>
|
||||
@@ -4099,7 +4268,7 @@ function renderOverlay() {
|
||||
.map(
|
||||
(passing) => `
|
||||
<div class="overlay-passing">
|
||||
<strong>${escapeHtml(passing.driverName || t("common.unknown_driver"))}</strong>
|
||||
<strong>${escapeHtml(passing.displayName || passing.teamName || passing.driverName || t("common.unknown_driver"))}</strong>
|
||||
<span>${new Date(passing.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
`
|
||||
@@ -4158,13 +4327,13 @@ function renderOverlay() {
|
||||
<span>${t("overlay.fastest_lap")}</span>
|
||||
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
|
||||
</div>
|
||||
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.driverName || "-")}</div>
|
||||
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}</div>
|
||||
</section>
|
||||
<section class="overlay-stats-row">
|
||||
<article class="overlay-stat-card">
|
||||
<span>${t("table.laps")}</span>
|
||||
<strong>${topRow?.laps || 0}</strong>
|
||||
<small>${escapeHtml(topRow?.driverName || "-")}</small>
|
||||
<small>${escapeHtml(topRow?.displayName || topRow?.driverName || "-")}</small>
|
||||
</article>
|
||||
<article class="overlay-stat-card">
|
||||
<span>${t("timing.total_passings")}</span>
|
||||
@@ -4189,18 +4358,18 @@ function renderOverlay() {
|
||||
<span>${t("overlay.fastest_lap")}</span>
|
||||
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
|
||||
</div>
|
||||
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.driverName || "-")}</div>
|
||||
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}</div>
|
||||
</section>
|
||||
<section class="overlay-stats-row">
|
||||
<article class="overlay-stat-card">
|
||||
<span>${t("overlay.fastest_lap")}</span>
|
||||
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
|
||||
<small>${escapeHtml(fastestRow?.driverName || "-")}</small>
|
||||
<small>${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}</small>
|
||||
</article>
|
||||
<article class="overlay-stat-card">
|
||||
<span>${t("table.laps")}</span>
|
||||
<strong>${topRow?.laps || 0}</strong>
|
||||
<small>${escapeHtml(topRow?.driverName || "-")}</small>
|
||||
<small>${escapeHtml(topRow?.displayName || topRow?.driverName || "-")}</small>
|
||||
</article>
|
||||
<article class="overlay-stat-card">
|
||||
<span>${t("timing.total_passings")}</span>
|
||||
@@ -4251,7 +4420,7 @@ function buildOverlayPanels(active, recent) {
|
||||
.map(
|
||||
(passing) => `
|
||||
<div class="overlay-passing">
|
||||
<strong>${escapeHtml(passing.driverName || passing.transponder || t("common.unknown_driver"))}</strong>
|
||||
<strong>${escapeHtml(passing.displayName || passing.teamName || passing.driverName || passing.transponder || t("common.unknown_driver"))}</strong>
|
||||
<span>${new Date(passing.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
`
|
||||
@@ -4365,7 +4534,7 @@ function renderLeaderboardModal(session, row) {
|
||||
<button class="btn" id="leaderboardModalClose">${t("timing.close_details")}</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p><strong>${escapeHtml(row.driverName)}</strong> • ${escapeHtml(row.carName)}</p>
|
||||
<p><strong>${escapeHtml(row.displayName || row.driverName)}</strong> • ${escapeHtml(row.subLabel || row.carName)}</p>
|
||||
<p>${t("table.transponder")}: ${escapeHtml(row.transponder)}</p>
|
||||
${renderQuickAddActions(session, row.transponder, "leaderboardModal")}
|
||||
<p>${t("table.laps")}: ${row.laps}</p>
|
||||
@@ -4423,8 +4592,8 @@ function renderLeaderboard(rows) {
|
||||
return `
|
||||
<tr>
|
||||
<td><span class="pos-pill ${posClass}">${idx + 1}</span></td>
|
||||
<td>${escapeHtml(row.driverName)}</td>
|
||||
<td>${escapeHtml(row.carName)}</td>
|
||||
<td>${escapeHtml(row.displayName || row.driverName)}</td>
|
||||
<td>${escapeHtml(row.subLabel || row.carName)}</td>
|
||||
<td>${escapeHtml(row.transponder)}</td>
|
||||
<td>${row.laps}</td>
|
||||
<td>${escapeHtml(row.resultDisplay)}</td>
|
||||
@@ -4456,8 +4625,8 @@ function renderOverlayLeaderboard(rows) {
|
||||
<span class="pos-pill ${posClass}">${idx + 1}</span>
|
||||
</div>
|
||||
<div class="overlay-race-driver">
|
||||
<strong>${escapeHtml(row.driverName)}</strong>
|
||||
<span>${escapeHtml(row.transponder || "-")}</span>
|
||||
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
|
||||
<span>${escapeHtml(row.subLabel || row.transponder || "-")}</span>
|
||||
<div class="overlay-prediction">
|
||||
<div class="overlay-prediction-meta">
|
||||
<label>${t("overlay.next_predicted_lap")}</label>
|
||||
@@ -4509,8 +4678,8 @@ function renderRecentPassings(session) {
|
||||
<tr>
|
||||
<td>${new Date(p.timestamp).toLocaleTimeString()}</td>
|
||||
<td>${escapeHtml(p.transponder)}</td>
|
||||
<td>${escapeHtml(p.driverName || t("common.unknown_driver"))}</td>
|
||||
<td>${escapeHtml(p.carName || "-")}</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>${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)}</td>
|
||||
@@ -5025,8 +5194,12 @@ function processDecoderMessage(msg) {
|
||||
if (!result.competitors[key]) {
|
||||
result.competitors[key] = {
|
||||
key,
|
||||
teamId: competitor.teamId || null,
|
||||
teamName: competitor.teamName || "",
|
||||
driverId: competitor.driverId,
|
||||
driverName: competitor.driverName,
|
||||
displayName: competitor.displayName || competitor.driverName,
|
||||
subLabel: competitor.subLabel || competitor.carName || "",
|
||||
carId: competitor.carId,
|
||||
carName: competitor.carName,
|
||||
transponder,
|
||||
@@ -5039,6 +5212,15 @@ function processDecoderMessage(msg) {
|
||||
}
|
||||
|
||||
const entry = result.competitors[key];
|
||||
entry.teamId = competitor.teamId || entry.teamId || null;
|
||||
entry.teamName = competitor.teamName || entry.teamName || "";
|
||||
entry.displayName = competitor.displayName || entry.displayName || competitor.driverName;
|
||||
entry.subLabel = competitor.subLabel || entry.subLabel || competitor.carName || "";
|
||||
entry.driverId = competitor.driverId ?? entry.driverId;
|
||||
entry.driverName = competitor.driverName || entry.driverName;
|
||||
entry.carId = competitor.carId ?? entry.carId;
|
||||
entry.carName = competitor.carName || entry.carName;
|
||||
entry.transponder = transponder;
|
||||
const startMode = normalizeStartMode(session.startMode);
|
||||
|
||||
if (startMode === "staggered" && !entry.startTimestamp) {
|
||||
@@ -5067,8 +5249,12 @@ function processDecoderMessage(msg) {
|
||||
const passing = {
|
||||
timestamp,
|
||||
transponder,
|
||||
teamId: entry.teamId,
|
||||
teamName: entry.teamName,
|
||||
driverId: entry.driverId,
|
||||
driverName: entry.driverName,
|
||||
displayName: entry.displayName,
|
||||
subLabel: entry.subLabel,
|
||||
carId: entry.carId,
|
||||
carName: entry.carName,
|
||||
competitorKey: key,
|
||||
@@ -5080,19 +5266,19 @@ function processDecoderMessage(msg) {
|
||||
|
||||
result.passings.push(passing);
|
||||
persistPassingToBackend(session.id, passing);
|
||||
pushOverlayEvent("passing", `${entry.driverName} • ${formatLap(entry.lastLapMs)}`);
|
||||
pushOverlayEvent("passing", `${entry.displayName || entry.driverName} • ${formatLap(entry.lastLapMs)}`);
|
||||
const leaderboard = buildLeaderboard(session);
|
||||
const leader = leaderboard[0];
|
||||
if (leader?.key && lastOverlayLeaderKeyBySession[session.id] !== leader.key) {
|
||||
lastOverlayLeaderKeyBySession[session.id] = leader.key;
|
||||
pushOverlayEvent("leader", `${leader.driverName} • P1`);
|
||||
pushOverlayEvent("leader", `${leader.displayName || leader.driverName} • P1`);
|
||||
}
|
||||
if (entry.bestLapMs && Number.isFinite(entry.bestLapMs)) {
|
||||
const bestKey = `${session.id}:${entry.key}`;
|
||||
const previousBest = lastOverlayBestLapByKey[bestKey];
|
||||
if (!previousBest || entry.bestLapMs < previousBest) {
|
||||
lastOverlayBestLapByKey[bestKey] = entry.bestLapMs;
|
||||
pushOverlayEvent("bestlap", `${entry.driverName} • ${formatLap(entry.bestLapMs)}`);
|
||||
pushOverlayEvent("bestlap", `${entry.displayName || entry.driverName} • ${formatLap(entry.bestLapMs)}`);
|
||||
}
|
||||
}
|
||||
const top3Keys = leaderboard.slice(0, 3).map((row) => row.key);
|
||||
@@ -5119,6 +5305,7 @@ function resolveCompetitor(session, transponder) {
|
||||
const isOpenPractice = sessionType === "open_practice";
|
||||
const isFreePractice = sessionType === "free_practice";
|
||||
const isOpenMonitoringSession = isOpenPractice || isFreePractice;
|
||||
const event = state.events.find((item) => item.id === session?.eventId) || null;
|
||||
if (session.mode === "track") {
|
||||
const matchingAssignments = (session.assignments || []).filter((a) => {
|
||||
const car = state.cars.find((c) => c.id === a.carId);
|
||||
@@ -5157,6 +5344,31 @@ function resolveCompetitor(session, transponder) {
|
||||
};
|
||||
}
|
||||
|
||||
if (session.mode === "race" && sessionType === "team_race") {
|
||||
const driver = state.drivers.find((d) => d.transponder === transponder) || null;
|
||||
const car = state.cars.find((c) => c.transponder === transponder) || null;
|
||||
const team = event ? findEventTeamForPassing(event, driver?.id || null, car?.id || null) : null;
|
||||
if (!team) {
|
||||
return {
|
||||
key: `ignore_team_${transponder}`,
|
||||
ignore: true,
|
||||
};
|
||||
}
|
||||
|
||||
const memberBits = [driver?.name || "", car?.name || ""].filter(Boolean);
|
||||
return {
|
||||
key: `team_${team.id}`,
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
displayName: team.name,
|
||||
subLabel: memberBits.join(" • ") || transponder,
|
||||
driverId: driver?.id || null,
|
||||
driverName: driver?.name || team.name,
|
||||
carId: car?.id || null,
|
||||
carName: car?.name || t("common.unknown_car"),
|
||||
};
|
||||
}
|
||||
|
||||
const driver = state.drivers.find((d) => d.transponder === transponder);
|
||||
if (driver) {
|
||||
if (!isOpenMonitoringSession && Array.isArray(session.driverIds) && session.driverIds.length && !session.driverIds.includes(driver.id)) {
|
||||
@@ -5169,6 +5381,8 @@ function resolveCompetitor(session, transponder) {
|
||||
key: `driver_${driver.id}`,
|
||||
driverId: driver.id,
|
||||
driverName: driver.name,
|
||||
displayName: driver.name,
|
||||
subLabel: driver.transponder || "",
|
||||
carId: null,
|
||||
carName: t("common.driver_car"),
|
||||
};
|
||||
@@ -5178,6 +5392,8 @@ function resolveCompetitor(session, transponder) {
|
||||
key: `driver_tp_${transponder}`,
|
||||
driverId: null,
|
||||
driverName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"),
|
||||
displayName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"),
|
||||
subLabel: transponder,
|
||||
carId: null,
|
||||
carName: t("common.unknown_car"),
|
||||
};
|
||||
@@ -5516,6 +5732,18 @@ function getEventDrivers(event) {
|
||||
return classDrivers.filter((driver) => (event.raceConfig.driverIds || []).includes(driver.id));
|
||||
}
|
||||
|
||||
function getEventTeams(event) {
|
||||
return Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [];
|
||||
}
|
||||
|
||||
function findEventTeamForPassing(event, driverId, carId) {
|
||||
return getEventTeams(event).find((team) => {
|
||||
const driverMatch = driverId && Array.isArray(team.driverIds) && team.driverIds.includes(driverId);
|
||||
const carMatch = carId && Array.isArray(team.carIds) && team.carIds.includes(carId);
|
||||
return Boolean(driverMatch || carMatch);
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getSessionEntrants(session) {
|
||||
const event = state.events.find((item) => item.id === session.eventId);
|
||||
const eventDrivers = event ? getEventDrivers(event) : state.drivers;
|
||||
@@ -5656,6 +5884,51 @@ function renderRaceStandingsTable(rows, emptyLabel) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildTeamRaceStandings(event) {
|
||||
return getSessionsForEvent(event.id)
|
||||
.filter((session) => session.type === "team_race")
|
||||
.sort((left, right) => getSessionSortWeight(left) - getSessionSortWeight(right) || String(left.name).localeCompare(String(right.name)))
|
||||
.map((session) => ({
|
||||
session,
|
||||
rows: buildLeaderboard(session),
|
||||
}));
|
||||
}
|
||||
|
||||
function renderTeamRaceStandings(event) {
|
||||
const groups = buildTeamRaceStandings(event);
|
||||
if (!groups.length) {
|
||||
return `<p>${t("events.no_team_results")}</p>`;
|
||||
}
|
||||
|
||||
return groups
|
||||
.map(
|
||||
({ session, rows }) => `
|
||||
<section class="team-standings-block">
|
||||
<h4>${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}</h4>
|
||||
${
|
||||
rows.length
|
||||
? renderTable(
|
||||
[t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")],
|
||||
rows.map(
|
||||
(row, index) => `
|
||||
<tr>
|
||||
<td>${index + 1}</td>
|
||||
<td>${escapeHtml(row.displayName || row.driverName)}</td>
|
||||
<td>${row.laps}</td>
|
||||
<td>${escapeHtml(row.resultDisplay)}</td>
|
||||
<td>${formatLap(row.bestLapMs)}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
)
|
||||
: `<p>${t("events.no_team_results")}</p>`
|
||||
}
|
||||
</section>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getSessionSortWeight(session) {
|
||||
const order = {
|
||||
open_practice: 0,
|
||||
@@ -5664,6 +5937,7 @@ function getSessionSortWeight(session) {
|
||||
qualification: 3,
|
||||
heat: 4,
|
||||
final: 5,
|
||||
team_race: 6,
|
||||
};
|
||||
return order[String(session?.type || "").toLowerCase()] || 99;
|
||||
}
|
||||
@@ -5693,6 +5967,24 @@ function getSessionGridEntries(session) {
|
||||
});
|
||||
}
|
||||
|
||||
if (session.type === "team_race") {
|
||||
const event = state.events.find((item) => item.id === session.eventId);
|
||||
return getEventTeams(event).map((team, index) => ({
|
||||
slot: index + 1,
|
||||
name: team.name,
|
||||
meta:
|
||||
team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") ||
|
||||
team.carIds
|
||||
.map((carId) => {
|
||||
const car = state.cars.find((item) => item.id === carId);
|
||||
return car ? `${car.name} (${car.transponder || "-"})` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ") ||
|
||||
"-",
|
||||
}));
|
||||
}
|
||||
|
||||
return getSessionGridOrder(session).map((driverId, index) => {
|
||||
const driver = state.drivers.find((item) => item.id === driverId);
|
||||
return {
|
||||
@@ -6266,6 +6558,10 @@ function buildRaceResultsHtml(event) {
|
||||
<h2>${t("events.final_standings")}</h2>
|
||||
${renderRaceStandingsTable(buildFinalStandings(event), t("events.no_final_results"))}
|
||||
</section>
|
||||
<section class="print-block">
|
||||
<h2>${t("events.team_standings")}</h2>
|
||||
${renderTeamRaceStandings(event)}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -6437,6 +6733,19 @@ async function exportRaceResultsPdf(event) {
|
||||
[t("table.pos"), t("table.driver"), t("table.score")],
|
||||
buildFinalStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"])
|
||||
),
|
||||
...buildTeamRaceStandings(event).map(({ session, rows }) =>
|
||||
buildPdfSection(
|
||||
`${t("events.team_standings")} • ${session.name}`,
|
||||
[t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")],
|
||||
rows.map((row, index) => [
|
||||
String(index + 1),
|
||||
row.displayName || row.driverName || "-",
|
||||
String(row.laps || 0),
|
||||
row.resultDisplay || "-",
|
||||
formatLap(row.bestLapMs),
|
||||
])
|
||||
)
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -584,6 +584,31 @@ select:focus {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.team-card,
|
||||
.team-standings-block {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.team-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.team-standings-block {
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.team-standings-block h4 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.grid-editor-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -814,6 +839,15 @@ select:focus {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.overlay-shell-tv .overlay-header {
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.overlay-shell-tv .overlay-header:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overlay-speaker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||
|
||||
Reference in New Issue
Block a user