diff --git a/src/app.js b/src/app.js index 60831b9..ed6608e 100644 --- a/src/app.js +++ b/src/app.js @@ -221,6 +221,10 @@ const TRANSLATIONS = { "events.team_standings": "Lagställning", "events.no_team_results": "Inga teamresultat ännu.", "events.edit_team": "Redigera lag", + "events.team_stint_log": "Stint- och förarbyteslogg", + "events.team_report": "Lagrapport", + "events.print_team_results": "Skriv ut lagrapport", + "events.pdf_team_results": "PDF lagrapport", "events.add_session": "Lägg till session", "events.set_active": "Sätt aktiv", "events.assignments": "Tilldelningar", @@ -729,6 +733,10 @@ const TRANSLATIONS = { "events.team_standings": "Team standings", "events.no_team_results": "No team results yet.", "events.edit_team": "Edit team", + "events.team_stint_log": "Stint and driver-change log", + "events.team_report": "Team report", + "events.print_team_results": "Print team report", + "events.pdf_team_results": "PDF team report", "events.add_session": "Add Session", "events.set_active": "Set Active", "events.assignments": "Assignments", @@ -3161,7 +3169,13 @@ function renderEventManager(eventId) {

${t("events.team_standings")}

+
+ + +
+
${renderTeamRaceStandings(event)} +
@@ -3713,6 +3727,10 @@ function renderEventManager(eventId) { openPrintWindow(`${event.name} - ${t("events.results_overview")}`, buildRaceResultsHtml(event)); }); + document.getElementById("printTeamResults")?.addEventListener("click", () => { + openPrintWindow(`${event.name} - ${t("events.team_report")}`, buildTeamRaceResultsHtml(event)); + }); + document.getElementById("pdfStartlists")?.addEventListener("click", async () => { await exportRaceStartListsPdf(event); }); @@ -3721,6 +3739,10 @@ function renderEventManager(eventId) { await exportRaceResultsPdf(event); }); + document.getElementById("pdfTeamResults")?.addEventListener("click", async () => { + await exportTeamRaceResultsPdf(event); + }); + document.getElementById("gridResetOrder")?.addEventListener("click", () => { if (!selectedGridSession) { return; @@ -4727,7 +4749,7 @@ function renderLeaderboard(rows) { ${idx + 1}
${escapeHtml(row.displayName || row.driverName)}
- ${row.teamId && row.subLabel ? `
${t("overlay.active_member")}: ${escapeHtml(row.subLabel)}
` : ""} + ${row.teamId ? `
${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
` : ""} ${escapeHtml(row.subLabel || row.carName)} ${escapeHtml(row.transponder)} @@ -4762,7 +4784,7 @@ function renderOverlayLeaderboard(rows) {
${escapeHtml(row.displayName || row.driverName)} - ${escapeHtml(row.teamId && row.subLabel ? `${t("overlay.active_member")}: ${row.subLabel}` : row.subLabel || row.transponder || "-")} + ${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")}
@@ -4814,7 +4836,7 @@ function renderTeamOverlay(rows, result, sessionTiming) { ${index + 1} ${escapeHtml(row.displayName || row.driverName)}

${escapeHtml(row.resultDisplay || "-")}

- ${t("overlay.active_member")}: ${escapeHtml(row.subLabel || "-")} + ${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))} ` ) @@ -6077,6 +6099,14 @@ function renderRaceStandingsTable(rows, emptyLabel) { ); } +function formatTeamActiveMemberLabel(rowOrPassing) { + if (!rowOrPassing) { + return "-"; + } + const parts = [rowOrPassing.driverName || "", rowOrPassing.carName || ""].filter(Boolean); + return parts.join(" • ") || rowOrPassing.subLabel || "-"; +} + function buildTeamRaceStandings(event) { return getSessionsForEvent(event.id) .filter((session) => session.type === "team_race") @@ -6087,6 +6117,83 @@ function buildTeamRaceStandings(event) { })); } +function buildTeamStintLog(session, row) { + const passings = getCompetitorPassings(session, row); + if (!passings.length) { + return []; + } + + const stints = []; + let current = null; + + passings.forEach((passing) => { + const memberLabel = formatTeamActiveMemberLabel(passing); + const memberKey = `${passing.driverId || passing.driverName || "-"}|${passing.carId || passing.carName || "-"}`; + if (!current || current.memberKey !== memberKey) { + current = { + memberKey, + memberLabel, + driverName: passing.driverName || "-", + carName: passing.carName || "-", + startTs: passing.timestamp, + endTs: passing.timestamp, + laps: 1, + }; + stints.push(current); + return; + } + current.endTs = passing.timestamp; + current.laps += 1; + }); + + return stints.map((stint, index) => ({ + ...stint, + index: index + 1, + durationMs: Math.max(0, stint.endTs - stint.startTs), + })); +} + +function renderTeamStintLog(session, rows) { + if (!rows.length) { + return `

${t("events.no_team_results")}

`; + } + + return ` +
+ ${rows + .map((row) => { + const stints = buildTeamStintLog(session, row); + return ` +
+
${escapeHtml(row.displayName || row.driverName)}
+
${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
+ ${ + stints.length + ? renderTable( + [t("events.slot"), t("table.driver"), t("table.car"), t("table.time"), t("table.duration"), t("table.laps")], + stints.map( + (stint) => ` + + ${stint.index} + ${escapeHtml(stint.driverName || "-")} + ${escapeHtml(stint.carName || "-")} + ${new Date(stint.startTs).toLocaleTimeString()} + ${formatRaceClock(stint.durationMs)} + ${stint.laps} + + ` + ) + ) + : `

${t("timing.no_passings")}

` + } +
+ `; + }) + .join("")} +
+ `; +} + function renderTeamRaceStandings(event) { const groups = buildTeamRaceStandings(event); if (!groups.length) { @@ -6108,7 +6215,7 @@ function renderTeamRaceStandings(event) { ${index + 1}
${escapeHtml(row.displayName || row.driverName)}
- ${row.subLabel ? `
${t("overlay.active_member")}: ${escapeHtml(row.subLabel)}
` : ""} +
${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
${row.laps} ${escapeHtml(row.resultDisplay)} @@ -6119,6 +6226,10 @@ function renderTeamRaceStandings(event) { ) : `

${t("events.no_team_results")}

` } +
+
${t("events.team_stint_log")}
+ ${rows.length ? renderTeamStintLog(session, rows) : `

${t("events.no_team_results")}

`} +
` ) @@ -6761,6 +6872,52 @@ function buildRaceResultsHtml(event) { `; } +function buildTeamRaceResultsHtml(event) { + const branding = resolveEventBranding(event); + const groups = buildTeamRaceStandings(event); + return ` + + ${groups + .map( + ({ session, rows }) => ` + + ` + ) + .join("")} + `; +} + function openPrintWindow(title, bodyHtml) { const printWindow = window.open("", "_blank", "noopener,noreferrer,width=1200,height=900"); if (!printWindow) { @@ -6946,6 +7103,56 @@ async function exportRaceResultsPdf(event) { }); } +async function exportTeamRaceResultsPdf(event) { + const branding = resolveEventBranding(event); + const sections = []; + buildTeamRaceStandings(event).forEach(({ session, rows }) => { + sections.push( + buildPdfSection( + `${t("events.team_report")} • ${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), + ]) + ) + ); + + rows.forEach((row) => { + const stints = buildTeamStintLog(session, row); + sections.push( + buildPdfSection( + `${session.name} • ${row.displayName || row.driverName} • ${t("events.team_stint_log")}`, + [t("events.slot"), t("table.driver"), t("table.car"), t("table.time"), t("table.duration"), t("table.laps")], + stints.map((stint) => [ + String(stint.index), + stint.driverName || "-", + stint.carName || "-", + new Date(stint.startTs).toLocaleTimeString(), + formatRaceClock(stint.durationMs), + String(stint.laps || 0), + ]) + ) + ); + }); + }); + + await requestPdfExport({ + filename: `${event.name.replaceAll(/\s+/g, "_")}_team_report.pdf`, + title: event.name, + subtitle: `${t("events.team_report")} • ${getClassName(event.classId)} • ${event.date || "-"}`, + brandName: branding.brandName, + brandTagline: branding.brandTagline, + footer: branding.pdfFooter, + theme: branding.pdfTheme, + logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), + sections, + }); +} + async function exportSessionHeatSheetPdf(session) { const event = state.events.find((item) => item.id === session.eventId); const branding = resolveEventBranding(event); diff --git a/src/styles.css b/src/styles.css index a89a8b5..e573de6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -609,6 +609,22 @@ select:focus { margin: 0 0 10px; } +.team-log-grid { + display: grid; + gap: 12px; +} + +.team-log-card { + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + background: rgba(255, 255, 255, 0.02); +} + +.team-log-card h5 { + margin: 0 0 8px; +} + .table-primary { font-weight: 700; }