From 4a747857914fa3d336725e2879ac2ec44226acf9 Mon Sep 17 00:00:00 2001 From: larssand Date: Mon, 24 Aug 2026 16:54:35 +0200 Subject: [PATCH] Add secure public driver training profiles --- README.md | 23 ++ README.sv.md | 23 ++ docs/javascript-architecture.md | 108 ++++++++- public-training.html | 22 ++ server.js | 201 +++++++++++++++- src/app.js | 43 +++- src/misc_views.js | 2 +- src/public_training.css | 403 ++++++++++++++++++++++++++++++++ src/public_training.js | 348 +++++++++++++++++++++++++++ src/styles.css | 52 +++++ src/training_logic.js | 20 +- src/training_views.js | 127 ++++++++++ 12 files changed, 1360 insertions(+), 12 deletions(-) create mode 100644 public-training.html create mode 100644 src/public_training.css create mode 100644 src/public_training.js diff --git a/README.md b/README.md index 891abdf..aa8a8a8 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,29 @@ Training statistics currently include only valid laps linked to a registered dri Judging corrections update ledger laps while the source session still exists. Once a session is deleted, its synchronized training laps remain in the ledger so lifetime progression is not lost. Full data export includes the ledger and reward goals. +### Read-only mobile profile + +Each registered driver can have an optional personal mobile link: + +1. Open `Training` and select the driver. +2. Click `Enable mobile profile`. +3. Copy or open the generated link. +4. Use `Create new link` to revoke the previous URL, or `Disable profile` to turn sharing off. + +The URL uses a 192-bit random bearer token rather than a driver ID. The public API returns only that driver's display name, class/brand, aggregate practice statistics, daily summaries, goals and reward text. It does not return transponder IDs, other driver names, race setup or admin state. + +The mobile page also includes a local 20-minute practice timer: 10 minutes maximum focus followed by 10 minutes controlled 80-90% driving. Timer state stays in that phone's browser and does not modify race data. + +For internet publishing, proxy only these paths: + +```text +/public-training/* +/src/public_training.js +/src/public_training.css +``` + +Do not expose `/api/state` or the full admin application. Static serving is restricted to `/src` and `/docs`; `data/`, server scripts and bundled binaries are not public static files. + ## Create Race Wizard - `Race Setup` now starts with a four-step wizard for new races: diff --git a/README.sv.md b/README.sv.md index 79ccd2d..1df2b02 100644 --- a/README.sv.md +++ b/README.sv.md @@ -176,6 +176,29 @@ Träningsstatistiken räknar just nu bara giltiga varv som är kopplade till en Domarkorrigeringar uppdaterar loggade varv så länge källsessionen finns kvar. När sessionen senare tas bort ligger de synkade träningsvarven kvar så förarens livstidsutveckling inte försvinner. Full dataexport inkluderar både varvloggen och belöningsmålen. +### Skrivskyddad mobilprofil + +Varje registrerad förare kan få en valfri personlig mobillänk: + +1. Öppna `Träning` och välj föraren. +2. Klicka `Aktivera mobilprofil`. +3. Kopiera eller öppna länken som skapas. +4. Använd `Skapa ny länk` för att återkalla den gamla eller `Stäng av profil` för att stoppa delningen. + +Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Det publika API:t lämnar bara ut den valda förarens visningsnamn, klass/märke, summerad träningsstatistik, dagshistorik, mål och belöningstext. Transponder-ID, andra förares namn, race setup och admin-state lämnas inte ut. + +Mobilsidan har också en lokal 20-minuterstimer: 10 minuter maxfokus följt av 10 minuter kontrollerad körning på 80-90 procent. Timern sparas bara i mobilens webbläsare och ändrar ingen racedata. + +Vid extern publicering ska bara dessa paths proxas: + +```text +/public-training/* +/src/public_training.js +/src/public_training.css +``` + +Exponera inte `/api/state` eller hela admin-appen. Statisk servering är begränsad till `/src` och `/docs`; `data/`, serverscripts och bundlade binärer publiceras inte som statiska filer. + ## Create Race Wizard - `Race Setup` startar nu med en fyrstegs wizard för nya race: diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index f76d9c8..8881c14 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -126,6 +126,7 @@ Owns: - file watching for app version - AMMC config + process state - public overlay route serving via same frontend shell +- tokenized public driver training profile API and standalone mobile shell - PDF export endpoint Main API groups: @@ -138,11 +139,20 @@ Main API groups: - `/api/ammc/start` - `/api/ammc/stop` - `/api/export/pdf` +- `/public-training/api/:shareToken` Static routes of interest: - `/` - `/public-overlay` - `/public-overlay/obs` +- `/public-training/:shareToken` + +Runtime environment variables: +- `PORT`: HTTP port, default `8081` +- `HOST`: bind address, default `0.0.0.0` +- `RC_TIMING_DATA_DIR`: optional absolute/relative data directory override; defaults to `/data` +- `RC_TIMING_PID_FILE`: optional PID-file path used by process control +- `PUBLIC_OVERLAY_TOKEN`: optional shared token for public overlay routes ### `scripts/serverctl.js` Role: @@ -428,12 +438,48 @@ Owns: Mutations: - replaces or appends entries in `state.training.goals` +- creates, rotates, enables, or disables entries in `state.training.shares` - always calls injected `saveState()` before rerender Route behavior: - `/?view=training` opens the training view directly - `driver=` selects a driver and supports mobile bookmarks -- this is currently the same trusted-LAN admin shell, not a separate public read-only profile route +- an explicitly enabled driver receives `/public-training/` as a separate read-only profile +- replacing or disabling the share invalidates its old route immediately after state sync + +### `public-training.html` +Type: +- standalone public-profile entry document + +Loads: +- `/src/public_training.css` +- `/src/public_training.js` + +Served only after `server.js` validates the enabled share token in `/public-training/:shareToken`. + +### `src/public_training.js` +Type: +- standalone public profile frontend + +Owns: +- fetching only `/public-training/api/:shareToken` +- rendering one driver's sanitized statistics, goals, rewards, and daily history +- local 20-minute practice timer with 10-minute push/control phases +- Swedish/English copy selected by `lang` query or browser locale + +Important isolation: +- does not import `app.js` +- does not call `/api/state` +- does not mutate backend or race state +- timer state is stored in the mobile browser under a token-scoped localStorage key + +### `src/public_training.css` +Type: +- standalone responsive public-profile stylesheet + +Owns: +- phone-first layout independent from the admin sidebar/theme system +- profile, level, timer, goals, and daily-history presentation ## 3. Direct Import Dependencies @@ -832,6 +878,9 @@ sequenceDiagram | `src/settings.js` | view/render | none | `app.js` | yes | no | no | | `src/training_logic.js` | logic | none | `app.js` | no | no | no, returns normalized/derived data | | `src/training_views.js` | view/controller | none | `app.js` | yes | no | yes, through injected state access | +| `public-training.html` | standalone entry | `public_training.css`, `public_training.js` | `server.js` route | root only | no | no | +| `src/public_training.js` | standalone public view | none | `public-training.html` | yes | local timer only | no backend mutation | +| `src/public_training.css` | standalone stylesheet | n/a | `public-training.html` | n/a | no | no | | `server.js` | backend runtime | node core + deps | npm/node | n/a | backend process state | yes | | `scripts/serverctl.js` | process control | node core | npm scripts | n/a | no | pid/log files only | @@ -1442,7 +1491,8 @@ This bucket is the most dynamic part of state and is mutated heavily during live ```js { goals: TrainingGoal[], - ledger: TrainingLap[] + ledger: TrainingLap[], + shares: TrainingShare[] } ``` @@ -1460,6 +1510,24 @@ This bucket is the most dynamic part of state and is mutated heavily during live } ``` +`TrainingShare`: + +```js +{ + driverId: string, + token: string, // 24 random bytes encoded as base64url (192 bits) + enabled: boolean, + createdAt: string +} +``` + +Share rules: +- no share exists until an admin explicitly enables the selected driver profile +- only one normalized share entry is retained per driver +- disabled shares fail public route lookup +- rotating a share replaces its token; the previous URL no longer resolves after persistence +- tokens are generated in the browser with `crypto.getRandomValues()`, never from the shorter UI `uid()` helper + `TrainingLap`: ```js @@ -1814,13 +1882,45 @@ Routes: - `/public-overlay/:mode` Behavior: -- if `PUBLIC_OVERLAY_TOKEN` is set, query param `token` must match -- otherwise returns `403 Forbidden` +- if `PUBLIC_OVERLAY_TOKEN` is set, query param `token` must match or the route returns `403 Forbidden` +- if no token environment variable is set, the overlay route is available without a query token - on success serves `index.html` Use for: - public OBS/browser-source overlays without exposing admin path directly +## 16.14 Public training profile routes + +Routes: +- page: `GET /public-training/:shareToken` +- data: `GET /public-training/api/:shareToken` + +Authorization: +- `shareToken` must match an enabled entry in `state.training.shares` +- token length is at least 24 characters after normalization +- missing, disabled, replaced, or unknown tokens return `404` rather than revealing whether a driver exists + +Sanitized response fields: +- selected driver display name, class, and brand +- numeric club rank without other driver names +- aggregate totals, best/average time, consistency, controlled streak, and daily summaries +- next automatic milestone +- recommendation level and target lap +- selected driver's goal titles, reward text, progress, and claimed state +- public club branding and safe PNG/JPEG/WebP logo data + +Explicitly excluded: +- transponder IDs +- driver IDs and names of other drivers +- cars, events, sessions, assignments, and raw passings +- race settings and decoder/AMMC configuration +- complete `/api/state` payload + +Static serving boundary: +- Express serves static files only under `/src` and `/docs` +- `data/`, `AMMC/`, `server.js`, scripts, manifests, and package files are not static downloads +- `public-training.html` and `index.html` are served only by explicit routes/fallback handlers + ## 17. Regression History / Known Gotchas This section captures bugs already seen in this codebase after the modular split. diff --git a/public-training.html b/public-training.html new file mode 100644 index 0000000..2e5d624 --- /dev/null +++ b/public-training.html @@ -0,0 +1,22 @@ + + + + + + + RC Training Profile + + + + + + +
+
+ +

Laddar träningsprofil...

+
+
+ + + diff --git a/server.js b/server.js index 4558b01..aa427ec 100644 --- a/server.js +++ b/server.js @@ -7,13 +7,14 @@ const Database = require("better-sqlite3"); const PORT = Number(process.env.PORT || 8081); const HOST = process.env.HOST || "0.0.0.0"; -const DB_DIR = path.join(__dirname, "data"); +const DB_DIR = process.env.RC_TIMING_DATA_DIR ? path.resolve(process.env.RC_TIMING_DATA_DIR) : path.join(__dirname, "data"); const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite"); const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json"); const PID_FILE = process.env.RC_TIMING_PID_FILE || ""; const SRC_DIR = path.join(__dirname, "src"); const WATCHED_FILES = [ path.join(__dirname, "index.html"), + path.join(__dirname, "public-training.html"), ...fs.readdirSync(SRC_DIR) .filter((fileName) => /\.(?:js|css)$/i.test(fileName)) .map((fileName) => path.join(SRC_DIR, fileName)), @@ -280,8 +281,38 @@ app.get(["/public-overlay", "/public-overlay/:mode"], (req, res) => { res.sendFile(path.join(__dirname, "index.html")); }); +app.get("/public-training/api/:shareToken", (req, res) => { + try { + const stored = readStoredAppState(); + const profile = stored ? buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt) : null; + if (!profile) { + res.status(404).json({ error: "Profile not found" }); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.json(profile); + } catch (error) { + res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); + } +}); + +app.get("/public-training/:shareToken", (req, res) => { + try { + const stored = readStoredAppState(); + if (!stored || !hasPublicTrainingShare(stored.state, String(req.params.shareToken || ""))) { + res.status(404).type("text/plain").send("Profile not found"); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.sendFile(path.join(__dirname, "public-training.html")); + } catch { + res.status(404).type("text/plain").send("Profile not found"); + } +}); + app.use( - express.static(__dirname, { + "/src", + express.static(SRC_DIR, { setHeaders: (res, filePath) => { if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) { res.setHeader("Cache-Control", "no-store"); @@ -290,6 +321,8 @@ app.use( }) ); +app.use("/docs", express.static(path.join(__dirname, "docs"))); + app.get("*", (_req, res) => { res.sendFile(path.join(__dirname, "index.html")); }); @@ -335,6 +368,170 @@ function initSchema() { `); } +function readStoredAppState() { + const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get(); + if (!row?.state_json) return null; + return { state: JSON.parse(row.state_json), updatedAt: row.updated_at }; +} + +function findPublicTrainingShare(state, shareToken) { + if (!shareToken || shareToken.length < 24) return null; + const shares = Array.isArray(state?.training?.shares) ? state.training.shares : []; + return shares.find((share) => share?.enabled !== false && String(share?.token || "") === shareToken) || null; +} + +function hasPublicTrainingShare(state, shareToken) { + return Boolean(findPublicTrainingShare(state, shareToken)); +} + +function buildPublicTrainingProfile(state, shareToken, updatedAt) { + const share = findPublicTrainingShare(state, shareToken); + if (!share) return null; + const driverId = String(share.driverId || ""); + const drivers = Array.isArray(state?.drivers) ? state.drivers : []; + const driver = drivers.find((item) => String(item?.id || "") === driverId); + if (!driver) return null; + + const classes = Array.isArray(state?.classes) ? state.classes : []; + const driverClass = classes.find((item) => String(item?.id || "") === String(driver.classId || "")); + const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; + const driverLaps = ledger + .filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500) + .map((entry) => ({ + timestamp: Math.max(0, Number(entry.timestamp || 0) || 0), + lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)), + sessionId: String(entry.sessionId || ""), + })) + .sort((left, right) => left.timestamp - right.timestamp); + const stats = buildPublicTrainingStats(driverLaps); + const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : []) + .filter((goal) => String(goal?.driverId || "") === driverId) + .map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps)); + const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean))); + const rankIndex = rankedTotals.findIndex(([id]) => id === driverId); + const settings = state?.settings && typeof state.settings === "object" ? state.settings : {}; + + return { + updatedAt: String(updatedAt || ""), + profile: { + name: String(driver.name || "Driver").trim(), + brand: String(driver.brand || "").trim(), + className: String(driverClass?.name || "").trim(), + clubRank: rankIndex >= 0 ? rankIndex + 1 : 0, + rankedDrivers: rankedTotals.length, + }, + branding: { + clubName: String(settings.clubName || "JMK RB RaceController").trim(), + clubTagline: String(settings.clubTagline || "RC Timing System").trim(), + logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl), + }, + stats, + nextMilestone: getPublicTrainingMilestone(stats.totalLaps), + recommendation: getPublicTrainingRecommendation(stats), + goals, + }; +} + +function buildPublicTrainingGoal(goal, currentLaps) { + const targetLaps = Math.max(1, Math.round(Number(goal?.targetLaps || 1) || 1)); + return { + title: String(goal?.title || "").trim(), + reward: String(goal?.reward || "").trim(), + targetLaps, + currentLaps, + remainingLaps: Math.max(0, targetLaps - currentLaps), + percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), + reached: currentLaps >= targetLaps, + claimed: Boolean(goal?.claimedAt), + }; +} + +function getRankedTrainingTotals(ledger, registeredDriverIds) { + const totals = new Map(); + ledger.forEach((entry) => { + const driverId = String(entry?.driverId || ""); + if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return; + totals.set(driverId, (totals.get(driverId) || 0) + 1); + }); + return [...totals.entries()].sort((left, right) => right[1] - left[1]); +} + +function buildPublicTrainingStats(laps) { + const lapTimes = laps.map((lap) => lap.lapMs); + const recent = lapTimes.slice(-30); + const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; + const sortedRecent = [...recent].sort((left, right) => left - right); + const percentile = (ratio) => sortedRecent.length + ? sortedRecent[Math.min(sortedRecent.length - 1, Math.max(0, Math.round((sortedRecent.length - 1) * ratio)))] + : 0; + const recentAverageMs = average(recent); + const daily = new Map(); + laps.forEach((lap) => { + const date = new Date(lap.timestamp); + const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 }; + day.laps += 1; + day.totalMs += lap.lapMs; + day.bestLapMs = day.bestLapMs ? Math.min(day.bestLapMs, lap.lapMs) : lap.lapMs; + day.latestTimestamp = Math.max(day.latestTimestamp, lap.timestamp); + daily.set(dateKey, day); + }); + const dailySummaries = [...daily.values()] + .map((day) => ({ + dateKey: day.dateKey, + laps: day.laps, + bestLapMs: day.bestLapMs, + latestTimestamp: day.latestTimestamp, + averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0, + })) + .sort((left, right) => right.latestTimestamp - left.latestTimestamp) + .slice(0, 30); + const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0; + const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; + let controlledLapStreak = 0; + for (let index = lapTimes.length - 1; index >= 0; index -= 1) { + if (!controlTargetMs || lapTimes[index] > controlTargetMs) break; + controlledLapStreak += 1; + } + + return { + totalLaps: laps.length, + totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size, + trainingDays: daily.size, + bestLapMs, + averageLapMs: average(lapTimes), + recentAverageMs, + recentLapCount: recent.length, + consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0, + controlledLapStreak, + dailySummaries, + }; +} + +function normalizePublicLogoDataUrl(value) { + const logo = String(value || "").trim(); + return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : ""; +} + +function getPublicTrainingMilestone(currentLaps) { + const presets = [100, 250, 500, 1000, 1500, 2500, 5000]; + const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000; + return { + currentLaps, + targetLaps, + remainingLaps: Math.max(0, targetLaps - currentLaps), + percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), + }; +} + +function getPublicTrainingRecommendation(stats) { + if (stats.totalLaps < 10) return { level: "baseline", targetLapMs: 0 }; + if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) { + return { level: "consistency", targetLapMs: Math.round(stats.bestLapMs * 1.08) }; + } + return { level: "progression", targetLapMs: Math.round(stats.bestLapMs * 1.05) }; +} + function normalizePdfExportPayload(input = {}) { const title = String(input.title || "JMK RB Live Event").trim(); const subtitle = String(input.subtitle || "").trim(); diff --git a/src/app.js b/src/app.js index 37af21f..2f89d43 100644 --- a/src/app.js +++ b/src/app.js @@ -334,6 +334,20 @@ const TRANSLATIONS = { "training.date": "Datum", "training.laps": "Varv", "training.pb_improvement": "Senaste 30: {lap} snabbare", + "training.public_profile": "Mobilprofil för förare", + "training.public_profile_active": "Aktiv", + "training.public_profile_off": "Avstängd", + "training.public_profile_help": "Skapa en skrivskyddad mobilvy med den här förarens träningsstatistik, mål, belöningar och 20-minuterstimer.", + "training.public_profile_private": "Länken innehåller en unik hemlig nyckel. Den visar inte transponder-ID, övriga förare eller adminfunktioner.", + "training.public_profile_link": "Personlig delningslänk", + "training.copy_link": "Kopiera länk", + "training.open_profile": "Öppna profil", + "training.replace_link": "Skapa ny länk", + "training.disable_profile": "Stäng av profil", + "training.enable_profile": "Aktivera mobilprofil", + "training.replace_link_confirm": "Den gamla länken slutar fungera direkt. Vill du skapa en ny?", + "training.club_progress": "Klubbens träningsöversikt", + "training.next_target": "Nästa mål", "mode.track": "Sponsor Event", "mode.race": "Race", "dashboard.events": "Event", @@ -952,7 +966,7 @@ const TRANSLATIONS = { "edit.event_name": "Redigera eventnamn", "edit.event_date": "Redigera eventdatum (YYYY-MM-DD)", "guide.title": "Guide och dokumentation", - "guide.intro": "Här finns steg-för-steg för sponsor-event, vanligt race och nya Create Race Wizard i Race Setup. Guiden beskriver också hur Hantera-flödet är uppdelat samt var AMMC faktiskt körs i Managed AMMC-läget.", + "guide.intro": "Här finns steg-för-steg för sponsor-event, vanligt race, träningsstatistik och Create Race Wizard i Race Setup. Guiden beskriver också hur Hantera-flödet är uppdelat samt var AMMC faktiskt körs i Managed AMMC-läget.", "guide.card_sponsor_blurb": "Delade bilar, roterande förare och heat/finaler för sponsor- och prova-på-event.", "guide.card_race_blurb": "Personliga transpondrar, kval/finaler, raceformat och seedning för riktiga tävlingsrace.", "guide.card_team_blurb": "Lagrace och endurance med team, stintlogg och summerade varv över lång tid.", @@ -1035,6 +1049,9 @@ const TRANSLATIONS = { "guide.training_4": "4. Nästa automatiska nivå räknas utan konfiguration. Skapa egna delmål om klubben eller en förälder vill koppla en fysisk belöning till exempelvis 500, 1000 eller 1500 varv.", "guide.training_5": "5. När målet nåtts kan belöningen markeras som utdelad. Direktlänken `/?view=training&driver=` kan bokmärkas på förarens mobil.", "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. Under Mobilprofil för förare klickar du Aktivera mobilprofil. Kopiera den personliga länken och öppna den på förarens eller förälderns mobil.", + "guide.training_8": "8. Mobilprofilen är skrivskyddad och visar bara vald förares statistik. Där finns också en lokal 20-minuterstimer som växlar från 10 minuter maxfokus till 10 minuter kontrollerad körning.", + "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`.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -1082,7 +1099,7 @@ const TRANSLATIONS = { "guide.host_3": "3. Kör backend på Linux-servern -> Linux-binären används: `AMMC/linux_x86-64/ammc-amb`.", "guide.host_4": "4. Kör backend på Windows-burken -> Windows-binären används: `AMMC/windows64/ammc-amb.exe`.", "guide.host_5": "5. Fältet `AMMC binär` i Settings är sökvägen på hosten där backend kör, inte på klient-laptopen.", - "guide.host_6": "6. Publicera helst bara `/public-overlay/*` externt via reverse proxy, inte hela admin-UI:t.", + "guide.host_6": "6. Publicera helst bara `/public-overlay/*` och valda `/public-training/*`-routes externt via reverse proxy, inte hela admin-UI:t eller `/api/state`.", "guide.host_7": "7. OBS overlay finns nu som publik URL: `/public-overlay/obs` och kan konfigureras från Overlay-menyn.", "guide.host_8": "8. Sätt `PUBLIC_OVERLAY_TOKEN` på servern om du vill kräva `?token=...` för publika overlay-länkar.", "guide.windows_title": "Windows + AMMC + npm", @@ -1206,6 +1223,20 @@ const TRANSLATIONS = { "training.date": "Date", "training.laps": "Laps", "training.pb_improvement": "Latest 30: {lap} faster", + "training.public_profile": "Driver mobile profile", + "training.public_profile_active": "Active", + "training.public_profile_off": "Disabled", + "training.public_profile_help": "Create a read-only mobile view with this driver's practice statistics, goals, rewards and 20-minute timer.", + "training.public_profile_private": "The link contains a unique secret key. It does not show transponder IDs, other drivers or admin controls.", + "training.public_profile_link": "Personal share link", + "training.copy_link": "Copy link", + "training.open_profile": "Open profile", + "training.replace_link": "Create new link", + "training.disable_profile": "Disable profile", + "training.enable_profile": "Enable mobile profile", + "training.replace_link_confirm": "The old link will stop working immediately. Create a new one?", + "training.club_progress": "Club practice overview", + "training.next_target": "Next target", "mode.track": "Track Event", "mode.race": "Race", "dashboard.events": "Events", @@ -1824,7 +1855,7 @@ const TRANSLATIONS = { "edit.event_name": "Edit event name", "edit.event_date": "Edit event date (YYYY-MM-DD)", "guide.title": "Guide and Documentation", - "guide.intro": "Step-by-step setup for sponsor events, regular race mode and the new Create Race Wizard in Race Setup. The guide also explains how the Manage flow is split up and where Managed AMMC actually runs.", + "guide.intro": "Step-by-step setup for sponsor events, regular race mode, training statistics and the Create Race Wizard in Race Setup. The guide also explains how the Manage flow is split up and where Managed AMMC actually runs.", "guide.card_sponsor_blurb": "Shared cars, rotating drivers and heat/final flow for sponsor or try-out events.", "guide.card_race_blurb": "Personal transponders, qualifying/finals, race format and seeding for competition racing.", "guide.card_team_blurb": "Team race and endurance with teams, stint logs and total laps over long sessions.", @@ -1907,6 +1938,9 @@ const TRANSLATIONS = { "guide.training_4": "4. The next automatic level needs no configuration. Add custom milestones when the club or a parent wants to attach a physical reward to targets such as 500, 1000 or 1500 laps.", "guide.training_5": "5. When a target is reached, mark its reward as delivered. Bookmark `/?view=training&driver=` on the driver's phone for direct access.", "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. Under Driver mobile profile, 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 is read-only and only shows the selected driver's statistics. It also includes a local 20-minute timer that changes from 10 minutes maximum focus to 10 minutes controlled driving.", + "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`.", "overlay.title": "Overlay", "overlay.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", @@ -1954,6 +1988,9 @@ const TRANSLATIONS = { "guide.host_3": "3. Run the backend on Linux -> the Linux binary is used: `AMMC/linux_x86-64/ammc-amb`.", "guide.host_4": "4. Run the backend on Windows -> the Windows binary is used: `AMMC/windows64/ammc-amb.exe`.", "guide.host_5": "5. The `AMMC executable` field in Settings is a path on the backend host, not on the client laptop.", + "guide.host_6": "6. Prefer publishing only `/public-overlay/*` and selected `/public-training/*` routes through a reverse proxy, not the full admin UI or `/api/state`.", + "guide.host_7": "7. The OBS overlay is available at `/public-overlay/obs` and can be configured from the Overlay menu.", + "guide.host_8": "8. Set `PUBLIC_OVERLAY_TOKEN` on the server if public overlay links should require `?token=...`.", "guide.windows_title": "Windows + AMMC + npm", "guide.windows_1": "1. Use 64-bit Windows 11. Double-click `windows/install.bat`; it installs Node.js LTS with winget when needed and runs `npm ci`.", "guide.windows_2": "2. Setup requires Node 22 or 24 LTS and automatically verifies Express, SQLite, and `AMMC/windows64`.", diff --git a/src/misc_views.js b/src/misc_views.js index 250ee73..6970307 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -64,7 +64,7 @@ export function renderGuideView({ dom, t }) {
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3"], "", { t })} - ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6"], "", { t })} + ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9"], "", { t })}
diff --git a/src/public_training.css b/src/public_training.css new file mode 100644 index 0000000..05e9861 --- /dev/null +++ b/src/public_training.css @@ -0,0 +1,403 @@ +:root { + color-scheme: dark; + --bg: #080d15; + --surface: #101925; + --surface-2: #151f2d; + --line: #27364a; + --text: #f4f7fb; + --muted: #91a1b8; + --accent: #f12a24; + --gold: #f5c84c; + --green: #43d58b; + --shadow: 0 18px 55px rgba(0, 0, 0, 0.32); +} + +* { + box-sizing: border-box; +} + +body { + min-width: 280px; + margin: 0; + background: + radial-gradient(circle at 12% -4%, rgba(241, 42, 36, 0.24), transparent 30rem), + radial-gradient(circle at 100% 28%, rgba(44, 114, 170, 0.14), transparent 28rem), + var(--bg); + color: var(--text); + font-family: Barlow, sans-serif; +} + +button { + font: inherit; +} + +.public-training-root { + width: min(980px, 100%); + min-height: 100vh; + margin: 0 auto; + padding: max(18px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(28px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); +} + +.public-training-loading, +.public-training-error { + display: grid; + min-height: 70vh; + place-content: center; + justify-items: center; + color: var(--muted); + text-align: center; +} + +.public-training-pulse { + width: 42px; + height: 42px; + border: 3px solid var(--line); + border-top-color: var(--accent); + border-radius: 50%; + animation: profile-spin 0.8s linear infinite; +} + +@keyframes profile-spin { + to { transform: rotate(360deg); } +} + +.profile-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 18px 2px 28px; +} + +.profile-brand { + display: flex; + align-items: center; + gap: 13px; +} + +.profile-brand img, +.profile-brand-mark { + width: 52px; + height: 52px; + border-radius: 14px; + object-fit: contain; +} + +.profile-brand-mark { + display: grid; + place-items: center; + background: linear-gradient(145deg, #ff443c, #b40b08); + box-shadow: 0 8px 28px rgba(241, 42, 36, 0.32); + font-family: Orbitron, sans-serif; + font-weight: 800; +} + +.profile-brand strong, +.profile-brand span { + display: block; +} + +.profile-brand strong { + font-family: Orbitron, sans-serif; + font-size: 0.95rem; +} + +.profile-brand span, +.profile-updated { + margin-top: 3px; + color: var(--muted); + font-size: 0.78rem; +} + +.profile-hero { + position: relative; + overflow: hidden; + padding: clamp(22px, 5vw, 42px); + border: 1px solid color-mix(in srgb, var(--line) 60%, var(--accent) 40%); + border-radius: 24px; + background: + linear-gradient(120deg, rgba(241, 42, 36, 0.2), transparent 52%), + linear-gradient(145deg, var(--surface-2), var(--surface)); + box-shadow: var(--shadow); +} + +.profile-hero::after { + content: "RC"; + position: absolute; + right: -18px; + bottom: -54px; + color: rgba(255, 255, 255, 0.035); + font-family: Orbitron, sans-serif; + font-size: clamp(8rem, 30vw, 16rem); + font-weight: 800; + pointer-events: none; +} + +.profile-eyebrow { + color: #ff8f89; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.profile-hero h1 { + position: relative; + z-index: 1; + margin: 8px 0 6px; + font-family: Orbitron, sans-serif; + font-size: clamp(2rem, 8vw, 4.6rem); + line-height: 1.02; +} + +.profile-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + color: var(--muted); +} + +.profile-pill { + padding: 5px 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(7, 12, 20, 0.38); + font-size: 0.8rem; +} + +.profile-stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 14px; +} + +.profile-card { + border: 1px solid var(--line); + border-radius: 18px; + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: var(--shadow); +} + +.profile-stat { + padding: 17px; +} + +.profile-stat span, +.profile-mini-label { + color: var(--muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.profile-stat strong { + display: block; + margin-top: 7px; + font-family: Orbitron, sans-serif; + font-size: clamp(1.15rem, 4vw, 1.65rem); +} + +.profile-grid { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(300px, 0.95fr); + gap: 14px; + margin-top: 14px; +} + +.profile-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 15px 17px; + border-bottom: 1px solid var(--line); +} + +.profile-card-head h2 { + margin: 0; + font-family: Orbitron, sans-serif; + font-size: 1rem; +} + +.profile-card-body { + padding: 18px; +} + +.profile-progress { + height: 8px; + overflow: hidden; + border-radius: 99px; + background: #222d3c; +} + +.profile-progress span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), var(--gold)); +} + +.profile-level-row { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.profile-level-row h3 { + margin: 5px 0 0; + font-family: Orbitron, sans-serif; + font-size: 1.35rem; +} + +.profile-level-row strong { + white-space: nowrap; +} + +.profile-plan-copy { + color: var(--muted); + line-height: 1.55; +} + +.profile-timer { + margin: 18px 0 14px; + font-family: Orbitron, sans-serif; + font-size: clamp(2.5rem, 12vw, 5.2rem); + line-height: 1; + text-align: center; +} + +.profile-timer-phase { + margin-bottom: 14px; + color: var(--gold); + font-weight: 700; + text-align: center; +} + +.profile-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} + +.profile-button { + min-height: 42px; + padding: 9px 15px; + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface-2); + color: var(--text); + cursor: pointer; + font-weight: 700; +} + +.profile-button-primary { + border-color: #ff4a43; + background: linear-gradient(135deg, #ef2b25, #b80d09); +} + +.profile-button:disabled { + opacity: 0.55; + cursor: default; +} + +.profile-goals { + display: grid; + gap: 10px; +} + +.profile-goal { + padding: 14px; + border: 1px solid var(--line); + border-radius: 13px; + background: rgba(255, 255, 255, 0.025); +} + +.profile-goal.reached { + border-color: color-mix(in srgb, var(--green) 55%, var(--line) 45%); +} + +.profile-goal h3, +.profile-goal p { + margin: 0 0 9px; +} + +.profile-goal p { + color: var(--muted); +} + +.profile-goal-row { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.profile-history { + width: 100%; + border-collapse: collapse; +} + +.profile-history th, +.profile-history td { + padding: 11px 12px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; +} + +.profile-history th { + color: var(--muted); + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.profile-history-wrap { + overflow-x: auto; +} + +.profile-empty { + margin: 0; + color: var(--muted); +} + +@media (max-width: 760px) { + .profile-header { + align-items: flex-start; + flex-direction: column; + } + + .profile-updated { + margin-left: 65px; + margin-top: -18px; + } + + .profile-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .profile-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 420px) { + .public-training-root { + padding-left: 10px; + padding-right: 10px; + } + + .profile-stat { + padding: 14px 12px; + } + + .profile-level-row { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/public_training.js b/src/public_training.js new file mode 100644 index 0000000..fe4f338 --- /dev/null +++ b/src/public_training.js @@ -0,0 +1,348 @@ +const root = document.getElementById("publicTrainingRoot"); +const shareToken = decodeURIComponent(window.location.pathname.split("/").filter(Boolean).at(-1) || ""); +const requestedLanguage = new URLSearchParams(window.location.search).get("lang"); +const language = requestedLanguage === "en" || requestedLanguage === "sv" + ? requestedLanguage + : String(navigator.language || "sv").toLowerCase().startsWith("sv") ? "sv" : "en"; +const TIMER_DURATION_MS = 20 * 60 * 1000; +const TIMER_PHASE_MS = 10 * 60 * 1000; +const TIMER_STORAGE_KEY = `rc-training-timer:${shareToken}`; + +const messages = { + sv: { + loading: "Laddar träningsprofil...", + loadError: "Kunde inte läsa träningsprofilen. Länken kan vara avstängd eller ersatt.", + development: "Förarutveckling", + rank: "Klubbrank {rank} av {count}", + laps: "Totala varv", + days: "Träningsdagar", + best: "Bästa varv", + consistency: "Jämnhet senaste 30", + nextLevel: "Nästa nivå", + reachLaps: "Nå {laps} varv", + lapsRemaining: "{laps} varv kvar", + plan: "Dagens 20-minuterspass", + baselineTitle: "Bygg en baslinje", + baselineBody: "Kör lugnt och jämnt tills du har minst 10 rena varv. Upprepa samma linje och bromspunkt.", + consistencyTitle: "Höj din lägstanivå", + consistencyBody: "Kör 10 minuter nära max, sedan 10 minuter på 80-90 procent med fokus på rena, repeterbara varv.", + progressionTitle: "Balansera fart och kontroll", + progressionBody: "Kör 10 minuter med maxfokus och avsluta med 10 kontrollerade minuter utan misstag.", + target: "Kontrollmål {lap}", + ready: "Redo att börja", + push: "Maxfokus: hitta gränsen", + control: "80-90%: höj lägstanivån", + finished: "Passet är klart", + start: "Starta pass", + pause: "Pausa", + resume: "Fortsätt", + running: "Pågår", + reset: "Nollställ", + goals: "Mål och belöningar", + noGoals: "Inga personliga mål har lagts till ännu.", + goalReached: "Målet är nått", + rewardDelivered: "Belöning utdelad", + reward: "Belöning", + history: "Senaste träningsdagar", + date: "Datum", + dayLaps: "Varv", + average: "Snitt", + updated: "Uppdaterad {time}", + switchCue: "Byt till kontrollerad körning", + finishCue: "Träningspasset är klart", + }, + en: { + loading: "Loading training profile...", + loadError: "The training profile could not be loaded. The link may be disabled or replaced.", + development: "Driver development", + rank: "Club rank {rank} of {count}", + laps: "Total laps", + days: "Practice days", + best: "Best lap", + consistency: "Latest 30 consistency", + nextLevel: "Next level", + reachLaps: "Reach {laps} laps", + lapsRemaining: "{laps} laps remaining", + plan: "Today's 20-minute session", + baselineTitle: "Build a baseline", + baselineBody: "Drive calmly and consistently until at least 10 clean laps are recorded. Repeat the same line and braking point.", + consistencyTitle: "Raise your minimum level", + consistencyBody: "Run 10 minutes near maximum pace, then 10 minutes at 80-90 percent with clean, repeatable laps.", + progressionTitle: "Balance pace and control", + progressionBody: "Run 10 minutes at maximum focus and finish with 10 controlled minutes without mistakes.", + target: "Control target {lap}", + ready: "Ready to start", + push: "Maximum focus: find the limit", + control: "80-90%: raise the floor", + finished: "Session completed", + start: "Start session", + pause: "Pause", + resume: "Resume", + running: "Running", + reset: "Reset", + goals: "Goals and rewards", + noGoals: "No personal goals have been added yet.", + goalReached: "Goal reached", + rewardDelivered: "Reward delivered", + reward: "Reward", + history: "Latest practice days", + date: "Date", + dayLaps: "Laps", + average: "Average", + updated: "Updated {time}", + switchCue: "Switch to controlled driving", + finishCue: "The training session is complete", + }, +}; + +let profileData = null; +let timerHandle = null; +let lastTimerPhase = ""; + +function t(key, vars = {}) { + const template = messages[language]?.[key] || messages.en[key] || key; + return String(template).replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? "")); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatLap(ms) { + const value = Math.max(0, Number(ms || 0)); + if (!value) return "-"; + const minutes = Math.floor(value / 60000); + const seconds = Math.floor((value % 60000) / 1000); + const millis = Math.floor(value % 1000); + return `${minutes}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; +} + +function formatTimer(ms) { + const seconds = Math.max(0, Math.ceil(ms / 1000)); + return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; +} + +function recommendationCopy(recommendation) { + const level = ["baseline", "consistency", "progression"].includes(recommendation?.level) ? recommendation.level : "baseline"; + return { + title: t(`${level}Title`), + body: t(`${level}Body`), + }; +} + +function renderProfile(data) { + profileData = data; + const { profile, branding, stats, nextMilestone, recommendation, goals } = data; + const recommendationText = recommendationCopy(recommendation); + const rank = profile.clubRank && profile.rankedDrivers ? t("rank", { rank: profile.clubRank, count: profile.rankedDrivers }) : ""; + const logo = /^data:image\/(?:png|jpeg|webp);base64,/i.test(branding.logoDataUrl || "") + ? `` + : `
RC
`; + const goalMarkup = goals.length + ? goals.map((goal) => ` +
+
+ ${escapeHtml(goal.title || t("reachLaps", { laps: goal.targetLaps }))} + ${goal.currentLaps} / ${goal.targetLaps} +
+
+ ${goal.reward ? `

${escapeHtml(t("reward"))}
${escapeHtml(goal.reward)}

` : ""} + ${goal.claimed ? `${escapeHtml(t("rewardDelivered"))}` : goal.reached ? `${escapeHtml(t("goalReached"))}` : ""} +
+ `).join("") + : `

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

`; + const historyRows = stats.dailySummaries.length + ? stats.dailySummaries.slice(0, 12).map((day) => ` + + ${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(language))} + ${day.laps} + ${formatLap(day.bestLapMs)} + ${formatLap(day.averageLapMs)} + + `).join("") + : `-`; + + document.documentElement.lang = language; + document.title = `${profile.name} - ${branding.clubName}`; + root.innerHTML = ` +
+
+ ${logo} +
${escapeHtml(branding.clubName)}${escapeHtml(branding.clubTagline)}
+
+
${escapeHtml(t("updated", { time: new Date(data.updatedAt || Date.now()).toLocaleString(language) }))}
+
+ +
+ ${escapeHtml(t("development"))} +

${escapeHtml(profile.name)}

+
+ ${profile.className ? `${escapeHtml(profile.className)}` : ""} + ${profile.brand ? `${escapeHtml(profile.brand)}` : ""} + ${rank ? `${escapeHtml(rank)}` : ""} +
+
+ +
+ ${renderStat(t("laps"), stats.totalLaps)} + ${renderStat(t("days"), stats.trainingDays)} + ${renderStat(t("best"), formatLap(stats.bestLapMs))} + ${renderStat(t("consistency"), stats.recentLapCount ? `${Number(stats.consistencySpreadPct).toFixed(1)}%` : "-")} +
+ +
+
+

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

+
+
+
${escapeHtml(t("lapsRemaining", { laps: nextMilestone.remainingLaps }))}

${escapeHtml(t("reachLaps", { laps: nextMilestone.targetLaps }))}

+ ${nextMilestone.currentLaps} / ${nextMilestone.targetLaps} +
+
+
+
+ +
+

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

+
+ ${recommendation.targetLapMs ? escapeHtml(t("target", { lap: formatLap(recommendation.targetLapMs) })) : ""} +

${escapeHtml(recommendationText.title)}

+

${escapeHtml(recommendationText.body)}

+
20:00
+
${escapeHtml(t("ready"))}
+
+ + + +
+
+
+
+ +
+

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

+
${goalMarkup}
+
+ +
+

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

+
+ + + ${historyRows} +
${escapeHtml(t("date"))}${escapeHtml(t("dayLaps"))}${escapeHtml(t("best"))}${escapeHtml(t("average"))}
+
+
+ `; + bindTimerControls(); + updateTimerDisplay(); +} + +function renderStat(label, value) { + return `
${escapeHtml(label)}${escapeHtml(value)}
`; +} + +function readTimerState() { + try { + const parsed = JSON.parse(localStorage.getItem(TIMER_STORAGE_KEY) || "null"); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function writeTimerState(value) { + if (value) localStorage.setItem(TIMER_STORAGE_KEY, JSON.stringify(value)); + else localStorage.removeItem(TIMER_STORAGE_KEY); +} + +function getTimerElapsed(state, now = Date.now()) { + if (!state?.startedAt) return 0; + const endpoint = state.pausedAt || now; + return Math.max(0, endpoint - state.startedAt - Number(state.pausedTotalMs || 0)); +} + +function bindTimerControls() { + document.getElementById("profileTimerStart")?.addEventListener("click", () => { + const current = readTimerState(); + if (current?.pausedAt) { + current.pausedTotalMs = Number(current.pausedTotalMs || 0) + Date.now() - current.pausedAt; + current.pausedAt = 0; + writeTimerState(current); + } else if (!current || getTimerElapsed(current) >= TIMER_DURATION_MS) { + writeTimerState({ startedAt: Date.now(), pausedAt: 0, pausedTotalMs: 0 }); + lastTimerPhase = "push"; + } + startTimerLoop(); + updateTimerDisplay(); + }); + document.getElementById("profileTimerPause")?.addEventListener("click", () => { + const current = readTimerState(); + if (current?.startedAt && !current.pausedAt && getTimerElapsed(current) < TIMER_DURATION_MS) { + current.pausedAt = Date.now(); + writeTimerState(current); + } + updateTimerDisplay(); + }); + document.getElementById("profileTimerReset")?.addEventListener("click", () => { + writeTimerState(null); + lastTimerPhase = ""; + updateTimerDisplay(); + }); + startTimerLoop(); +} + +function startTimerLoop() { + if (timerHandle) clearInterval(timerHandle); + timerHandle = setInterval(updateTimerDisplay, 250); +} + +function updateTimerDisplay() { + const timer = document.getElementById("profileTimer"); + const phase = document.getElementById("profileTimerPhase"); + const startButton = document.getElementById("profileTimerStart"); + const pauseButton = document.getElementById("profileTimerPause"); + if (!timer || !phase || !startButton || !pauseButton) return; + const state = readTimerState(); + const elapsed = getTimerElapsed(state); + const remaining = Math.max(0, TIMER_DURATION_MS - elapsed); + const phaseName = !state?.startedAt ? "ready" : remaining <= 0 ? "finished" : elapsed < TIMER_PHASE_MS ? "push" : "control"; + timer.textContent = formatTimer(remaining || (phaseName === "ready" ? TIMER_DURATION_MS : 0)); + phase.textContent = t(phaseName); + startButton.textContent = state?.pausedAt ? t("resume") : !state?.startedAt || remaining <= 0 ? t("start") : t("running"); + startButton.disabled = Boolean(state?.startedAt && !state.pausedAt && remaining > 0); + pauseButton.disabled = !state?.startedAt || Boolean(state.pausedAt) || remaining <= 0; + if (state?.startedAt && !state.pausedAt && phaseName !== lastTimerPhase) { + if (lastTimerPhase === "push" && phaseName === "control") speak(t("switchCue")); + if (phaseName === "finished") speak(t("finishCue")); + lastTimerPhase = phaseName; + } +} + +function speak(text) { + if (!("speechSynthesis" in window)) return; + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = language === "sv" ? "sv-SE" : "en-GB"; + window.speechSynthesis.speak(utterance); +} + +async function loadProfile() { + try { + const response = await fetch(`/public-training/api/${encodeURIComponent(shareToken)}`, { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + renderProfile(await response.json()); + } catch { + root.innerHTML = `
RC

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

`; + } +} + +loadProfile(); +setInterval(() => { + if (!document.hidden) loadProfile(); +}, 30000); diff --git a/src/styles.css b/src/styles.css index f477232..f9ddc0a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -447,6 +447,49 @@ body { text-transform: uppercase; } +.training-club-table tr.is-selected td { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + +.training-club-driver { + padding: 0; + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; + font: inherit; + font-weight: 700; +} + +.training-club-driver:hover { + color: color-mix(in srgb, var(--accent) 65%, var(--text) 35%); + text-decoration: underline; +} + +.training-share-body { + display: grid; + grid-template-columns: minmax(240px, 0.8fr) minmax(320px, 1.2fr); + align-items: end; + gap: 16px; +} + +.training-share-body p { + margin: 0; + color: var(--muted); +} + +.training-share-url { + display: grid; + grid-column: 2; + gap: 6px; + color: var(--muted); + font-size: 0.82rem; +} + +.training-share-body > .actions { + grid-column: 2; +} + .training-layout { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr); @@ -2466,6 +2509,15 @@ select:focus { border-bottom: 0; } + .training-share-body { + grid-template-columns: 1fr; + } + + .training-share-url, + .training-share-body > .actions { + grid-column: auto; + } + .field-card-checkbox { grid-column: span 1; } diff --git a/src/training_logic.js b/src/training_logic.js index 29bdde9..c62a7a8 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -41,7 +41,23 @@ export function normalizeTrainingState(training, { uid }) { .sort((left, right) => left.timestamp - right.timestamp) : []; - return { goals, ledger }; + const seenShareDrivers = new Set(); + const shares = Array.isArray(source.shares) + ? source.shares + .map((share) => ({ + driverId: String(share?.driverId || ""), + token: String(share?.token || "").trim(), + enabled: share?.enabled !== false, + createdAt: String(share?.createdAt || new Date().toISOString()), + })) + .filter((share) => { + if (!share.driverId || share.token.length < 24 || seenShareDrivers.has(share.driverId)) return false; + seenShareDrivers.add(share.driverId); + return true; + }) + : []; + + return { goals, ledger, shares }; } function getPassingSourceKey(sessionId, passing, index) { @@ -82,7 +98,7 @@ export function syncTrainingLedger(training, state, { uid }) { }); }); - return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current] }, { uid }); + return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares }, { uid }); } export function getDriverTrainingLaps(state, driverId) { diff --git a/src/training_views.js b/src/training_views.js index 555f4c6..38efc7a 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -42,6 +42,8 @@ export function renderTrainingView(deps) { const recommendation = buildTrainingRecommendation(stats); const nextMilestone = getNextTrainingMilestone(stats); const goals = state.training.goals.filter((goal) => goal.driverId === selectedDriverId); + const profileShare = (state.training.shares || []).find((share) => share.driverId === selectedDriverId && share.enabled); + const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language) : ""; const driverOptions = drivers .map((driver) => ``) .join(""); @@ -93,6 +95,22 @@ export function renderTrainingView(deps) { `).join("") : `${escapeHtml(t("training.no_history"))}`; + const clubRows = drivers + .map((driver) => ({ driver, stats: driver.id === selectedDriverId ? stats : buildDriverTrainingStats(state, driver.id) })) + .sort((left, right) => right.stats.totalLaps - left.stats.totalLaps || String(left.driver.name).localeCompare(String(right.driver.name))) + .map((entry, index) => { + const milestone = getNextTrainingMilestone(entry.stats); + return ` + + ${index + 1} + + ${entry.stats.totalLaps} + ${entry.stats.trainingDays} + ${formatLap(entry.stats.bestLapMs)} + ${milestone.targetLaps} + + `; + }).join(""); dom.view.innerHTML = `
@@ -162,6 +180,35 @@ export function renderTrainingView(deps) {
+
+
+

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

+ ${escapeHtml(t(profileShare ? "training.public_profile_active" : "training.public_profile_off"))} +
+
+
+

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

+

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

+
+ ${profileShare ? ` + +
+ + + + +
+ ` : ` +
+ +
+ `} +
+
+

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

@@ -175,6 +222,16 @@ export function renderTrainingView(deps) {
+
+

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

${drivers.length}
+
+ + + ${clubRows} +
#${escapeHtml(t("training.driver"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.training_days"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.next_target"))}
+
+
+

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

${goals.length}
${goalMarkup}
@@ -222,6 +279,35 @@ export function renderTrainingView(deps) { renderView(); }); }); + + document.getElementById("trainingPublicProfileEnable")?.addEventListener("click", () => { + replacePublicProfileShare(state, selectedDriverId); + saveState(); + renderView(); + }); + document.getElementById("trainingPublicProfileCopy")?.addEventListener("click", async () => { + await copyText(publicProfileUrl); + }); + document.getElementById("trainingPublicProfileOpen")?.addEventListener("click", () => { + window.open(publicProfileUrl, "_blank", "noopener,noreferrer"); + }); + document.getElementById("trainingPublicProfileRotate")?.addEventListener("click", () => { + if (!confirm(t("training.replace_link_confirm"))) return; + replacePublicProfileShare(state, selectedDriverId); + saveState(); + renderView(); + }); + document.getElementById("trainingPublicProfileDisable")?.addEventListener("click", () => { + state.training.shares = state.training.shares.map((share) => share.driverId === selectedDriverId ? { ...share, enabled: false } : share); + saveState(); + renderView(); + }); + document.querySelectorAll(".training-club-driver").forEach((button) => { + button.addEventListener("click", () => { + setSelectedDriverId(button.dataset.driverId || ""); + renderView(); + }); + }); } function trainingStat(label, value, note) { @@ -231,3 +317,44 @@ function trainingStat(label, value, note) { function trainingInsight(label, value) { return `
${label}${value}
`; } + +function buildPublicProfileUrl(token, language) { + const url = new URL(`/public-training/${encodeURIComponent(token)}`, window.location.origin); + url.searchParams.set("lang", language === "en" ? "en" : "sv"); + return url.toString(); +} + +function generateShareToken() { + const bytes = new Uint8Array(24); + crypto.getRandomValues(bytes); + return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function replacePublicProfileShare(state, driverId) { + const nextShare = { + driverId, + token: generateShareToken(), + enabled: true, + createdAt: new Date().toISOString(), + }; + state.training.shares = [...state.training.shares.filter((share) => share.driverId !== driverId), nextShare]; +} + +async function copyText(value) { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } catch { + // HTTP LAN pages may not receive Clipboard API permission. + } + } + const input = document.createElement("textarea"); + input.value = value; + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + input.remove(); +}