From 28e5c960107cda9f8bff79d29312c226b3dd0747 Mon Sep 17 00:00:00 2001 From: larssand Date: Fri, 28 Aug 2026 21:55:28 +0200 Subject: [PATCH] =?UTF-8?q?fix=20tr=C3=A4nings=20l=C3=A4nken=20och=20race?= =?UTF-8?q?=20day?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/public_training_profile.js | 48 +++++++-- src/public_training.css | 91 +++++++++++++++++ src/public_training.js | 65 ++++++++++++ src/race_day_logic.js | 133 ++++++++++++++++++++++++ src/race_day_view.js | 139 ++++++++++++++++++++++++++ src/session_control.js | 40 ++++++++ tests/public_training_profile.test.js | 21 ++++ 7 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 src/race_day_logic.js create mode 100644 src/race_day_view.js create mode 100644 src/session_control.js diff --git a/server/public_training_profile.js b/server/public_training_profile.js index 065ad11..67cd295 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -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) { diff --git a/src/public_training.css b/src/public_training.css index 7d4584a..f4e9ef0 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -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; + } } diff --git a/src/public_training.js b/src/public_training.js index d6276ec..3cf0026 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -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) => ` +
+ ${achievement.unlocked ? "OK" : `${Math.round(Number(achievement.percent || 0))}%`} +
${escapeHtml(t(`achievement_${achievement.id}`))}${escapeHtml(t(achievement.unlocked ? "unlocked" : "nextAchievement"))}
+
+ `).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 || "") ? `` : `
RC
`; @@ -250,6 +303,18 @@ function renderProfile(data) { ${renderStat(t("consistency"), stats.recentLapCount ? `${Number(stats.consistencySpreadPct).toFixed(1)}%` : "-")} +
+
+ ${escapeHtml(t("recentProgress"))} + ${escapeHtml(t("averagePace"))} +

${escapeHtml(paceTrend)}

+
+
+
${escapeHtml(t("achievements"))}${unlockedAchievements.length}/${achievements.length}
+
${achievementMarkup}
+
+
+

${escapeHtml(t("nextLevel"))}

diff --git a/src/race_day_logic.js b/src/race_day_logic.js new file mode 100644 index 0000000..820022c --- /dev/null +++ b/src/race_day_logic.js @@ -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 }; +} diff --git a/src/race_day_view.js b/src/race_day_view.js new file mode 100644 index 0000000..933c730 --- /dev/null +++ b/src/race_day_view.js @@ -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 `
${t(positionKey)}${t("race_day.none")}
`; + return ` +
+ ${t(positionKey)} + ${escapeHtml(session.name)} + ${escapeHtml(getEventName(session.eventId))} · ${escapeHtml(getSessionTypeLabel(session.type))} + ${escapeHtml(getStatusLabel(session.status))} + +
`; +} + +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 = ` +
+
+ ${t("race_day.current")} +

${session ? escapeHtml(session.name) : t("race_day.no_session")}

+

${session ? `${escapeHtml(getEventName(session.eventId))} · ${escapeHtml(getSessionTypeLabel(session.type))} · ${escapeHtml(getStartModeLabel(session.startMode))}` : t("race_day.no_session_hint")}

+
+
${clock}${session ? escapeHtml(getStatusLabel(session.status)) : t("race_day.none")}
+
+ +
+
+
+
+

${t("race_day.control")}

${t("race_day.control_hint")}

+ ${t(preflight.ready ? "race_day.ready" : "race_day.not_ready")} +
+
+ ${session && !isSelected ? `` : ""} +
+ + + + +
+
+ ${[1, 2, 3, 4, 5].map((light) => ``).join("")} + ${escapeHtml(getStartLightState())} +
+
+ + + +
+
+
+ +
+

${t("race_day.start_list")}

${grid.length}
+
+ ${grid.length ? grid.map((entry) => `
${entry.slot}${escapeHtml(entry.name)}${escapeHtml(entry.meta || "-")}
`).join("") : `

${t("race_day.no_start_list")}

`} +
+
+
+ + +
+ +
+

${t("race_day.heat_queue")}

+
+ ${renderQueueSession(session, "race_day.current", deps)} + ${renderQueueSession(queue.next, "race_day.next", deps)} + ${renderQueueSession(queue.afterNext, "race_day.after_next", deps)} +
+
`; + + 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")); +} diff --git a/src/session_control.js b/src/session_control.js new file mode 100644 index 0000000..6d4966d --- /dev/null +++ b/src/session_control.js @@ -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; +} diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index 342cf30..39abef6 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -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);