Add secure public driver training profiles
This commit is contained in:
23
README.md
23
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.
|
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
|
## Create Race Wizard
|
||||||
- `Race Setup` now starts with a four-step wizard for new races:
|
- `Race Setup` now starts with a four-step wizard for new races:
|
||||||
|
|||||||
23
README.sv.md
23
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.
|
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
|
## Create Race Wizard
|
||||||
- `Race Setup` startar nu med en fyrstegs wizard för nya race:
|
- `Race Setup` startar nu med en fyrstegs wizard för nya race:
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ Owns:
|
|||||||
- file watching for app version
|
- file watching for app version
|
||||||
- AMMC config + process state
|
- AMMC config + process state
|
||||||
- public overlay route serving via same frontend shell
|
- public overlay route serving via same frontend shell
|
||||||
|
- tokenized public driver training profile API and standalone mobile shell
|
||||||
- PDF export endpoint
|
- PDF export endpoint
|
||||||
|
|
||||||
Main API groups:
|
Main API groups:
|
||||||
@@ -138,11 +139,20 @@ Main API groups:
|
|||||||
- `/api/ammc/start`
|
- `/api/ammc/start`
|
||||||
- `/api/ammc/stop`
|
- `/api/ammc/stop`
|
||||||
- `/api/export/pdf`
|
- `/api/export/pdf`
|
||||||
|
- `/public-training/api/:shareToken`
|
||||||
|
|
||||||
Static routes of interest:
|
Static routes of interest:
|
||||||
- `/`
|
- `/`
|
||||||
- `/public-overlay`
|
- `/public-overlay`
|
||||||
- `/public-overlay/obs`
|
- `/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 `<repo>/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`
|
### `scripts/serverctl.js`
|
||||||
Role:
|
Role:
|
||||||
@@ -428,12 +438,48 @@ Owns:
|
|||||||
|
|
||||||
Mutations:
|
Mutations:
|
||||||
- replaces or appends entries in `state.training.goals`
|
- replaces or appends entries in `state.training.goals`
|
||||||
|
- creates, rotates, enables, or disables entries in `state.training.shares`
|
||||||
- always calls injected `saveState()` before rerender
|
- always calls injected `saveState()` before rerender
|
||||||
|
|
||||||
Route behavior:
|
Route behavior:
|
||||||
- `/?view=training` opens the training view directly
|
- `/?view=training` opens the training view directly
|
||||||
- `driver=<driverId>` selects a driver and supports mobile bookmarks
|
- `driver=<driverId>` 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/<shareToken>` 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
|
## 3. Direct Import Dependencies
|
||||||
|
|
||||||
@@ -832,6 +878,9 @@ sequenceDiagram
|
|||||||
| `src/settings.js` | view/render | none | `app.js` | yes | no | no |
|
| `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_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 |
|
| `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 |
|
| `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 |
|
| `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
|
```js
|
||||||
{
|
{
|
||||||
goals: TrainingGoal[],
|
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`:
|
`TrainingLap`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -1814,13 +1882,45 @@ Routes:
|
|||||||
- `/public-overlay/:mode`
|
- `/public-overlay/:mode`
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
- if `PUBLIC_OVERLAY_TOKEN` is set, query param `token` must match
|
- if `PUBLIC_OVERLAY_TOKEN` is set, query param `token` must match or the route returns `403 Forbidden`
|
||||||
- otherwise returns `403 Forbidden`
|
- if no token environment variable is set, the overlay route is available without a query token
|
||||||
- on success serves `index.html`
|
- on success serves `index.html`
|
||||||
|
|
||||||
Use for:
|
Use for:
|
||||||
- public OBS/browser-source overlays without exposing admin path directly
|
- 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
|
## 17. Regression History / Known Gotchas
|
||||||
|
|
||||||
This section captures bugs already seen in this codebase after the modular split.
|
This section captures bugs already seen in this codebase after the modular split.
|
||||||
|
|||||||
22
public-training.html
Normal file
22
public-training.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="sv">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#0b111b" />
|
||||||
|
<title>RC Training Profile</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700&family=Orbitron:wght@600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/src/public_training.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="publicTrainingRoot" class="public-training-root">
|
||||||
|
<section class="public-training-loading">
|
||||||
|
<span class="public-training-pulse"></span>
|
||||||
|
<p>Laddar träningsprofil...</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/src/public_training.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
201
server.js
201
server.js
@@ -7,13 +7,14 @@ const Database = require("better-sqlite3");
|
|||||||
|
|
||||||
const PORT = Number(process.env.PORT || 8081);
|
const PORT = Number(process.env.PORT || 8081);
|
||||||
const HOST = process.env.HOST || "0.0.0.0";
|
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 DB_PATH = path.join(DB_DIR, "rc_timing.sqlite");
|
||||||
const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json");
|
const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json");
|
||||||
const PID_FILE = process.env.RC_TIMING_PID_FILE || "";
|
const PID_FILE = process.env.RC_TIMING_PID_FILE || "";
|
||||||
const SRC_DIR = path.join(__dirname, "src");
|
const SRC_DIR = path.join(__dirname, "src");
|
||||||
const WATCHED_FILES = [
|
const WATCHED_FILES = [
|
||||||
path.join(__dirname, "index.html"),
|
path.join(__dirname, "index.html"),
|
||||||
|
path.join(__dirname, "public-training.html"),
|
||||||
...fs.readdirSync(SRC_DIR)
|
...fs.readdirSync(SRC_DIR)
|
||||||
.filter((fileName) => /\.(?:js|css)$/i.test(fileName))
|
.filter((fileName) => /\.(?:js|css)$/i.test(fileName))
|
||||||
.map((fileName) => path.join(SRC_DIR, 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"));
|
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(
|
app.use(
|
||||||
express.static(__dirname, {
|
"/src",
|
||||||
|
express.static(SRC_DIR, {
|
||||||
setHeaders: (res, filePath) => {
|
setHeaders: (res, filePath) => {
|
||||||
if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) {
|
if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) {
|
||||||
res.setHeader("Cache-Control", "no-store");
|
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) => {
|
app.get("*", (_req, res) => {
|
||||||
res.sendFile(path.join(__dirname, "index.html"));
|
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 = {}) {
|
function normalizePdfExportPayload(input = {}) {
|
||||||
const title = String(input.title || "JMK RB Live Event").trim();
|
const title = String(input.title || "JMK RB Live Event").trim();
|
||||||
const subtitle = String(input.subtitle || "").trim();
|
const subtitle = String(input.subtitle || "").trim();
|
||||||
|
|||||||
43
src/app.js
43
src/app.js
@@ -334,6 +334,20 @@ const TRANSLATIONS = {
|
|||||||
"training.date": "Datum",
|
"training.date": "Datum",
|
||||||
"training.laps": "Varv",
|
"training.laps": "Varv",
|
||||||
"training.pb_improvement": "Senaste 30: {lap} snabbare",
|
"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.track": "Sponsor Event",
|
||||||
"mode.race": "Race",
|
"mode.race": "Race",
|
||||||
"dashboard.events": "Event",
|
"dashboard.events": "Event",
|
||||||
@@ -952,7 +966,7 @@ const TRANSLATIONS = {
|
|||||||
"edit.event_name": "Redigera eventnamn",
|
"edit.event_name": "Redigera eventnamn",
|
||||||
"edit.event_date": "Redigera eventdatum (YYYY-MM-DD)",
|
"edit.event_date": "Redigera eventdatum (YYYY-MM-DD)",
|
||||||
"guide.title": "Guide och dokumentation",
|
"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_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_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.",
|
"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_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=<id>` kan bokmärkas på förarens mobil.",
|
"guide.training_5": "5. När målet nåtts kan belöningen markeras som utdelad. Direktlänken `/?view=training&driver=<id>` 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_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.title": "Overlay",
|
||||||
"overlay.subtitle": "Extern leaderboard-skärm",
|
"overlay.subtitle": "Extern leaderboard-skärm",
|
||||||
"overlay.no_active": "Ingen aktiv session vald.",
|
"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_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_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_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_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.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",
|
"guide.windows_title": "Windows + AMMC + npm",
|
||||||
@@ -1206,6 +1223,20 @@ const TRANSLATIONS = {
|
|||||||
"training.date": "Date",
|
"training.date": "Date",
|
||||||
"training.laps": "Laps",
|
"training.laps": "Laps",
|
||||||
"training.pb_improvement": "Latest 30: {lap} faster",
|
"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.track": "Track Event",
|
||||||
"mode.race": "Race",
|
"mode.race": "Race",
|
||||||
"dashboard.events": "Events",
|
"dashboard.events": "Events",
|
||||||
@@ -1824,7 +1855,7 @@ const TRANSLATIONS = {
|
|||||||
"edit.event_name": "Edit event name",
|
"edit.event_name": "Edit event name",
|
||||||
"edit.event_date": "Edit event date (YYYY-MM-DD)",
|
"edit.event_date": "Edit event date (YYYY-MM-DD)",
|
||||||
"guide.title": "Guide and Documentation",
|
"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_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_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.",
|
"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_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=<id>` on the driver's phone for direct access.",
|
"guide.training_5": "5. When a target is reached, mark its reward as delivered. Bookmark `/?view=training&driver=<id>` 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_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.title": "Overlay",
|
||||||
"overlay.subtitle": "External leaderboard screen",
|
"overlay.subtitle": "External leaderboard screen",
|
||||||
"overlay.no_active": "No active session selected.",
|
"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_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_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_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_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_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`.",
|
"guide.windows_2": "2. Setup requires Node 22 or 24 LTS and automatically verifies Express, SQLite, and `AMMC/windows64`.",
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function renderGuideView({ dom, t }) {
|
|||||||
|
|
||||||
<div class="guide-section-grid mt-16">
|
<div class="guide-section-grid mt-16">
|
||||||
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3"], "", { 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 })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="guide-section-grid mt-16">
|
<div class="guide-section-grid mt-16">
|
||||||
|
|||||||
403
src/public_training.css
Normal file
403
src/public_training.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
348
src/public_training.js
Normal file
348
src/public_training.js
Normal file
@@ -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 || "")
|
||||||
|
? `<img src="${escapeHtml(branding.logoDataUrl)}" alt="" />`
|
||||||
|
: `<div class="profile-brand-mark">RC</div>`;
|
||||||
|
const goalMarkup = goals.length
|
||||||
|
? goals.map((goal) => `
|
||||||
|
<article class="profile-goal ${goal.reached ? "reached" : ""}">
|
||||||
|
<div class="profile-goal-row">
|
||||||
|
<strong>${escapeHtml(goal.title || t("reachLaps", { laps: goal.targetLaps }))}</strong>
|
||||||
|
<span>${goal.currentLaps} / ${goal.targetLaps}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-progress"><span style="width:${Number(goal.percent || 0).toFixed(2)}%"></span></div>
|
||||||
|
${goal.reward ? `<p><span class="profile-mini-label">${escapeHtml(t("reward"))}</span><br />${escapeHtml(goal.reward)}</p>` : ""}
|
||||||
|
${goal.claimed ? `<span class="profile-mini-label">${escapeHtml(t("rewardDelivered"))}</span>` : goal.reached ? `<span class="profile-mini-label">${escapeHtml(t("goalReached"))}</span>` : ""}
|
||||||
|
</article>
|
||||||
|
`).join("")
|
||||||
|
: `<p class="profile-empty">${escapeHtml(t("noGoals"))}</p>`;
|
||||||
|
const historyRows = stats.dailySummaries.length
|
||||||
|
? stats.dailySummaries.slice(0, 12).map((day) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(language))}</td>
|
||||||
|
<td>${day.laps}</td>
|
||||||
|
<td>${formatLap(day.bestLapMs)}</td>
|
||||||
|
<td>${formatLap(day.averageLapMs)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join("")
|
||||||
|
: `<tr><td colspan="4">-</td></tr>`;
|
||||||
|
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
document.title = `${profile.name} - ${branding.clubName}`;
|
||||||
|
root.innerHTML = `
|
||||||
|
<header class="profile-header">
|
||||||
|
<div class="profile-brand">
|
||||||
|
${logo}
|
||||||
|
<div><strong>${escapeHtml(branding.clubName)}</strong><span>${escapeHtml(branding.clubTagline)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-updated">${escapeHtml(t("updated", { time: new Date(data.updatedAt || Date.now()).toLocaleString(language) }))}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="profile-hero">
|
||||||
|
<span class="profile-eyebrow">${escapeHtml(t("development"))}</span>
|
||||||
|
<h1>${escapeHtml(profile.name)}</h1>
|
||||||
|
<div class="profile-meta">
|
||||||
|
${profile.className ? `<span class="profile-pill">${escapeHtml(profile.className)}</span>` : ""}
|
||||||
|
${profile.brand ? `<span class="profile-pill">${escapeHtml(profile.brand)}</span>` : ""}
|
||||||
|
${rank ? `<span class="profile-pill">${escapeHtml(rank)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="profile-stat-grid">
|
||||||
|
${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)}%` : "-")}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="profile-grid">
|
||||||
|
<section class="profile-card">
|
||||||
|
<div class="profile-card-head"><h2>${escapeHtml(t("nextLevel"))}</h2></div>
|
||||||
|
<div class="profile-card-body">
|
||||||
|
<div class="profile-level-row">
|
||||||
|
<div><span class="profile-mini-label">${escapeHtml(t("lapsRemaining", { laps: nextMilestone.remainingLaps }))}</span><h3>${escapeHtml(t("reachLaps", { laps: nextMilestone.targetLaps }))}</h3></div>
|
||||||
|
<strong>${nextMilestone.currentLaps} / ${nextMilestone.targetLaps}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="profile-progress"><span style="width:${Number(nextMilestone.percent || 0).toFixed(2)}%"></span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="profile-card">
|
||||||
|
<div class="profile-card-head"><h2>${escapeHtml(t("plan"))}</h2></div>
|
||||||
|
<div class="profile-card-body">
|
||||||
|
<span class="profile-mini-label">${recommendation.targetLapMs ? escapeHtml(t("target", { lap: formatLap(recommendation.targetLapMs) })) : ""}</span>
|
||||||
|
<h3>${escapeHtml(recommendationText.title)}</h3>
|
||||||
|
<p class="profile-plan-copy">${escapeHtml(recommendationText.body)}</p>
|
||||||
|
<div id="profileTimer" class="profile-timer">20:00</div>
|
||||||
|
<div id="profileTimerPhase" class="profile-timer-phase">${escapeHtml(t("ready"))}</div>
|
||||||
|
<div class="profile-actions">
|
||||||
|
<button id="profileTimerStart" class="profile-button profile-button-primary" type="button">${escapeHtml(t("start"))}</button>
|
||||||
|
<button id="profileTimerPause" class="profile-button" type="button">${escapeHtml(t("pause"))}</button>
|
||||||
|
<button id="profileTimerReset" class="profile-button" type="button">${escapeHtml(t("reset"))}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="profile-card" style="margin-top:14px">
|
||||||
|
<div class="profile-card-head"><h2>${escapeHtml(t("goals"))}</h2></div>
|
||||||
|
<div class="profile-card-body profile-goals">${goalMarkup}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="profile-card" style="margin-top:14px">
|
||||||
|
<div class="profile-card-head"><h2>${escapeHtml(t("history"))}</h2></div>
|
||||||
|
<div class="profile-history-wrap">
|
||||||
|
<table class="profile-history">
|
||||||
|
<thead><tr><th>${escapeHtml(t("date"))}</th><th>${escapeHtml(t("dayLaps"))}</th><th>${escapeHtml(t("best"))}</th><th>${escapeHtml(t("average"))}</th></tr></thead>
|
||||||
|
<tbody>${historyRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
bindTimerControls();
|
||||||
|
updateTimerDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStat(label, value) {
|
||||||
|
return `<article class="profile-card profile-stat"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<section class="public-training-error"><div class="profile-brand-mark">RC</div><h1>${escapeHtml(t("loadError"))}</h1></section>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProfile();
|
||||||
|
setInterval(() => {
|
||||||
|
if (!document.hidden) loadProfile();
|
||||||
|
}, 30000);
|
||||||
@@ -447,6 +447,49 @@ body {
|
|||||||
text-transform: uppercase;
|
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 {
|
.training-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
|
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr);
|
||||||
@@ -2466,6 +2509,15 @@ select:focus {
|
|||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.training-share-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.training-share-url,
|
||||||
|
.training-share-body > .actions {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.field-card-checkbox {
|
.field-card-checkbox {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,23 @@ export function normalizeTrainingState(training, { uid }) {
|
|||||||
.sort((left, right) => left.timestamp - right.timestamp)
|
.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) {
|
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) {
|
export function getDriverTrainingLaps(state, driverId) {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export function renderTrainingView(deps) {
|
|||||||
const recommendation = buildTrainingRecommendation(stats);
|
const recommendation = buildTrainingRecommendation(stats);
|
||||||
const nextMilestone = getNextTrainingMilestone(stats);
|
const nextMilestone = getNextTrainingMilestone(stats);
|
||||||
const goals = state.training.goals.filter((goal) => goal.driverId === selectedDriverId);
|
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
|
const driverOptions = drivers
|
||||||
.map((driver) => `<option value="${escapeHtml(driver.id)}" ${driver.id === selectedDriverId ? "selected" : ""}>${escapeHtml(driver.name)}</option>`)
|
.map((driver) => `<option value="${escapeHtml(driver.id)}" ${driver.id === selectedDriverId ? "selected" : ""}>${escapeHtml(driver.name)}</option>`)
|
||||||
.join("");
|
.join("");
|
||||||
@@ -93,6 +95,22 @@ export function renderTrainingView(deps) {
|
|||||||
</tr>
|
</tr>
|
||||||
`).join("")
|
`).join("")
|
||||||
: `<tr><td colspan="4">${escapeHtml(t("training.no_history"))}</td></tr>`;
|
: `<tr><td colspan="4">${escapeHtml(t("training.no_history"))}</td></tr>`;
|
||||||
|
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 `
|
||||||
|
<tr class="${entry.driver.id === selectedDriverId ? "is-selected" : ""}">
|
||||||
|
<td>${index + 1}</td>
|
||||||
|
<td><button class="training-club-driver" data-driver-id="${escapeHtml(entry.driver.id)}" type="button">${escapeHtml(entry.driver.name)}</button></td>
|
||||||
|
<td>${entry.stats.totalLaps}</td>
|
||||||
|
<td>${entry.stats.trainingDays}</td>
|
||||||
|
<td>${formatLap(entry.stats.bestLapMs)}</td>
|
||||||
|
<td>${milestone.targetLaps}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
dom.view.innerHTML = `
|
dom.view.innerHTML = `
|
||||||
<section class="training-hero">
|
<section class="training-hero">
|
||||||
@@ -162,6 +180,35 @@ export function renderTrainingView(deps) {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="panel mt-16 training-share-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>${escapeHtml(t("training.public_profile"))}</h3>
|
||||||
|
<span class="badge ${profileShare ? "badge-online" : "badge-offline"}">${escapeHtml(t(profileShare ? "training.public_profile_active" : "training.public_profile_off"))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body training-share-body">
|
||||||
|
<div>
|
||||||
|
<p>${escapeHtml(t("training.public_profile_help"))}</p>
|
||||||
|
<p class="hint">${escapeHtml(t("training.public_profile_private"))}</p>
|
||||||
|
</div>
|
||||||
|
${profileShare ? `
|
||||||
|
<label class="training-share-url">
|
||||||
|
<span>${escapeHtml(t("training.public_profile_link"))}</span>
|
||||||
|
<input id="trainingPublicProfileUrl" value="${escapeHtml(publicProfileUrl)}" readonly />
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" id="trainingPublicProfileCopy" type="button">${escapeHtml(t("training.copy_link"))}</button>
|
||||||
|
<button class="btn" id="trainingPublicProfileOpen" type="button">${escapeHtml(t("training.open_profile"))}</button>
|
||||||
|
<button class="btn" id="trainingPublicProfileRotate" type="button">${escapeHtml(t("training.replace_link"))}</button>
|
||||||
|
<button class="btn btn-danger" id="trainingPublicProfileDisable" type="button">${escapeHtml(t("training.disable_profile"))}</button>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-primary" id="trainingPublicProfileEnable" type="button">${escapeHtml(t("training.enable_profile"))}</button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel mt-16">
|
<section class="panel mt-16">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3>${escapeHtml(t("training.history"))}</h3>
|
<h3>${escapeHtml(t("training.history"))}</h3>
|
||||||
@@ -175,6 +222,16 @@ export function renderTrainingView(deps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel mt-16">
|
||||||
|
<div class="panel-header"><h3>${escapeHtml(t("training.club_progress"))}</h3><span class="badge">${drivers.length}</span></div>
|
||||||
|
<div class="panel-body table-wrap training-history-wrap">
|
||||||
|
<table class="training-history-table training-club-table">
|
||||||
|
<thead><tr><th>#</th><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
|
||||||
|
<tbody>${clubRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel mt-16">
|
<section class="panel mt-16">
|
||||||
<div class="panel-header"><h3>${escapeHtml(t("training.goals"))}</h3><span class="badge">${goals.length}</span></div>
|
<div class="panel-header"><h3>${escapeHtml(t("training.goals"))}</h3><span class="badge">${goals.length}</span></div>
|
||||||
<div class="panel-body training-goals">${goalMarkup}</div>
|
<div class="panel-body training-goals">${goalMarkup}</div>
|
||||||
@@ -222,6 +279,35 @@ export function renderTrainingView(deps) {
|
|||||||
renderView();
|
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) {
|
function trainingStat(label, value, note) {
|
||||||
@@ -231,3 +317,44 @@ function trainingStat(label, value, note) {
|
|||||||
function trainingInsight(label, value) {
|
function trainingInsight(label, value) {
|
||||||
return `<div><span>${label}</span><strong>${value}</strong></div>`;
|
return `<div><span>${label}</span><strong>${value}</strong></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user