fix tränings statse sidan
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
22
src/app.js
22
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();
|
||||
|
||||
@@ -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 = `
|
||||
<section class="panel training-empty">
|
||||
<div class="panel-header"><h3>${escapeHtml(t("training.no_drivers_title"))}</h3></div>
|
||||
@@ -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 = `<option value="">${escapeHtml(t("training.overview_title"))}</option>` + 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) {
|
||||
<select id="training-class">${classOptions}</select>
|
||||
</label>
|
||||
<div class="actions training-hero-actions">
|
||||
<button class="btn" id="trainingBackOverview" type="button">${escapeHtml(t("training.back_overview"))}</button>
|
||||
${profileShare ? `
|
||||
<button class="btn btn-primary" id="trainingHeroOpenProfile" type="button">${escapeHtml(t("training.start_personal"))}</button>
|
||||
<button class="btn" id="trainingHeroCopyProfile" type="button">${escapeHtml(t("training.copy_mobile_link"))}</button>
|
||||
@@ -349,6 +355,11 @@ export function renderTrainingView(deps) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
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) => (
|
||||
`<option value="${escapeHtml(profile.id)}">${escapeHtml(profile.representative?.name || "-")}</option>`
|
||||
)).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 `
|
||||
<tr>
|
||||
<td>${index + 1}</td>
|
||||
<td><button class="training-club-driver" data-driver-id="${escapeHtml(row.profile.id)}" data-class-id="${escapeHtml(row.classId)}" type="button">${escapeHtml(row.driver?.name || "-")}</button></td>
|
||||
<td>${escapeHtml(row.className)}</td>
|
||||
<td>${row.stats.totalLaps}</td>
|
||||
<td>${row.stats.trainingDays}</td>
|
||||
<td>${formatLap(row.stats.bestLapMs)}</td>
|
||||
<td>${escapeHtml(lastActivity)}</td>
|
||||
<td>${milestone.targetLaps}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
dom.view.innerHTML = `
|
||||
<section class="training-hero training-overview-hero">
|
||||
<div>
|
||||
<span class="training-kicker">${escapeHtml(t("training.eyebrow"))}</span>
|
||||
<h3>${escapeHtml(t("training.overview_title"))}</h3>
|
||||
<p>${escapeHtml(t("training.overview_intro"))}</p>
|
||||
</div>
|
||||
<div class="training-hero-controls">
|
||||
<label class="training-driver-select">
|
||||
<span>${escapeHtml(t("training.driver"))}</span>
|
||||
<select id="training-overview-driver">
|
||||
<option value="">${escapeHtml(t("training.select_driver"))}</option>
|
||||
${driverOptions}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid cols-4 training-stats">
|
||||
${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"))}
|
||||
</div>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${escapeHtml(t("training.club_progress"))}</h3><span class="badge">${rows.length}</span></div>
|
||||
<div class="panel-body">
|
||||
<p class="hint">${escapeHtml(t("training.overview_class_hint"))}</p>
|
||||
<div class="table-wrap training-history-wrap">
|
||||
<table class="training-history-table training-club-table">
|
||||
<thead><tr><th>#</th><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.class"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.last_activity"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
|
||||
<tbody>${tableRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
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 `<article class="stat-card"><p>${label}</p><h3>${value}</h3><small>${note}</small></article>`;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
35
tests/training_views.test.js
Normal file
35
tests/training_views.test.js
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user