feat(training): base training stats on practice, keep race laps separate

Training stats count Open/Free practice by default; laps from
qualifying, heats and finals are kept and shown under Based on: Races
or All, marked with race, session and series. Races are listed per race
session and never split into stints. Same picker in the mobile profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-10-10 20:08:17 +02:00
parent 9a9379aa83
commit af79b610db
15 changed files with 377 additions and 42 deletions

View File

@@ -14,6 +14,7 @@ import {
buildTrainingPaceBuckets,
buildTrainingRecommendation,
buildTrainingStintAnalysis,
buildTrainingRaceSessions,
buildTrainingWeekdayStats,
countExerciseSessions,
countDriverExerciseSessions,
@@ -983,3 +984,47 @@ test("layout choice \"none\" picks the laps driven before any layout existed", (
assert.deepEqual(getDriverTrainingLapsForLayout(state, "driver_1", "", "current").map((lap) => lap.lapMs), [16_000]);
assert.equal(getDriverTrainingLapsForLayout(state, "driver_1", "", "all").length, 2);
});
test("training stats count practice by default; race laps are kept, marked and shown per race session, never as stints", () => {
let n = 0;
const uid = (prefix) => `${prefix}_${++n}`;
const lap = (sessionId, timestamp, lapMs) => ({ driverId: "d1", competitorKey: "d1", sessionId, lapMs, timestamp, validLap: true });
const state = {
drivers: [{ id: "d1", name: "D1", classId: "c1" }],
classes: [{ id: "c1", name: "2WD" }],
events: [{ id: "e_practice", name: "Club practice" }, { id: "e_race", name: "Winter #3 2WD" }],
sessions: [
{ id: "op", name: "Open Practice", type: "open_practice", eventId: "e_practice" },
{ id: "q2", name: "Kval 2", type: "qualification", eventId: "e_race" },
],
series: [{ id: "s", name: "Winter Cup", eventIds: ["e_race"] }],
resultsBySession: {
op: { passings: [lap("op", 1000, 20000), lap("op", 21000, 20000), lap("op", 41000, 19500), lap("op", 60500, 19500)] },
q2: { passings: [lap("q2", 500000, 18000), lap("q2", 518000, 18200), lap("q2", 536200, 18400)] },
},
};
state.training = syncTrainingLedger({}, state, { uid });
assert.deepEqual(state.training.ledger.map((entry) => entry.sessionType), ["open_practice", "open_practice", "open_practice", "open_practice", "qualification", "qualification", "qualification"]);
assert.equal(getDriverTrainingLaps(state, "d1").length, 4, "practice by default");
assert.equal(getDriverTrainingLaps(state, "d1", "", { source: "race" }).length, 3);
assert.equal(getDriverTrainingLaps(state, "d1", "", { source: "all" }).length, 7);
assert.equal(buildDriverTrainingStats(state, "d1").bestLapMs, 19500, "a race lap doesn't become the training best");
assert.equal(buildDriverTrainingStats(state, "d1", "", { source: "all" }).bestLapMs, 18000);
// Stints come from practice laps only, even with "all"; with "race" there are none.
const allStints = buildTrainingStintAnalysis(state, "d1", "", { source: "all" });
assert.equal(allStints.rows.reduce((sum, row) => sum + row.lapCount, 0), 4);
assert.equal(buildTrainingStintAnalysis(state, "d1", "", { source: "race" }).rows.length, 0);
const races = buildTrainingRaceSessions(state, "d1");
assert.equal(races.length, 1);
assert.deepEqual([races[0].sessionName, races[0].eventName, races[0].eventId, races[0].lapCount, races[0].bestLapMs, races[0].averageLapMs], ["Kval 2", "Winter #3 2WD", "e_race", 3, 18000, 18200]);
// A lap saved before session types were recorded gets it from its (still existing) session.
const old = { ...state, training: { ...state.training, ledger: state.training.ledger.map(({ sessionType, ...entry }) => entry) } };
assert.equal(getDriverTrainingLaps(old, "d1", "", { source: "race" }).length, 3);
delete old.resultsBySession;
const resynced = syncTrainingLedger(old.training, old, { uid });
assert.equal(resynced.ledger.filter((entry) => entry.sessionType === "qualification").length, 3, "back-filled on the next sync");
});