From bfe13de59c4461b4989bd0c7d8d2b5c835c994b5 Mon Sep 17 00:00:00 2001 From: larssand Date: Sat, 5 Sep 2026 09:49:19 +0200 Subject: [PATCH] fixrefresh in mbil prof --- README.md | 2 + README.sv.md | 2 + src/public_training.js | 30 +++++++++++-- tests/public_training_refresh.test.js | 62 +++++++++++++++++++++++++++ tests/run.js | 1 + 5 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 tests/public_training_refresh.test.js diff --git a/README.md b/README.md index e64bada..b8d21bb 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,8 @@ Each registered driver can have an optional personal mobile link: The URL uses a 192-bit random bearer token rather than a driver ID. The same link provides a class selector with fully separate lap times and practice results for each linked class. The public API does not return transponder IDs, other driver names, club ranking, race setup or admin state. The only server-side mobile action is adding a setup marker for the token owner and selected linked class. +Mobile auto-refresh pauses while editing a field or saving a car change. Unsaved car-change notes remain separate per class during page updates and class switching (until the page is reloaded). Temporary refresh failures keep the last loaded statistics visible. + The performance section also shows active 30-minute pace blocks and lets the driver mark a class-specific car/setup change. Open detail sections remain expanded during live polling. The mobile page also includes a local practice timer where the driver chooses minutes for `Maximum focus` and `Controlled`. The default is `10 + 10`, while a five-minute heat can for example use `5 + 0`. While a shared Free Practice session keeps running, the card shows that driver's lap count, best lap and average since the personal timer started. Timer state and duration choices stay in that phone's browser, separately per driver and class, and do not modify race data. Durations are locked while a session runs and can be changed after finishing or resetting it. diff --git a/README.sv.md b/README.sv.md index 898a87b..bdcebda 100644 --- a/README.sv.md +++ b/README.sv.md @@ -269,6 +269,8 @@ Varje registrerad förare kan få en valfri personlig mobillänk: Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Samma länk visar profilens klasser i ett klassval, med helt separata varvtider och träningsresultat. Det publika API:t lämnar inte ut transponder-ID, andra förares namn, klubbrankning, race setup eller admin-state. Den enda skrivande mobilfunktionen är att lägga till en setupmarkör för länkens förare och vald kopplad klass. +Mobilens automatiska uppdatering pausas medan du redigerar ett fält eller sparar en biländring. Osparade bilanteckningar behålls separat per klass vid uppdateringar och klassbyte (tills sidan laddas om). Vid tillfälliga uppdateringsfel ligger senast laddad statistik kvar. + Prestationssektionen visar även aktiva 30-minutersblock och låter föraren markera en klasspecifik bil- eller setupändring. Öppna detaljsektioner ligger kvar utfällda under live-polling. Mobilsidan har också en lokal träningstimer där föraren själv väljer minuter för `Maxfokus` och `Kontrollerad`. Standard är `10 + 10`, men ett femminutersheat kan exempelvis köras som `5 + 0`. Medan en gemensam Free Practice fortsätter vara igång visar kortet just den förarens antal varv, bästa varv och snitt sedan den personliga timern startades. Timer och tidsval sparas bara i mobilens webbläsare, separat per förare och klass, och ändrar ingen racedata. Tiderna låses medan passet pågår och kan ändras efter målgång eller nollställning. diff --git a/src/public_training.js b/src/public_training.js index 94d6de6..f0567f2 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -347,7 +347,19 @@ function renderPaceChart(points) { const path = coordinates.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" "); return `
${coordinates.map((item) => `${escapeHtml(item.point.dateKey)}: ${escapeHtml(formatLap(item.point.averageLapMs))}`).join("")}
${escapeHtml(values[0].dateKey)}${escapeHtml(formatLap(values.at(-1).averageLapMs))}${escapeHtml(values.at(-1).dateKey)}
`; } +const markerDrafts = new Map(); + +function profileIsBeingEdited() { + return (root.contains(document.activeElement) && document.activeElement.matches("input, select, textarea")) + || Boolean(root.querySelector("#publicTrainingMarkerForm button:disabled")); +} + function renderProfile(data) { + const previousForm = root.querySelector("#publicTrainingMarkerForm"); + if (previousForm) { + const values = new FormData(previousForm); + markerDrafts.set(previousForm.dataset.classId, { type: values.get("type"), note: values.get("note") }); + } const openSections = new Set([...root.querySelectorAll("details[data-persist-open][open]")].map((element) => element.dataset.persistOpen)); profileData = data; const { profile, branding } = data; @@ -531,7 +543,7 @@ function renderProfile(data) {

${escapeHtml(t("performance"))}

+
-
+

${escapeHtml(t("markerTitle"))}

${escapeHtml(t("markerHint"))}

@@ -576,6 +588,12 @@ function renderProfile(data) {
`; + const markerForm = document.getElementById("publicTrainingMarkerForm"); + const draft = markerDrafts.get(selectedClassId); + if (markerForm && draft) { + markerForm.elements.namedItem("type").value = draft.type; + markerForm.elements.namedItem("note").value = draft.note; + } document.getElementById("publicTrainingMarkerForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; @@ -770,7 +788,8 @@ function speak(text) { window.speechSynthesis.speak(utterance); } -async function loadProfile() { +async function loadProfile(background = false) { + if (background && profileIsBeingEdited()) return; try { const timerState = readTimerState(); const apiUrl = new URL(`/public-training/api/${encodeURIComponent(shareToken)}`, window.location.origin); @@ -784,13 +803,16 @@ async function loadProfile() { } const response = await fetch(apiUrl, { cache: "no-store" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - renderProfile(await response.json()); + const data = await response.json(); + if (background && profileIsBeingEdited()) return; + renderProfile(data); } catch { + if (background && profileData) return; root.innerHTML = `
RC

${escapeHtml(t("loadError"))}

`; } } loadProfile(); setInterval(() => { - if (!document.hidden) loadProfile(); + if (!document.hidden) loadProfile(true); }, 10000); diff --git a/tests/public_training_refresh.test.js b/tests/public_training_refresh.test.js new file mode 100644 index 0000000..6e7dd88 --- /dev/null +++ b/tests/public_training_refresh.test.js @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; + +const source = fs.readFileSync(new URL("../src/public_training.js", import.meta.url), "utf8"); + +function profileHarness() { + const ui = { editing: false, saving: false, requests: 0, renders: 0, html: "existing profile" }; + const root = { + contains: () => ui.editing, + querySelector: () => ui.saving, + set innerHTML(value) { ui.html = value; }, + }; + const context = vm.createContext({ + URL, URLSearchParams, + document: { getElementById: () => root, activeElement: { matches: () => true } }, + window: { location: { pathname: "/public-training/test", search: "?lang=sv", origin: "http://localhost" } }, + fetch: async () => { ui.requests++; return { ok: true, json: async () => ({}) }; }, + onRender: () => { ui.renders++; }, + }); + vm.runInContext(source.slice(0, source.lastIndexOf("\nloadProfile();")), context); + vm.runInContext('readTimerState = () => null; renderProfile = () => onRender(); profileData = {};', context); + return { ui, context, refresh: () => vm.runInContext("loadProfile(true)", context) }; +} + +test("mobile polling leaves an active field or pending save untouched", async () => { + const { ui, refresh } = profileHarness(); + ui.editing = true; + await refresh(); + ui.editing = false; + ui.saving = true; + await refresh(); + assert.equal(ui.requests, 0); + assert.equal(ui.renders, 0); + ui.saving = false; + await refresh(); + assert.equal(ui.renders, 1); +}); + +test("mobile polling rechecks editing after an in-flight response", async () => { + const { ui, context, refresh } = profileHarness(); + context.fetch = async () => ({ ok: true, json: async () => { ui.editing = true; return {}; } }); + await refresh(); + assert.equal(ui.renders, 0); +}); + +test("mobile polling retains loaded statistics after a network failure", async () => { + const { ui, context, refresh } = profileHarness(); + context.fetch = async () => { throw new Error("offline"); }; + await refresh(); + assert.equal(ui.html, "existing profile"); + assert.equal(ui.renders, 0); +}); + +test("an initial mobile load failure still displays the error", async () => { + const { ui, context, refresh } = profileHarness(); + vm.runInContext("profileData = null", context); + context.fetch = async () => { throw new Error("offline"); }; + await refresh(); + assert.match(ui.html, /public-training-error/); +}); diff --git a/tests/run.js b/tests/run.js index 97251fd..36a2f74 100644 --- a/tests/run.js +++ b/tests/run.js @@ -14,3 +14,4 @@ import "./server_persistence.integration.test.js"; import "./training_views.test.js"; import "./session_replay.test.js"; +import "./public_training_refresh.test.js";