From b3bec531de368a04b0e9e7e532d6e5cf466fb938 Mon Sep 17 00:00:00 2001 From: larssand Date: Tue, 8 Sep 2026 19:27:37 +0200 Subject: [PATCH] connecton satus mobil profile --- CHANGELOG.md | 4 ++ README.md | 4 ++ README.sv.md | 4 ++ ROADMAP.md | 4 +- docs/javascript-architecture.md | 8 ++++ public-training.html | 4 ++ src/app.js | 4 +- src/public_training.css | 5 ++ src/public_training.js | 54 ++++++++++++++++++++- tests/browser_smoke.js | 16 +++++++ tests/public_training_refresh.test.js | 69 ++++++++++++++++++++++++++- 11 files changed, 169 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e67a70d..37b278e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The application version is maintained in `package.json` and displayed in the adm ## Unreleased +### Added + +- Personal mobile profiles show last successful data retrieval, stale-data and failed-update warnings, and a Retry button without replacing loaded statistics or active forms. Requests time out after 15 seconds and cannot overlap. + ## 1.1.2 - 2026-09-08 ### Reliability checks diff --git a/README.md b/README.md index 2658012..20be299 100644 --- a/README.md +++ b/README.md @@ -853,3 +853,7 @@ Open **Settings > Decoder / socket diagnostics** to inspect Main, Split, Pit ent Use the AMMC host IP in WebSocket URLs (for example `ws://192.168.1.75:9000`), not the physical decoder IP. Give each AMMC channel its own port; enable only installed channels. `127.0.0.1` means the computer running the browser. Frames include all received WebSocket messages; PASSING counts recognized passing messages, not accepted laps. Handled includes split/pit events and start references. Ignored, invalid laps and JSON errors are separate. Select a channel to inspect its latest raw frame and 50 diagnostic events. Pause freezes only this display; Clear resets only that channel's diagnostic counters/log, preserving connection timestamps. Copy provides a manual fallback on plain HTTP. Diagnostics are local to this browser, memory-only, capped at 512 characters per sample, and reset on page reload. They neither change timing nor expose a new public API. Logs may contain transponder numbers; review before sharing. + +### Mobile Profile Connection Status + +The personal training link displays the last successful data retrieval. After 30 seconds without applying fresh data, it warns that statistics may be outdated (including when updates are deferred during input). A failed request keeps existing statistics and shows **Retry**. Finish any active input before retrying. Requests time out after 15 seconds; automatic polling continues every 10 seconds while the page is visible, without overlapping requests. The status describes profile retrieval, not decoder connectivity or confirmation that a car-change marker was saved. diff --git a/README.sv.md b/README.sv.md index 732b73a..e169b35 100644 --- a/README.sv.md +++ b/README.sv.md @@ -842,3 +842,7 @@ Webbläsartesterna täcker även sponsor-event, endurance-raceguiden, skapa/redi Använd AMMC-datorns IP i WebSocket-url (exempel `ws://192.168.1.75:9000`), inte den fysiska decoderns IP. Varje AMMC-kanal behöver egen port; aktivera bara installerade kanaler. `127.0.0.1` avser datorn med webbläsaren. Frames räknar mottagna socketmeddelanden; PASSING är passeringsmeddelanden, inte godkända varv. Hanterade inkluderar mellantider, depåhändelser och startreferenser. Ignorerade, ogiltiga varv och JSON-fel visas separat. Välj kanal för senaste råmeddelandet och 50 händelser. Pausa stoppar bara visningen. Rensa nollställer kanalens diagnostikräknare/logg men behåller anslutningstider. Kopiera erbjuder manuell kopiering på HTTP. Diagnostiken gäller bara denna webbläsare, sparas endast i minnet och försvinner vid omladdning. Varje exempel begränsas till 512 tecken. Tidtagningen påverkas inte och inget nytt publikt API tillkommer. Loggen kan innehålla transpondernummer; granska innan delning. + +### Anslutningsstatus i Mobilprofilen + +Den personliga träningslänken visar när data senast hämtades. Efter 30 sekunder utan tillämpad uppdatering visas en varning om att statistiken kan vara inaktuell, även om uppdateringen väntar på att du skriver klart. Vid hämtningsfel ligger statistiken kvar och **Försök igen** visas. Avsluta aktiv inmatning före återförsök. Hämtningar avbryts efter 15 sekunder; automatisk hämtning fortsätter var tionde sekund när sidan är synlig, utan överlappande anrop. Statusen gäller profilhämtningen, inte decoderanslutningen eller att en biländringsmarkering har sparats. diff --git a/ROADMAP.md b/ROADMAP.md index d35b4bb..fa6c215 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,7 +38,7 @@ Acceptance: users can distinguish server contact, pending edits, an active save - [x] Warn when the server connection is lost or its status becomes stale. - [x] Offer manual retry for unsaved changes without discarding local edits. - [x] Verify save failure/reconnection in a real browser. -- [ ] Extend stale-data feedback to the personal mobile profile. +- [x] Extend stale-data feedback to the personal mobile profile: last applied retrieval, 30-second stale warning, timeout and Retry without discarding loaded data. - [ ] Review concurrent writes, reconnect recovery and multiple controllers editing the same race. ### Implemented: decoder/socket diagnostics @@ -54,6 +54,8 @@ Acceptance: each configured channel distinguishes an open socket from actual rec - [ ] Verify automatic reconnect against a real decoder/network interruption. - [x] Document IP/port/channel setup and the difference between decoder connection and incoming data. +Verified 2026-09-08: 128 logic/API tests and browser smoke tests passed, including mobile profile retention during network loss and recovery via Retry. Real decoder/network reconnect remains a hardware check. + ## 3. Clearer practice follow-up Acceptance: a driver receives a short, encouraging summary based on their own class-specific data, with one useful next goal. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 9daefab..c5fa4a6 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -2675,3 +2675,11 @@ Dependency graph: `app.js -> decoder_runtime.js -> decoder_diagnostics.js`; `app Reason codes: invalid_message (not an object), not_passing, no_running_session, missing_transponder, not_registered, already_in_pit, pit_exit_without_entry, split_without_reference, split_locktime, below_min (stored invalid lap). Handled events: pit_entry, pit_exit, split, start_reference, lap_counted or the existing validation warning. invalid_json means parsing failed before processing. The raw frame helps diagnose upstream protocol problems without changing parsing behavior. Tests: decoder_diagnostics.test.js covers bounds, isolation, snapshots and outcomes; decoder_runtime.test.js exercises socket reception/JSON errors/disconnection/open status without settings rerender. browser_smoke.js verifies live diagnostics update while an edited settings URL remains unchanged. Hardware/network reconnect behavior still requires a real-decoder smoke test. + +## Mobile Profile Retrieval Status + +`public-training.html` owns a connection-status container outside `publicTrainingRoot`. `src/public_training.js` keeps a browser-memory `profileConnection` (lastSuccessAt, failed, loading) and patches only status text, state attribute and retry button each second. No additional script dependency or reverse-proxy allowlist route is needed. `src/public_training.css` supplies compact theme-aware status styling. + +`loadProfile` serializes requests, uses AbortController with a 15-second timer, and releases its lock in finally. Existing 10-second visible-page polling and before/after-response edit guards remain. Only a successfully applied profile updates lastSuccessAt; responses deferred due to active editing never falsely refresh it. Failed background requests preserve loaded DOM and data. A 30-second age marks data stale. Retry uses the same background-safe load path. Status is not a write acknowledgement or decoder health indicator. This change adds no persistence or server/API contract. + +`tests/public_training_refresh.test.js` exercises stale/failure/recovery, non-overlap, deferred responses, independent status DOM and timeout recovery. Browser smoke coverage simulates network loss on a real personal profile and retries after network restoration using isolated state. diff --git a/public-training.html b/public-training.html index 2e5d624..70c7fdc 100644 --- a/public-training.html +++ b/public-training.html @@ -11,6 +11,10 @@ +
diff --git a/src/app.js b/src/app.js index 72b699f..822c26d 100644 --- a/src/app.js +++ b/src/app.js @@ -1638,7 +1638,7 @@ const TRANSLATIONS = { "guide.training_6": "6. Träningsloggen uppdateras av domarkorrigeringar medan sessionen finns, men behålls när sessionen senare raderas. Full dataexport i Inställningar inkluderar både logg och mål.", "guide.training_7": "7. Öppna Mål och mobilprofil och klicka Aktivera mobilprofil. Kopiera den personliga länken och öppna den på förarens eller förälderns mobil.", "guide.training_8": "8. Mobilprofilen visar bara vald förares statistik. Föraren väljer minuter för Maxfokus och Kontrollerad körning och kan markera en biländring för vald klass. Inga race- eller registerinställningar kan ändras.", - "guide.training_9": "9. Klicka Skapa ny länk eller Stäng av profil om länken spridits fel. Publicera bara `/public-training/*`, `/src/public_training.js` och `/src/public_training.css` externt, aldrig hela admin-sidan eller `/api/state`.", + "guide.training_9": "9. Mobilprofilens statusrad visar senaste hämtning och varnar vid gammal data eller hämtningsfel. Skriv klart i aktiva fält och använd Försök igen vid behov; statistiken ligger kvar. Klicka Skapa ny länk eller Stäng av profil om länken spridits fel. Publicera bara `/public-training/*`, `/src/public_training.js` och `/src/public_training.css` externt, aldrig hela admin-sidan eller `/api/state`.", "guide.training_10": "10. När Open Practice, Free Practice eller Practice pågår visar Träning ett livekort för vald förare med varv, bästa varv, snitt och senaste varv. Alla förare kör fortfarande i samma gemensamma session.", "guide.training_11": "11. Knappen högst upp skapar eller öppnar den valda förarens personliga mobillänk. Föraren ställer in och startar sin egen timer i mobilen utan att starta om eller påverka den gemensamma practice-sessionen. Tiden sparas separat per förare och klass.", "guide.training_12": "12. Ett glapp över 2 minuter mellan passeringar startar automatiskt en ny stint. Ett långt pausvarv används som gräns men räknas inte som prestationsvarv i stintstatistiken.", @@ -3024,7 +3024,7 @@ const TRANSLATIONS = { "guide.training_6": "6. The training ledger follows judging corrections while the source session exists, but remains when that session is later deleted. Full data export in Settings includes both ledger and goals.", "guide.training_7": "7. Open Goals and mobile profile, then click Enable mobile profile. Copy the personal link and open it on the driver's or parent's phone.", "guide.training_8": "8. The mobile profile only shows the selected driver's statistics. The driver chooses Maximum focus and Controlled minutes and may mark a car change for the selected class. Race and register settings cannot be changed.", - "guide.training_9": "9. Click Create new link or Disable profile if the link is shared incorrectly. Expose only `/public-training/*`, `/src/public_training.js` and `/src/public_training.css` externally, never the full admin site or `/api/state`.", + "guide.training_9": "9. The mobile status line shows the last retrieval and warns about stale data or failed updates. Finish active input and use Retry if needed; loaded statistics remain visible. Click Create new link or Disable profile if the link is shared incorrectly. Expose only `/public-training/*`, `/src/public_training.js` and `/src/public_training.css` externally, never the full admin site or `/api/state`.", "guide.training_10": "10. While Open Practice, Free Practice, or Practice is running, Training shows a live card for the selected driver with laps, best lap, average, and latest lap. Every driver still uses the same shared session.", "guide.training_11": "11. The top action creates or opens the selected driver's personal mobile link. The driver configures and starts an individual timer on the phone without restarting or changing the shared practice session. Time is stored separately per driver and class.", "guide.training_12": "12. A gap longer than 2 minutes between passings automatically starts a new stint. A long return lap defines the boundary but is excluded from stint performance metrics.", diff --git a/src/public_training.css b/src/public_training.css index 2130c81..5e26ff2 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -1354,3 +1354,8 @@ button { overflow-wrap: anywhere; } } + +.public-training-connection { width: min(980px, 100%); margin: 0 auto; padding: 8px 16px 0; display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--muted); } +.public-training-connection[data-state="failed"], .public-training-connection[data-state="stale"] { color: var(--gold); } +.public-training-connection button { flex-shrink: 0; padding: 6px 10px; color: var(--text); background: var(--surface); border: 1px solid var(--line); border-radius: 6px; } +.public-training-connection button[hidden] { display: none; } diff --git a/src/public_training.js b/src/public_training.js index f0567f2..58ac024 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -10,6 +10,13 @@ let selectedClassId = new URLSearchParams(window.location.search).get("class") | const messages = { sv: { + connection_loading: "Hämtar statistik...", + connection_current: "Statistiken är uppdaterad.", + connection_failed: "Uppdateringen misslyckades. Visar senast hämtade data om de finns.", + connection_stale: "Statistiken kan vara inaktuell. Uppdatering väntar; avsluta eventuell inmatning.", + connection_last: "Senast hämtad {time}.", + connection_retry: "Försök igen", + connection_retrying: "Hämtar...", loading: "Laddar träningsprofil...", loadError: "Kunde inte läsa träningsprofilen. Länken kan vara avstängd eller ersatt.", development: "Förarutveckling", @@ -130,6 +137,13 @@ const messages = { challengeProgress: "{current} / {target}", }, en: { + connection_loading: "Loading statistics...", + connection_current: "Statistics are up to date.", + connection_failed: "Update failed. Showing previously loaded data if available.", + connection_stale: "Statistics may be outdated. Update pending; finish any active input.", + connection_last: "Last loaded {time}.", + connection_retry: "Retry", + connection_retrying: "Loading...", loading: "Loading training profile...", loadError: "The training profile could not be loaded. The link may be disabled or replaced.", development: "Driver development", @@ -788,8 +802,35 @@ function speak(text) { window.speechSynthesis.speak(utterance); } +const profileConnection = { lastSuccessAt: null, failed: false, loading: false }; + +function getProfileConnectionState(now = Date.now()) { + if (profileConnection.failed) return "failed"; + if (profileConnection.lastSuccessAt !== null && now - profileConnection.lastSuccessAt >= 30000) return "stale"; + return profileConnection.lastSuccessAt === null ? "loading" : "current"; +} + +function updateProfileConnectionStatus() { + const status = document.getElementById("publicTrainingConnection"); + const label = document.getElementById("publicTrainingConnectionText"); + const retry = document.getElementById("publicTrainingRetry"); + if (!status || !label || !retry) return; + const state = getProfileConnectionState(); + status.dataset.state = state; + const last = profileConnection.lastSuccessAt; + const text = t(`connection_${state}`) + (last === null ? "" : " " + t("connection_last", { time: new Date(last).toLocaleTimeString(language) })); + if (label.textContent !== text) label.textContent = text; + retry.textContent = t(profileConnection.loading ? "connection_retrying" : "connection_retry"); + retry.hidden = state === "current" || (state === "loading" && !profileConnection.failed); + retry.disabled = profileConnection.loading; +} + async function loadProfile(background = false) { - if (background && profileIsBeingEdited()) return; + if (profileConnection.loading || (background && profileIsBeingEdited())) return; + profileConnection.loading = true; + updateProfileConnectionStatus(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); try { const timerState = readTimerState(); const apiUrl = new URL(`/public-training/api/${encodeURIComponent(shareToken)}`, window.location.origin); @@ -801,18 +842,27 @@ async function loadProfile(background = false) { apiUrl.searchParams.set("startedAgoMs", String(startedAgoMs)); apiUrl.searchParams.set("windowMs", String(Math.min(startedAgoMs, maximumWindowMs))); } - const response = await fetch(apiUrl, { cache: "no-store" }); + const response = await fetch(apiUrl, { cache: "no-store", signal: controller.signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); if (background && profileIsBeingEdited()) return; renderProfile(data); + profileConnection.lastSuccessAt = Date.now(); + profileConnection.failed = false; } catch { + profileConnection.failed = true; if (background && profileData) return; root.innerHTML = `
RC

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

`; + } finally { + clearTimeout(timeout); + profileConnection.loading = false; + updateProfileConnectionStatus(); } } loadProfile(); +document.getElementById("publicTrainingRetry")?.addEventListener("click", () => loadProfile(true)); +setInterval(updateProfileConnectionStatus, 1000); setInterval(() => { if (!document.hidden) loadProfile(true); }, 10000); diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 68a650e..697569f 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -133,6 +133,22 @@ try { })()`); console.log("PASS decoder diagnostics update without resetting settings draft"); await runOverlaySmoke({ evaluate }); + const mobileState = await savedState(); + const mobileToken = "browser-mobile-profile-token-1234567890"; + mobileState.training ||= {}; + mobileState.training.shares = [{ driverId: mobileState.drivers[0].id, token: mobileToken, enabled: true }]; + const mobileSave = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(mobileState) }); + assert.ok(mobileSave.ok); + await send("Page.navigate", { url: base + "/public-training/" + mobileToken + "?lang=sv" }); + await waitFor(async () => await evaluate('document.getElementById("publicTrainingConnection")?.dataset.state === "current"'), "mobile profile loaded"); + await evaluate('window.mobileContentBeforeFailure = document.getElementById("publicTrainingRoot").firstElementChild'); + await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); + await waitFor(async () => await evaluate('document.getElementById("publicTrainingConnection")?.dataset.state === "failed"'), "mobile connection warning"); + assert.ok(await evaluate('window.mobileContentBeforeFailure === document.getElementById("publicTrainingRoot").firstElementChild')); + await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); + await evaluate('document.getElementById("publicTrainingRetry").click()'); + await waitFor(async () => await evaluate('document.getElementById("publicTrainingConnection")?.dataset.state === "current"'), "mobile retry recovered"); + console.log("PASS mobile offline status preserves profile nodes and retry recovers"); assert.deepEqual(errors, []); console.log("PASS offline save failure, local draft retention, retry and confirmed saved status"); } finally { diff --git a/tests/public_training_refresh.test.js b/tests/public_training_refresh.test.js index 6e7dd88..ec6f08f 100644 --- a/tests/public_training_refresh.test.js +++ b/tests/public_training_refresh.test.js @@ -13,8 +13,8 @@ function profileHarness() { set innerHTML(value) { ui.html = value; }, }; const context = vm.createContext({ - URL, URLSearchParams, - document: { getElementById: () => root, activeElement: { matches: () => true } }, + URL, URLSearchParams, AbortController, setTimeout, clearTimeout, + document: { getElementById: (id) => id === "publicTrainingRoot" ? root : null, 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++; }, @@ -60,3 +60,68 @@ test("an initial mobile load failure still displays the error", async () => { await refresh(); assert.match(ui.html, /public-training-error/); }); + +test("mobile status detects stale data and recovery after failure", async () => { + const { context, refresh } = profileHarness(); + await refresh(); + assert.equal(vm.runInContext('getProfileConnectionState()', context), "current"); + assert.equal(vm.runInContext('getProfileConnectionState(profileConnection.lastSuccessAt + 30000)', context), "stale"); + context.fetch = async () => { throw new Error("offline"); }; + await refresh(); + assert.equal(vm.runInContext('getProfileConnectionState()', context), "failed"); + context.fetch = async () => ({ ok: true, json: async () => ({}) }); + await refresh(); + assert.equal(vm.runInContext('getProfileConnectionState()', context), "current"); +}); + +test("mobile requests cannot overlap and a skipped edit never refreshes the timestamp", async () => { + const { context, ui, refresh } = profileHarness(); + let resolve; + context.fetch = () => { ui.requests++; return new Promise(done => { resolve = done; }); }; + const pending = refresh(); + await refresh(); + assert.equal(ui.requests, 1); + ui.editing = true; + resolve({ ok: true, json: async () => ({}) }); + await pending; + assert.equal(vm.runInContext('profileConnection.lastSuccessAt', context), null); + assert.equal(vm.runInContext('profileConnection.loading', context), false); +}); + +test("mobile status updates its own nodes without replacing loaded profile", async () => { + const { context, ui, refresh } = profileHarness(); + const nodes = { + publicTrainingConnection: { dataset: {} }, + publicTrainingConnectionText: { textContent: "" }, + publicTrainingRetry: {}, + }; + context.document.getElementById = id => nodes[id] || null; + await refresh(); + assert.equal(nodes.publicTrainingConnection.dataset.state, "current"); + assert.equal(nodes.publicTrainingRetry.hidden, true); + context.fetch = async () => { throw new Error("offline"); }; + await refresh(); + assert.equal(nodes.publicTrainingConnection.dataset.state, "failed"); + assert.equal(nodes.publicTrainingRetry.hidden, false); + assert.equal(nodes.publicTrainingRetry.disabled, false); + assert.equal(ui.html, "existing profile"); + assert.equal(ui.renders, 1); +}); + +test("a timed-out mobile fetch releases its lock and allows retry", async () => { + const { context, refresh } = profileHarness(); + let expire; + context.setTimeout = callback => { expire = callback; return 1; }; + context.clearTimeout = () => {}; + context.fetch = (_, { signal }) => new Promise((resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("aborted"))); + }); + const pending = refresh(); + expire(); + await pending; + assert.equal(vm.runInContext('profileConnection.loading', context), false); + assert.equal(vm.runInContext('getProfileConnectionState()', context), "failed"); + context.fetch = async () => ({ ok: true, json: async () => ({}) }); + await refresh(); + assert.equal(vm.runInContext('getProfileConnectionState()', context), "current"); +});