Scope driver training-profile stats by track layout too
The user pointed out that track layout changes matter most for Open Practice/training, not races - so the admin Training page and the driver's own mobile profile (server/public_training_profile.js, the actual thing most viewed after practice) needed the same layout scoping already applied to the live "track record" audio cue. Every stat now splits into two groups: lifetime/commitment numbers (total laps, sessions, training days, milestone badges) still read a driver's whole history, since a track rebuild doesn't erase past practice. Pace/record/consistency numbers (personal best, recent-pace trend, consistency spread, the "stable pace" control zone, stint achievements, "best stint") now read only laps tagged with whichever layout is currently active. Each ledger entry is tagged with trackLayoutId at the moment it's synced from a session, so it survives the source session later being deleted. Verified with new unit tests covering both implementations and a real headless-browser run confirming both the admin page and the actual public mobile profile show the current-layout best lap (never a faster but incomparable old-layout one) while keeping the correct full lifetime lap count in both places. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm
|
||||
|
||||
No changes recorded yet.
|
||||
|
||||
## 1.10.0 - 2026-09-10
|
||||
|
||||
### Added
|
||||
|
||||
- Track layouts now also scope the driver training profile's personal-best, pace-trend and consistency statistics (both on the admin Training page and the driver's own mobile link) - the part of the app actually most affected by a layout change, since it's built from ongoing Open Practice. Lifetime totals (laps, sessions, training days, milestone badges) still count everything a driver has ever done; only pace/record comparisons are now scoped to the currently active layout.
|
||||
|
||||
## 1.9.0 - 2026-09-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -177,10 +177,12 @@ Implemented 2026-09-10 (`src/track_layouts_logic.js`, new):
|
||||
- [x] The "track_record" audio cue (`getTrackRecordMs` in `src/timing_logic.js`) now only compares a live lap against other sessions tagged with the *same* layout. With no layouts configured, every session's `trackLayoutId` is `""`, so the comparison is unscoped exactly as before - the feature is fully inert until a club actually adds a layout.
|
||||
- [x] The Results Archive shows which layout each session ran on, both in the session list and the detail view's header badge.
|
||||
|
||||
Deliberately not touched in this pass - a real remaining gap, not an oversight: the mobile/driver training profile's own cross-session personal-best trend and pace-baseline statistics (`buildDriverTrainingStats` and related functions in `src/training_logic.js`) still compare across a driver's entire lap ledger regardless of layout. Only the live-race "personal_best" cue is already session-scoped by design (it resets every session) and needed no change; only the cross-session "track_record" cue needed - and got - the fix above. Revisit the training-ledger side as a separate, later pass if it turns out to matter in practice.
|
||||
|
||||
Verified 2026-09-10: unit tests (`tests/track_layouts_logic.test.js`) cover normalization/sorting, the contiguous "layout active at time X" lookup at its boundaries, and `getTrackRecordMs`'s layout scoping (including that a layout-tagged session is never conflated with an untagged legacy one). A full headless-browser run against an isolated temp SQLite instance added two layouts through the real Settings UI (confirming the default "Layout 1" label, the custom-label case, and that a backdated second layout is rejected with a clear message), started a session and confirmed it was stamped with whichever layout was active at that moment, confirmed the Results Archive shows the correct layout in both the list and the detail header, and confirmed deleting a layout still in use is blocked while deleting an unused one succeeds.
|
||||
|
||||
Extended 2026-09-10, per the user pointing out this matters most for Open Practice/training use, not races: the mobile driver training profile's own cross-session personal-best, pace-baseline and consistency statistics (`buildDriverTrainingStats`/`buildTrainingStintAnalysis` in `src/training_logic.js`, and the duplicate server-side implementation in `server/public_training_profile.js` that actually powers the public mobile link) were still comparing across a driver's entire lap ledger regardless of layout. Both now split every stat into two groups: lifetime/commitment numbers (total laps, total sessions, training days, calendar history, milestone badges) still read the full history - a track rebuild doesn't erase past practice - while every pace/record/consistency number (personal best, recent-average trend, consistency spread, the "stable pace" control zone, stint pace/streak achievements, "best stint") now reads only laps tagged with whichever layout is active right now. Each ledger entry is tagged with `trackLayoutId` at the moment it's synced from a session's passings (`syncTrainingLedger`), so it survives the source session later being deleted, the same way `sessionName`/`eventName` already do. Left deliberately unscoped, as lower-risk trend visualizations rather than single "your record is X" claims: the pace-bucket and weekday charts, marker before/after comparisons (narrow time windows, virtually always same-layout in practice), and the split/pit-stop timing-channel stats (track-geometry-independent for pit stops; splits are a smaller, less-used feature left for a future pass if needed).
|
||||
|
||||
Verified 2026-09-10: new unit tests in `tests/training_logic.test.js` and `tests/public_training_profile.test.js` cover a driver with laps on two different layouts, confirming lifetime totals count every lap while personal-best/consistency numbers only reflect the current layout, and that `buildTrainingStintAnalysis`'s "best stint" pick never surfaces a faster stint from an old, incomparable layout. A full headless-browser run seeded a driver with a fast old-layout lap and a slower current-layout lap, then confirmed **both** the admin Training page and the real public mobile profile page show the slower current-layout lap as "Best lap" (never the faster but incomparable old one) while still showing the correct full lifetime lap count in both places.
|
||||
|
||||
## PDF export character encoding
|
||||
|
||||
Fixed 2026-09-09, reported while exporting an old race from the new Results archive - correctly suspected as a general PDF export bug, not specific to that one feature. `buildSimplePdf` in `server.js` (the shared renderer behind every PDF export: race results, start lists, heat sheets, team reports, and the archive) declared its font with no `/Encoding` at all, so viewers fell back to the standard font's built-in encoding, which has no å/ä/ö glyphs - and separately, the content stream was written as UTF-8, so even a correctly-encoded font would have received the wrong bytes (a multi-byte UTF-8 sequence where a single WinAnsi byte was expected), the classic "åäö" mojibake pattern. Fixed by declaring `/Encoding /WinAnsiEncoding` and writing the content stream through a proper WinAnsi (~CP1252) byte mapper instead of UTF-8 - Swedish å/ä/ö/Å/Ä/Ö map directly to their Latin-1 byte values, with the handful of CP1252 "smart punctuation" code points (curly quotes, en/em dash, €, etc.) mapped explicitly; anything else falls back to `?` rather than corrupting the byte stream.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rc-racecore",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rc-racecore",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rc-racecore",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence",
|
||||
"engines": {
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
// Mirrors getTrackLayoutIdAt in src/track_layouts_logic.js - see the note above
|
||||
// buildPublicEvenLapsProgress for why this file re-implements rather than imports from
|
||||
// the browser-only admin module (this is CommonJS, that module is ESM).
|
||||
function getCurrentTrackLayoutId(state, atMs = Date.now()) {
|
||||
const layouts = Array.isArray(state?.trackLayouts) ? state.trackLayouts : [];
|
||||
let current = "";
|
||||
layouts
|
||||
.slice()
|
||||
.sort((a, b) => Number(a?.startedAt || 0) - Number(b?.startedAt || 0))
|
||||
.forEach((layout) => {
|
||||
if (Number(layout?.startedAt || 0) <= atMs) current = String(layout?.id || "");
|
||||
});
|
||||
return current;
|
||||
}
|
||||
|
||||
function findPublicTrainingShare(state, shareToken) {
|
||||
if (!shareToken || shareToken.length < 24) return null;
|
||||
const shares = Array.isArray(state?.training?.shares) ? state.training.shares : [];
|
||||
@@ -74,6 +89,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp
|
||||
|
||||
const classes = Array.isArray(state?.classes) ? state.classes : [];
|
||||
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
||||
const currentLayoutId = getCurrentTrackLayoutId(state);
|
||||
const profileReferences = new Set([driverProfile.id, ...driverProfile.drivers.map((item) => String(item.id || ""))]);
|
||||
const profileClassIds = [...new Set(driverProfile.drivers.map((item) => String(item.classId || "")).filter(Boolean))];
|
||||
const classProfiles = profileClassIds.map((classId) => {
|
||||
@@ -88,9 +104,15 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp
|
||||
sourceId: String(entry.sourceId || entry.driverId || ""),
|
||||
excludedFromPace: Boolean(entry.excludedFromPace),
|
||||
stintBoundary: Boolean(entry.stintBoundary),
|
||||
trackLayoutId: String(entry.trackLayoutId || ""),
|
||||
}))
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
const stats = buildPublicTrainingStats(classLaps);
|
||||
// Lifetime totals (laps/sessions/days/milestones) read every lap ever recorded - a
|
||||
// track rebuild doesn't erase past practice. Pace/record/consistency numbers instead
|
||||
// read only laps tagged with today's active layout, so a personal best from a track
|
||||
// that no longer exists can't be shown as current. See getCurrentTrackLayoutId above.
|
||||
const classLayoutLaps = classLaps.filter((lap) => lap.trackLayoutId === currentLayoutId);
|
||||
const stats = buildPublicTrainingStats(classLaps, classLayoutLaps);
|
||||
const classMarkers = (Array.isArray(state?.training?.markers) ? state.training.markers : []).filter((marker) => classDriverIds.has(String(marker?.driverId || "")) && String(marker?.classId || "") === classId);
|
||||
stats.paceBuckets = buildPublicTrainingPaceBuckets(classLaps, classMarkers);
|
||||
const channelStats = buildPublicTimingChannelStats(state, classDriverIds);
|
||||
@@ -533,8 +555,13 @@ function publicTrainingSpread(values) {
|
||||
return mean ? ((publicTrainingPercentile(values, 0.9) - publicTrainingPercentile(values, 0.1)) / mean) * 100 : 0;
|
||||
}
|
||||
|
||||
function buildPublicTrainingStats(laps) {
|
||||
const paceLaps = laps.filter((lap) => !lap.excludedFromPace);
|
||||
// `allLaps` feeds lifetime/commitment stats (total laps, sessions, training days) - a
|
||||
// track rebuild doesn't erase past practice. `layoutLaps` (defaulting to allLaps, so every
|
||||
// other caller of this function stays exactly as before) feeds every pace/record/
|
||||
// consistency stat instead, scoped to whichever track layout is active right now.
|
||||
function buildPublicTrainingStats(allLaps, layoutLaps = allLaps) {
|
||||
const historyPaceLaps = allLaps.filter((lap) => !lap.excludedFromPace);
|
||||
const paceLaps = layoutLaps.filter((lap) => !lap.excludedFromPace);
|
||||
const lapTimes = paceLaps.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));
|
||||
@@ -546,7 +573,7 @@ function buildPublicTrainingStats(laps) {
|
||||
const previousConsistencySpreadPct = spread(previous);
|
||||
const trendSampleReady = recent.length >= 10 && previous.length >= 10;
|
||||
const daily = new Map();
|
||||
paceLaps.forEach((lap) => {
|
||||
historyPaceLaps.forEach((lap) => {
|
||||
const date = new Date(lap.timestamp);
|
||||
const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 };
|
||||
@@ -568,7 +595,7 @@ function buildPublicTrainingStats(laps) {
|
||||
.slice(0, 30);
|
||||
const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0;
|
||||
const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
|
||||
const stints = splitPublicTrainingStints(laps);
|
||||
const stints = splitPublicTrainingStints(layoutLaps);
|
||||
const summarizeStints = (items) => {
|
||||
let controlledLapStreak = 0;
|
||||
let outlierLaps = 0;
|
||||
@@ -598,7 +625,7 @@ function buildPublicTrainingStats(laps) {
|
||||
const stintSummary = summarizeStints(stints);
|
||||
const today = new Date();
|
||||
const todayKey = [today.getFullYear(), String(today.getMonth() + 1).padStart(2, "0"), String(today.getDate()).padStart(2, "0")].join("-");
|
||||
const todayStintSummary = summarizeStints(splitPublicTrainingStints(laps.filter((lap) => {
|
||||
const todayStintSummary = summarizeStints(splitPublicTrainingStints(layoutLaps.filter((lap) => {
|
||||
const date = new Date(lap.timestamp);
|
||||
const dateKey = [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-");
|
||||
return !Number.isNaN(date.getTime()) && dateKey === todayKey;
|
||||
@@ -618,8 +645,8 @@ function buildPublicTrainingStats(laps) {
|
||||
}).sort((left, right) => right.startedAt - left.startedAt).slice(0, 8);
|
||||
|
||||
const stats = {
|
||||
totalLaps: laps.length,
|
||||
totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size,
|
||||
totalLaps: allLaps.length,
|
||||
totalSessions: new Set(allLaps.map((lap) => lap.sessionId).filter(Boolean)).size,
|
||||
trainingDays: daily.size,
|
||||
bestLapMs,
|
||||
averageLapMs: average(lapTimes),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getDriverProfile, getDriverProfileReferences } from "./driver_profile_logic.js";
|
||||
import { getTrackLayoutIdAt } from "./track_layouts_logic.js";
|
||||
|
||||
const PUBLIC_TRAINING_SECTIONS = ["progress", "achievements", "performance", "goals", "history"];
|
||||
|
||||
@@ -81,6 +82,7 @@ export function normalizeTrainingState(training, { uid }) {
|
||||
timestamp,
|
||||
lapMs: Math.max(0, Math.round(Number(entry?.lapMs || 0) || 0)),
|
||||
recordedAt: String(entry?.recordedAt || (timestamp ? new Date(timestamp).toISOString() : new Date().toISOString())),
|
||||
trackLayoutId: String(entry?.trackLayoutId || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter((entry) => {
|
||||
@@ -234,6 +236,7 @@ export function syncTrainingLedger(training, state, { uid }) {
|
||||
timestamp,
|
||||
lapMs,
|
||||
recordedAt: timestamp ? new Date(timestamp).toISOString() : new Date().toISOString(),
|
||||
trackLayoutId: String(session?.trackLayoutId || "").trim(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -262,10 +265,24 @@ export function getDriverTrainingLaps(state, driverId, classId = "") {
|
||||
stintBoundary: Boolean(entry.stintBoundary),
|
||||
timestamp: Number(entry.timestamp || 0) || 0,
|
||||
lapMs: Number(entry.lapMs || 0),
|
||||
trackLayoutId: String(entry.trackLayoutId || ""),
|
||||
}))
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
}
|
||||
|
||||
// Every lifetime/commitment stat (total laps ever driven, training days, achievement
|
||||
// badges) intentionally keeps reading the full history via getDriverTrainingLaps above -
|
||||
// a track rebuild doesn't erase how much a driver has practiced. Only pace-comparison
|
||||
// stats (personal best, consistency, the "stable pace" cue's target) should stop blending
|
||||
// laps from a track that no longer exists into today's numbers, so those read this
|
||||
// layout-scoped subset instead. With no track layouts configured, every lap's
|
||||
// trackLayoutId is "" and getTrackLayoutIdAt() also returns "" - so this returns exactly
|
||||
// the same laps as getDriverTrainingLaps, and the feature stays fully inert until used.
|
||||
export function getDriverTrainingLapsForCurrentLayout(state, driverId, classId = "") {
|
||||
const currentLayoutId = getTrackLayoutIdAt(state?.trackLayouts);
|
||||
return getDriverTrainingLaps(state, driverId, classId).filter((lap) => lap.trackLayoutId === currentLayoutId);
|
||||
}
|
||||
|
||||
function percentile(values, ratio) {
|
||||
if (!values.length) return 0;
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
@@ -417,7 +434,9 @@ function getTrainingStintMetrics(stint) {
|
||||
}
|
||||
|
||||
export function buildTrainingStintAnalysis(state, driverId, classId = "") {
|
||||
const stints = splitTrainingStints(getDriverTrainingLaps(state, driverId, classId));
|
||||
// Scoped to the currently active track layout, same as buildDriverTrainingStats - a
|
||||
// "best stint" pick spanning a track that no longer exists would not be comparable.
|
||||
const stints = splitTrainingStints(getDriverTrainingLapsForCurrentLayout(state, driverId, classId));
|
||||
const rows = stints.map((stint, index) => {
|
||||
const { laps, values, outlierLaps, startPaceMs, endPaceMs, endChangeMs, strongFinish } = getTrainingStintMetrics(stint);
|
||||
return {
|
||||
@@ -637,8 +656,15 @@ export function getBestTrainingDurationResult(stints, targetMs = 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
export function buildDriverTrainingStats(state, driverId, classId = "") {
|
||||
// Lifetime commitment stats (how much a driver has trained, ever) read the full lap
|
||||
// history - a track rebuild doesn't erase past practice. Every pace/record/consistency
|
||||
// stat below instead reads layoutLaps, scoped to whichever track layout is active right
|
||||
// now, so a lap from a track that no longer exists can't be reported as today's "record"
|
||||
// or blend into today's consistency numbers. See getDriverTrainingLapsForCurrentLayout.
|
||||
const laps = getDriverTrainingLaps(state, driverId, classId);
|
||||
const paceLaps = laps.filter((lap) => !lap.excludedFromPace);
|
||||
const layoutLaps = getDriverTrainingLapsForCurrentLayout(state, driverId, classId);
|
||||
const historyPaceLaps = laps.filter((lap) => !lap.excludedFromPace);
|
||||
const paceLaps = layoutLaps.filter((lap) => !lap.excludedFromPace);
|
||||
const lapTimes = paceLaps.map((lap) => lap.lapMs);
|
||||
const recentLapTimes = lapTimes.slice(-30);
|
||||
const previousWindowLapTimes = lapTimes.slice(Math.max(0, lapTimes.length - 60), Math.max(0, lapTimes.length - 30));
|
||||
@@ -656,8 +682,8 @@ export function buildDriverTrainingStats(state, driverId, classId = "") {
|
||||
const personalBestGainMs = previousBestMs && recentBestMs ? Math.max(0, previousBestMs - recentBestMs) : 0;
|
||||
const controlZoneMinMs = bestLapMs;
|
||||
const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
|
||||
const dailySummaries = buildDailySummaries(paceLaps);
|
||||
const stints = splitTrainingStints(laps);
|
||||
const dailySummaries = buildDailySummaries(historyPaceLaps);
|
||||
const stints = splitTrainingStints(layoutLaps);
|
||||
const stintMetrics = stints.map((stint) => ({ stint, ...getTrainingStintMetrics(stint) }));
|
||||
const controlledLapStreak = getLongestControlledLapStreak(stints, controlTargetMs);
|
||||
const completedFiveMinuteStints = stints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length;
|
||||
@@ -667,7 +693,7 @@ export function buildDriverTrainingStats(state, driverId, classId = "") {
|
||||
const bestThreeConsecutive = getBestConsecutiveTrainingLaps(stints, 3);
|
||||
const bestFiveMinuteResult = getBestTrainingDurationResult(stints, 5 * 60 * 1000);
|
||||
const todayKey = getLocalDayKey(Date.now());
|
||||
const todayLaps = laps.filter((lap) => getLocalDayKey(lap.timestamp) === todayKey);
|
||||
const todayLaps = layoutLaps.filter((lap) => getLocalDayKey(lap.timestamp) === todayKey);
|
||||
const todayStints = splitTrainingStints(todayLaps);
|
||||
const todayStintMetrics = todayStints.map((stint) => ({ stint, ...getTrainingStintMetrics(stint) }));
|
||||
const todayLapTimes = todayLaps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs);
|
||||
|
||||
@@ -197,6 +197,34 @@ test("public profile contains only the selected driver's sanitized aggregates",
|
||||
assert.equal(serialized.includes("secret-decoder"), false);
|
||||
});
|
||||
|
||||
test("public profile's personal-best and lifetime totals are scoped correctly across a track layout change", () => {
|
||||
const now = Date.now();
|
||||
const oldLayoutDay = now - 60 * 24 * 60 * 60 * 1000;
|
||||
const state = buildState(now);
|
||||
state.trackLayouts = [
|
||||
{ id: "layout_1", label: "Layout 1", startedAt: oldLayoutDay - 1000 },
|
||||
{ id: "layout_2", label: "Layout 2", startedAt: now - 24 * 60 * 60 * 1000 },
|
||||
];
|
||||
// Old layout: a much faster time from a track that no longer exists.
|
||||
state.training.ledger.push(
|
||||
{ driverId: "driver_1", sessionId: "old_track", timestamp: oldLayoutDay, lapMs: 9000, trackLayoutId: "layout_1" },
|
||||
{ driverId: "driver_1", sessionId: "old_track", timestamp: oldLayoutDay + 10000, lapMs: 9200, trackLayoutId: "layout_1" },
|
||||
);
|
||||
// Every lap already in buildState's fixture predates any layout tracking (no
|
||||
// trackLayoutId at all) - it must not be conflated with "layout_2" either.
|
||||
const profile = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "");
|
||||
const classOne = profile.classProfiles.find((item) => item.classId === "class_1");
|
||||
assert.equal(classOne.stats.totalLaps, 6, "lifetime total counts the old-layout laps too");
|
||||
assert.equal(classOne.stats.bestLapMs, 0, "no lap is tagged with the currently active layout yet, so there is no current-layout best");
|
||||
|
||||
// Tag one of the existing (previously untagged) laps as run on the current layout.
|
||||
state.training.ledger.find((lap) => lap.lapMs === 16000).trackLayoutId = "layout_2";
|
||||
const updatedProfile = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "");
|
||||
const updatedClassOne = updatedProfile.classProfiles.find((item) => item.classId === "class_1");
|
||||
assert.equal(updatedClassOne.stats.bestLapMs, 16000, "the current-layout lap is now the reported best, not the faster but incomparable old-layout lap");
|
||||
assert.equal(updatedClassOne.stats.totalLaps, 6, "lifetime total is unaffected by which layout is current");
|
||||
});
|
||||
|
||||
test("public profile visibility removes disabled sections from the API payload", () => {
|
||||
const state = buildState();
|
||||
state.training.shares[0].visibility = { progress: false, achievements: false, performance: false, goals: false, history: false };
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getBestConsecutiveTrainingLaps,
|
||||
getBestTrainingDurationResult,
|
||||
getDriverTrainingLaps,
|
||||
getDriverTrainingLapsForCurrentLayout,
|
||||
getNextTrainingMilestone,
|
||||
getClubTrainingGoalProgress,
|
||||
getTrainingGoalProgress,
|
||||
@@ -176,6 +177,67 @@ test("driver statistics remain isolated and aggregate days and sessions", () =>
|
||||
assert.equal(stats.dailySummaries[1].laps, 2);
|
||||
});
|
||||
|
||||
test("driver pace/PB/consistency stats only compare laps from the current track layout, but lifetime totals still count every lap", () => {
|
||||
const oldLayoutDay = new Date(2026, 0, 1, 12, 0, 0).getTime();
|
||||
const newLayoutDay = new Date(2026, 2, 1, 12, 0, 0).getTime();
|
||||
const state = {
|
||||
trackLayouts: [{ id: "layout_1", label: "Layout 1", startedAt: new Date(2026, 0, 1).getTime() }, { id: "layout_2", label: "Layout 2", startedAt: new Date(2026, 1, 1).getTime() }],
|
||||
training: {
|
||||
ledger: [
|
||||
// Old layout: much faster (e.g. a shorter loop) - must not count as today's record.
|
||||
{ driverId: "driver_1", sessionId: "old_session", timestamp: oldLayoutDay, lapMs: 10_000, trackLayoutId: "layout_1" },
|
||||
{ driverId: "driver_1", sessionId: "old_session", timestamp: oldLayoutDay + 11_000, lapMs: 10_500, trackLayoutId: "layout_1" },
|
||||
// Current layout: slower, genuinely the best time achievable on today's track.
|
||||
{ driverId: "driver_1", sessionId: "new_session", timestamp: newLayoutDay, lapMs: 18_000, trackLayoutId: "layout_2" },
|
||||
{ driverId: "driver_1", sessionId: "new_session", timestamp: newLayoutDay + 19_000, lapMs: 17_500, trackLayoutId: "layout_2" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layoutLaps = getDriverTrainingLapsForCurrentLayout(state, "driver_1");
|
||||
assert.equal(layoutLaps.length, 2, "only the current layout's laps");
|
||||
assert.ok(layoutLaps.every((lap) => lap.trackLayoutId === "layout_2"));
|
||||
|
||||
const stats = buildDriverTrainingStats(state, "driver_1");
|
||||
assert.equal(stats.totalLaps, 4, "lifetime total still counts every lap ever driven");
|
||||
assert.equal(stats.totalSessions, 2, "lifetime session count is unaffected by layout scoping");
|
||||
assert.equal(stats.trainingDays, 2, "training-day history is unaffected by layout scoping");
|
||||
assert.equal(stats.bestLapMs, 17_500, "best lap only compares laps from today's layout, not the faster old-layout lap");
|
||||
});
|
||||
|
||||
test("with no track layouts configured, layout scoping is a no-op (every lap has trackLayoutId \"\")", () => {
|
||||
const state = {
|
||||
training: {
|
||||
ledger: [
|
||||
{ driverId: "driver_1", sessionId: "s1", timestamp: 1000, lapMs: 15_000 },
|
||||
{ driverId: "driver_1", sessionId: "s1", timestamp: 16_000, lapMs: 14_000 },
|
||||
],
|
||||
},
|
||||
};
|
||||
assert.deepEqual(getDriverTrainingLapsForCurrentLayout(state, "driver_1"), getDriverTrainingLaps(state, "driver_1"));
|
||||
assert.equal(buildDriverTrainingStats(state, "driver_1").bestLapMs, 14_000);
|
||||
});
|
||||
|
||||
test("buildTrainingStintAnalysis's best-stint pick is scoped to the current track layout", () => {
|
||||
const state = {
|
||||
trackLayouts: [{ id: "layout_1", label: "Layout 1", startedAt: 0 }, { id: "layout_2", label: "Layout 2", startedAt: 50_000 }],
|
||||
training: {
|
||||
ledger: [
|
||||
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "fast_old", timestamp: 10_000, lapMs: 10_000, trackLayoutId: "layout_1" },
|
||||
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "fast_old", timestamp: 20_000, lapMs: 10_000, trackLayoutId: "layout_1" },
|
||||
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "fast_old", timestamp: 30_000, lapMs: 10_000, trackLayoutId: "layout_1" },
|
||||
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "slower_new", timestamp: 100_000, lapMs: 16_000, trackLayoutId: "layout_2" },
|
||||
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "slower_new", timestamp: 116_000, lapMs: 16_000, trackLayoutId: "layout_2" },
|
||||
{ driverId: "driver_1", sourceId: "tp_1", sessionId: "slower_new", timestamp: 132_000, lapMs: 16_000, trackLayoutId: "layout_2" },
|
||||
],
|
||||
},
|
||||
};
|
||||
const { rows, best } = buildTrainingStintAnalysis(state, "driver_1");
|
||||
assert.equal(rows.length, 1, "the old-layout stint is excluded entirely");
|
||||
assert.equal(rows[0].sessionId, "slower_new");
|
||||
assert.equal(best.sessionId, "slower_new", "the faster old-layout stint is never picked as \"best\"");
|
||||
});
|
||||
|
||||
test("counted stint-boundary laps do not distort driver pace averages", () => {
|
||||
const state = {
|
||||
training: {
|
||||
|
||||
Reference in New Issue
Block a user