fix tränings länken och race day

This commit is contained in:
larssand
2026-08-28 21:55:28 +02:00
parent 375bfb2416
commit 28e5c96010
7 changed files with 531 additions and 6 deletions

View File

@@ -173,12 +173,23 @@ function buildPublicTrainingGoal(goal, currentLaps) {
function buildPublicTrainingStats(laps) {
const lapTimes = laps.map((lap) => lap.lapMs);
const recent = lapTimes.slice(-30);
const previous = lapTimes.slice(Math.max(0, lapTimes.length - 60), Math.max(0, lapTimes.length - 30));
const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0;
const sortedRecent = [...recent].sort((left, right) => left - right);
const percentile = (ratio) => sortedRecent.length
? sortedRecent[Math.min(sortedRecent.length - 1, Math.max(0, Math.round((sortedRecent.length - 1) * ratio)))]
: 0;
const percentile = (values, ratio) => {
const sorted = [...values].sort((left, right) => left - right);
return sorted.length
? sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * ratio)))]
: 0;
};
const spread = (values) => {
const mean = average(values);
return mean ? ((percentile(values, 0.9) - percentile(values, 0.1)) / mean) * 100 : 0;
};
const recentAverageMs = average(recent);
const previousRecentAverageMs = average(previous);
const consistencySpreadPct = spread(recent);
const previousConsistencySpreadPct = spread(previous);
const trendSampleReady = recent.length >= 10 && previous.length >= 10;
const daily = new Map();
laps.forEach((lap) => {
const date = new Date(lap.timestamp);
@@ -211,21 +222,46 @@ function buildPublicTrainingStats(laps) {
const bestThreeConsecutive = getPublicBestConsecutiveLaps(stints, 3);
const bestFiveMinuteResult = getPublicBestDurationResult(stints, 5 * 60 * 1000);
return {
const stats = {
totalLaps: laps.length,
totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size,
trainingDays: daily.size,
bestLapMs,
averageLapMs: average(lapTimes),
recentAverageMs,
previousRecentAverageMs,
averageTrendMs: trendSampleReady ? previousRecentAverageMs - recentAverageMs : 0,
recentLapCount: recent.length,
consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0,
consistencySpreadPct,
previousConsistencySpreadPct,
consistencyTrendPct: trendSampleReady ? previousConsistencySpreadPct - consistencySpreadPct : 0,
trendSampleReady,
controlledLapStreak,
dailySummaries,
stintCount: stints.length,
bestThreeConsecutive,
bestFiveMinuteResult,
};
return { ...stats, achievements: buildPublicTrainingAchievements(stats) };
}
function buildPublicTrainingAchievements(stats) {
const definitions = [
["first_session", Number(stats.totalSessions || 0), 1],
["laps_100", Number(stats.totalLaps || 0), 100],
["laps_500", Number(stats.totalLaps || 0), 500],
["laps_1000", Number(stats.totalLaps || 0), 1000],
["laps_1500", Number(stats.totalLaps || 0), 1500],
["days_3", Number(stats.trainingDays || 0), 3],
["days_10", Number(stats.trainingDays || 0), 10],
["controlled_5", Number(stats.controlledLapStreak || 0), 5],
["five_minute", stats.bestFiveMinuteResult ? 1 : 0, 1],
];
return definitions.map(([id, current, target]) => ({
id,
percent: Math.min(100, Math.max(0, (current / target) * 100)),
unlocked: current >= target,
}));
}
function splitPublicTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) {

View File

@@ -224,6 +224,89 @@ button {
margin-top: 14px;
}
.profile-growth-grid {
display: grid;
grid-template-columns: minmax(240px, 0.7fr) minmax(0, 1.3fr);
gap: 12px;
margin-top: 14px;
}
.profile-growth-card {
padding: 17px;
}
.profile-growth-card > strong {
display: block;
margin-top: 8px;
font-family: Orbitron, sans-serif;
}
.profile-growth-card > p {
margin: 6px 0 0;
color: var(--muted);
}
.profile-growth-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.profile-achievements {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.profile-achievement {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
padding: 9px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.025);
}
.profile-achievement.is-next {
opacity: 0.64;
}
.profile-achievement > span {
display: grid;
flex: 0 0 auto;
place-items: center;
width: 32px;
height: 32px;
border-radius: 9px;
background: rgba(241, 42, 36, 0.16);
color: #ff8f89;
font-family: Orbitron, sans-serif;
font-size: 0.65rem;
font-weight: 700;
}
.profile-achievement.is-unlocked > span {
background: rgba(67, 213, 139, 0.16);
color: var(--green);
}
.profile-achievement strong,
.profile-achievement small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.profile-achievement small {
margin-top: 2px;
color: var(--muted);
font-size: 0.7rem;
}
.profile-card-head {
display: flex;
align-items: center;
@@ -546,6 +629,10 @@ button {
grid-template-columns: 1fr;
}
.profile-growth-grid {
grid-template-columns: 1fr;
}
.profile-performance-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -577,4 +664,8 @@ button {
align-items: flex-start;
flex-direction: column;
}
.profile-achievements {
grid-template-columns: 1fr;
}
}

View File

@@ -66,6 +66,24 @@ const messages = {
bestSplit: "Bästa mellantid",
pitStops: "Depåstopp",
totalPit: "Total depåtid",
recentProgress: "Din senaste utveckling",
averagePace: "Snittempo",
collectingTrend: "Samlar jämförbart underlag",
fasterTrend: "{lap} snabbare per varv",
slowerTrend: "{lap} långsammare just nu",
stableTrend: "Stabil nivå",
achievements: "Achievements",
unlocked: "Upplåst",
nextAchievement: "Nästa delmål",
achievement_first_session: "Första träningspasset",
achievement_laps_100: "100 träningsvarv",
achievement_laps_500: "500 träningsvarv",
achievement_laps_1000: "1 000 träningsvarv",
achievement_laps_1500: "1 500 träningsvarv",
achievement_days_3: "3 träningsdagar",
achievement_days_10: "10 träningsdagar",
achievement_controlled_5: "5 kontrollerade varv i rad",
achievement_five_minute: "Fullföljd femminutersstint",
},
en: {
loading: "Loading training profile...",
@@ -124,6 +142,24 @@ const messages = {
bestSplit: "Best split",
pitStops: "Pit stops",
totalPit: "Total pit time",
recentProgress: "Your recent development",
averagePace: "Average pace",
collectingTrend: "Collecting comparable data",
fasterTrend: "{lap} faster per lap",
slowerTrend: "{lap} slower right now",
stableTrend: "Stable level",
achievements: "Achievements",
unlocked: "Unlocked",
nextAchievement: "Next milestone",
achievement_first_session: "First practice session",
achievement_laps_100: "100 practice laps",
achievement_laps_500: "500 practice laps",
achievement_laps_1000: "1,000 practice laps",
achievement_laps_1500: "1,500 practice laps",
achievement_days_3: "3 practice days",
achievement_days_10: "10 practice days",
achievement_controlled_5: "5 controlled laps in a row",
achievement_five_minute: "Completed five-minute stint",
},
};
@@ -193,6 +229,23 @@ function renderProfile(data) {
const timerConfig = getTimerConfig(timerState);
const timerDurationMs = getTimerDurationMs(timerConfig);
const timerLocked = Boolean(timerState?.startedAt && getTimerElapsed(timerState) < timerDurationMs);
const achievements = Array.isArray(stats.achievements) ? stats.achievements : [];
const unlockedAchievements = achievements.filter((achievement) => achievement.unlocked);
const nextAchievement = achievements.find((achievement) => !achievement.unlocked);
const visibleAchievements = [...unlockedAchievements.slice(-5), ...(nextAchievement ? [nextAchievement] : [])];
const achievementMarkup = visibleAchievements.map((achievement) => `
<article class="profile-achievement ${achievement.unlocked ? "is-unlocked" : "is-next"}">
<span>${achievement.unlocked ? "OK" : `${Math.round(Number(achievement.percent || 0))}%`}</span>
<div><strong>${escapeHtml(t(`achievement_${achievement.id}`))}</strong><small>${escapeHtml(t(achievement.unlocked ? "unlocked" : "nextAchievement"))}</small></div>
</article>
`).join("");
const paceTrend = !stats.trendSampleReady
? t("collectingTrend")
: stats.averageTrendMs > 0
? t("fasterTrend", { lap: formatLap(stats.averageTrendMs) })
: stats.averageTrendMs < 0
? t("slowerTrend", { lap: formatLap(Math.abs(stats.averageTrendMs)) })
: t("stableTrend");
const logo = /^data:image\/(?:png|jpeg|webp);base64,/i.test(branding.logoDataUrl || "")
? `<img src="${escapeHtml(branding.logoDataUrl)}" alt="" />`
: `<div class="profile-brand-mark">RC</div>`;
@@ -250,6 +303,18 @@ function renderProfile(data) {
${renderStat(t("consistency"), stats.recentLapCount ? `${Number(stats.consistencySpreadPct).toFixed(1)}%` : "-")}
</section>
<section class="profile-growth-grid">
<article class="profile-card profile-growth-card">
<span class="profile-mini-label">${escapeHtml(t("recentProgress"))}</span>
<strong>${escapeHtml(t("averagePace"))}</strong>
<p>${escapeHtml(paceTrend)}</p>
</article>
<article class="profile-card profile-growth-card">
<div class="profile-growth-head"><span class="profile-mini-label">${escapeHtml(t("achievements"))}</span><strong>${unlockedAchievements.length}/${achievements.length}</strong></div>
<div class="profile-achievements">${achievementMarkup}</div>
</article>
</section>
<div class="profile-grid">
<section class="profile-card">
<div class="profile-card-head"><h2>${escapeHtml(t("nextLevel"))}</h2></div>

133
src/race_day_logic.js Normal file
View File

@@ -0,0 +1,133 @@
function unique(values) {
return [...new Set(values.map((value) => String(value || "").trim()).filter(Boolean))];
}
function duplicateValues(values) {
const counts = new Map();
values.filter(Boolean).forEach((value) => counts.set(value, (counts.get(value) || 0) + 1));
return [...counts.entries()].filter(([, count]) => count > 1).map(([value]) => value);
}
function getEvent(state, session) {
return state.events.find((event) => event.id === session?.eventId) || null;
}
function getRaceDriverIds(state, session, event) {
if (Array.isArray(session?.driverIds) && session.driverIds.length) {
return unique(session.driverIds);
}
if (event?.raceConfig?.participantsConfigured) {
return unique(event.raceConfig.driverIds || []);
}
return state.drivers
.filter((driver) => !event?.classId || driver.classId === event.classId)
.map((driver) => driver.id);
}
function check(id, level, data = {}) {
return { id, level, ...data };
}
export function buildRaceDayQueue(state) {
const sessions = Array.isArray(state?.sessions) ? state.sessions : [];
let current = sessions.find((session) => session.id === state?.activeSessionId) || null;
if (!current) {
current = sessions.find((session) => ["running", "paused"].includes(session.status))
|| sessions.find((session) => session.status === "ready")
|| null;
}
if (!current) {
return { current: null, next: null, afterNext: null, upcoming: [] };
}
const eventSessions = sessions.filter((session) => session.eventId === current.eventId);
const currentIndex = eventSessions.findIndex((session) => session.id === current.id);
const upcoming = eventSessions
.slice(Math.max(0, currentIndex + 1))
.filter((session) => !["finished", "stopped"].includes(session.status));
return { current, next: upcoming[0] || null, afterNext: upcoming[1] || null, upcoming };
}
export function buildRaceDayPreflight(state, session, { backendAvailable = false } = {}) {
if (!session) {
return { ready: false, checks: [check("session", "error")] };
}
const event = getEvent(state, session);
const checks = [
check("session", "ok", { sessionId: session.id }),
check("backend", backendAvailable ? "ok" : "warning"),
check("decoder", state?.decoder?.connected ? "ok" : "error"),
];
if (state?.settings?.splitDecoderEnabled) {
checks.push(check("split_decoder", state?.decoder?.splitConnected ? "ok" : "warning"));
}
if (state?.settings?.pitDecoderEnabled) {
const pitConnected = state?.decoder?.pitEntryConnected && state?.decoder?.pitExitConnected;
checks.push(check("pit_decoders", pitConnected ? "ok" : "warning"));
}
const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0);
const maxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 0) || 0);
checks.push(check("lap_window", event?.mode !== "race" || (maxLapMs > minLapMs && maxLapMs > 0) ? "ok" : "error", { minLapMs, maxLapMs }));
let entrantIds = [];
let transponders = [];
if (session.type === "open_practice") {
checks.push(check("participants", "ok", { openPractice: true, count: 0 }));
checks.push(check("transponders", "ok", { openPractice: true, count: 0 }));
} else if (session.mode === "track") {
const assignments = Array.isArray(session.assignments) ? session.assignments : [];
checks.push(check("participants", assignments.length ? "ok" : "error", { count: assignments.length }));
const cars = assignments.map((assignment) => state.cars.find((car) => car.id === assignment.carId)).filter(Boolean);
const missingCount = assignments.length - cars.filter((car) => String(car.transponder || "").trim()).length;
transponders = cars.map((car) => String(car.transponder || "").trim()).filter(Boolean);
checks.push(check("transponders", missingCount ? "error" : "ok", { count: transponders.length, missingCount }));
} else if (session.type === "team_race") {
const teams = Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams : [];
checks.push(check("participants", teams.length ? "ok" : "error", { count: teams.length, teamRace: true }));
const teamTransponders = teams.map((team) => {
const driverTransponders = (team.driverIds || [])
.map((id) => state.drivers.find((driver) => driver.id === id)?.transponder)
.map((value) => String(value || "").trim())
.filter(Boolean);
const carTransponders = (team.carIds || [])
.map((id) => state.cars.find((car) => car.id === id)?.transponder)
.map((value) => String(value || "").trim())
.filter(Boolean);
return unique([...driverTransponders, ...carTransponders]);
});
const missingCount = teamTransponders.filter((values) => !values.length).length;
transponders = teamTransponders.flat();
checks.push(check("transponders", missingCount ? "error" : "ok", { count: transponders.length, missingCount, teamRace: true }));
} else {
entrantIds = getRaceDriverIds(state, session, event);
checks.push(check("participants", entrantIds.length ? "ok" : "error", { count: entrantIds.length }));
const drivers = entrantIds.map((id) => state.drivers.find((driver) => driver.id === id)).filter(Boolean);
const missingCount = entrantIds.length - drivers.filter((driver) => String(driver.transponder || "").trim()).length;
transponders = drivers.map((driver) => String(driver.transponder || "").trim()).filter(Boolean);
checks.push(check("transponders", missingCount ? "error" : "ok", { count: transponders.length, missingCount }));
}
const duplicates = duplicateValues(transponders);
checks.push(check("duplicate_transponders", duplicates.length ? "error" : "ok", { duplicates }));
if (String(session.startMode || "").toLowerCase() === "position" && session.type !== "team_race") {
const gridIds = unique(session.manualGridIds || session.driverIds || []);
const expectedIds = session.mode === "track"
? unique((session.assignments || []).map((assignment) => assignment.driverId))
: entrantIds;
const gridComplete = expectedIds.length > 0
&& gridIds.length === expectedIds.length
&& expectedIds.every((id) => gridIds.includes(id));
checks.push(check("grid", gridComplete ? "ok" : "error", { count: gridIds.length, expectedCount: expectedIds.length }));
} else {
checks.push(check("grid", "ok", { notRequired: true }));
}
const raceAudioEnabled = state?.settings?.raceAudio?.enabled !== false;
checks.push(check("audio", raceAudioEnabled ? "ok" : "warning"));
return { ready: !checks.some((item) => item.level === "error"), checks };
}

139
src/race_day_view.js Normal file
View File

@@ -0,0 +1,139 @@
function renderPreflightDetail(item, { t, formatLap, escapeHtml }) {
if (item.id === "lap_window") return `${formatLap(item.minLapMs || 0)} / ${formatLap(item.maxLapMs || 0)}`;
if (item.id === "participants") return item.openPractice ? t("race_day.open_practice_any") : t("race_day.count", { count: item.count || 0 });
if (item.id === "transponders") {
return item.openPractice ? t("race_day.open_practice_any") : item.missingCount
? t("race_day.missing_count", { count: item.missingCount })
: t("race_day.count", { count: item.count || 0 });
}
if (item.id === "duplicate_transponders" && item.duplicates?.length) return escapeHtml(item.duplicates.join(", "));
if (item.id === "grid") return item.notRequired ? t("race_day.not_required") : `${item.count || 0}/${item.expectedCount || 0}`;
return t(`race_day.check_${item.level}`);
}
function renderQueueSession(session, positionKey, deps) {
const { t, escapeHtml, getEventName, getSessionTypeLabel, getStatusLabel } = deps;
if (!session) return `<article class="race-day-queue-card is-empty"><span>${t(positionKey)}</span><strong>${t("race_day.none")}</strong></article>`;
return `
<article class="race-day-queue-card">
<span>${t(positionKey)}</span>
<strong>${escapeHtml(session.name)}</strong>
<small>${escapeHtml(getEventName(session.eventId))} · ${escapeHtml(getSessionTypeLabel(session.type))}</small>
<span class="pill ${session.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(session.status))}</span>
<button class="btn btn-mini" type="button" data-race-day-select="${escapeHtml(session.id)}">${t("race_day.select")}</button>
</article>`;
}
export function renderRaceDayView(deps) {
const {
state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock,
getEventName, getSessionTypeLabel, getStatusLabel, getStartModeLabel,
getSessionTiming, getSessionGridEntries, buildRaceDayQueue, buildRaceDayPreflight,
backendAvailable, selectSession, startSession, finishSession, pauseSession, resumeSession,
navigateTo, getStartLightState,
} = deps;
const queue = buildRaceDayQueue(state);
const session = queue.current;
const preflight = buildRaceDayPreflight(state, session, { backendAvailable });
const timing = session ? getSessionTiming(session) : null;
const clock = !timing ? "-" : timing.followUpActive
? formatCountdown(timing.followUpRemainingMs)
: timing.untimed ? formatElapsedClock(timing.elapsedMs) : formatCountdown(timing.remainingMs);
const grid = session ? getSessionGridEntries(session) : [];
const isSelected = Boolean(session && state.activeSessionId === session.id);
const canStart = Boolean(session && preflight.ready && !["running", "paused"].includes(session.status));
dom.view.innerHTML = `
<section class="race-day-hero ${preflight.ready ? "is-ready" : "has-blockers"}">
<div>
<span class="race-day-kicker">${t("race_day.current")}</span>
<h2>${session ? escapeHtml(session.name) : t("race_day.no_session")}</h2>
<p>${session ? `${escapeHtml(getEventName(session.eventId))} · ${escapeHtml(getSessionTypeLabel(session.type))} · ${escapeHtml(getStartModeLabel(session.startMode))}` : t("race_day.no_session_hint")}</p>
</div>
<div class="race-day-clock"><strong data-race-day-clock>${clock}</strong><span>${session ? escapeHtml(getStatusLabel(session.status)) : t("race_day.none")}</span></div>
</section>
<div class="race-day-layout">
<div class="race-day-main">
<section class="panel race-day-control-panel">
<div class="panel-header">
<div><h3>${t("race_day.control")}</h3><p class="hint">${t("race_day.control_hint")}</p></div>
<span class="pill ${preflight.ready ? "pill-green" : "pill-red"}">${t(preflight.ready ? "race_day.ready" : "race_day.not_ready")}</span>
</div>
<div class="panel-body">
${session && !isSelected ? `<button id="raceDayActivate" class="btn" type="button">${t("race_day.activate")}</button>` : ""}
<div class="race-day-actions">
<button id="raceDayStart" class="btn btn-primary" type="button" ${canStart ? "" : "disabled"}>${t("timing.start")}</button>
<button id="raceDayPause" class="btn" type="button" ${session?.status === "running" ? "" : "disabled"}>${t("audio.race_pause")}</button>
<button id="raceDayResume" class="btn" type="button" ${session?.status === "paused" ? "" : "disabled"}>${t("audio.race_resume")}</button>
<button id="raceDayFinish" class="btn btn-danger" type="button" ${["running", "paused"].includes(session?.status) ? "" : "disabled"}>${t("race_day.finish")}</button>
</div>
<div id="raceDayStartLights" class="race-start-lights" data-state="${escapeHtml(getStartLightState())}">
${[1, 2, 3, 4, 5].map((light) => `<span data-start-light="${light}"></span>`).join("")}
<strong>${escapeHtml(getStartLightState())}</strong>
</div>
<div class="race-day-shortcuts">
<button id="raceDayTiming" class="btn btn-mini" type="button">${t("nav.timing")}</button>
<button id="raceDayOverlay" class="btn btn-mini" type="button">${t("nav.overlay")}</button>
<button id="raceDayJudging" class="btn btn-mini" type="button">${t("nav.judging")}</button>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header"><h3>${t("race_day.start_list")}</h3><span class="pill">${grid.length}</span></div>
<div class="panel-body race-day-grid-list">
${grid.length ? grid.map((entry) => `<article><span>${entry.slot}</span><strong>${escapeHtml(entry.name)}</strong><small>${escapeHtml(entry.meta || "-")}</small></article>`).join("") : `<p class="hint">${t("race_day.no_start_list")}</p>`}
</div>
</section>
</div>
<aside class="race-day-side">
<section class="panel">
<div class="panel-header"><h3>${t("race_day.preflight")}</h3></div>
<div class="panel-body race-day-checks">
${preflight.checks.map((item) => `<article class="race-day-check is-${item.level}"><span aria-hidden="true"></span><div><strong>${t(`race_day.check_${item.id}`)}</strong><small>${renderPreflightDetail(item, { t, formatLap, escapeHtml })}</small></div></article>`).join("")}
</div>
</section>
</aside>
</div>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("race_day.heat_queue")}</h3></div>
<div class="panel-body race-day-queue">
${renderQueueSession(session, "race_day.current", deps)}
${renderQueueSession(queue.next, "race_day.next", deps)}
${renderQueueSession(queue.afterNext, "race_day.after_next", deps)}
</div>
</section>`;
const choose = (sessionId) => sessionId && selectSession(sessionId);
document.getElementById("raceDayActivate")?.addEventListener("click", () => choose(session?.id));
document.querySelectorAll("[data-race-day-select]").forEach((node) => node.addEventListener("click", () => choose(node.dataset.raceDaySelect)));
document.getElementById("raceDayStart")?.addEventListener("click", async () => {
if (!session || !preflight.ready) return;
if (!isSelected) selectSession(session.id, { render: false });
const button = document.getElementById("raceDayStart");
if (button) button.disabled = true;
const updateStartDisplay = (label, lights = 0) => {
if (button && label) button.textContent = label;
const panel = document.getElementById("raceDayStartLights");
if (!panel) return;
panel.querySelector("strong")?.replaceChildren(document.createTextNode(label));
panel.querySelectorAll("[data-start-light]").forEach((node) => node.classList.toggle("is-on", Number(node.dataset.startLight) <= lights));
};
const result = await startSession(session, {
onCountdown: (seconds) => updateStartDisplay(String(seconds), 0),
onLight: (light) => updateStartDisplay(`LIGHT_${light}`, light),
onArmed: () => updateStartDisplay("WAIT_RANDOM", 5),
});
if (!result.ok && result.message) alert(result.message);
if (!result.ok && button) button.disabled = false;
});
document.getElementById("raceDayPause")?.addEventListener("click", () => pauseSession(session));
document.getElementById("raceDayResume")?.addEventListener("click", () => resumeSession(session));
document.getElementById("raceDayFinish")?.addEventListener("click", () => finishSession(session));
document.getElementById("raceDayTiming")?.addEventListener("click", () => navigateTo("timing"));
document.getElementById("raceDayOverlay")?.addEventListener("click", () => navigateTo("overlay"));
document.getElementById("raceDayJudging")?.addEventListener("click", () => navigateTo("judging"));
}

40
src/session_control.js Normal file
View File

@@ -0,0 +1,40 @@
export function beginSession(session, nowTs = Date.now()) {
session.status = "running";
session.startedAt = nowTs;
session.endedAt = null;
session.finishedByTimer = false;
session.followUpStartedAt = null;
session.pausedAt = null;
session.totalPausedMs = 0;
session.followUpPausedMs = 0;
session.audioAnnouncements = [];
return session;
}
export function finishSession(session, nowTs = Date.now()) {
session.status = "finished";
session.endedAt = nowTs;
session.finishedByTimer = false;
session.followUpStartedAt = null;
session.pausedAt = null;
return session;
}
export function pauseSession(session, nowTs = Date.now()) {
if (session?.status !== "running") return false;
session.status = "paused";
session.pausedAt = nowTs;
return true;
}
export function resumeSession(session, nowTs = Date.now()) {
if (session?.status !== "paused") return false;
const pausedDuration = Math.max(0, nowTs - Number(session.pausedAt || nowTs));
session.totalPausedMs = Math.max(0, Number(session.totalPausedMs || 0)) + pausedDuration;
if (session.followUpStartedAt) {
session.followUpPausedMs = Math.max(0, Number(session.followUpPausedMs || 0)) + pausedDuration;
}
session.pausedAt = null;
session.status = "running";
return true;
}

View File

@@ -100,6 +100,7 @@ test("public profile contains only the selected driver's sanitized aggregates",
assert.equal(profile.goals[0].percent, 80);
assert.equal(profile.classProfiles.length, 2);
assert.equal(profile.classProfiles[0].stats.totalLaps, 4);
assert.equal(profile.classProfiles[0].stats.achievements.find((item) => item.id === "first_session").unlocked, true);
assert.deepEqual(profile.classProfiles[0].channelStats, {
splitCount: 2,
bestSplitMs: 7000,
@@ -122,6 +123,26 @@ test("public profile contains only the selected driver's sanitized aggregates",
assert.equal(serialized.includes("secret-decoder"), false);
});
test("public profile exposes only class-scoped development trends and achievements", () => {
const now = Date.now();
const state = buildState(now);
state.training.ledger = Array.from({ length: 60 }, (_, index) => ({
driverId: "driver_1",
sessionId: "practice_2wd",
timestamp: now - (60 - index) * 18_000,
lapMs: index < 30 ? 18_000 : 17_000,
}));
const profile = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "");
const twoWheelDrive = profile.classProfiles.find((item) => item.classId === "class_1");
const fourWheelDrive = profile.classProfiles.find((item) => item.classId === "class_2");
assert.equal(twoWheelDrive.stats.trendSampleReady, true);
assert.equal(twoWheelDrive.stats.averageTrendMs, 1_000);
assert.equal(twoWheelDrive.stats.achievements.find((item) => item.id === "controlled_5").unlocked, true);
assert.equal(fourWheelDrive.stats.totalLaps, 0);
assert.equal(fourWheelDrive.stats.trendSampleReady, false);
});
test("public timer window freezes aggregates after the personal session", () => {
const now = Date.now();
const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "", now - 30 * 60000, 20 * 60000);