fix lag tävling och TV overlay

This commit is contained in:
larssand
2026-03-15 14:10:56 +01:00
parent 7e13ecd4ac
commit 3dd2b8cbfd
2 changed files with 365 additions and 22 deletions

View File

@@ -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),
])
)
),
],
});
}

View File

@@ -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);