From 3dd2b8cbfd9aa05255a24c61a2298a216c66d9c3 Mon Sep 17 00:00:00 2001 From: larssand Date: Sun, 15 Mar 2026 14:10:56 +0100 Subject: [PATCH] =?UTF-8?q?fix=20lag=20t=C3=A4vling=20och=20=20TV=20overla?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.js | 353 ++++++++++++++++++++++++++++++++++++++++++++++--- src/styles.css | 34 +++++ 2 files changed, 365 insertions(+), 22 deletions(-) diff --git a/src/app.js b/src/app.js index 542e495..ca4a92f 100644 --- a/src/app.js +++ b/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) => ``) .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) => ``) .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 ` ${escapeHtml(s.name)} @@ -2877,6 +2929,80 @@ function renderEventManager(eventId) { +
+

${t("events.teams")}

+
+

${t("events.team_race_intro")}

+
+ + +
+

${t("events.team_hint")}

+
+
+

${t("events.team_drivers")}

+
+ ${raceDrivers + .map( + (driver) => ` + + ` + ) + .join("")} +
+
+
+

${t("events.team_cars")}

+
+ ${state.cars + .map( + (car) => ` + + ` + ) + .join("")} +
+
+
+
+ ${ + raceTeams.length + ? raceTeams + .map( + (team) => ` +
+
+ ${escapeHtml(team.name)} +
${t("events.team_drivers")}: ${escapeHtml( + team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") || "-" + )}
+
${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(", ") || "-" + )}
+
+ +
+ ` + ) + .join("") + : `

${t("events.no_teams")}

` + } +
+
+
+

${t("events.race_format")}

@@ -3012,6 +3138,13 @@ function renderEventManager(eventId) {
+
+

${t("events.team_standings")}

+
+ ${renderTeamRaceStandings(event)} +
+
+

${t("events.final_matrix")}

@@ -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() {
+
+

${t("guide.team_title")}

+
+
    +
  • ${t("guide.team_1")}
  • +
  • ${t("guide.team_2")}
  • +
  • ${t("guide.team_3")}
  • +
  • ${t("guide.team_4")}
  • +
  • ${t("guide.team_5")}
  • +
+
+
+

${t("guide.host_title")}

@@ -4055,7 +4224,7 @@ function renderOverlay() { const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null; dom.view.innerHTML = ` -
+
${ active ? ` @@ -4086,7 +4255,7 @@ function renderOverlay() {
P1
-

${escapeHtml(topRow?.driverName || t("common.unknown_driver"))}

+

${escapeHtml(topRow?.displayName || topRow?.driverName || t("common.unknown_driver"))}

${t("table.result")}: ${escapeHtml(topRow?.resultDisplay || "-")}

${t("table.best_lap")}: ${formatLap(topRow?.bestLapMs)}

@@ -4099,7 +4268,7 @@ function renderOverlay() { .map( (passing) => `
- ${escapeHtml(passing.driverName || t("common.unknown_driver"))} + ${escapeHtml(passing.displayName || passing.teamName || passing.driverName || t("common.unknown_driver"))} ${new Date(passing.timestamp).toLocaleTimeString()}
` @@ -4158,13 +4327,13 @@ function renderOverlay() { ${t("overlay.fastest_lap")} ${formatLap(fastestRow?.bestLapMs)}
-
${escapeHtml(fastestRow?.driverName || "-")}
+
${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}
${t("table.laps")} ${topRow?.laps || 0} - ${escapeHtml(topRow?.driverName || "-")} + ${escapeHtml(topRow?.displayName || topRow?.driverName || "-")}
${t("timing.total_passings")} @@ -4189,18 +4358,18 @@ function renderOverlay() { ${t("overlay.fastest_lap")} ${formatLap(fastestRow?.bestLapMs)} -
${escapeHtml(fastestRow?.driverName || "-")}
+
${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}
${t("overlay.fastest_lap")} ${formatLap(fastestRow?.bestLapMs)} - ${escapeHtml(fastestRow?.driverName || "-")} + ${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}
${t("table.laps")} ${topRow?.laps || 0} - ${escapeHtml(topRow?.driverName || "-")} + ${escapeHtml(topRow?.displayName || topRow?.driverName || "-")}
${t("timing.total_passings")} @@ -4251,7 +4420,7 @@ function buildOverlayPanels(active, recent) { .map( (passing) => `
- ${escapeHtml(passing.driverName || passing.transponder || t("common.unknown_driver"))} + ${escapeHtml(passing.displayName || passing.teamName || passing.driverName || passing.transponder || t("common.unknown_driver"))} ${new Date(passing.timestamp).toLocaleTimeString()}
` @@ -4365,7 +4534,7 @@ function renderLeaderboardModal(session, row) {
-

${escapeHtml(row.driverName)} • ${escapeHtml(row.carName)}

+

${escapeHtml(row.displayName || row.driverName)} • ${escapeHtml(row.subLabel || row.carName)}

${t("table.transponder")}: ${escapeHtml(row.transponder)}

${renderQuickAddActions(session, row.transponder, "leaderboardModal")}

${t("table.laps")}: ${row.laps}

@@ -4423,8 +4592,8 @@ function renderLeaderboard(rows) { return ` ${idx + 1} - ${escapeHtml(row.driverName)} - ${escapeHtml(row.carName)} + ${escapeHtml(row.displayName || row.driverName)} + ${escapeHtml(row.subLabel || row.carName)} ${escapeHtml(row.transponder)} ${row.laps} ${escapeHtml(row.resultDisplay)} @@ -4456,8 +4625,8 @@ function renderOverlayLeaderboard(rows) { ${idx + 1}
- ${escapeHtml(row.driverName)} - ${escapeHtml(row.transponder || "-")} + ${escapeHtml(row.displayName || row.driverName)} + ${escapeHtml(row.subLabel || row.transponder || "-")}
@@ -4509,8 +4678,8 @@ function renderRecentPassings(session) { ${new Date(p.timestamp).toLocaleTimeString()} ${escapeHtml(p.transponder)} - ${escapeHtml(p.driverName || t("common.unknown_driver"))} - ${escapeHtml(p.carName || "-")} + ${escapeHtml(p.teamName ? `${p.teamName} • ${p.driverName || t("common.unknown_driver")}` : p.driverName || t("common.unknown_driver"))} + ${escapeHtml(p.carName || p.subLabel || "-")} ${escapeHtml(p.loopId || "-")} ${p.strength ?? "-"} ${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)} @@ -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 `

${t("events.no_team_results")}

`; + } + + return groups + .map( + ({ session, rows }) => ` +
+

${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}

+ ${ + rows.length + ? renderTable( + [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")], + rows.map( + (row, index) => ` + + ${index + 1} + ${escapeHtml(row.displayName || row.driverName)} + ${row.laps} + ${escapeHtml(row.resultDisplay)} + ${formatLap(row.bestLapMs)} + + ` + ) + ) + : `

${t("events.no_team_results")}

` + } +
+ ` + ) + .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) {

${t("events.final_standings")}

${renderRaceStandingsTable(buildFinalStandings(event), t("events.no_final_results"))}
+ `; } @@ -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), + ]) + ) + ), ], }); } diff --git a/src/styles.css b/src/styles.css index 8404c9f..1f6e006 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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);