Expose guided training exercises on the driver's mobile profile link
"Jämna varv" and "Stegträning" previously only lived on the admin Training page. A driver's own personal mobile profile link now offers the same self-service configure/start/stop flow already used for the 5-minute practice timer there, with live progress computed server-side from the driver's own share token so raw lap rows and driver IDs never reach the client. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -436,6 +436,64 @@ button {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.is-positive {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.is-negative {
|
||||
color: #ff8f89;
|
||||
}
|
||||
|
||||
.profile-step-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 10px 0 12px;
|
||||
}
|
||||
|
||||
.profile-step-pill {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.profile-step-pill.is-current {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-step-pill.is-done {
|
||||
border-color: color-mix(in srgb, var(--green) 55%, var(--line) 45%);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.profile-steptraining-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.profile-steptraining-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) 90px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-steptraining-row select,
|
||||
.profile-steptraining-row input {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.profile-progress {
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
@@ -1164,6 +1222,15 @@ button {
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.profile-steptraining-row select,
|
||||
.profile-steptraining-row input {
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.profile-step-pill {
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.profile-timer {
|
||||
margin: 8px 0;
|
||||
font-size: clamp(1.8rem, 8vw, 2.4rem);
|
||||
|
||||
@@ -79,6 +79,21 @@ const messages = {
|
||||
markerSetup: "Ny setup", markerTires: "Däck", markerSuspension: "Dämpare", markerCar: "Bil", markerNoteType: "Anteckning",
|
||||
markerSaved: "Biländringen är sparad.", markerError: "Kunde inte spara biländringen.",
|
||||
blockLaps: "{laps} varv", blockMarkers: "{count} ändring(ar)",
|
||||
evenLapsTitle: "Jämna varv", evenLapsEyebrow: "Övning",
|
||||
evenLapsHint: "Ställ in måltid och tolerans, spara, och starta när du är redo att köra.",
|
||||
evenLapsTargetLabel: "Måltid (sek)", evenLapsToleranceLabel: "Tolerans ± (sek)", evenLapsLapCountLabel: "Antal varv i serien",
|
||||
evenLapsSuggestionHint: "Förslag baserat på ditt bästa varv: {lap}",
|
||||
evenLapsStreak: "{current}/{target} jämna varv", evenLapsNewStreak: "Ny serie – försök slå din bästa",
|
||||
evenLapsStartHint: "Kör ditt första varv för att starta en serie.",
|
||||
evenLapsBestTodayLabel: "Bästa serie idag", evenLapsNotStarted: "Inte startad ännu.",
|
||||
stepTrainingTitle: "Stegträning", stepTrainingEyebrow: "Övning",
|
||||
stepTrainingHint: "Bygg din stegserie, spara, och starta när du är redo. Standard är pyramiden: lugnt, kontrollerat, snabbt, kontrollerat, lugnt.",
|
||||
stepTrainingComplete: "Serien klar – bra jobbat!",
|
||||
stepTrainingCurrent: "Steg {step}/{total}: {effort}", stepTrainingRemaining: "{laps} varv kvar",
|
||||
stepTrainingCompare: "Kontrollerat steg 1: {first} · Kontrollerat steg 2: {last} ({diff})",
|
||||
stepTrainingNotStarted: "Inte startad ännu.",
|
||||
effortCalm: "Lugnt", effortControlled: "Kontrollerat", effortFast: "Snabbt",
|
||||
saveSetup: "Spara", stop: "Stoppa",
|
||||
stints: "Stints",
|
||||
bestThree: "Bästa 3 sammanhängande",
|
||||
bestFive: "Bästa 5 min",
|
||||
@@ -206,6 +221,21 @@ const messages = {
|
||||
markerSetup: "New setup", markerTires: "Tires", markerSuspension: "Suspension", markerCar: "Car", markerNoteType: "Note",
|
||||
markerSaved: "The car change was saved.", markerError: "The car change could not be saved.",
|
||||
blockLaps: "{laps} laps", blockMarkers: "{count} change(s)",
|
||||
evenLapsTitle: "Even laps", evenLapsEyebrow: "Exercise",
|
||||
evenLapsHint: "Set a target and tolerance, save, and start when you're ready to run.",
|
||||
evenLapsTargetLabel: "Target (sec)", evenLapsToleranceLabel: "Tolerance ± (sec)", evenLapsLapCountLabel: "Laps in the streak",
|
||||
evenLapsSuggestionHint: "Suggested from your best lap: {lap}",
|
||||
evenLapsStreak: "{current}/{target} even laps", evenLapsNewStreak: "New streak - try to beat your best",
|
||||
evenLapsStartHint: "Run your first lap to start a streak.",
|
||||
evenLapsBestTodayLabel: "Best streak today", evenLapsNotStarted: "Not started yet.",
|
||||
stepTrainingTitle: "Step training", stepTrainingEyebrow: "Exercise",
|
||||
stepTrainingHint: "Build your step sequence, save, and start when ready. The default is the pyramid: calm, controlled, fast, controlled, calm.",
|
||||
stepTrainingComplete: "Sequence complete - nice work!",
|
||||
stepTrainingCurrent: "Step {step}/{total}: {effort}", stepTrainingRemaining: "{laps} laps remaining",
|
||||
stepTrainingCompare: "Controlled step 1: {first} · Controlled step 2: {last} ({diff})",
|
||||
stepTrainingNotStarted: "Not started yet.",
|
||||
effortCalm: "Calm", effortControlled: "Controlled", effortFast: "Fast",
|
||||
saveSetup: "Save", stop: "Stop",
|
||||
stints: "Stints",
|
||||
bestThree: "Best 3 consecutive",
|
||||
bestFive: "Best 5 min",
|
||||
@@ -387,6 +417,20 @@ function renderProfile(data) {
|
||||
const recommendation = activeClass?.recommendation || data.recommendation;
|
||||
const goals = activeClass?.goals || data.goals || [];
|
||||
const currentSession = activeClass?.currentSession || data.currentSession;
|
||||
const evenLapsConfig = activeClass?.evenLapsConfig || null;
|
||||
const evenLapsProgress = activeClass?.evenLapsProgress || null;
|
||||
const evenLapsSuggestedTargetMs = stats.bestLapMs ? Math.round(stats.bestLapMs * 1.05) : 0;
|
||||
const evenLapsRunning = Boolean(readExerciseRunState("evenlaps"));
|
||||
const stepTrainingConfig = activeClass?.stepTrainingConfig || null;
|
||||
const stepTrainingProgress = activeClass?.stepTrainingProgress || null;
|
||||
const stepTrainingRunning = Boolean(readExerciseRunState("steptraining"));
|
||||
const defaultStepTrainingRows = [
|
||||
{ effort: "calm", lapCount: 5 }, { effort: "controlled", lapCount: 5 }, { effort: "fast", lapCount: 5 },
|
||||
{ effort: "controlled", lapCount: 5 }, { effort: "calm", lapCount: 5 },
|
||||
];
|
||||
const stepTrainingFormRows = stepTrainingConfig?.steps?.length
|
||||
? [...stepTrainingConfig.steps, ...Array(Math.max(0, 5 - stepTrainingConfig.steps.length)).fill({ effort: "calm", lapCount: 0 })].slice(0, 5)
|
||||
: defaultStepTrainingRows;
|
||||
const motivation = buildProfileMotivation(stats, channelStats);
|
||||
const recommendationText = recommendationCopy(recommendation);
|
||||
const timerState = readTimerState();
|
||||
@@ -555,6 +599,84 @@ function renderProfile(data) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="profile-card profile-exercise-card" style="margin-top:14px">
|
||||
<div class="profile-card-head"><div><span class="profile-mini-label">${escapeHtml(t("evenLapsEyebrow"))}</span><h2>${escapeHtml(t("evenLapsTitle"))}</h2></div></div>
|
||||
<div class="profile-card-body">
|
||||
${evenLapsConfig && evenLapsProgress?.active ? `
|
||||
<div class="profile-session-heading">
|
||||
${escapeHtml(evenLapsProgress.currentStreak > 0
|
||||
? t("evenLapsStreak", { current: evenLapsProgress.currentStreak, target: evenLapsConfig.lapCount })
|
||||
: evenLapsProgress.bestStreakToday > 0
|
||||
? t("evenLapsNewStreak")
|
||||
: t("evenLapsStartHint"))}
|
||||
</div>
|
||||
<div class="profile-session-stats">
|
||||
<div><span>${escapeHtml(t("evenLapsBestTodayLabel"))}</span><strong>${evenLapsProgress.bestStreakToday}</strong></div>
|
||||
${evenLapsProgress.lastLapMs != null ? `<div><span>${escapeHtml(t("sessionLaps"))}</span><strong class="${evenLapsProgress.lastLapWithinTolerance ? "is-positive" : "is-negative"}">${formatLap(evenLapsProgress.lastLapMs)}</strong></div>` : ""}
|
||||
</div>
|
||||
` : `<p class="profile-empty">${escapeHtml(evenLapsConfig ? t("evenLapsNotStarted") : t("evenLapsHint"))}</p>`}
|
||||
<fieldset id="profileEvenLapsSetup" class="profile-timer-setup">
|
||||
<legend>${escapeHtml(t("evenLapsHint"))}</legend>
|
||||
<label><span>${escapeHtml(t("evenLapsTargetLabel"))}</span><input id="profileEvenLapsTarget" type="number" min="0.1" step="0.1" value="${evenLapsConfig ? (evenLapsConfig.targetMs / 1000).toFixed(1) : evenLapsSuggestedTargetMs ? (evenLapsSuggestedTargetMs / 1000).toFixed(1) : ""}" placeholder="${evenLapsSuggestedTargetMs ? (evenLapsSuggestedTargetMs / 1000).toFixed(1) : ""}" /></label>
|
||||
<label><span>${escapeHtml(t("evenLapsToleranceLabel"))}</span><input id="profileEvenLapsTolerance" type="number" min="0.1" step="0.1" value="${evenLapsConfig ? (evenLapsConfig.toleranceMs / 1000).toFixed(1) : "0.5"}" /></label>
|
||||
<label><span>${escapeHtml(t("evenLapsLapCountLabel"))}</span><input id="profileEvenLapsLapCount" type="number" min="1" step="1" value="${evenLapsConfig ? evenLapsConfig.lapCount : 5}" /></label>
|
||||
</fieldset>
|
||||
${evenLapsSuggestedTargetMs && !evenLapsConfig ? `<small>${escapeHtml(t("evenLapsSuggestionHint", { lap: formatLap(evenLapsSuggestedTargetMs) }))}</small>` : ""}
|
||||
<div class="profile-actions">
|
||||
<button id="profileEvenLapsSave" class="profile-button" type="button">${escapeHtml(t("saveSetup"))}</button>
|
||||
<button id="profileEvenLapsStart" class="profile-button profile-button-primary" type="button" ${evenLapsRunning ? "disabled" : ""}>${escapeHtml(t("start"))}</button>
|
||||
<button id="profileEvenLapsStop" class="profile-button" type="button" ${evenLapsRunning ? "" : "disabled"}>${escapeHtml(t("stop"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="profile-card profile-exercise-card" style="margin-top:14px">
|
||||
<div class="profile-card-head"><div><span class="profile-mini-label">${escapeHtml(t("stepTrainingEyebrow"))}</span><h2>${escapeHtml(t("stepTrainingTitle"))}</h2></div></div>
|
||||
<div class="profile-card-body">
|
||||
${stepTrainingConfig && stepTrainingProgress?.active ? `
|
||||
${stepTrainingProgress.complete
|
||||
? `<div class="profile-session-heading"><strong>${escapeHtml(t("stepTrainingComplete"))}</strong></div>`
|
||||
: stepTrainingProgress.currentStep
|
||||
? `<div class="profile-session-heading">
|
||||
${escapeHtml(t("stepTrainingCurrent", { step: stepTrainingProgress.currentStepIndex + 1, total: stepTrainingProgress.totalSteps, effort: t(`effort${stepTrainingProgress.currentStep.effort.charAt(0).toUpperCase()}${stepTrainingProgress.currentStep.effort.slice(1)}`) }))}
|
||||
<strong>${escapeHtml(t("stepTrainingRemaining", { laps: stepTrainingProgress.currentStep.lapsRemaining }))}</strong>
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="profile-step-pills">
|
||||
${stepTrainingProgress.steps.map((step) => `
|
||||
<span class="profile-step-pill ${step.done ? "is-done" : step.index === stepTrainingProgress.currentStepIndex ? "is-current" : ""}">
|
||||
${escapeHtml(t(`effort${step.effort.charAt(0).toUpperCase()}${step.effort.slice(1)}`))} · ${step.lapsCompleted}/${step.lapCount}
|
||||
</span>
|
||||
`).join("")}
|
||||
</div>
|
||||
${stepTrainingProgress.controlledComparison ? `
|
||||
<small>${escapeHtml(t("stepTrainingCompare", {
|
||||
first: formatLap(stepTrainingProgress.controlledComparison.firstAvgMs),
|
||||
last: formatLap(stepTrainingProgress.controlledComparison.lastAvgMs),
|
||||
diff: `${stepTrainingProgress.controlledComparison.diffMs >= 0 ? "+" : ""}${(stepTrainingProgress.controlledComparison.diffMs / 1000).toFixed(2)}s`,
|
||||
}))}</small>
|
||||
` : ""}
|
||||
` : `<p class="profile-empty">${escapeHtml(stepTrainingConfig ? t("stepTrainingNotStarted") : t("stepTrainingHint"))}</p>`}
|
||||
<div class="profile-steptraining-form">
|
||||
${stepTrainingFormRows.map((row, index) => `
|
||||
<div class="profile-steptraining-row">
|
||||
<select id="profileStepEffort${index}">
|
||||
<option value="calm" ${row.effort === "calm" ? "selected" : ""}>${escapeHtml(t("effortCalm"))}</option>
|
||||
<option value="controlled" ${row.effort === "controlled" ? "selected" : ""}>${escapeHtml(t("effortControlled"))}</option>
|
||||
<option value="fast" ${row.effort === "fast" ? "selected" : ""}>${escapeHtml(t("effortFast"))}</option>
|
||||
</select>
|
||||
<input id="profileStepLaps${index}" type="number" min="0" step="1" value="${row.lapCount || ""}" placeholder="0" />
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
<div class="profile-actions">
|
||||
<button id="profileStepTrainingSave" class="profile-button" type="button">${escapeHtml(t("saveSetup"))}</button>
|
||||
<button id="profileStepTrainingStart" class="profile-button profile-button-primary" type="button" ${stepTrainingRunning ? "disabled" : ""}>${escapeHtml(t("start"))}</button>
|
||||
<button id="profileStepTrainingStop" class="profile-button" type="button" ${stepTrainingRunning ? "" : "disabled"}>${escapeHtml(t("stop"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="profile-card profile-disclosure profile-performance ${visibility.performance ? "" : "is-hidden"}" data-persist-open="performance" ${openSections.has("performance") ? "open" : ""} style="margin-top:14px">
|
||||
<summary class="profile-card-head"><h2>${escapeHtml(t("performance"))}</h2><span>+</span></summary>
|
||||
<form id="publicTrainingMarkerForm" class="profile-marker-form" data-class-id="${escapeHtml(selectedClassId)}">
|
||||
@@ -638,6 +760,7 @@ function renderProfile(data) {
|
||||
});
|
||||
bindTimerControls();
|
||||
updateTimerDisplay();
|
||||
bindExerciseControls();
|
||||
}
|
||||
|
||||
function renderStat(label, value) {
|
||||
@@ -698,6 +821,29 @@ function getTimerConfigStorageKey() {
|
||||
return `rc-training-timer-config:${shareToken}:${selectedClassId || "default"}`;
|
||||
}
|
||||
|
||||
// The two guided exercises track progress by laps completed since the driver tapped
|
||||
// "start", not by elapsed time, so unlike the timer above there is no pause/duration to
|
||||
// manage locally - just a start timestamp (or none) per class, mirrored to the server as
|
||||
// "started ago" on each poll so it can compute the sanitized streak/step progress.
|
||||
function getExerciseRunStorageKey(kind) {
|
||||
return `rc-training-${kind}-run:${shareToken}:${selectedClassId || "default"}`;
|
||||
}
|
||||
|
||||
function readExerciseRunState(kind) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(getExerciseRunStorageKey(kind)) || "null");
|
||||
return parsed && typeof parsed === "object" && parsed.startedAt ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeExerciseRunState(kind, value) {
|
||||
const key = getExerciseRunStorageKey(kind);
|
||||
if (value) localStorage.setItem(key, JSON.stringify(value));
|
||||
else localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
function getTimerElapsed(state, now = Date.now()) {
|
||||
if (!state?.startedAt) return 0;
|
||||
const endpoint = state.pausedAt || now;
|
||||
@@ -754,6 +900,57 @@ function bindTimerControls() {
|
||||
startTimerLoop();
|
||||
}
|
||||
|
||||
function bindExerciseControls() {
|
||||
document.getElementById("profileEvenLapsSave")?.addEventListener("click", async () => {
|
||||
const targetMs = Math.round(Number(document.getElementById("profileEvenLapsTarget")?.value || 0) * 1000);
|
||||
const toleranceMs = Math.round(Number(document.getElementById("profileEvenLapsTolerance")?.value || 0) * 1000);
|
||||
const lapCount = Math.max(1, Math.round(Number(document.getElementById("profileEvenLapsLapCount")?.value || 0) || 5));
|
||||
if (!(targetMs > 0) || !(toleranceMs >= 0)) return;
|
||||
const button = document.getElementById("profileEvenLapsSave");
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
await fetch(`/public-training/api/${encodeURIComponent(shareToken)}/even-laps`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classId: selectedClassId, targetMs, toleranceMs, lapCount }) });
|
||||
await loadProfile();
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
});
|
||||
document.getElementById("profileEvenLapsStart")?.addEventListener("click", () => {
|
||||
writeExerciseRunState("evenlaps", { startedAt: Date.now() });
|
||||
void loadProfile();
|
||||
});
|
||||
document.getElementById("profileEvenLapsStop")?.addEventListener("click", () => {
|
||||
writeExerciseRunState("evenlaps", null);
|
||||
void loadProfile();
|
||||
});
|
||||
|
||||
document.getElementById("profileStepTrainingSave")?.addEventListener("click", async () => {
|
||||
const steps = [0, 1, 2, 3, 4]
|
||||
.map((index) => ({
|
||||
effort: String(document.getElementById(`profileStepEffort${index}`)?.value || "calm"),
|
||||
lapCount: Math.max(0, Math.round(Number(document.getElementById(`profileStepLaps${index}`)?.value || 0) || 0)),
|
||||
}))
|
||||
.filter((step) => step.lapCount > 0);
|
||||
if (!steps.length) return;
|
||||
const button = document.getElementById("profileStepTrainingSave");
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
await fetch(`/public-training/api/${encodeURIComponent(shareToken)}/step-training`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classId: selectedClassId, steps }) });
|
||||
await loadProfile();
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
});
|
||||
document.getElementById("profileStepTrainingStart")?.addEventListener("click", () => {
|
||||
writeExerciseRunState("steptraining", { startedAt: Date.now() });
|
||||
void loadProfile();
|
||||
});
|
||||
document.getElementById("profileStepTrainingStop")?.addEventListener("click", () => {
|
||||
writeExerciseRunState("steptraining", null);
|
||||
void loadProfile();
|
||||
});
|
||||
}
|
||||
|
||||
function startTimerLoop() {
|
||||
if (timerHandle) clearInterval(timerHandle);
|
||||
timerHandle = setInterval(updateTimerDisplay, 250);
|
||||
@@ -842,6 +1039,10 @@ async function loadProfile(background = false) {
|
||||
apiUrl.searchParams.set("startedAgoMs", String(startedAgoMs));
|
||||
apiUrl.searchParams.set("windowMs", String(Math.min(startedAgoMs, maximumWindowMs)));
|
||||
}
|
||||
const evenLapsRun = readExerciseRunState("evenlaps");
|
||||
if (evenLapsRun) apiUrl.searchParams.set("evenLapsStartedAgoMs", String(Math.max(0, Date.now() - evenLapsRun.startedAt)));
|
||||
const stepTrainingRun = readExerciseRunState("steptraining");
|
||||
if (stepTrainingRun) apiUrl.searchParams.set("stepTrainingStartedAgoMs", String(Math.max(0, Date.now() - stepTrainingRun.startedAt)));
|
||||
const response = await fetch(apiUrl, { cache: "no-store", signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
|
||||
Reference in New Issue
Block a user