Files
boehmitools-training/plugins/trainingstracker/static/index.html
T
2026-09-01 16:27:43 +02:00

483 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f766e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<title>Training</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js"></script>
<style>
:root {
color-scheme: dark light;
--ink: var(--bt-ink, #eef2f5);
--ink-soft: var(--bt-ink-soft, #cbd3da);
--muted: var(--bt-muted, #8f9aa5);
--line: var(--bt-line, #303842);
--line-soft: var(--bt-line-soft, #273039);
--paper: var(--bt-surface, #1d2228);
--paper-2: var(--bt-surface-2, #20262d);
--wash: var(--bt-bg, #111418);
--wash-2: var(--bt-bg-2, #171b20);
--accent: var(--bt-accent, #ff7a1a);
--accent-ink: var(--bt-accent-ink, #fff);
--accent-soft: var(--bt-accent-soft, rgba(255,122,26,.13));
--teal: var(--bt-teal, #0f766e);
--ok: var(--bt-ok, #2f9e68);
--danger: var(--bt-err, #e2554f);
--shadow: var(--bt-shadow, 0 8px 24px rgba(0,0,0,.22));
--radius: var(--bt-r-lg, 12px);
--radius-sm: var(--bt-r, 9px);
--appbar-h: var(--bt-appbar-h, 48px);
}
* { box-sizing: border-box; }
html, body { max-width: 100%; overflow-x: hidden; }
body { margin: 0; min-height: 100vh; background: var(--wash); color: var(--ink); }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
button:disabled, select:disabled { cursor: default; opacity: .55; }
.hidden { display: none !important; }
.tracker-toolbar {
position: sticky; top: var(--appbar-h); z-index: 32;
display: flex; align-items: center; gap: 9px;
min-height: 52px; padding: 7px 12px;
background: color-mix(in srgb, var(--paper) 94%, transparent);
backdrop-filter: blur(14px); border-bottom: 1px solid var(--line);
}
.tracker-plan { min-width: 0; flex: 1; }
.tracker-plan .toolbar-label { display: block; color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.tracker-plan strong { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 13px; color: var(--ink); }
.toolbar-actions { display: flex; align-items: center; gap: 7px; }
.toolbar-btn, .icon-btn {
min-height: 36px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--paper-2); color: var(--ink); padding: 7px 10px; font-weight: 700;
}
.toolbar-btn .wide-label { display: none; }
.save-pill {
min-height: 25px; display: inline-flex; align-items: center; border-radius: 999px;
padding: 4px 8px; background: var(--paper-2); border: 1px solid var(--line);
color: var(--muted); font-size: 10px; font-weight: 750; white-space: nowrap;
}
.save-pill.saving { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 48%, var(--line)); }
.save-pill.saved { color: var(--ok); }
.save-pill.error { color: var(--danger); }
.bottom-nav {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
display: grid; grid-template-columns: repeat(2,1fr);
padding: 7px 8px max(7px, env(safe-area-inset-bottom));
background: color-mix(in srgb, var(--paper) 96%, transparent);
backdrop-filter: blur(14px); border-top: 1px solid var(--line);
}
.nav-btn { border: 0; background: transparent; color: var(--muted); border-radius: var(--radius-sm); padding: 7px 3px; font-size: 10px; font-weight: 750; }
.nav-btn span { display: block; font-size: 18px; margin-bottom: 1px; }
.nav-btn.active { color: var(--accent); background: var(--accent-soft); }
.app { width: min(960px, 100%); min-width: 0; max-width: 100%; margin: 0 auto; padding: 12px 10px 96px; }
.card {
min-width: 0; max-width: 100%;
background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius);
box-shadow: var(--shadow); margin-bottom: 10px; overflow: hidden;
}
.card-pad { padding: 14px; }
.hero { padding: 15px; background: var(--paper); border-left: 3px solid var(--accent); }
.eyebrow { color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
h1, h2, h3, p { margin-top: 0; }
h1 { font-size: clamp(20px, 6vw, 29px); line-height: 1.1; margin-bottom: 7px; }
h2 { font-size: 17px; margin-bottom: 0; }
h3 { font-size: 14px; margin-bottom: 5px; }
.muted { color: var(--muted); }
.tiny { font-size: 12px; }
.row { display: flex; align-items: center; gap: 9px; }
.between { justify-content: space-between; }
.grow { flex: 1; min-width: 0; }
.day-strip { display: flex; gap: 7px; overflow-x: auto; scrollbar-width: none; padding: 2px 1px 8px; scroll-snap-type: x proximity; }
.day-strip::-webkit-scrollbar { display: none; }
.chip {
flex: 0 0 auto; scroll-snap-align: start; border: 1px solid var(--line);
background: var(--paper-2); color: var(--ink); border-radius: 999px;
padding: 8px 12px; font-size: 12px; font-weight: 750;
}
.chip.active { border-color: var(--accent); background: var(--accent); color: var(--accent-ink); }
.status-badge { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border-radius: 999px; font-size: 10px; font-weight: 800; background: var(--paper-2); color: var(--muted); border: 1px solid var(--line); }
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 9px; padding: 13px 14px 9px; }
.section-body { padding: 0 14px 14px; }
.list { display: grid; gap: 7px; }
.plain-item { padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper-2); font-size: 13px; line-height: 1.4; }
.exercise { padding: 13px 14px; border-top: 1px solid var(--line-soft); }
.exercise:first-child { border-top: 0; }
.exercise-title { display: grid; grid-template-columns: minmax(0,1fr) minmax(180px,280px); gap: 10px; align-items: end; }
.exercise-name strong { display: block; font-size: 14px; }
.exercise-name small { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; }
label.field { display: grid; gap: 5px; color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; margin: 0; }
select {
width: 100%; min-height: 39px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--paper-2); color: var(--ink); padding: 8px 10px; outline: none;
}
select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); }
.progression-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
.progression-pill { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px; color: var(--muted); background: var(--paper-2); font-size: 10px; font-weight: 750; }
.progression-pill.current { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.plan-day { border-top: 1px solid var(--line); padding: 13px 14px; }
.plan-day:first-child { border-top: 0; }
.plan-day h3 { margin-bottom: 7px; }
.overview-line { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
.empty { padding: 25px 16px; text-align: center; color: var(--muted); }
.error-card { border: 1px solid color-mix(in srgb, var(--danger) 55%, var(--line)); background: color-mix(in srgb, var(--danger) 13%, var(--paper)); color: var(--danger); padding: 13px; border-radius: var(--radius); }
.modal-backdrop { position: fixed; inset: 0; z-index: 80; background: rgba(0,0,0,.68); display: grid; align-items: end; }
.modal { width: min(760px,100%); max-height: 88vh; overflow: auto; margin: 0 auto; border-radius: 18px 18px 0 0; padding: 16px 12px max(16px, env(safe-area-inset-bottom)); background: var(--paper); border: 1px solid var(--line); box-shadow: var(--shadow); }
.plan-choice { display: block; width: 100%; text-align: left; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper-2); color: var(--ink); padding: 11px; margin-top: 8px; }
.plan-choice.active { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
.plan-choice strong, .plan-choice span { display: block; }
.plan-choice span { color: var(--muted); font-size: 11px; margin-top: 4px; }
@media (max-width: 520px) {
.exercise-title { grid-template-columns: minmax(0,1fr); }
}
@media (min-width: 560px) {
.app { padding-left: 14px; padding-right: 14px; }
.toolbar-btn .wide-label { display: inline; }
}
@media (min-width: 780px) {
.tracker-toolbar { padding-left: 16px; padding-right: 16px; }
.bottom-nav {
position: sticky; top: calc(var(--appbar-h) + 52px); z-index: 31;
display: flex; justify-content: center; gap: 3px; padding: 0 16px;
background: var(--wash); border-top: 0; border-bottom: 1px solid var(--line);
}
.nav-btn { border-radius: var(--radius-sm) var(--radius-sm) 0 0; padding: 9px 14px; font-size: 12px; border-bottom: 2px solid transparent; }
.nav-btn span { display: inline; font-size: 13px; margin: 0 5px 0 0; }
.nav-btn.active { background: var(--wash-2); border-bottom-color: var(--accent); }
.app { padding-top: 16px; padding-bottom: 70px; }
.modal-backdrop { align-items: center; padding: 24px; }
.modal { border-radius: var(--radius); }
}
</style>
</head>
<body data-bt-title="Training" data-bt-icon="📱" data-base="__APP_BASE__">
<header class="tracker-toolbar">
<div class="tracker-plan">
<span class="toolbar-label">Aktiver Trainingsplan</span>
<strong id="headerPlan">Trainingsplan wird geladen</strong>
</div>
<div class="toolbar-actions">
<span class="save-pill" id="saveState">Bereit</span>
<button class="toolbar-btn" id="choosePlanBtn" aria-label="Trainingsplan wechseln" title="Trainingsplan wechseln"><span class="wide-label">Plan wechseln</span></button>
</div>
</header>
<nav class="bottom-nav" aria-label="Training-Navigation">
<button class="nav-btn active" data-view="day"><span></span>Tag</button>
<button class="nav-btn" data-view="plan"><span></span>Plan</button>
</nav>
<main class="app">
<div id="fatalError" class="error-card hidden"></div>
<section id="dayView"></section>
<section id="planView" class="hidden"></section>
</main>
<div id="planModal" class="modal-backdrop hidden" role="dialog" aria-modal="true">
<div class="modal">
<div class="row between">
<div>
<div class="eyebrow">Trainingsquelle</div>
<h2>Trainingsplan auswählen</h2>
</div>
<button class="icon-btn" id="closePlanModal" aria-label="Schließen">×</button>
</div>
<p class="muted tiny">Es werden nur Trainingspläne aus <code>data/trainingsplan/plans</code> angezeigt.</p>
<div id="planChoices"></div>
</div>
</div>
<script>
(() => {
'use strict';
const BASE = document.body.dataset.base || '';
const api = path => `${BASE}${path}`;
const state = {plans: [], selectedId: null, plan: null, dayNum: 1, view: 'day', saving: false};
const $ = sel => document.querySelector(sel);
const $$ = sel => [...document.querySelectorAll(sel)];
const esc = value => String(value ?? '').replace(/[&<>'"]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[ch]));
function plain(value) {
const div = document.createElement('div');
div.innerHTML = String(value || '');
return (div.textContent || '').trim();
}
function storageKey(planId = state.selectedId) {
return planId ? `trainingstracker.slim.${planId}` : '';
}
function persistUiState() {
const key = storageKey();
if (!key) return;
try { localStorage.setItem(key, JSON.stringify({view: state.view, dayNum: state.dayNum})); } catch (_) {}
}
function restoreUiState() {
const key = storageKey();
if (!key) return false;
try {
const saved = JSON.parse(localStorage.getItem(key) || 'null');
if (!saved || typeof saved !== 'object') return false;
if (['day','plan'].includes(saved.view)) state.view = saved.view;
const dayNum = Number(saved.dayNum);
if (state.plan?.config?.days?.some(day => Number(day.num) === dayNum)) state.dayNum = dayNum;
return true;
} catch (_) { return false; }
}
async function fetchJSON(url, options = {}) {
const response = await fetch(url, {headers: {'Content-Type':'application/json', ...(options.headers || {})}, ...options});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
return payload;
}
function setSaveState(text, cls = '') {
const el = $('#saveState');
el.textContent = text;
el.className = `save-pill ${cls}`;
}
async function loadPlans(openChooser = false) {
const data = await fetchJSON(api('/api/plans'));
state.plans = data.plans || [];
if (!state.plans.length) throw new Error('Keine Trainingsplan-JSON gefunden.');
await selectPlan(data.selected || state.plans[0].id, false);
if (openChooser) openPlanModal();
}
async function selectPlan(id, persist = true) {
persistUiState();
if (persist) await fetchJSON(api(`/api/plans/${encodeURIComponent(id)}/select`), {method:'POST', body:'{}'});
const data = await fetchJSON(api(`/api/plans/${encodeURIComponent(id)}`));
state.selectedId = id;
state.plan = data.plan;
state.dayNum = state.plan.config.days[0]?.num || 1;
state.view = 'day';
restoreUiState();
$('#headerPlan').textContent = plain(state.plan.name || state.plan.config.meta?.title);
setSaveState('Bereit');
renderEverything();
}
function currentDay() {
return state.plan?.config?.days?.find(day => Number(day.num) === Number(state.dayNum)) || state.plan?.config?.days?.[0] || null;
}
function exerciseById(id) {
return (state.plan?.config?.exercises || []).find(exercise => exercise.id === id) || null;
}
function progressionOptions(exercise) {
const current = exercise?.current_progression_id || exercise?.progressions?.[0]?.id || '';
return (exercise?.progressions || []).map(progression =>
`<option value="${esc(progression.id)}"${progression.id === current ? ' selected' : ''}>${esc(progression.name || progression.id)}</option>`
).join('');
}
function currentProgression(exercise) {
return exercise?.current_progression || (exercise?.progressions || []).find(item => item.id === exercise?.current_progression_id) || exercise?.progressions?.[0] || null;
}
function renderEverything() {
renderDay();
renderPlan();
switchView(state.view);
}
function renderDay() {
const cfg = state.plan?.config;
const day = currentDay();
if (!cfg || !day) {
$('#dayView').innerHTML = '<section class="card"><div class="empty">Kein Tag im Plan vorhanden.</div></section>';
return;
}
const days = cfg.days.map(item =>
`<button class="chip ${Number(item.num) === Number(state.dayNum) ? 'active' : ''}" data-day="${esc(item.num)}">Tag ${esc(item.num)}</button>`
).join('');
$('#dayView').innerHTML = `
<section class="card hero">
<div class="eyebrow">Tag ${esc(day.num)}</div>
<div class="row between" style="align-items:flex-start;margin-top:6px">
<div class="grow"><h1>${esc(plain(day.name))}</h1><div class="muted tiny">${esc(plain(cfg.meta?.title || state.plan.name))}</div></div>
<span class="status-badge">${cfg.exercises.length} Übungen</span>
</div>
<div class="overview-line">
<span class="status-badge">${cfg.schema_version}/${cfg.contract_version}</span>
<span class="status-badge">${cfg.days.length} Tage</span>
</div>
</section>
<section class="card card-pad">
<div class="eyebrow">Tage</div>
<div class="day-strip">${days}</div>
</section>
${renderListSection('Warm-up', day.warmup)}
${renderTraining(day)}
${renderListSection('Cool-down', day.cooldown)}
`;
}
function renderListSection(title, section) {
const items = section?.items || [];
if (!items.length) return '';
return `<section class="card">
<div class="section-head"><div><div class="eyebrow">Bestandteil</div><h2>${esc(title)}</h2></div></div>
<div class="section-body"><div class="list">${items.map(item => `<div class="plain-item">${esc(item)}</div>`).join('')}</div></div>
</section>`;
}
function renderTraining(day) {
const exercises = day.exercises || [];
if (!exercises.length) return '<section class="card"><div class="empty">Für diesen Tag sind keine Übungen hinterlegt.</div></section>';
return `<section class="card">
<div class="section-head"><div><div class="eyebrow">Training</div><h2>Übungen</h2></div><span class="status-badge">${exercises.length}</span></div>
${exercises.map(item => renderExercise(item.exercise || exerciseById(item.exercise_id))).join('')}
</section>`;
}
function renderExercise(exercise) {
if (!exercise) return '';
const current = currentProgression(exercise);
const options = progressionOptions(exercise);
const disabled = options ? '' : 'disabled';
return `<div class="exercise" data-exercise-id="${esc(exercise.id)}">
<div class="exercise-title">
<div class="exercise-name">
<strong>${esc(plain(exercise.name))}</strong>
<small>${current ? esc(current.name) : 'Keine Progression hinterlegt'}</small>
</div>
<label class="field">Aktuell
<select data-current-progression ${disabled}>
${options || '<option value="">Keine Progression</option>'}
</select>
</label>
</div>
${renderProgressionPills(exercise)}
</div>`;
}
function renderProgressionPills(exercise) {
const current = exercise.current_progression_id || exercise.progressions?.[0]?.id || '';
const items = exercise.progressions || [];
if (!items.length) return '';
return `<div class="progression-list">${items.map(item =>
`<span class="progression-pill ${item.id === current ? 'current' : ''}">${esc(item.name)}</span>`
).join('')}</div>`;
}
function renderPlan() {
const cfg = state.plan?.config;
if (!cfg) return;
$('#planView').innerHTML = `<section class="card hero">
<div class="eyebrow">Plan</div>
<h1>${esc(plain(cfg.meta?.title || state.plan.name))}</h1>
<div class="overview-line">
<span class="status-badge">${cfg.days.length} Tage</span>
<span class="status-badge">${cfg.exercises.length} Übungen</span>
</div>
</section>
<section class="card">
${cfg.days.map(day => `<div class="plan-day">
<h3>Tag ${esc(day.num)} · ${esc(plain(day.name))}</h3>
<div class="tiny muted">${(day.exercises || []).map(item => esc(plain((item.exercise || exerciseById(item.exercise_id))?.name || ''))).filter(Boolean).join(' · ')}</div>
</div>`).join('')}
</section>`;
}
function switchView(view) {
state.view = view;
persistUiState();
$$('.nav-btn').forEach(button => button.classList.toggle('active', button.dataset.view === view));
$('#dayView').classList.toggle('hidden', view !== 'day');
$('#planView').classList.toggle('hidden', view !== 'plan');
}
async function saveCurrentProgression(exerciseId, progressionId) {
if (state.saving) return;
state.saving = true;
setSaveState('Speichert …', 'saving');
try {
const data = await fetchJSON(api(`/api/plans/${encodeURIComponent(state.selectedId)}/exercises/${encodeURIComponent(exerciseId)}/current`), {
method: 'PATCH',
body: JSON.stringify({current_progression_id: progressionId}),
});
state.plan = data.plan;
setSaveState('Gespeichert', 'saved');
renderEverything();
setTimeout(() => setSaveState('Bereit'), 1200);
} catch (error) {
setSaveState('Fehler', 'error');
window.alert(error.message);
} finally {
state.saving = false;
}
}
function openPlanModal() {
$('#planChoices').innerHTML = state.plans.map(plan => `<button class="plan-choice ${plan.id === state.selectedId ? 'active' : ''}" data-plan-choice="${esc(plan.id)}">
<strong>${esc(plain(plan.name))}</strong>
<span>${esc(plan.days)} Tage · ${esc(plan.exercises)} Übungen</span>
</button>`).join('');
$('#planModal').classList.remove('hidden');
}
function closePlanModal() {
$('#planModal').classList.add('hidden');
}
document.addEventListener('click', async event => {
const dayButton = event.target.closest('[data-day]');
if (dayButton) {
state.dayNum = Number(dayButton.dataset.day);
persistUiState();
renderEverything();
return;
}
const nav = event.target.closest('[data-view]');
if (nav) {
switchView(nav.dataset.view);
return;
}
if (event.target.closest('#choosePlanBtn')) {
openPlanModal();
return;
}
if (event.target.closest('#closePlanModal') || event.target === $('#planModal')) {
closePlanModal();
return;
}
const planChoice = event.target.closest('[data-plan-choice]');
if (planChoice) {
closePlanModal();
await selectPlan(planChoice.dataset.planChoice);
}
});
document.addEventListener('change', event => {
const select = event.target.closest('[data-current-progression]');
if (!select) return;
const row = select.closest('[data-exercise-id]');
if (!row || !select.value) return;
saveCurrentProgression(row.dataset.exerciseId, select.value);
});
const ready = window.BT?.ready || Promise.resolve();
ready.then(() => loadPlans()).catch(error => {
$('#fatalError').textContent = error.message;
$('#fatalError').classList.remove('hidden');
$('#headerPlan').textContent = 'Kein Plan gefunden';
});
})();
</script>
</body>
</html>