Files
Live_RC/src/misc_views.js
larssand fd6efe2c7e Add track layout tracking so lap-time stats stay comparable
Clubs that reconfigure their track (e.g. running indoors in the same
building) can now add named track layouts under Settings, each with
just a start date/time - layouts are contiguous, so a layout's end is
implicitly the next one's start, with no separate field to keep in
sync. Every session freezes which layout was active when it started,
so the "track record" audio cue and the Results Archive (which now
shows each session's layout) only compare laps run on the same
physical track. With no layouts configured, everything behaves
exactly as before - the feature is fully inert until a club uses it.

Design settled through conversation with the user: global scope (not
per class), start-date-only (no redundant stop date), and no changes
needed to driver/transponder mapping, since that's already fully
independent of track geometry in this app.

Deliberately out of scope for this pass: the mobile/driver training
profile's own cross-session personal-best trend stats still compare
across a driver's whole lap ledger regardless of layout - logged in
ROADMAP.md as a real remaining gap for a later pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 14:17:06 +02:00

294 lines
19 KiB
JavaScript

function renderGuidePanel(titleKey, itemKeys, extras = "", { t }) {
return `
<section class="panel">
<div class="panel-header"><h3>${t(titleKey)}</h3></div>
<div class="panel-body">
<ul class="guide-list">
${itemKeys.map((key) => `<li>${t(key)}</li>`).join("")}
</ul>
${extras}
</div>
</section>
`;
}
function renderGuideOverviewCard(titleKey, blurbKey, { t }) {
return `
<article class="guide-overview-card">
<strong>${t(titleKey)}</strong>
<p>${t(blurbKey)}</p>
</article>
`;
}
export function renderGuideView({ dom, t }) {
dom.view.innerHTML = `
<section class="panel">
<div class="panel-header"><h3>${t("guide.title")}</h3></div>
<div class="panel-body">
<p>${t("guide.intro")}</p>
<div class="guide-overview-grid mt-16">
${renderGuideOverviewCard("guide.sponsor_title", "guide.card_sponsor_blurb", { t })}
${renderGuideOverviewCard("guide.race_title", "guide.card_race_blurb", { t })}
${renderGuideOverviewCard("guide.race_day_title", "guide.card_race_day_blurb", { t })}
${renderGuideOverviewCard("guide.team_title", "guide.card_team_blurb", { t })}
${renderGuideOverviewCard("guide.training_title", "guide.card_training_blurb", { t })}
${renderGuideOverviewCard("guide.archive_title", "guide.card_archive_blurb", { t })}
${renderGuideOverviewCard("guide.windows_title", "guide.card_decoder_blurb", { t })}
</div>
</div>
</section>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6", "guide.race_day_7"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.sponsor_title", ["guide.sponsor_1", "guide.sponsor_2", "guide.sponsor_3", "guide.sponsor_4", "guide.sponsor_5", "guide.sponsor_6"], "", { t })}
${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6", "guide.race_wizard_7"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.race_title", ["guide.race_1", "guide.race_2", "guide.race_3", "guide.race_4", "guide.race_4a", "guide.race_5", "guide.race_6", "guide.race_7", "guide.race_8", "guide.race_9", "guide.race_10"], "", { t })}
${renderGuidePanel("guide.manage_steps_title", ["guide.manage_steps_1", "guide.manage_steps_2", "guide.manage_steps_3", "guide.manage_steps_4", "guide.manage_steps_5", "guide.manage_steps_6", "guide.manage_steps_7", "guide.manage_steps_8"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.race_format_title", ["guide.race_format_0", "guide.race_format_1", "guide.race_format_2", "guide.race_format_3", "guide.race_format_4", "guide.race_format_5", "guide.race_format_6", "guide.race_format_7", "guide.race_format_8", "guide.race_format_9", "guide.race_format_10", "guide.race_format_11", "guide.race_format_11a", "guide.race_format_12", "guide.race_format_13", "guide.race_format_14"], "", { t })}
${renderGuidePanel("guide.validation_title", ["guide.validation_1", "guide.validation_2", "guide.validation_3", "guide.validation_4", "guide.validation_5", "guide.validation_6", "guide.validation_7", "guide.validation_8"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.free_practice_title", ["guide.free_practice_1", "guide.free_practice_2", "guide.free_practice_3"], "", { t })}
${renderGuidePanel("guide.open_practice_title", ["guide.open_practice_1", "guide.open_practice_2", "guide.open_practice_3", "guide.open_practice_4", "guide.open_practice_5"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.team_title", ["guide.team_1", "guide.team_2", "guide.team_3", "guide.team_4", "guide.team_5", "guide.team_6"], "", { t })}
${renderGuidePanel("guide.qualifying_title", ["guide.qualifying_1", "guide.qualifying_2", "guide.qualifying_3", "guide.qualifying_4", "guide.qualifying_5"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3", "guide.dashboard_4"], "", { t })}
${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9", "guide.training_10", "guide.training_11", "guide.training_12", "guide.training_13", "guide.training_14", "guide.training_15", "guide.training_16", "guide.training_17", "guide.training_18", "guide.training_19", "guide.training_20", "guide.training_21", "guide.training_22", "guide.training_23", "guide.training_24", "guide.training_25", "guide.training_26", "guide.training_27", "guide.training_28", "guide.training_29", "guide.training_30", "guide.training_31"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.archive_title", ["guide.archive_1", "guide.archive_2", "guide.archive_3", "guide.archive_4", "guide.archive_5", "guide.archive_6"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.audio_title", ["guide.audio_1", "guide.audio_2", "guide.audio_3", "guide.audio_4", "guide.audio_5", "guide.audio_6", "guide.audio_7", "guide.audio_8"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.decoder_roles_title", ["guide.decoder_roles_1", "guide.decoder_roles_2", "guide.decoder_roles_3", "guide.decoder_roles_4", "guide.decoder_roles_5", "guide.decoder_roles_6", "guide.decoder_roles_7", "guide.decoder_roles_8", "guide.decoder_roles_9", "guide.decoder_roles_10", "guide.decoder_roles_11", "guide.decoder_roles_12", "guide.decoder_roles_13", "guide.decoder_roles_14"], "", { t })}
${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })}
</div>
<div class="guide-section-grid mt-16">
${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"], "", { t })}
${renderGuidePanel("guide.linux_title", ["guide.linux_1", "guide.linux_2", "guide.linux_3"], "", { t })}
</div>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.sqlite_title")}</h3></div>
<div class="panel-body">
<ul class="guide-list">
<li>${t("guide.sqlite_1")}</li>
<li>${t("guide.sqlite_2")}</li>
<li>${t("guide.sqlite_3")}</li>
<li>${t("guide.sqlite_4")}</li>
<li>${t("guide.sqlite_5")}</li>
<li>${t("guide.sqlite_6")}</li>
</ul>
<p><a href="https://www.ammconverter.eu/docs/intro/quick-start/" target="_blank" rel="noreferrer">${t("guide.ammc_ref")}</a></p>
</div>
</section>
`;
}
export function renderOverlayPageView(deps) {
const { state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl } = deps;
const active = getActiveSession();
const leaderboard = active ? buildLeaderboard(active).slice(0, 12) : [];
const result = active ? ensureSessionResult(active.id) : null;
const sessionTiming = active ? getSessionTiming(active) : null;
const overlayClock = sessionTiming?.followUpActive
? formatCountdown(sessionTiming?.followUpRemainingMs ?? 0)
: sessionTiming?.untimed
? formatElapsedClock(sessionTiming?.elapsedMs ?? 0)
: formatCountdown(sessionTiming?.remainingMs ?? 0);
const recent = active && result ? getVisiblePassings(result).slice(-8).reverse() : [];
const event = active ? state.events.find((item) => item.id === active.eventId) : null;
const branding = resolveEventBranding(event);
const practiceRows = event ? buildPracticeStandings(event) : [];
const qualifyingRows = event ? buildQualifyingStandings(event) : [];
const finalRows = event ? buildFinalStandings(event) : [];
const topRow = leaderboard[0] || null;
const fastestRow = [...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
const modeLabel = getOverlayModeLabel(overlayViewMode, { t });
const overlayStatusLabel = sessionTiming?.followUpActive ? t("timing.follow_up_active") : active ? getStatusLabel(active.status) : "";
const obsConfig = overlayViewMode === "obs" ? getObsOverlayConfig() : null;
const rotatingPanels = buildOverlayPanels(active, recent, { t, overlayEvents, normalizeStartMode, renderPositionGrid });
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
const denseOverlay = overlayViewMode === "leaderboard" || overlayViewMode === "tv";
dom.view.innerHTML = `
${overlayMode ? "" : `
<section class="panel">
<div class="panel-header"><h3>${t("overlay.title")}</h3></div>
<div class="panel-body">
<div class="actions">
<button id="overlayLaunchLeaderboard" class="btn" type="button">${t("timing.open_overlay")}</button>
<button id="overlayLaunchSpeaker" class="btn" type="button">${t("timing.open_speaker_overlay")}</button>
<button id="overlayLaunchResults" class="btn" type="button">${t("timing.open_results_overlay")}</button>
<button id="overlayLaunchTv" class="btn" type="button">${t("timing.open_tv_overlay")}</button>
<button id="overlayLaunchTeam" class="btn" type="button">${t("timing.open_team_overlay")}</button>
<button id="overlayLaunchObs" class="btn" type="button">${t("timing.open_obs_overlay")}</button>
<button id="overlayCopyObsUrl" class="btn" type="button">${t("overlay.obs_copy_url")}</button>
</div>
</div>
</section>
`}
<section class="overlay-shell ${overlayViewMode === "tv" ? "overlay-shell-tv" : ""} ${overlayViewMode === "obs" ? "overlay-shell-obs" : ""} ${overlayViewMode === "obs" && obsConfig ? `overlay-shell-obs-theme-${obsConfig.theme}` : ""} ${overlayViewMode === "obs" && obsConfig ? `overlay-shell-obs-layout-${obsConfig.layout}` : ""} ${denseOverlay ? "overlay-shell-dense" : ""} ${publicOverlayMode ? "overlay-shell-public" : ""}">
${active ? `
${overlayViewMode === "obs" ? "" : `<header class="overlay-header">
<div class="overlay-header-main">
${branding.logoDataUrl ? `<img class="overlay-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
<div class="overlay-header-copy">
<div class="overlay-kicker-row">
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<span class="pill">${escapeHtml(getSessionTypeLabel(active.type))}</span>
${overlayViewMode !== "tv" ? `<span class="pill">${escapeHtml(getStartModeLabel(active.startMode))}</span>` : ""}
<span class="pill">${escapeHtml(modeLabel)}</span>
</div>
<h1>${escapeHtml(active.name)}</h1>
<p class="overlay-header-sub">${escapeHtml(branding.brandName || "RC RaceCore")}</p>
</div>
</div>
<div class="overlay-meta">
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `<div class="overlay-clock" data-overlay-clock>${overlayClock}</div>`}
<div class="overlay-status">${escapeHtml(overlayStatusLabel)}</div>
</div>
</header>`}
${overlayViewMode === "speaker" ? `
<section class="overlay-speaker">
<div class="overlay-speaker-main">
<div class="overlay-speaker-label">P1</div>
<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>
<div class="overlay-speaker-side">
<section class="overlay-side-card">
<h3>${t("overlay.last_passings")}</h3>
${recent.length ? recent.map((passing) => `
<div class="overlay-passing">
<strong>${escapeHtml(passing.displayName || passing.teamName || passing.driverName || t("common.unknown_driver"))}</strong>
<span>${formatLap(passing.lapMs)}</span>
</div>
`).join("") : `<p>${t("timing.no_passings")}</p>`}
</section>
<section class="overlay-side-card">
<h3>${t("events.position_grid")}</h3>
${normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : `<p>${t("events.na")}</p>`}
</section>
<section class="overlay-side-card">
<h3>${t("overlay.event_markers")}</h3>
${overlayEvents.length ? overlayEvents.map((item) => `
<div class="overlay-passing">
<strong>${escapeHtml(item.label)}</strong>
<span>${new Date(item.ts).toLocaleTimeString()}</span>
</div>
`).join("") : `<p>${t("timing.no_passings")}</p>`}
</section>
</div>
</section>
` : overlayViewMode === "results" ? `
<section class="overlay-results">
<div class="overlay-side-card">
<h3>${t("events.practice_standings")}</h3>
${renderRaceStandingsTableView(practiceRows, t("events.no_practice_results"))}
</div>
<div class="overlay-side-card">
<h3>${t("events.qualifying_standings")}</h3>
${renderRaceStandingsTableView(qualifyingRows, t("events.no_qualifying_results"))}
</div>
<div class="overlay-side-card">
<h3>${t("events.final_standings")}</h3>
${renderRaceStandingsTableView(finalRows, t("events.no_final_results"))}
</div>
</section>
` : overlayViewMode === "team" ? renderTeamOverlay(leaderboard, result, sessionTiming, { t, escapeHtml, formatLap, formatPredictedLapDelta, formatTeamActiveMemberLabel, getVisiblePassings, renderOverlayLeaderboard }) : overlayViewMode === "obs" ? renderObsOverlay(active, leaderboard, result, sessionTiming, branding, { t, escapeHtml, formatLap, getVisiblePassings, getSessionTypeLabel, getStartModeLabel, getStatusLabel, getEventName, getObsOverlayConfig, normalizeStartMode, renderPositionGrid, getSessionGridEntries, formatTeamActiveMemberLabel, renderOverlayLeaderboard }) : overlayViewMode === "tv" ? `
<section class="overlay-board overlay-board-tv">
<div class="overlay-table-wrap overlay-display-wrap overlay-display-wrap-dense">
<section class="overlay-fastest-banner overlay-fastest-banner-dense">
<div class="overlay-fastest-banner-copy">
<span>${t("overlay.fastest_lap")}</span>
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
</div>
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}</div>
<div class="overlay-fastest-meta">${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${getVisiblePassings(result).length || 0}</div>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-tv overlay-leaderboard-card-dense">
${renderOverlayLeaderboard(leaderboard, { t, escapeHtml, formatTeamActiveMemberLabel, formatLap, formatPredictedLapDelta })}
</div>
</div>
</section>
` : `
<section class="overlay-board">
<div class="overlay-table-wrap overlay-display-wrap overlay-display-wrap-dense">
<section class="overlay-fastest-banner overlay-fastest-banner-dense">
<div class="overlay-fastest-banner-copy">
<span>${t("overlay.fastest_lap")}</span>
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
</div>
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}</div>
<div class="overlay-fastest-meta">${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${result?.passings.length || 0}</div>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-dense">
${renderOverlayLeaderboard(leaderboard, { t, escapeHtml, formatTeamActiveMemberLabel, formatLap, formatPredictedLapDelta })}
</div>
</div>
<aside class="overlay-side">
${activePanel ? renderOverlaySidePanel(activePanel, { t, escapeHtml }) : `<section class="overlay-side-card"><p>${t("timing.no_passings")}</p></section>`}
</aside>
</section>
`}
` : `
<section class="overlay-empty">
<h1>${t("overlay.title")}</h1>
<p>${t("overlay.no_active")}</p>
</section>
`}
</section>
`;
document.getElementById("overlayFullscreen")?.addEventListener("click", async () => {
const target = document.documentElement;
if (!document.fullscreenElement) {
await target.requestFullscreen?.().catch(() => {});
return;
}
await document.exitFullscreen?.().catch(() => {});
});
document.getElementById("overlayLaunchLeaderboard")?.addEventListener("click", () => openOverlayWindow("leaderboard"));
document.getElementById("overlayLaunchSpeaker")?.addEventListener("click", () => openOverlayWindow("speaker"));
document.getElementById("overlayLaunchResults")?.addEventListener("click", () => openOverlayWindow("results"));
document.getElementById("overlayLaunchTv")?.addEventListener("click", () => openOverlayWindow("tv"));
document.getElementById("overlayLaunchTeam")?.addEventListener("click", () => openOverlayWindow("team"));
document.getElementById("overlayLaunchObs")?.addEventListener("click", () => openOverlayWindow("obs", { public: true }));
document.getElementById("overlayCopyObsUrl")?.addEventListener("click", async () => {
const url = buildOverlayUrl("obs", { public: true });
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url).catch(() => {});
return;
}
window.prompt("Copy OBS URL", url);
});
}