diff --git a/README.md b/README.md index dd074e8..380ceba 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Recommended deployment: The `Training` menu provides a non-race progression view for registered drivers: -The default view is intentionally simple: four key statistics, the next lap target, today's practice plan and active rewards. Goal administration, mobile sharing, detailed history and the club comparison remain available in collapsed sections when needed. +The default view is a club overview with totals and one row per driver and class. Select a driver from the list to open the intentionally simple personal view with four key statistics, the next lap target, today's practice plan and active rewards. Goal administration, mobile sharing and detailed history remain available in collapsed sections when needed. When Open Practice, Free Practice, or Practice is running, the selected driver gets a compact live card with current valid laps, best lap, average, and latest lap. The session remains shared by everyone; only the mobile personal timer and profile link are unique per driver and class. The primary action at the top creates or opens that link. diff --git a/README.sv.md b/README.sv.md index 7d073cd..19568d1 100644 --- a/README.sv.md +++ b/README.sv.md @@ -166,7 +166,7 @@ Rekommenderad deploy: Menyn `Träning` ger förarna utveckling och delmål även mellan tävlingarna: -Grundvyn är avsiktligt enkel: fyra nyckeltal, nästa varvmål, dagens träningspass och aktiva belöningar. Administration av mål, mobildelning, detaljhistorik och klubbjämförelse finns kvar i utfällbara sektioner när de behövs. +Grundvyn är en klubböversikt med totaler och en separat rad per förare och klass. Välj en förare i listan för att öppna den avsiktligt enkla personliga vyn med fyra nyckeltal, nästa varvmål, dagens träningspass och aktiva belöningar. Administration av mål, mobildelning och detaljhistorik finns kvar i utfällbara sektioner när de behövs. När Open Practice, Free Practice eller Practice pågår får vald förare ett kompakt livekort med aktuella giltiga varv, bästa varv, snitt och senaste varv. Sessionen är fortfarande gemensam för alla; endast mobilens personliga timer och profillänk är unik per förare och klass. Primärknappen högst upp skapar eller öppnar länken. diff --git a/src/app.js b/src/app.js index 40890f9..9fd8e64 100644 --- a/src/app.js +++ b/src/app.js @@ -335,6 +335,15 @@ const TRANSLATIONS = { "ui.decoder_offline": "Decoder offline", "training.eyebrow": "Förarutveckling", "training.intro": "Följ träning över flera sessioner och gör utvecklingen synlig mellan tävlingarna.", + "training.overview_title": "Träningsöversikt", + "training.overview_intro": "Se klubbens registrerade träning per förare och klass. Välj en förare för personliga mål, rekommendationer och detaljerad statistik.", + "training.select_driver": "Välj förare...", + "training.back_overview": "Till översikten", + "training.registered_drivers": "Registrerade förare", + "training.active_drivers": "Förare med träning", + "training.profile_count_note": "Unika förarprofiler", + "training.with_training_data": "Med giltiga träningsvarv", + "training.overview_class_hint": "Varje klass visas separat så att exempelvis 2WD och 4WD aldrig blandas i varvtider eller statistik.", "training.driver": "Förare", "training.class": "Klass", "training.no_drivers_title": "Ingen förare registrerad", @@ -1446,6 +1455,15 @@ const TRANSLATIONS = { "ui.decoder_offline": "Decoder Offline", "training.eyebrow": "Driver development", "training.intro": "Track practice across sessions and make progress visible between race days.", + "training.overview_title": "Practice overview", + "training.overview_intro": "Review recorded club practice by driver and class. Select a driver for personal goals, recommendations, and detailed statistics.", + "training.select_driver": "Select driver...", + "training.back_overview": "Back to overview", + "training.registered_drivers": "Registered drivers", + "training.active_drivers": "Drivers with practice", + "training.profile_count_note": "Unique driver profiles", + "training.with_training_data": "With valid practice laps", + "training.overview_class_hint": "Every class is listed separately so lap times and statistics from categories such as 2WD and 4WD are never combined.", "training.driver": "Driver", "training.class": "Class", "training.no_drivers_title": "No driver registered", @@ -3527,6 +3545,10 @@ function renderNav() { button.className = `nav-item ${item.id === currentView ? "active" : ""}`; button.textContent = t(item.titleKey); button.addEventListener("click", () => { + if (item.id === "training") { + selectedTrainingDriverId = ""; + selectedTrainingClassId = ""; + } currentView = item.id; updateViewUrl(currentView); renderNav(); diff --git a/src/training_views.js b/src/training_views.js index f609e6e..214c1c6 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -30,17 +30,17 @@ export function renderTrainingView(deps) { const profiles = getDriverProfileGroups(state) .sort((left, right) => String(left.representative?.name || "").localeCompare(String(right.representative?.name || ""))); let selectedProfileId = String(getSelectedDriverId() || ""); - let selectedProfile = profiles.find((profile) => profile.id === selectedProfileId || profile.driverIds.includes(selectedProfileId)); - if (!selectedProfile) { - selectedProfile = profiles[0] || null; - selectedProfileId = selectedProfile?.id || ""; - setSelectedDriverId(selectedProfileId); - } else if (selectedProfileId !== selectedProfile.id) { + let selectedProfile = resolveTrainingProfile(profiles, selectedProfileId); + if (selectedProfileId && !selectedProfile) { + selectedProfileId = ""; + setSelectedDriverId(""); + setSelectedClassId(""); + } else if (selectedProfile && selectedProfileId !== selectedProfile.id) { selectedProfileId = selectedProfile.id; setSelectedDriverId(selectedProfileId); } - if (!selectedProfileId || !selectedProfile) { + if (!profiles.length) { dom.view.innerHTML = `

${escapeHtml(t("training.no_drivers_title"))}

@@ -50,6 +50,11 @@ export function renderTrainingView(deps) { return; } + if (!selectedProfileId || !selectedProfile) { + renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles, buildDriverTrainingStats, getNextTrainingMilestone, setSelectedDriverId, setSelectedClassId, renderView }); + return; + } + let selectedClassId = String(getSelectedClassId() || ""); if (!selectedProfile.classIds.includes(selectedClassId)) { selectedClassId = selectedProfile.classIds[0] || ""; @@ -68,7 +73,7 @@ export function renderTrainingView(deps) { )); const profileShare = (state.training.shares || []).find((share) => profileReferences.has(String(share.driverId || "")) && share.enabled); const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language) : ""; - const driverOptions = profiles + const driverOptions = `` + profiles .map((profile) => { const classNames = profile.classIds .map((classId) => state.classes.find((item) => item.id === classId)?.name || "") @@ -167,6 +172,7 @@ export function renderTrainingView(deps) {
+ ${profileShare ? ` @@ -349,6 +355,11 @@ export function renderTrainingView(deps) {
`; + document.getElementById("trainingBackOverview")?.addEventListener("click", () => { + setSelectedDriverId(""); + setSelectedClassId(""); + renderView(); + }); document.getElementById("training-driver")?.addEventListener("change", (event) => { setSelectedDriverId(event.currentTarget.value); setSelectedClassId(""); @@ -440,6 +451,112 @@ export function renderTrainingView(deps) { }); } +export function resolveTrainingProfile(profiles, selectedId) { + const reference = String(selectedId || ""); + if (!reference) return null; + return (Array.isArray(profiles) ? profiles : []).find((profile) => ( + profile.id === reference || profile.driverIds.includes(reference) + )) || null; +} + +export function buildTrainingOverviewRows(state, profiles, buildDriverTrainingStats) { + return (Array.isArray(profiles) ? profiles : []).flatMap((profile) => { + const classIds = profile.classIds.length ? profile.classIds : [""]; + return classIds.map((classId) => ({ + profile, + driver: profile.representative, + classId, + className: state.classes.find((item) => String(item.id || "") === String(classId || ""))?.name || classId || "-", + stats: buildDriverTrainingStats(state, profile.id, classId), + })); + }).sort((left, right) => ( + right.stats.totalLaps - left.stats.totalLaps + || String(left.driver?.name || "").localeCompare(String(right.driver?.name || "")) + || String(left.className).localeCompare(String(right.className)) + )); +} + +function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles, buildDriverTrainingStats, getNextTrainingMilestone, setSelectedDriverId, setSelectedClassId, renderView }) { + const rows = buildTrainingOverviewRows(state, profiles, buildDriverTrainingStats); + const ledger = Array.isArray(state.training?.ledger) ? state.training.ledger : []; + const activeProfiles = new Set(rows.filter((row) => row.stats.totalLaps > 0).map((row) => row.profile.id)).size; + const recordedSessions = new Set(ledger.map((entry) => String(entry.sessionId || "")).filter(Boolean)).size; + const driverOptions = profiles.map((profile) => ( + `` + )).join(""); + const tableRows = rows.map((row, index) => { + const milestone = getNextTrainingMilestone(row.stats); + const lastActivity = row.stats.lastLapAt + ? new Date(row.stats.lastLapAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) + : "-"; + return ` + + ${index + 1} + + ${escapeHtml(row.className)} + ${row.stats.totalLaps} + ${row.stats.trainingDays} + ${formatLap(row.stats.bestLapMs)} + ${escapeHtml(lastActivity)} + ${milestone.targetLaps} + + `; + }).join(""); + + dom.view.innerHTML = ` +
+
+ ${escapeHtml(t("training.eyebrow"))} +

${escapeHtml(t("training.overview_title"))}

+

${escapeHtml(t("training.overview_intro"))}

+
+
+ +
+
+ +
+ ${trainingStat(t("training.total_laps"), String(ledger.length), t("training.valid_laps"))} + ${trainingStat(t("training.registered_drivers"), String(profiles.length), t("training.profile_count_note"))} + ${trainingStat(t("training.active_drivers"), String(activeProfiles), t("training.with_training_data"))} + ${trainingStat(t("training.sessions"), String(recordedSessions), t("training.recorded_sessions"))} +
+ +
+

${escapeHtml(t("training.club_progress"))}

${rows.length}
+
+

${escapeHtml(t("training.overview_class_hint"))}

+
+ + + ${tableRows} +
#${escapeHtml(t("training.driver"))}${escapeHtml(t("training.class"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.training_days"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.last_activity"))}${escapeHtml(t("training.next_target"))}
+
+
+
+ `; + + document.getElementById("training-overview-driver")?.addEventListener("change", (event) => { + if (!event.currentTarget.value) return; + setSelectedDriverId(event.currentTarget.value); + setSelectedClassId(""); + renderView(); + }); + document.querySelectorAll(".training-club-driver").forEach((button) => { + button.addEventListener("click", () => { + setSelectedDriverId(button.dataset.driverId || ""); + setSelectedClassId(button.dataset.classId || ""); + renderView(); + }); + }); +} + function trainingStat(label, value, note) { return `

${label}

${value}

${note}
`; } diff --git a/tests/run.js b/tests/run.js index b629880..35b2930 100644 --- a/tests/run.js +++ b/tests/run.js @@ -8,3 +8,5 @@ import "./race_audio.test.js"; import "./decoder_runtime.test.js"; import "./csv_import.test.js"; import "./server_persistence.integration.test.js"; + +import "./training_views.test.js"; diff --git a/tests/training_views.test.js b/tests/training_views.test.js new file mode 100644 index 0000000..4583b3e --- /dev/null +++ b/tests/training_views.test.js @@ -0,0 +1,35 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getDriverProfileGroups } from "../src/driver_profile_logic.js"; +import { buildDriverTrainingStats } from "../src/training_logic.js"; +import { buildTrainingOverviewRows, resolveTrainingProfile } from "../src/training_views.js"; + +test("training opens without implicitly selecting the first driver", () => { + const profiles = [{ id: "profile_1", driverIds: ["driver_1"] }]; + assert.equal(resolveTrainingProfile(profiles, ""), null); + assert.equal(resolveTrainingProfile(profiles, "missing"), null); + assert.equal(resolveTrainingProfile(profiles, "driver_1")?.id, "profile_1"); +}); + +test("training overview keeps each driver class on a separate statistics row", () => { + const state = { + classes: [{ id: "2wd", name: "2WD" }, { id: "4wd", name: "4WD" }], + drivers: [ + { id: "driver_2wd", profileId: "profile_1", name: "Alex", classId: "2wd" }, + { id: "driver_4wd", profileId: "profile_1", name: "Alex", classId: "4wd" }, + ], + training: { + ledger: [ + { driverId: "driver_2wd", sessionId: "s1", timestamp: 1_000, lapMs: 17_000 }, + { driverId: "driver_2wd", sessionId: "s1", timestamp: 18_000, lapMs: 16_500 }, + { driverId: "driver_4wd", sessionId: "s2", timestamp: 2_000, lapMs: 15_000 }, + ], + }, + }; + const profiles = getDriverProfileGroups(state); + const rows = buildTrainingOverviewRows(state, profiles, buildDriverTrainingStats); + assert.equal(rows.length, 2); + assert.equal(rows.find((row) => row.classId === "2wd").stats.totalLaps, 2); + assert.equal(rows.find((row) => row.classId === "4wd").stats.totalLaps, 1); +});