fixrefresh in mbil prof

This commit is contained in:
larssand
2026-09-05 09:49:19 +02:00
parent 130378cc38
commit bfe13de59c
5 changed files with 93 additions and 4 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -347,7 +347,19 @@ function renderPaceChart(points) {
const path = coordinates.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" ");
return `<div class="profile-pace-chart"><svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(t("paceChart"))}"><polyline class="profile-pace-line" points="${path}" />${coordinates.map((item) => `<circle cx="${item.x.toFixed(1)}" cy="${item.y.toFixed(1)}" r="5"><title>${escapeHtml(item.point.dateKey)}: ${escapeHtml(formatLap(item.point.averageLapMs))}</title></circle>`).join("")}</svg><div class="profile-chart-labels"><span>${escapeHtml(values[0].dateKey)}</span><strong>${escapeHtml(formatLap(values.at(-1).averageLapMs))}</strong><span>${escapeHtml(values.at(-1).dateKey)}</span></div></div>`;
}
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) {
<details class="profile-card profile-disclosure profile-performance ${visibility.performance ? "" : "is-hidden"}" data-persist-open="performance" ${openSections.has("performance") ? "open" : ""} style="margin-top:14px">
<summary class="profile-card-head"><h2>${escapeHtml(t("performance"))}</h2><span>+</span></summary>
<form id="publicTrainingMarkerForm" class="profile-marker-form">
<form id="publicTrainingMarkerForm" class="profile-marker-form" data-class-id="${escapeHtml(selectedClassId)}">
<div><h3>${escapeHtml(t("markerTitle"))}</h3><p>${escapeHtml(t("markerHint"))}</p></div>
<label><span>${escapeHtml(t("markerType"))}</span><select name="type"><option value="setup">${escapeHtml(t("markerSetup"))}</option><option value="tires">${escapeHtml(t("markerTires"))}</option><option value="suspension">${escapeHtml(t("markerSuspension"))}</option><option value="car">${escapeHtml(t("markerCar"))}</option><option value="note">${escapeHtml(t("markerNoteType"))}</option></select></label>
<label><span>${escapeHtml(t("markerNote"))}</span><input name="note" maxlength="160" /></label>
@@ -576,6 +588,12 @@ function renderProfile(data) {
</div>
</details>
`;
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 = `<section class="public-training-error"><div class="profile-brand-mark">RC</div><h1>${escapeHtml(t("loadError"))}</h1></section>`;
}
}
loadProfile();
setInterval(() => {
if (!document.hidden) loadProfile();
if (!document.hidden) loadProfile(true);
}, 10000);

View File

@@ -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/);
});

View File

@@ -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";