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")}
+
+
+
+
+
+
+
+ ${session && !isSelected ? `
` : ""}
+
+
+
+
+
+
+
+ ${[1, 2, 3, 4, 5].map((light) => ``).join("")}
+ ${escapeHtml(getStartLightState())}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${grid.length ? grid.map((entry) => `
${entry.slot}${escapeHtml(entry.name)}${escapeHtml(entry.meta || "-")}`).join("") : `
${t("race_day.no_start_list")}
`}
+
+
+
+
+
+
+
+
+
+
+ ${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);