Files
2026-07-25 21:10:53 +02:00

1638 lines
114 KiB
HTML
Raw Permalink 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>Trainings-Session-Tracker</title>
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js"></script>
<style>
/* Mobile-first Session-Tracker. Das globale App-Design und der Theme-
Umschalter kommen aus /shared/boehmi.css und /shared/boehmi.js. */
: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);
--teal-soft: var(--bt-teal-soft, rgba(15,118,110,.18));
--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 { background: var(--wash); color: var(--ink); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; min-height: 100vh; max-width: 100%; overflow-x: hidden; background: var(--wash); color: var(--ink); }
html { max-width: 100%; overflow-x: hidden; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .55; }
a { color: var(--accent); }
code { color: var(--ink-soft); }
.hidden { display: none !important; }
/* Plugin-Leiste direkt unter der globalen boehmitools-App-Bar. */
.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; }
.icon-btn, .toolbar-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); }
.app { width: min(1180px, 100%); min-width: 0; max-width: 100%; margin: 0 auto; padding: 12px 10px 104px; }
.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; }
.row.wrap { flex-wrap: wrap; }
.between { justify-content: space-between; }
.grow { flex: 1; min-width: 0; }
.phase-card {
margin-top: 12px; padding: 12px; border-radius: var(--radius-sm);
background: var(--teal-soft); border: 1px solid color-mix(in srgb, var(--teal) 42%, var(--line));
}
.phase-card strong { color: color-mix(in srgb, var(--teal) 72%, var(--ink)); }
.progress-track { height: 7px; background: var(--paper-2); border-radius: 999px; overflow: hidden; margin-top: 11px; }
.progress-track > span { display: block; height: 100%; background: var(--accent); border-radius: inherit; transition: width .25s ease; }
.week-strip, .day-strip { display: flex; gap: 7px; overflow-x: auto; scrollbar-width: none; padding: 2px 1px 8px; scroll-snap-type: x proximity; }
.week-strip::-webkit-scrollbar, .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); }
.chip.done::after { content: " ✓"; }
.chip.stopped::after { content: " ■"; color: var(--accent); }
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 9px; padding: 13px 14px 9px; }
.section-body { padding: 0 14px 14px; }
.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); }
.status-badge.in_progress { background: var(--accent-soft); color: var(--accent); }
.status-badge.stopped { background: color-mix(in srgb, var(--muted) 13%, var(--paper)); color: var(--muted); }
.status-badge.completed { background: color-mix(in srgb, var(--ok) 16%, var(--paper)); color: var(--ok); }
.item-status-select{width:100%;min-width:0;max-width:132px;padding:5px 7px;font-size:11px;font-weight:750}
.exercise.skipped{opacity:.74}.exercise.partial{border-left-color:var(--accent)}
.skip-reason{margin-top:9px}
.notice { padding: 11px 12px; border-radius: var(--radius-sm); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--line)); font-size: 12px; margin-bottom: 10px; }
.plan-note { padding: 11px 12px; border-left: 3px solid var(--teal); background: var(--teal-soft); font-size: 12px; margin-bottom: 10px; }
.checklist { display: grid; gap: 7px; }
.check-item { display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: start; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper-2); }
.check-item.done { background: color-mix(in srgb, var(--ok) 10%, var(--paper)); border-color: color-mix(in srgb, var(--ok) 36%, var(--line)); }
.check-item input[type="checkbox"], .exercise-title input[type="checkbox"] {
appearance: none; width: 23px; height: 23px; border-radius: 6px;
border: 2px solid var(--muted); margin: 0; display: grid; place-items: center; background: var(--paper);
}
.check-item input[type="checkbox"]:checked, .exercise-title input[type="checkbox"]:checked { border-color: var(--ok); background: var(--ok); }
.check-item input[type="checkbox"]:checked::after, .exercise-title input[type="checkbox"]:checked::after { content: "✓"; color: white; font-weight: 900; }
.check-label { font-size: 13px; line-height: 1.4; }
.rotation { border-top: 1px solid var(--line); }
.rotation:first-child { border-top: 0; }
.rotation-label { padding: 9px 14px; color: var(--muted); background: var(--wash-2); font-size: 11px; font-weight: 800; letter-spacing: .04em; }
.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(112px,132px) minmax(0,1fr); gap: 10px; align-items: start; }
.exercise.done { background: color-mix(in srgb, var(--ok) 7%, var(--paper)); }
.cue { color: var(--muted); font-size: 12px; line-height: 1.45; margin-top: 4px; }
.progression-box { margin-top: 9px; padding: 9px; background: var(--teal-soft); border: 1px solid color-mix(in srgb, var(--teal) 25%, var(--line)); border-radius: var(--radius-sm); font-size: 12px; }
.progression-box .planned { color: color-mix(in srgb, var(--teal) 70%, var(--ink)); }
.field-grid { display: grid; gap: 8px; margin-top: 9px; }
label.field { display: grid; gap: 5px; color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; }
input[type="text"], input[type="date"], input[type="number"], select, textarea {
width: 100%; min-height: 41px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--paper-2); color: var(--ink); padding: 9px 10px; outline: none;
}
textarea { min-height: 72px; resize: vertical; text-transform: none; letter-spacing: normal; font-weight: 500; }
input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); }
.details-link { display: inline-flex; margin-top: 7px; font-size: 12px; font-weight: 750; text-decoration: none; }
.section-note { margin-top: 9px; }
.result-editor { margin-top: 9px; padding: 11px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--wash-2); min-width: 0; }
.result-editor-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; min-width: 0; }
.result-editor-head > div { min-width: 0; }
.result-format { display: inline-flex; flex-wrap: wrap; gap: 5px; align-items: center; color: var(--ink); font-size: 12px; font-weight: 800; }
.result-format span { display: inline-flex; padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--paper-2); color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .05em; }
.result-help { margin-top: 4px; color: var(--muted); font-size: 11px; line-height: 1.4; }
.result-controls { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-top: 10px; }
.result-controls.one { grid-template-columns: minmax(0,1fr); }
.result-set-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(54px,1fr)); gap: 7px; margin-top: 10px; }
.result-set-field { display: grid; gap: 4px; min-width: 0; color: var(--muted); font-size: 9px; font-weight: 800; text-align: center; text-transform: uppercase; }
.result-set-field input { min-width: 0; min-height: 39px; padding: 7px 5px; text-align: center; font-variant-numeric: tabular-nums; }
.result-side-grid { display: grid; grid-template-columns: 38px minmax(0,1fr) minmax(0,1fr); gap: 6px; align-items: center; margin-top: 10px; }
.result-side-grid .side-head { color: var(--muted); font-size: 9px; font-weight: 800; text-align: center; text-transform: uppercase; }
.result-side-grid .set-label { color: var(--muted); font-size: 10px; font-weight: 800; }
.result-side-grid input { min-width: 0; min-height: 39px; padding: 7px 5px; text-align: center; font-variant-numeric: tabular-nums; }
.result-summary { margin-top: 9px; padding: 8px 9px; border-radius: 7px; background: var(--paper-2); color: var(--ink-soft); font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
.result-summary.empty { color: var(--muted); }
.result-legacy { margin-top: 9px; padding: 8px 9px; border-left: 3px solid var(--accent); border-radius: 0 7px 7px 0; background: var(--accent-soft); color: var(--ink-soft); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
.result-options { margin-top: 9px; }
.result-options summary { cursor: pointer; color: var(--muted); font-size: 11px; font-weight: 750; }
.result-options .result-controls { margin-top: 8px; }
.result-required { color: var(--accent); }
.result-error { margin-top: 8px; color: var(--danger); font-size: 11px; font-weight: 750; }
.exercise.result-missing { box-shadow: inset 3px 0 0 var(--danger); }
.action-row { display: grid; grid-template-columns: 1fr; gap: 8px; padding: 0 14px 14px; }
.btn { min-height: 44px; border: 1px solid transparent; border-radius: var(--radius-sm); padding: 10px 13px; font-weight: 800; }
.btn.primary, .btn.warm { background: var(--accent); color: var(--accent-ink); }
.btn.primary:hover, .btn.warm:hover { filter: brightness(1.06); }
.btn.secondary { background: var(--paper-2); color: var(--ink); border-color: var(--line); }
.btn.ghost { border-color: var(--line); background: transparent; color: var(--ink); }
.btn.danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--line)); background: color-mix(in srgb, var(--danger) 10%, var(--paper)); color: var(--danger); }
.btn:disabled { opacity: .52; cursor: not-allowed; filter: none; }
.btn.full { width: 100%; }
.session-actions { grid-template-columns: repeat(2,minmax(0,1fr)); }
.session-actions .reset-session { grid-column: 1 / -1; }
.empty { padding: 25px 16px; text-align: center; color: var(--muted); }
.history { display: grid; gap: 7px; min-width: 0; max-width: 100%; }
.history-item { display: grid; grid-template-columns: minmax(0,1fr) auto; min-width: 0; max-width: 100%; gap: 8px; align-items: center; padding: 11px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper-2); }
.history-item button { width: 100%; min-width: 0; max-width: 100%; overflow: hidden; border: 0; background: transparent; color: inherit; padding: 0; text-align: left; }
.history-item button strong, .history-item button div { display: block; min-width: 0; max-width: 100%; overflow-wrap: anywhere; word-break: break-word; }
.history-item button .muted { margin-top: 3px; line-height: 1.35; }
.section-body, .analysis-result, .analysis-section { min-width: 0; max-width: 100%; }
.progress-card { padding: 13px; border-top: 1px solid var(--line); }
.progress-card:first-child { border-top: 0; }
.previous-session { margin-top: 8px; padding: 9px 10px; border: 1px dashed var(--line); border-radius: var(--radius-sm); background: var(--wash-2); line-height: 1.45; }
.analysis-result h2 { margin: 7px 0 10px; }
.analysis-copy { white-space: pre-line; line-height: 1.55; }
.analysis-meta { margin-bottom: 4px; }
.analysis-controls { display: grid; gap: 10px; }
.analysis-status { padding: 10px 11px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper-2); }
.analysis-status.running { border-color: var(--accent); background: var(--accent-soft); }
.analysis-status.error { border-color: var(--danger); color: var(--danger); }
.job-progress { height: 7px; overflow: hidden; margin-top: 8px; border-radius: 999px; background: var(--wash-2); }
.job-progress span { display: block; height: 100%; background: var(--accent); transition: width .25s ease; }
.analysis-picker { width: 100%; }
.analysis-adjustment { margin-top: 9px; padding: 11px; border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: var(--radius-sm); background: var(--paper-2); }
.analysis-adjustment p { margin: 5px 0 0; font-size: 12px; line-height: 1.5; }
.analysis-adjustment .manual { margin-top: 7px; padding: 8px; border-radius: var(--radius-sm); background: var(--accent-soft); }
.metric-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin: 12px 0; }
.metric { padding: 11px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper-2); }
.metric span, .metric small { display: block; color: var(--muted); font-size: 10px; }
.metric strong { display: block; margin: 3px 0; font-size: 18px; color: var(--accent); }
.analysis-chart, .analysis-flow, .analysis-section { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
.chart-scroll, .flow-scroll { overflow-x: auto; margin: 8px -4px; padding: 4px; }
.chart-legend { display: flex; flex-wrap: wrap; gap: 8px; font-size: 10px; color: var(--muted); }
.chart-legend span { display: inline-flex; align-items: center; gap: 5px; }
.chart-legend i { width: 10px; height: 10px; border-radius: 3px; }
.flow-row { display: flex; align-items: center; min-width: max-content; padding: 7px 2px; }
.flow-node-wrap { display: flex; align-items: center; }
.flow-node { max-width: 170px; padding: 9px 11px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper-2); font-size: 11px; font-weight: 750; text-align: center; }
.flow-node.done { border-color: var(--ok); color: var(--ok); }
.flow-node.current { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
.flow-node.next { border-color: var(--teal); color: color-mix(in srgb,var(--teal) 70%,var(--ink)); }
.flow-node.blocked { border-color: var(--danger); color: var(--danger); }
.flow-arrow { padding: 0 7px; color: var(--muted); font-weight: 900; }
.analysis-exercise { margin-top: 9px; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper-2); }
.analysis-exercise p, .analysis-exercise ul { font-size: 12px; line-height: 1.5; }
.analysis-exercise ul { padding-left: 18px; }
.trend { font-size: 10px; font-weight: 800; padding: 4px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
.trend.up { color: var(--ok); }.trend.down { color: var(--danger); }.trend.stable { color: var(--teal); }
.next-step { padding: 9px; border-radius: var(--radius-sm); background: var(--teal-soft); font-size: 12px; line-height: 1.5; }
.recommendation { margin-top: 8px; padding: 10px 11px; border-left: 3px solid var(--teal); background: var(--paper-2); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
.recommendation.high { border-left-color: var(--accent); }.recommendation.low { border-left-color: var(--muted); }
.recommendation p { margin: 4px 0 0; font-size: 12px; line-height: 1.5; }
.warning-box { margin-top: 14px; padding: 11px; border: 1px solid color-mix(in srgb,var(--danger) 45%,var(--line)); border-radius: var(--radius-sm); background: color-mix(in srgb,var(--danger) 10%,var(--paper)); }
.warning-box ul { margin-bottom: 0; padding-left: 18px; font-size: 12px; }
.closing { margin-top: 14px; font-weight: 650; }
.faq-block { margin-top: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--paper-2); overflow: hidden; }
.faq-block summary { cursor: pointer; padding: 12px; font-weight: 800; }
.faq-block[open] summary { border-bottom: 1px solid var(--line); }
.faq-content { padding: 12px; }
.table-scroll { max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.eq-table { width: 100%; min-width: 520px; border-collapse: collapse; font-size: 12px; }
.eq-table th, .eq-table td { padding: 8px 9px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
.eq-table th { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .05em; }
.eq-table td:nth-child(2) { white-space: nowrap; font-variant-numeric: tabular-nums; }
.source-list { margin: 8px 0 0; padding-left: 18px; }
.source-list li { margin: 7px 0; line-height: 1.4; }
/* Mobile: Daumenfreundliche Navigation unten. */
.bottom-nav {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
display: grid; grid-template-columns: repeat(4,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); }
.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; }
.rich { line-height: 1.5; overflow-wrap: anywhere; }
.rich p:last-child { margin-bottom: 0; }
.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); }
@media (max-width: 420px) {
.exercise-title { grid-template-columns: minmax(0,1fr); }
.item-status-select { max-width: none; }
.result-controls { grid-template-columns: minmax(0,1fr); }
.result-set-grid { grid-template-columns: repeat(3,minmax(0,1fr)); }
.result-editor { padding: 10px; }
}
@media (min-width: 560px) {
.app { padding-left: 14px; padding-right: 14px; }
.field-grid.two { grid-template-columns: 1fr 1fr; }
.action-row { grid-template-columns: 1fr 1fr; }
.metric-grid { grid-template-columns: repeat(3,minmax(0,1fr)); }
.toolbar-btn .wide-label { display: inline; }
}
/* Desktop/Tablet: dieselbe Tab-Anmutung wie die anderen Plugins. */
@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="Trainings-Session-Tracker" 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="Tracker-Navigation">
<button class="nav-btn active" data-view="session"><span></span>Session</button>
<button class="nav-btn" data-view="progress"><span></span>Fortschritt</button>
<button class="nav-btn" data-view="plan"><span></span>Plan</button>
<button class="nav-btn" data-view="faq"><span>?</span>FAQ</button>
</nav>
<main class="app">
<div id="fatalError" class="error-card hidden"></div>
<section id="sessionView"></section>
<section id="progressView" class="hidden"></section>
<section id="planView" class="hidden"></section>
<section id="faqView" 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. Rezeptsammlungen bleiben draußen.</p>
<div id="planChoices"></div>
</div>
</div>
<script>
(() => {
'use strict';
const BASE = document.body.dataset.base || '';
const RESULT_DATA_VERSION = 2;
const api = path => `${BASE}${path}`;
const state = {
plans: [], selectedId: null, plan: null, tracker: null, capabilities: {openai:false},
week: 1, dayNum: 1, view: 'session', saveTimer: null, saving: false, dirty: false, changeSeq: 0,
analyzing: false, analysisError: '', analysisCatalog: {weeks:[], overall:{}},
analysisSelection: 'overall', analysisPollTimer: null, equivalenceGuide: null
};
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 safeRich(value) {
const template = document.createElement('template');
template.innerHTML = String(value || '');
const allowed = new Set(['B','STRONG','BR','I','EM','U','FONT','P']);
const walk = node => {
[...node.children].forEach(child => {
if (!allowed.has(child.tagName)) {
child.replaceWith(document.createTextNode(child.textContent || ''));
return;
}
[...child.attributes].forEach(attr => {
if (!(child.tagName === 'FONT' && attr.name.toLowerCase() === 'color' && /^#[0-9a-f]{3,8}$/i.test(attr.value))) {
child.removeAttribute(attr.name);
}
});
walk(child);
});
};
walk(template.content);
return template.innerHTML;
}
function safeUrl(value) {
try {
const url = new URL(String(value || ''), window.location.href);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) { return ''; }
}
function plain(value) {
const div = document.createElement('div');
div.innerHTML = safeRich(value);
return (div.textContent || '').trim();
}
function localDateISO(date = new Date()) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function addDays(iso, days) {
if (!iso) return '';
const [y,m,d] = iso.split('-').map(Number);
const date = new Date(y, m - 1, d, 12, 0, 0);
date.setDate(date.getDate() + days);
return localDateISO(date);
}
function formatDate(iso) {
if (!iso) return 'ohne Datum';
const [y,m,d] = iso.split('-').map(Number);
return new Intl.DateTimeFormat('de-DE', {weekday:'short', day:'2-digit', month:'2-digit'}).format(new Date(y,m-1,d,12));
}
function formatTimestamp(value) {
const parsed = new Date(value || '');
if (Number.isNaN(parsed.getTime())) return 'unbekannter Zeitpunkt';
return new Intl.DateTimeFormat('de-DE',{dateStyle:'medium',timeStyle:'short'}).format(parsed);
}
function uiStorageKey(planId = state.selectedId) {
return planId ? `trainingstracker.ui.${planId}` : '';
}
function persistUiState() {
const key = uiStorageKey();
if (!key) return;
try {
localStorage.setItem(key, JSON.stringify({
view: state.view, week: state.week, dayNum: state.dayNum, analysisSelection: state.analysisSelection
}));
} catch (_) {}
}
function restoreUiState() {
const key = uiStorageKey();
if (!key) return false;
try {
const saved = JSON.parse(localStorage.getItem(key) || 'null');
if (!saved || typeof saved !== 'object') return false;
const week = Number(saved.week);
const dayNum = Number(saved.dayNum);
if (Number.isInteger(week) && week >= 1 && week <= state.plan.weeks) state.week = week;
if (state.plan.days.some(day => day.num === dayNum)) state.dayNum = dayNum;
if (['session','progress','plan','faq'].includes(saved.view)) state.view = saved.view;
if (typeof saved.analysisSelection === 'string') state.analysisSelection = saved.analysisSelection;
return true;
} catch (_) { return false; }
}
function sessionKey(week, dayNum) { return `w${String(week).padStart(2,'0')}-d${String(dayNum).padStart(2,'0')}`; }
function sessionDate(week, dayNum) {
const start = state.tracker?.profile?.start_date || '';
return start ? addDays(start, (week - 1) * 7 + (dayNum - 1)) : '';
}
function phaseIndexForWeek(week) { return state.plan.phase_by_week[Math.max(0, week - 1)] ?? 0; }
function phaseForWeek(week) { return state.plan.phases[phaseIndexForWeek(week)] || state.plan.phases[0]; }
function currentDay() { return state.plan.days.find(day => day.num === state.dayNum) || state.plan.days[0]; }
function currentSession() {
const key = sessionKey(state.week, state.dayNum);
state.tracker.sessions ||= {};
state.tracker.sessions[key] ||= blankSession();
state.tracker.sessions[key].items ||= {};
return state.tracker.sessions[key];
}
function itemState(id) {
const session = currentSession();
session.items[id] ||= { done: false, result: '', note: '' };
return session.items[id];
}
function parseSessionKey(key) {
const match = /^w(\d+)-d(\d+)$/.exec(String(key || ''));
return match ? {week:Number(match[1]), dayNum:Number(match[2])} : null;
}
function sessionOrdinal(key) {
const parsed = parseSessionKey(key);
return parsed ? parsed.week * 100 + parsed.dayNum : Number.MAX_SAFE_INTEGER;
}
function exerciseForSessionItem(sessionKeyValue, itemId) {
const parsed = parseSessionKey(sessionKeyValue);
const day = parsed ? state.plan.days.find(entry => entry.num === parsed.dayNum) : null;
if (!day) return null;
for (const rotation of day.rotations || []) {
const found = (rotation.exercises || []).find(exercise => exercise.id === itemId || exercise.legacy_id === itemId);
if (found) return found;
}
return null;
}
function latestPreviousEntry(exercise, requireProgression = false) {
const currentOrdinal = sessionOrdinal(sessionKey(state.week, state.dayNum));
const candidates = [];
Object.entries(state.tracker.sessions || {}).forEach(([key, session]) => {
if (sessionOrdinal(key) >= currentOrdinal || !session?.items) return;
Object.entries(session.items).forEach(([itemId, item]) => {
if (!item || typeof item !== 'object') return;
if (itemCompletionStatus(item) === 'skipped') return;
const priorExercise = exerciseForSessionItem(key, itemId);
const progressionId = item.progression_id || priorExercise?.progression_id;
if (progressionId !== exercise.progression_id) return;
const hasData = requireProgression
? Boolean(String(item.progression || '').trim())
: ([item.progression, item.result, item.note].some(value => String(value || '').trim()) || item.done);
if (hasData) candidates.push({key, item, exercise: priorExercise, ordinal: sessionOrdinal(key)});
});
});
candidates.sort((a,b) => b.ordinal - a.ordinal);
return candidates[0] || null;
}
function legacyProgression(exercise) {
return state.tracker.progressions?.[exercise.progression_id]?.value || '';
}
function stageStepName(step) { return typeof step === 'object' && step ? (step.name || '') : (step || ''); }
function plannedProgressionStep(exercise) {
const stage = exercise.key ? state.plan.stages[exercise.key] : null;
if (!stage?.steps?.length) return null;
return stage.steps[Math.min(phaseIndexForWeek(state.week), stage.steps.length - 1)] || null;
}
function plannedProgression(exercise) { return stageStepName(plannedProgressionStep(exercise)); }
function allProgressionOptions(exercise) {
const stage = exercise.key ? state.plan.stages[exercise.key] : null;
return (stage?.steps || []).filter(Boolean);
}
function progressionStepFor(exercise, progression) {
const wanted=plain(progression||'').toLocaleLowerCase('de-DE');
return allProgressionOptions(exercise).find(step=>plain(stageStepName(step)).toLocaleLowerCase('de-DE')===wanted)||null;
}
function derivedProgression(exercise, item = null) {
if (String(item?.progression || '').trim()) return item.progression;
const sameSession = Object.values(currentSession().items || {}).find(other =>
other && other !== item && other.progression_id === exercise.progression_id && String(other.progression || '').trim()
);
if (sameSession) return sameSession.progression;
const previous = latestPreviousEntry(exercise, true);
return previous?.item?.progression || legacyProgression(exercise) || plannedProgression(exercise) || '';
}
function exerciseItemState(exercise, persistDefault = false) {
const session=currentSession();
if (!session.items?.[exercise.id] && exercise.legacy_id && session.items?.[exercise.legacy_id]) {
session.items[exercise.id] = session.items[exercise.legacy_id];
delete session.items[exercise.legacy_id];
state.dirty = true;
}
const item = itemState(exercise.id);
if (persistDefault) {
const previous = latestPreviousEntry(exercise, true);
item.progression_id ||= exercise.progression_id;
item.exercise_name ||= plain(exercise.name);
item.progression ||= derivedProgression(exercise, item);
const step=progressionStepFor(exercise,item.progression); if(step?.id)item.progression_step_id ||= step.id;
item.inherited_from ||= previous?.key || '';
}
return item;
}
function latestEntriesByProgression() {
const result = new Map();
Object.entries(state.tracker.sessions || {})
.sort(([a],[b]) => sessionOrdinal(b) - sessionOrdinal(a))
.forEach(([key, session]) => {
Object.entries(session?.items || {}).forEach(([itemId, item]) => {
if (!item || typeof item !== 'object') return;
if (itemCompletionStatus(item) === 'skipped') return;
const exercise = exerciseForSessionItem(key, itemId);
const progressionId = item.progression_id || exercise?.progression_id;
if (!progressionId || result.has(progressionId)) return;
const hasData = [item.progression, item.result, item.note].some(value => String(value || '').trim()) || item.done;
if (hasData) result.set(progressionId, {key, item, exercise});
});
});
return result;
}
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) { const error=new Error(payload.error || `HTTP ${response.status}`); error.payload=payload; error.status=response.status; throw error; }
return payload;
}
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. Erwarteter Ordner: data/trainingsplan/plans');
}
const selected = data.selected || state.plans[0].id;
await selectPlan(selected, false);
if (openChooser) openPlanModal();
}
async function selectPlan(id, persist = true) {
persistUiState();
await flushSave();
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.tracker = data.tracker;
state.capabilities = data.capabilities || {openai:false};
state.analysisCatalog = data.analysis_catalog || {weeks:[],overall:{}};
state.equivalenceGuide = data.equivalence_guide || null;
state.tracker.profile ||= {start_date:'', display_name:'', plan_notes:''};
state.tracker.revision ||= 1;
state.tracker.progressions ||= {};
state.tracker.sessions ||= {};
state.tracker.week_statuses ||= {};
state.tracker.analysis_cache ||= {weeks:{},overall:null};
state.tracker.analysis_state ||= {status:'idle'};
state.tracker.analyses ||= [];
state.analyzing = state.tracker.analysis_state.status === 'running';
state.analysisError = state.tracker.analysis_state.status === 'error' ? (state.tracker.analysis_state.error || '') : '';
state.week = 1;
state.dayNum = state.plan.days[0]?.num || 1;
state.view = 'session';
state.analysisSelection = 'overall';
if (!restoreUiState()) chooseInitialSession();
ensureAnalysisSelection();
$('#headerPlan').textContent = plain(state.plan.title);
renderEverything();
manageAnalysisPolling();
}
function chooseInitialSession() {
state.week = 1;
state.dayNum = state.plan.days[0]?.num || 1;
const start = state.tracker.profile.start_date;
if (!start) { persistUiState(); return; }
const [sy,sm,sd] = start.split('-').map(Number);
const startDate = new Date(sy,sm-1,sd,12);
const now = new Date(); now.setHours(12,0,0,0);
const diff = Math.floor((now - startDate) / 86400000);
if (diff < 0) { persistUiState(); return; }
const week = Math.floor(diff / 7) + 1;
const day = (diff % 7) + 1;
state.week = Math.min(state.plan.weeks, Math.max(1, week));
const exact = state.plan.days.find(item => item.num === day);
if (exact) state.dayNum = exact.num;
else {
const previous = [...state.plan.days].reverse().find(item => item.num <= day);
state.dayNum = (previous || state.plan.days[0]).num;
}
persistUiState();
}
function markDirty() {
state.changeSeq += 1;
state.dirty = true;
setSaveState('Ungespeichert', 'saving');
clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(saveTracker, 650);
}
function setSaveState(text, cls = '') {
const el = $('#saveState');
el.textContent = text;
el.className = `save-pill ${cls}`;
}
async function saveTracker() {
if (!state.tracker || !state.selectedId || state.saving || !state.dirty) return;
state.saving = true;
const savingSeq = state.changeSeq;
setSaveState('Speichert …', 'saving');
try {
const key=sessionKey(state.week,state.dayNum);
const result = await fetchJSON(api(`/api/plans/${encodeURIComponent(state.selectedId)}/tracker/session`), {
method:'PATCH', body: JSON.stringify({expected_revision:state.tracker.revision||1,profile:state.tracker.profile,week_statuses:state.tracker.week_statuses,session_key:key,session:state.tracker.sessions?.[key]||blankSession()})
});
state.tracker.updated_at = result.updated_at; state.tracker.revision=result.revision||state.tracker.revision;
if (result.analysis_catalog) state.analysisCatalog = result.analysis_catalog;
if (result.analysis_state) { state.tracker.analysis_state = result.analysis_state; state.analyzing = result.analysis_state.status === 'running'; }
if (state.changeSeq === savingSeq) state.dirty = false;
setSaveState(state.dirty ? 'Weitere Änderungen …' : 'Gespeichert', state.dirty ? 'saving' : 'saved');
if (state.view === 'progress') renderProgress();
setTimeout(() => { if (!state.dirty) setSaveState('Bereit'); }, 1400);
} catch (error) {
setSaveState(`Speicherfehler: ${error.message}`, 'error');
if(error.status===409 && error.payload?.code==='revision_conflict'){
state.dirty=false; window.alert('Diese Session wurde in einem anderen Tab oder Gerät geändert. Der aktuelle Serverstand wird neu geladen.');
await selectPlan(state.selectedId,false);
}
} finally {
state.saving = false;
if (state.dirty) state.saveTimer = setTimeout(saveTracker, 700);
}
}
async function flushSave() {
clearTimeout(state.saveTimer);
if (state.dirty) await saveTracker();
}
function sessionCompletion(day, session) {
const ids = [];
day.warmup.items.forEach((_, i) => ids.push(`warmup-${i}`));
day.rotations.forEach(rot => rot.exercises.forEach(ex => ids.push(ex.id)));
day.cooldown.items.forEach((_, i) => ids.push(`cooldown-${i}`));
day.stretch.items.forEach((_, i) => ids.push(`stretch-${i}`));
if (!ids.length) return session.status === 'completed' ? 100 : 0;
const done = ids.filter(id => { const item=session.items?.[id]; const status=itemCompletionStatus(item); return item?.done || ['completed','partial','skipped'].includes(status); }).length;
return Math.round(done / ids.length * 100);
}
function sessionHasUserData(session) {
if (!session || typeof session !== 'object') return false;
if (session.status && session.status !== 'planned') return true;
if ([session.note, session.warmup_note, session.cooldown_note, session.stretch_note,
session.started_at, session.stopped_at, session.completed_at]
.some(value => String(value || '').trim())) return true;
return Object.values(session.items || {}).some(item => item && (
['completed','partial','skipped'].includes(itemCompletionStatus(item)) || item.done || (item.result_data && typeof item.result_data === 'object') || [item.progression, item.result, item.note, item.skip_reason]
.some(value => String(value || '').trim())
));
}
function blankSession() {
return {
status: 'planned', items: {}, note: '',
plan_id: state.plan?.plan_id || '', plan_revision: state.plan?.published_revision || 1,
warmup_note: '', cooldown_note: '', stretch_note: '',
started_at: '', stopped_at: '', completed_at: ''
};
}
function renderEverything() {
renderSession();
renderProgress();
renderPlan();
renderFAQ();
switchView(state.view);
}
function renderSession() {
const day = currentDay();
const session = currentSession();
const phase = phaseForWeek(state.week);
const completion = sessionCompletion(day, session);
const statusLabel = session.status === 'completed' ? 'Abgeschlossen' : session.status === 'in_progress' ? 'Läuft' : session.status === 'stopped' ? 'Gestoppt' : 'Geplant';
const weeks = Array.from({length: state.plan.weeks}, (_, i) => i + 1).map(week => {
const hasDone = state.plan.days.some(d => state.tracker.sessions?.[sessionKey(week,d.num)]?.status === 'completed');
return `<button class="chip ${week === state.week ? 'active' : ''} ${hasDone ? 'done' : ''}" data-week="${week}">W${week}</button>`;
}).join('');
const days = state.plan.days.map(d => {
const s = state.tracker.sessions?.[sessionKey(state.week,d.num)];
return `<button class="chip ${d.num === state.dayNum ? 'active' : ''} ${s?.status === 'completed' ? 'done' : ''} ${s?.status === 'stopped' ? 'stopped' : ''}" data-day="${d.num}">Tag ${d.num}</button>`;
}).join('');
const canReset = sessionHasUserData(session);
const startLabel = session.status === 'stopped' ? 'Session fortsetzen'
: session.status === 'completed' ? 'Wieder öffnen' : 'Session starten';
const leftAction = session.status === 'in_progress'
? '<button class="btn secondary" id="stopSession">Session stoppen</button>'
: `<button class="btn warm" id="startSession">${startLabel}</button>`;
$('#sessionView').innerHTML = `
<section class="card hero">
<div class="eyebrow">Woche ${state.week} · ${formatDate(sessionDate(state.week, state.dayNum))}</div>
<div class="row between" style="align-items:flex-start;margin-top:6px">
<div class="grow"><h1>${esc(plain(day.focus))}</h1><div class="muted tiny">${esc(plain(state.plan.title))}</div></div>
<span class="status-badge ${esc(session.status)}">${statusLabel}</span>
</div>
<div class="phase-card"><strong>${esc(plain(phase.name))}</strong><div class="rich tiny" style="margin-top:5px">${safeRich(phase.params)}</div></div>
<div class="progress-track"><span style="width:${completion}%"></span></div>
<div class="row between tiny muted" style="margin-top:6px"><span>Session-Fortschritt</span><strong>${completion}%</strong></div>
</section>
${state.tracker.source_changed ? '<div class="notice"><strong>Veröffentlichte Planrevision wurde verändert.</strong> Deine Notizen bleiben erhalten. Bereits dokumentierte Sessions behalten ihre gespeicherte Planrevision.</div>' : ''}
${session.plan_revision && Number(session.plan_revision)!==Number(state.plan.published_revision||1) ? `<div class="notice"><strong>Historische Session nach Planrevision R${Number(session.plan_revision)}.</strong> Aktuell veröffentlicht ist R${Number(state.plan.published_revision||1)}. Die gespeicherten Ergebnisse bleiben der damaligen Fassung zugeordnet.</div>` : ''}
<section class="card card-pad">
<div class="eyebrow">Navigation</div>
<div class="week-strip" id="weekStrip">${weeks}</div>
<div class="day-strip" id="dayStrip">${days}</div>
</section>
${day.note ? `<div class="plan-note rich">${safeRich(day.note)}</div>` : ''}
${renderChecklistSection('warmup', 'Warm-up', '🔥', day.warmup, session)}
${renderTraining(day, session)}
${renderChecklistSection('cooldown', 'Cool-down', '🌿', day.cooldown, session)}
${renderChecklistSection('stretch', 'Dehnen / Zusatzblock', '↔', day.stretch, session)}
<section class="card">
<div class="section-head"><div><div class="eyebrow">Tagesprotokoll</div><h2>Session-Notiz</h2></div></div>
<div class="section-body"><textarea id="sessionNote" placeholder="Wie lief die Einheit? Schmerzen, Energie, Technik, nächste Anpassung …">${esc(session.note || '')}</textarea></div>
<div class="action-row session-actions">
${leftAction}
<button class="btn ${session.status === 'completed' ? 'secondary' : 'primary'}" id="completeSession" ${session.status === 'completed' ? 'disabled' : ''}>${session.status === 'completed' ? 'Abgeschlossen ✓' : 'Abschließen'}</button>
<button class="btn danger reset-session" id="resetSession" ${canReset ? '' : 'disabled'}>Session zurücksetzen</button>
</div>
</section>`;
bindSessionEvents();
}
function renderChecklistSection(prefix, title, icon, section, session) {
if (!section?.items?.length && !section?.text) return '';
const items = section.items.length ? section.items : [plain(section.text)];
return `<section class="card">
<div class="section-head"><div><div class="eyebrow">${icon} Bestandteil</div><h2>${esc(title)}</h2></div></div>
<div class="section-body">
<div class="checklist">${items.map((text,i) => {
const id = `${prefix}-${i}`; const item = itemState(id);
return `<label class="check-item ${item.done ? 'done' : ''}">
<input type="checkbox" data-item-done="${esc(id)}" ${item.done ? 'checked' : ''}>
<span class="check-label">${esc(text)}</span>
</label>`;
}).join('')}</div>
<label class="field section-note">Notiz zu ${esc(title)}<textarea data-section-note="${prefix}" placeholder="Optional">${esc(session[`${prefix}_note`] || '')}</textarea></label>
</div>
</section>`;
}
function renderTraining(day, session) {
if (!day.rotations.length) return `<section class="card"><div class="empty">Für diesen Tag sind keine Trainingsblöcke hinterlegt.</div></section>`;
return `<section class="card">
<div class="section-head"><div><div class="eyebrow">🏋 Hauptteil</div><h2>Training</h2></div><span class="status-badge">${day.rotations.length} Blöcke</span></div>
${day.rotations.map(rotation => `<div class="rotation">
<div class="rotation-label">${safeRich(rotation.label)}</div>
${rotation.exercises.map(exercise => renderExercise(exercise)).join('')}
</div>`).join('')}
</section>`;
}
const RESULT_STATIC_RE = /(?:\bhold\b|\bplank\b|\bhang\b|wall\s*sit|wandsitz|isometr|stützposition)/i;
const RESULT_DYNAMIC_RE = /(?:push.?up|pull.?up|klimmzug|scapular|liegestütz|senkung|negativ|wiederhol|\breps?\b|rudern|\brow\b|squat|kniebeuge|dip|curl|deadlift|rdl|hinge|swing|raise|heben|climber|crunch|twist|lunge|ausfallschritt)/i;
function numericOrNull(value) {
if (value === '' || value === null || value === undefined) return null;
const parsed = Number(String(value).replace(',', '.'));
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 1000) / 1000 : null;
}
function displayNumber(value) {
const number = numericOrNull(value);
return number === null ? '' : String(number).replace('.', ',');
}
function ensureResultArray(values, sets) {
const result = Array.isArray(values) ? values.slice(0, sets).map(numericOrNull) : [];
while (result.length < sets) result.push(null);
return result;
}
function resultHasValues(data) {
if (!data || typeof data !== 'object') return false;
return [...(data.values || []), ...(data.left_values || []), ...(data.right_values || [])]
.some(value => numericOrNull(value) !== null);
}
function normalizedStageSchema(raw) {
if (!raw || typeof raw !== 'object') return null;
const result = {};
if (['auto','reps','seconds','minutes','none'].includes(raw.mode)) result.mode = raw.mode;
if (['none','optional','required'].includes(raw.weight_mode)) result.weight_mode = raw.weight_mode;
if (['bilateral','unilateral'].includes(raw.laterality)) result.laterality = raw.laterality;
if (['same','separate'].includes(raw.sides_mode)) result.sides_mode = raw.sides_mode;
const sets = Number(raw.sets || raw.default_sets || 0);
if (sets >= 1) { result.default_sets = Math.min(20, sets); result.locked_sets = raw.locked_sets !== false; }
else if (Object.prototype.hasOwnProperty.call(raw, 'locked_sets')) result.locked_sets = Boolean(raw.locked_sets);
return Object.keys(result).length ? result : null;
}
function progressionResultSchema(exercise, progression) {
const step=progressionStepFor(exercise, progression);
return normalizedStageSchema(step?.result_schema || null);
}
function resolvedResultSchema(exercise, progression, item = null) {
const exerciseBase = {...(exercise.result_schema || {})};
const stageOverride = progressionResultSchema(exercise, progression);
const base = {...exerciseBase, ...(stageOverride || {})};
const text = `${plain(exercise.name)} ${plain(progression || '')} ${plain(exercise.cue || '')}`;
const staticHit = RESULT_STATIC_RE.test(text);
const dynamicHit = RESULT_DYNAMIC_RE.test(plain(progression || ''));
let mode = base.mode || 'reps';
if (mode === 'auto') mode = staticHit && !dynamicHit ? 'seconds' : 'reps';
else if (!stageOverride?.mode && !['none','minutes'].includes(mode) && staticHit && !dynamicHit) mode = 'seconds';
const progressionText = plain(progression || '');
let laterality = base.laterality === 'unilateral' ? 'unilateral' : 'bilateral';
if (!stageOverride?.laterality) {
if (/(?:beidbeinig|zweiarmig|beide seiten gemeinsam)/i.test(progressionText)) laterality = 'bilateral';
else if (/(?:einarm|einbein|je seite|pro seite)/i.test(progressionText)) laterality = 'unilateral';
}
let weightMode = ['none','optional','required'].includes(base.weight_mode) ? base.weight_mode : 'none';
if (!stageOverride?.weight_mode) {
const mentionsUnweighted = /(?:ohne gewicht|ungewichtet|körpergewicht|koerpergewicht|\bbw\b)/i.test(progressionText);
const mentionsLoad = /(?:gewicht|gewichtet|zusatzlast|\blast\b|\bkg\b|kettlebell|\bkb\b|rucksack)/i.test(progressionText);
if (mentionsUnweighted && mentionsLoad) weightMode = 'optional';
else if (mentionsUnweighted) weightMode = 'none';
else if (mentionsLoad) weightMode = /(?:möglich|optional|bei 8)/i.test(progressionText) ? 'optional' : 'required';
else if (weightMode === 'optional' && progressionText) weightMode = 'none';
}
let sets = Math.max(1, Math.min(20, Number(base.default_sets || base.sets) || 3));
let sidesMode = base.sides_mode === 'separate' ? 'separate' : 'same';
let lockedSets = Boolean(base.locked_sets);
const ownFormat = item?.result_format;
if (ownFormat && typeof ownFormat === 'object') {
if (['reps','seconds','minutes','none'].includes(ownFormat.mode)) mode = ownFormat.mode;
if (['bilateral','unilateral'].includes(ownFormat.laterality)) laterality = ownFormat.laterality;
if (['same','separate'].includes(ownFormat.sides_mode)) sidesMode = ownFormat.sides_mode;
if (['none','optional','required'].includes(ownFormat.weight_mode)) weightMode = ownFormat.weight_mode;
}
const previous = latestPreviousEntry(exercise);
const previousData = previous?.item?.result_data;
if (!item?.result_data && !ownFormat && previousData && previous?.item?.progression === progression) {
if (!stageOverride?.mode && ['reps','seconds','minutes'].includes(previousData.mode)) mode = previousData.mode;
if (!stageOverride?.laterality && ['bilateral','unilateral'].includes(previousData.laterality)) laterality = previousData.laterality;
if (!stageOverride?.sides_mode && ['same','separate'].includes(previousData.sides_mode)) sidesMode = previousData.sides_mode;
if (!stageOverride?.default_sets && Number(previousData.sets) >= 1 && !lockedSets) sets = Math.min(20, Number(previousData.sets));
if (!stageOverride?.weight_mode && numericOrNull(previousData.weight_kg) !== null && weightMode === 'none') weightMode = 'optional';
}
if (!stageOverride?.weight_mode && /\d+(?:[.,]\d+)?\s*kg\b/i.test(String(item?.result || previous?.item?.result || '')) && weightMode === 'none') {
weightMode = 'optional';
}
return {
version: RESULT_DATA_VERSION, mode, laterality, sides_mode: laterality === 'unilateral' ? sidesMode : 'same',
weight_mode: weightMode,
default_sets: sets, locked_sets: lockedSets, fixed_interval: Boolean(base.fixed_interval),
work_seconds: Number(base.work_seconds) || null,
schema_source: ownFormat ? 'session' : (stageOverride ? 'progression' : 'exercise')
};
}
function emptyResultData(schema, sets = schema.default_sets) {
const count = schema.mode === 'none' ? 1 : Math.max(1, Math.min(20, Number(sets) || 1));
return {
version: RESULT_DATA_VERSION, mode: schema.mode, laterality: schema.laterality,
sides_mode: schema.laterality === 'unilateral' ? (schema.sides_mode || 'same') : 'same',
sets: count, weight_kg: null,
values: Array(count).fill(null), left_values: Array(count).fill(null), right_values: Array(count).fill(null)
};
}
function normalizeResultData(value, schema) {
const source = value && typeof value === 'object' ? value : {};
const sets = Math.max(1, Math.min(20, Number(source.sets) || Number(schema.default_sets) || 1));
const mode = ['reps','seconds','minutes','none'].includes(source.mode) ? source.mode : schema.mode;
const laterality = ['bilateral','unilateral'].includes(source.laterality) ? source.laterality : schema.laterality;
return {
version: RESULT_DATA_VERSION, mode, laterality,
sides_mode: laterality === 'unilateral' && source.sides_mode === 'separate' ? 'separate' : 'same',
sets, weight_kg: numericOrNull(source.weight_kg),
values: ensureResultArray(source.values, sets),
left_values: ensureResultArray(source.left_values, sets),
right_values: ensureResultArray(source.right_values, sets)
};
}
function parseLegacyResultData(result, schema) {
const raw = String(result || '').trim();
if (!raw) return {data:null, incompatible:false, formatOverride:null};
const low = raw.toLowerCase().replace(/×/g, 'x');
const explicitMode = /(?:\d|\s)(?:min\.?|minuten)\b/i.test(low) ? 'minutes'
: /(?:\d|\s)(?:s|sek\.?|sekunden)\b/i.test(low) ? 'seconds' : null;
const detectedMode = explicitMode || (['reps','seconds','minutes'].includes(schema.mode) ? schema.mode : 'reps');
const weightMatch = low.match(/(\d+(?:[.,]\d+)?)\s*kg\b/i);
const weight = weightMatch ? numericOrNull(weightMatch[1]) : null;
const numberList = part => [...String(part || '').matchAll(/\d+(?:[.,]\d+)?/g)].map(match => numericOrNull(match[0])).filter(value => value !== null);
const cleanPart = part => String(part || '').replace(/\d+(?:[.,]\d+)?\s*kg\b/ig, ' ').replace(/\b(?:reps?|wiederholungen|s|sek\.?|sekunden|min\.?|minuten|je seite|pro seite)\b/ig, ' ');
const separate = raw.match(/\bL(?:inks)?\s*[:]?\s*([^·;]+)[·;]\s*R(?:echts)?\s*[:]?\s*(.+)$/i);
if (separate) {
const left = numberList(cleanPart(separate[1]));
const right = numberList(cleanPart(separate[2]));
const sets = Math.max(left.length, right.length, 1);
const data = emptyResultData({...schema,laterality:'unilateral'}, sets);
data.mode = detectedMode; data.sides_mode = 'separate'; data.weight_kg = weight;
data.left_values = ensureResultArray(left, sets); data.right_values = ensureResultArray(right, sets);
const weightMode = weight !== null && schema.weight_mode === 'none' ? 'optional' : schema.weight_mode;
const formatOverride = data.mode !== schema.mode || data.laterality !== schema.laterality || data.sides_mode !== schema.sides_mode || weightMode !== schema.weight_mode
? {mode:data.mode,laterality:data.laterality,sides_mode:data.sides_mode,weight_mode:weightMode} : null;
return {data, incompatible:false, formatOverride};
}
let work = cleanPart(raw);
let values = [];
const repeated = work.match(/(\d+(?:[.,]\d+)?)\s*x\s*(\d+(?:[.,]\d+)?)/i);
if (repeated) {
const count = Math.max(1, Math.min(20, Number(repeated[1]) || 1));
values = Array(count).fill(numericOrNull(repeated[2]));
} else {
values = numberList(work);
}
if (!values.length && weight === null) return {data:null, incompatible:false, formatOverride:null};
const perSide = /(?:je|pro)\s+seite|\/\s*seite/i.test(low);
const sets = Math.max(1, Math.min(20, values.length || Number(schema.default_sets) || 1));
const data = emptyResultData({...schema,mode:detectedMode,laterality: perSide ? 'unilateral' : schema.laterality,sides_mode:'same'}, sets);
data.mode = detectedMode;
data.sides_mode = 'same';
data.weight_kg = weight; data.values = ensureResultArray(values, sets);
const weightMode = weight !== null && schema.weight_mode === 'none' ? 'optional' : schema.weight_mode;
const formatOverride = data.mode !== schema.mode || data.laterality !== schema.laterality || data.sides_mode !== schema.sides_mode || weightMode !== schema.weight_mode
? {mode:data.mode,laterality:data.laterality,sides_mode:data.sides_mode,weight_mode:weightMode} : null;
return {data, incompatible:false, formatOverride};
}
function resultEditorModel(exercise, item, progression) {
const recommended = resolvedResultSchema(exercise, progression, item);
if (item?.result_data && typeof item.result_data === 'object') {
const raw = item.result_data;
const compatibleMode = raw.mode === recommended.mode;
const compatibleLaterality = raw.laterality === recommended.laterality;
if (!item?.result_format && (!compatibleMode || !compatibleLaterality)) {
return {schema:recommended, data:emptyResultData(recommended), source:'legacy-incompatible', incompatible:true};
}
const data = normalizeResultData(raw, recommended);
const weightMode = numericOrNull(data.weight_kg) !== null && recommended.weight_mode === 'none' ? 'optional' : recommended.weight_mode;
return {schema:{...recommended,weight_mode:weightMode}, data, source:'structured', incompatible:false};
}
const legacy = parseLegacyResultData(item?.result || '', recommended);
if (legacy.data) {
const weightMode = numericOrNull(legacy.data.weight_kg) !== null && recommended.weight_mode === 'none' ? 'optional' : recommended.weight_mode;
const legacySchema = {...recommended,...(legacy.formatOverride || {}),weight_mode:legacy.formatOverride?.weight_mode || weightMode,schema_source:legacy.formatOverride ? 'session' : recommended.schema_source};
return {schema:legacySchema, data:legacy.data, source:'legacy', incompatible:false, formatOverride:legacy.formatOverride};
}
return {schema:recommended, data:emptyResultData(recommended), source:item?.result ? 'legacy-incompatible' : 'empty', incompatible:legacy.incompatible, formatOverride:null};
}
function formatResultData(data) {
if (!data || typeof data !== 'object') return '';
const unit = data.mode === 'seconds' ? ' s' : data.mode === 'minutes' ? ' min' : ' Reps';
const weight = numericOrNull(data.weight_kg);
const prefix = weight === null ? '' : `${displayNumber(weight)} kg · `;
const joined = values => ensureResultArray(values, data.sets).map(value => displayNumber(value) || '').join('/');
if (data.laterality === 'unilateral' && data.sides_mode === 'separate') {
return `${prefix}L ${joined(data.left_values)} · R ${joined(data.right_values)}${unit}`;
}
return `${prefix}${joined(data.values)}${unit}${data.laterality === 'unilateral' ? ' je Seite' : ''}`;
}
function resultRequirementIssue(item, model) {
if (model.schema.mode === 'none') return '';
if (String(item?.result || '').trim() && model.source.startsWith('legacy')) return '';
const data = model.data;
if (!resultHasValues(data)) return 'Bitte mindestens einen Satz bzw. ein Intervall eintragen.';
if (model.schema.weight_mode === 'required' && numericOrNull(data.weight_kg) === null) return 'Für diese Übung fehlt das verwendete Gewicht.';
if (data.laterality === 'unilateral' && data.sides_mode === 'separate') {
const left = (data.left_values || []).some(value => numericOrNull(value) !== null);
const right = (data.right_values || []).some(value => numericOrNull(value) !== null);
if (!left || !right) return 'Bei getrennter Erfassung werden Werte für links und rechts benötigt.';
}
return '';
}
function resultFormatLabel(schema) {
if (schema.mode === 'none') return 'nur Erledigt / Notiz';
const measure = schema.mode === 'seconds' ? 'Zeit in Sekunden' : schema.mode === 'minutes' ? 'Dauer in Minuten' : 'Wiederholungen';
const side = schema.laterality === 'unilateral' ? 'einseitig' : 'zweiseitig';
const weight = schema.weight_mode === 'required' ? 'Gewicht + ' : schema.weight_mode === 'optional' ? 'optional Gewicht + ' : '';
return `${weight}${measure} · ${side}`;
}
function renderResultValues(exercise, model) {
const data = model.data;
if (data.mode === 'none') return '<div class="result-help" style="margin-top:10px">Für diesen Punkt genügt die Erledigt-Markierung oder eine Notiz.</div>';
const unit = data.mode === 'seconds' ? 'Sek.' : data.mode === 'minutes' ? 'Min.' : 'Reps';
if (data.laterality === 'unilateral' && data.sides_mode === 'separate') {
return `<div class="result-side-grid"><span></span><span class="side-head">Links</span><span class="side-head">Rechts</span>
${Array.from({length:data.sets},(_,index)=>`<span class="set-label">${index + 1}</span>
<input type="number" min="0" step="1" inputmode="numeric" data-result-value="${esc(exercise.id)}" data-result-field="left_values" data-result-index="${index}" value="${esc(displayNumber(data.left_values[index]))}" aria-label="Satz ${index + 1} links ${unit}">
<input type="number" min="0" step="1" inputmode="numeric" data-result-value="${esc(exercise.id)}" data-result-field="right_values" data-result-index="${index}" value="${esc(displayNumber(data.right_values[index]))}" aria-label="Satz ${index + 1} rechts ${unit}">`).join('')}</div>`;
}
return `<div class="result-set-grid">${Array.from({length:data.sets},(_,index)=>`<label class="result-set-field">${index + 1}
<input type="number" min="0" step="1" inputmode="numeric" data-result-value="${esc(exercise.id)}" data-result-field="values" data-result-index="${index}" value="${esc(displayNumber(data.values[index]))}" aria-label="Satz ${index + 1} ${unit}"></label>`).join('')}</div>`;
}
function renderResultEditor(exercise, item, progression) {
const model = resultEditorModel(exercise, item, progression);
const schema = model.schema;
const data = model.data;
const intervalLabel = schema.mode === 'none' ? 'Kein Zahlenformat erforderlich'
: schema.mode === 'minutes' ? 'Dauer in Minuten'
: schema.fixed_interval && schema.work_seconds
? (schema.mode === 'seconds' ? `Tatsächlich gehaltene Sekunden je ${schema.work_seconds}-s-Intervall` : `Wiederholungen je ${schema.work_seconds}-s-Arbeitsintervall`)
: (schema.mode === 'seconds' ? 'Sekunden je Satz' : 'Wiederholungen je Satz');
const legacy = model.source === 'legacy'
? `<div class="result-legacy">Altwert erkannt und vorgefüllt: <strong>${esc(item.result)}</strong>. Bei der nächsten Änderung wird er strukturiert gespeichert.</div>`
: model.source === 'legacy-incompatible'
? `<div class="result-legacy">Bisheriger Altwert: <strong>${esc(item.result)}</strong>. Er passt nicht eindeutig zur erkannten Messart und bleibt erhalten, bis du hier neue Werte einträgst.</div>` : '';
const controls = [];
if (schema.weight_mode !== 'none') controls.push(`<label class="field">Gewicht in kg${schema.weight_mode === 'required' ? ' <span class="result-required">Pflicht</span>' : ' · optional'}
<input type="number" min="0" step="0.5" inputmode="decimal" data-result-weight="${esc(exercise.id)}" value="${esc(displayNumber(data.weight_kg))}" placeholder="z. B. 8"></label>`);
if (!schema.locked_sets && !['none','minutes'].includes(schema.mode)) controls.push(`<label class="field">Sätze / Intervalle
<input type="number" min="1" max="20" step="1" inputmode="numeric" data-result-sets="${esc(exercise.id)}" value="${data.sets}"></label>`);
if (schema.mode !== 'none' && data.laterality === 'unilateral') controls.push(`<label class="field">Seiten erfassen
<select data-result-sides="${esc(exercise.id)}"><option value="same" ${data.sides_mode === 'same' ? 'selected' : ''}>Werte gelten je Seite</option><option value="separate" ${data.sides_mode === 'separate' ? 'selected' : ''}>Links und rechts getrennt</option></select></label>`);
const summary = schema.mode === 'none' ? 'Keine Zahleneingabe nötig' : (resultHasValues(data) || numericOrNull(data.weight_kg) !== null ? formatResultData(data) : '');
const plannedSets = Math.max(1, Number(schema.default_sets) || data.sets || 1);
const countHint = schema.locked_sets
? (data.sets === plannedSets ? ` · ${plannedSets} ${plannedSets === 1 ? 'Wert' : 'Werte'} durch den Plan vorgegeben` : ` · Plan: ${plannedSets}, vorhandener Altwert: ${data.sets}`)
: '';
return `<div class="result-editor" data-result-editor="${esc(exercise.id)}">
<div class="result-editor-head"><div><div class="result-format">Ergebnis heute <span>${esc(resultFormatLabel(schema))}</span></div><div class="result-help">${esc(intervalLabel)}${esc(countHint)}${schema.schema_source === 'progression' ? ' · durch Progressionsstufe vorgegeben' : schema.schema_source === 'session' ? ' · für diese Session angepasst' : ''}</div></div></div>
${controls.length ? `<div class="result-controls ${controls.length === 1 ? 'one' : ''}">${controls.join('')}</div>` : ''}
${renderResultValues(exercise, model)}
<div class="result-summary ${summary ? '' : 'empty'}" data-result-summary="${esc(exercise.id)}">${schema.mode === 'none' ? esc(summary) : (summary ? `Gespeichert als: ${esc(summary)}` : 'Noch kein Ergebnis eingetragen.')}</div>
${legacy}
<details class="result-options"><summary>Eingabeart anpassen</summary><div class="result-controls">
<label class="field">Messart<select data-result-mode="${esc(exercise.id)}"><option value="reps" ${data.mode === 'reps' ? 'selected' : ''}>Wiederholungen</option><option value="seconds" ${data.mode === 'seconds' ? 'selected' : ''}>Zeit in Sekunden</option><option value="minutes" ${data.mode === 'minutes' ? 'selected' : ''}>Dauer in Minuten</option><option value="none" ${data.mode === 'none' ? 'selected' : ''}>Kein Messwert</option></select></label>
<label class="field">Ausführung<select data-result-laterality="${esc(exercise.id)}"><option value="bilateral" ${data.laterality === 'bilateral' ? 'selected' : ''}>Zweiseitig / gemeinsam</option><option value="unilateral" ${data.laterality === 'unilateral' ? 'selected' : ''}>Einseitig / je Seite</option></select></label>
<label class="field">Gewicht erfassen<select data-result-weight-mode="${esc(exercise.id)}"><option value="none" ${schema.weight_mode === 'none' ? 'selected' : ''}>Nein</option><option value="optional" ${schema.weight_mode === 'optional' ? 'selected' : ''}>Optional</option><option value="required" ${schema.weight_mode === 'required' ? 'selected' : ''}>Pflicht</option></select></label>
</div></details>
</div>`;
}
function exerciseById(id) {
return currentDay().rotations.flatMap(rotation => rotation.exercises).find(exercise => exercise.id === id) || null;
}
function editStructuredResult(exercise, mutator, rerender = false, storeFormat = false) {
const item = exerciseItemState(exercise, true);
const progression = derivedProgression(exercise, item);
const model = resultEditorModel(exercise, item, progression);
const data = normalizeResultData(model.data, model.schema);
mutator(data, model.schema);
const normalized = normalizeResultData(data, {...model.schema,mode:data.mode,laterality:data.laterality,default_sets:data.sets});
if (storeFormat || model.formatOverride) item.result_format = {mode: normalized.mode, laterality: normalized.laterality, sides_mode: normalized.sides_mode, weight_mode: model.schema.weight_mode};
if (normalized.mode !== 'none' && (resultHasValues(normalized) || numericOrNull(normalized.weight_kg) !== null)) {
item.result_data = normalized;
item.result = formatResultData(normalized);
} else {
delete item.result_data;
item.result = '';
}
markDirty();
if (rerender) renderSession();
else {
const summary = $$('[data-result-summary]').find(node => node.dataset.resultSummary === exercise.id);
if (summary) {
summary.textContent = item.result ? `Gespeichert als: ${item.result}` : 'Noch kein Ergebnis eingetragen.';
summary.classList.toggle('empty', !item.result);
}
}
}
function validateCurrentSessionResults() {
const issues = [];
currentDay().rotations.flatMap(rotation => rotation.exercises).forEach(exercise => {
const item = exerciseItemState(exercise);
if (itemCompletionStatus(item) !== 'completed') return;
const progression = derivedProgression(exercise, item);
const model = resultEditorModel(exercise, item, progression);
const issue = resultRequirementIssue(item, model);
if (issue) issues.push(`${plain(exercise.name)}: ${issue}`);
});
return issues;
}
function itemCompletionStatus(item){ return item?.completion_status || (item?.done ? 'completed' : 'planned'); }
function renderExercise(exercise) {
const item = exerciseItemState(exercise);
const itemStatus=itemCompletionStatus(item);
const previous = latestPreviousEntry(exercise);
const currentProgression = derivedProgression(exercise, item);
const planned = plannedProgression(exercise);
const options = allProgressionOptions(exercise);
const listId = `progress-${exercise.id}`;
const previousMeta = previous ? parseSessionKey(previous.key) : null;
const previousBits = [];
if (previous?.item?.progression) previousBits.push(`<strong>Stufe:</strong> ${esc(previous.item.progression)}`);
if (previous?.item?.result) previousBits.push(`<strong>Ergebnis:</strong> ${esc(previous.item.result)}`);
if (previous?.item?.note) previousBits.push(`<strong>Notiz:</strong> ${esc(previous.item.note)}`);
const resultModel = resultEditorModel(exercise, item, currentProgression);
const requiresResult=['completed'].includes(itemStatus);
const requirement = requiresResult && currentSession().status !== 'completed' ? resultRequirementIssue(item, resultModel) : '';
return `<article class="exercise ${itemStatus} ${requirement ? 'result-missing' : ''}">
<div class="exercise-title">
<select class="item-status-select" data-item-status="${esc(exercise.id)}" aria-label="Status ${esc(plain(exercise.name))}">
<option value="planned" ${itemStatus==='planned'?'selected':''}>Offen</option>
<option value="completed" ${itemStatus==='completed'?'selected':''}>Erledigt</option>
<option value="partial" ${itemStatus==='partial'?'selected':''}>Teilweise</option>
<option value="skipped" ${itemStatus==='skipped'?'selected':''}>Übersprungen</option>
</select>
<div><h3>${esc(plain(exercise.name))}</h3>${exercise.cue ? `<div class="cue rich">${safeRich(exercise.cue)}</div>` : ''}</div>
</div>
${(planned || currentProgression) ? `<div class="progression-box">
${planned ? `<div class="planned"><strong>Planvorgabe:</strong> ${safeRich(planned)}</div>` : ''}
${currentProgression ? `<div style="margin-top:4px"><strong>Diese Session:</strong> ${esc(currentProgression)}</div>` : ''}
</div>` : ''}
${previousBits.length ? `<div class="previous-session"><div class="tiny"><strong>Letzte Ausführung${previousMeta ? ` · W${previousMeta.week}/Tag ${previousMeta.dayNum}` : ''}</strong></div><div class="tiny muted">${previousBits.join(' · ')}</div></div>` : ''}
${itemStatus==='skipped' ? `<label class="field skip-reason">Grund für das Überspringen<select data-skip-reason="${esc(exercise.id)}"><option value="">Bitte wählen</option>${['Schmerzen/Beschwerden','Equipment nicht verfügbar','Zeitmangel','Regeneration','Bewusst ersetzt','Sonstiges'].map(value=>`<option value="${esc(value)}" ${item.skip_reason===value?'selected':''}>${esc(value)}</option>`).join('')}</select></label>` : `<div class="field-grid">
<label class="field">Progression dieser Session
<input type="text" list="${listId}" data-progression-item="${esc(exercise.id)}" value="${esc(currentProgression || '')}" placeholder="z. B. assistiert, hohe Erhöhung">
<datalist id="${listId}">${options.map(step => `<option value="${esc(plain(stageStepName(step)))}" data-step-id="${esc(step?.id||'')}"></option>`).join('')}</datalist>
</label>
</div>
${renderResultEditor(exercise, item, currentProgression)}
${requirement ? `<div class="result-error">${esc(requirement)}</div>` : ''}`}
<label class="field" style="margin-top:9px">Übungsnotiz
<textarea data-item-note="${esc(exercise.id)}" placeholder="Nur für heute: Besonderheiten oder nächste Anpassung …">${esc(item.note || '')}</textarea>
</label>
${exercise.library?.desc ? `<details style="margin-top:8px"><summary class="details-link">Anleitung anzeigen</summary><div class="cue rich" style="margin-top:7px">${safeRich(exercise.library.desc)}</div>${safeUrl(exercise.library.url) ? `<a class="details-link" href="${esc(safeUrl(exercise.library.url))}" target="_blank" rel="noopener">Original-Anleitung öffnen ↗</a>` : ''}</details>` : ''}
</article>`;
}
function bindSessionEvents() {
$$('[data-week]').forEach(btn => btn.addEventListener('click', () => { state.week = Number(btn.dataset.week); persistUiState(); renderEverything(); window.scrollTo({top:0,behavior:'smooth'}); }));
$$('[data-day]').forEach(btn => btn.addEventListener('click', () => { state.dayNum = Number(btn.dataset.day); persistUiState(); renderEverything(); window.scrollTo({top:0,behavior:'smooth'}); }));
$$('[data-item-done]').forEach(input => input.addEventListener('change', () => { const ex = currentDay().rotations.flatMap(r => r.exercises).find(e => e.id === input.dataset.itemDone); const item = ex ? exerciseItemState(ex, true) : itemState(input.dataset.itemDone); item.done = input.checked; item.completion_status=input.checked?'completed':'planned'; markDirty(); renderSession(); }));
$$('[data-item-status]').forEach(input => input.addEventListener('change', () => { const ex=exerciseById(input.dataset.itemStatus); if(!ex)return; const item=exerciseItemState(ex,true); item.completion_status=input.value; item.done=['completed','partial'].includes(input.value); if(input.value!=='skipped')delete item.skip_reason; markDirty(); renderSession(); }));
$$('[data-skip-reason]').forEach(input => input.addEventListener('change',()=>{const ex=exerciseById(input.dataset.skipReason);if(!ex)return;const item=exerciseItemState(ex,true);item.completion_status='skipped';item.done=false;item.skip_reason=input.value;markDirty();}));
$$('[data-result-weight]').forEach(input => input.addEventListener('input', () => { const ex = exerciseById(input.dataset.resultWeight); if (ex) editStructuredResult(ex, data => { data.weight_kg = numericOrNull(input.value); }); }));
$$('[data-result-value]').forEach(input => input.addEventListener('input', () => { const ex = exerciseById(input.dataset.resultValue); if (!ex) return; editStructuredResult(ex, data => { const field = input.dataset.resultField; const index = Number(input.dataset.resultIndex); data[field] = ensureResultArray(data[field], data.sets); data[field][index] = numericOrNull(input.value); }); }));
$$('[data-result-sets]').forEach(input => input.addEventListener('change', () => { const ex = exerciseById(input.dataset.resultSets); if (!ex) return; editStructuredResult(ex, data => { data.sets = Math.max(1, Math.min(20, Number(input.value) || 1)); data.values = ensureResultArray(data.values, data.sets); data.left_values = ensureResultArray(data.left_values, data.sets); data.right_values = ensureResultArray(data.right_values, data.sets); }, true); }));
$$('[data-result-sides]').forEach(input => input.addEventListener('change', () => { const ex = exerciseById(input.dataset.resultSides); if (ex) editStructuredResult(ex, data => { data.sides_mode = input.value === 'separate' ? 'separate' : 'same'; }, true); }));
$$('[data-result-mode]').forEach(input => input.addEventListener('change', () => { const ex = exerciseById(input.dataset.resultMode); if (!ex) return; editStructuredResult(ex, data => { if (data.mode !== input.value) { data.mode = input.value; data.values = Array(data.sets).fill(null); data.left_values = Array(data.sets).fill(null); data.right_values = Array(data.sets).fill(null); } }, true, true); }));
$$('[data-result-laterality]').forEach(input => input.addEventListener('change', () => { const ex = exerciseById(input.dataset.resultLaterality); if (!ex) return; editStructuredResult(ex, data => { data.laterality = input.value === 'unilateral' ? 'unilateral' : 'bilateral'; if (data.laterality === 'bilateral') data.sides_mode = 'same'; }, true, true); }));
$$('[data-result-weight-mode]').forEach(input => input.addEventListener('change', () => {
const ex = exerciseById(input.dataset.resultWeightMode); if (!ex) return;
const item = exerciseItemState(ex, true);
const progression = derivedProgression(ex, item);
const model = resultEditorModel(ex, item, progression);
const data = normalizeResultData(model.data, model.schema);
item.result_format = {...(item.result_format || {}), mode:data.mode, laterality:data.laterality, sides_mode:data.sides_mode, weight_mode:input.value};
if (input.value === 'none') data.weight_kg = null;
if (resultHasValues(data) || numericOrNull(data.weight_kg) !== null) { item.result_data = data; item.result = formatResultData(data); }
else { delete item.result_data; item.result = ''; }
markDirty(); renderSession();
}));
$$('[data-item-note]').forEach(input => input.addEventListener('input', () => { const ex = currentDay().rotations.flatMap(r => r.exercises).find(e => e.id === input.dataset.itemNote); const item = ex ? exerciseItemState(ex, true) : itemState(input.dataset.itemNote); item.note = input.value; markDirty(); }));
$$('[data-progression-item]').forEach(input => input.addEventListener('change', () => {
const ex = currentDay().rotations.flatMap(r => r.exercises).find(e => e.id === input.dataset.progressionItem); if (!ex) return;
currentDay().rotations.flatMap(r => r.exercises).filter(other => other.progression_id === ex.progression_id).forEach(other => {
const target = exerciseItemState(other, true);
if (target.progression !== input.value) {
target.progression = input.value;
const selectedStep=progressionStepFor(other,input.value); target.progression_step_id=selectedStep?.id||'';
delete target.result_format;
}
});
markDirty(); renderSession(); renderProgress();
}));
$$('[data-section-note]').forEach(input => input.addEventListener('input', () => { currentSession()[`${input.dataset.sectionNote}_note`] = input.value; markDirty(); }));
$('#sessionNote')?.addEventListener('input', event => { currentSession().note = event.target.value; markDirty(); });
$('#startSession')?.addEventListener('click', () => {
const session = currentSession();
currentDay().rotations.flatMap(r => r.exercises).forEach(ex => exerciseItemState(ex, true));
session.status = 'in_progress';
session.started_at ||= new Date().toISOString();
session.stopped_at = '';
session.completed_at = '';
markDirty(); renderEverything();
});
$('#stopSession')?.addEventListener('click', () => {
const session = currentSession();
if (session.status !== 'in_progress') return;
session.status = 'stopped';
session.stopped_at = new Date().toISOString();
session.completed_at = '';
markDirty(); renderEverything();
});
$('#completeSession')?.addEventListener('click', () => {
const session = currentSession();
if (session.status === 'completed') return;
currentDay().rotations.flatMap(r => r.exercises).forEach(ex => exerciseItemState(ex, true));
const resultIssues = validateCurrentSessionResults();
if (resultIssues.length) {
window.alert('Bitte vervollständige die Ergebnisse der abgehakten Übungen:\n\n' + resultIssues.join('\n'));
renderSession();
return;
}
session.status = 'completed';
session.started_at ||= new Date().toISOString();
session.stopped_at = '';
session.completed_at = new Date().toISOString();
markDirty(); renderEverything();
});
$('#resetSession')?.addEventListener('click', async () => {
const session = currentSession();
if (!sessionHasUserData(session)) return;
const label = plain(currentDay().focus) || `Tag ${state.dayNum}`;
const confirmed = window.confirm(
`Session „${label}“ vollständig zurücksetzen?\n\n` +
'Status, Zeiten, Checkboxen, Progressionsauswahl, Ergebnisse und sämtliche Notizen dieser Session werden gelöscht. Die aktuelle Wochen- und Gesamtanalyse bleibt gespeichert, gilt anschließend aber als veraltet und wird bei der nächsten Analyse überschrieben.'
);
if (!confirmed) return;
state.tracker.sessions[sessionKey(state.week, state.dayNum)] = blankSession();
markDirty();
renderEverything();
await flushSave();
});
}
function uniqueExercises() {
const map = new Map();
state.plan.days.forEach(day => day.rotations.forEach(rotation => rotation.exercises.forEach(exercise => {
if (!map.has(exercise.progression_id)) map.set(exercise.progression_id, exercise);
})));
return [...map.values()];
}
function analysisCache() {
state.tracker.analysis_cache ||= {weeks:{},overall:null};
state.tracker.analysis_cache.weeks ||= {};
return state.tracker.analysis_cache;
}
function catalogForSelection(selection = state.analysisSelection) {
if (selection === 'overall') return state.analysisCatalog?.overall || {status:'missing',pending_weeks:[]};
const match = /^week:(\d+)$/.exec(selection || '');
const week = match ? Number(match[1]) : 0;
return (state.analysisCatalog?.weeks || []).find(entry => entry.week === week) || {week,status:'missing'};
}
function analysisRecord(selection = state.analysisSelection) {
if (selection === 'overall') return analysisCache().overall || null;
const match = /^week:(\d+)$/.exec(selection || '');
return match ? (analysisCache().weeks?.[String(Number(match[1]))] || null) : null;
}
function ensureAnalysisSelection() {
const valid = new Set(['overall', ...(state.analysisCatalog?.weeks || []).map(entry => `week:${entry.week}`)]);
if (!valid.has(state.analysisSelection)) state.analysisSelection = 'overall';
}
function statusText(status) {
return ({current:'aktuell',stale:'veraltet',missing:'noch nicht erstellt',needs_weeks:'Wochen zuerst aktualisieren'})[status] || status || 'unbekannt';
}
function n(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function renderChart(chart, index) {
const labels = Array.isArray(chart?.labels) ? chart.labels.map(plain) : [];
const series = Array.isArray(chart?.series) ? chart.series.filter(item => Array.isArray(item?.values)) : [];
if (!labels.length || !series.length) return '';
const numeric = series.flatMap(item => item.values.map(n).filter(value => value !== null));
if (!numeric.length) return '';
const max = Math.max(...numeric, 1); const min = Math.min(...numeric, 0);
const width = Math.max(360, labels.length * 64); const height = 220;
const left = 38, right = 14, top = 18, bottom = 48, innerW = width-left-right, innerH=height-top-bottom;
const x = i => left + (labels.length === 1 ? innerW/2 : i * innerW/(labels.length-1));
const y = value => top + innerH - ((value-min)/(max-min || 1))*innerH;
const palette = ['var(--accent)','var(--teal)','var(--ok)','#8b5cf6','#d97706'];
let shapes = '';
if (chart.type === 'bar') {
const group = innerW / Math.max(labels.length,1); const barW = Math.max(7, Math.min(22, group/(series.length+1)));
series.forEach((serie, si) => serie.values.forEach((raw, i) => {
const value=n(raw); if(value===null)return;
const bx=left+i*group+group/2-(series.length*barW)/2+si*barW;
const by=y(value); shapes += `<rect x="${bx}" y="${by}" width="${barW-2}" height="${top+innerH-by}" rx="3" fill="${palette[si%palette.length]}"/>`;
}));
} else {
series.forEach((serie, si) => {
const points=serie.values.map((raw,i)=>{const value=n(raw);return value===null?null:`${x(i)},${y(value)}`}).filter(Boolean);
if(points.length) shapes += `<polyline points="${points.join(' ')}" fill="none" stroke="${palette[si%palette.length]}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`;
serie.values.forEach((raw,i)=>{const value=n(raw);if(value!==null)shapes += `<circle cx="${x(i)}" cy="${y(value)}" r="4" fill="${palette[si%palette.length]}"/>`;});
});
}
const axes = `<line x1="${left}" y1="${top+innerH}" x2="${width-right}" y2="${top+innerH}" stroke="var(--line)"/><line x1="${left}" y1="${top}" x2="${left}" y2="${top+innerH}" stroke="var(--line)"/>`;
const xLabels = labels.map((label,i)=>`<text x="${chart.type==='bar' ? left+(i+.5)*innerW/labels.length : x(i)}" y="${height-22}" text-anchor="middle" fill="var(--muted)" font-size="10">${esc(label.slice(0,12))}</text>`).join('');
const legend = series.map((serie,si)=>`<span><i style="background:${palette[si%palette.length]}"></i>${esc(plain(serie.name || `Reihe ${si+1}`))}</span>`).join('');
return `<section class="analysis-chart"><h3>${esc(plain(chart.title || `Diagramm ${index+1}`))}</h3><div class="chart-legend">${legend}</div><div class="chart-scroll"><svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="${esc(plain(chart.title || 'Fortschrittsdiagramm'))}">${axes}${shapes}${xLabels}</svg></div>${chart.insight ? `<p class="tiny muted">${esc(plain(chart.insight))}</p>` : ''}</section>`;
}
function renderFlowchart(flow) {
const nodes = Array.isArray(flow?.nodes) ? flow.nodes : [];
if (!nodes.length) return '';
const edgeList = Array.isArray(flow?.edges) ? flow.edges : [];
const edges = new Set(edgeList.map(edge => `${edge.from}->${edge.to}`));
return `<section class="analysis-flow"><h3>${esc(plain(flow.title || 'Progressionspfad'))}</h3><div class="flow-scroll"><div class="flow-row">${nodes.map((node,index)=>{
const next=nodes[index+1]; const linked=next && (edges.has(`${node.id}->${next.id}`) || !edgeList.length);
return `<div class="flow-node-wrap"><div class="flow-node ${esc(node.status || '')}">${esc(plain(node.label || node.id))}</div>${linked ? '<div class="flow-arrow">→</div>' : ''}</div>`;
}).join('')}</div></div>${flow.insight ? `<p class="tiny muted">${esc(plain(flow.insight))}</p>` : ''}</section>`;
}
function renderAnalysis(record) {
if (!record?.result) return `<div class="empty">Für diese Auswahl liegt noch keine Analyse vor.</div>`;
const result = record.result || {};
const metrics = Array.isArray(result.metrics) ? result.metrics : [];
const exercises = Array.isArray(result.exercise_updates) ? result.exercise_updates : (Array.isArray(result.exercises) ? result.exercises : []);
const adjustments = Array.isArray(result.plan_adjustments) ? result.plan_adjustments : [];
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
const visuals = record.visuals || {};
const summary = result.summary || result.overview?.summary || '';
const dataQuality = result.data_quality || result.overview?.data_quality || '';
const scope = record.type === 'week' ? `Woche ${record.week}` : 'Gesamt';
return `<div class="analysis-result">
<div class="analysis-meta tiny muted">${scope} · erstellt ${formatTimestamp(record.created_at)} · ${record.sessions_considered || 0} Sessions · ${esc(record.model || '')}</div>
<h2>${esc(plain(result.headline || 'Progressionsanalyse'))}</h2>
${summary ? `<p class="analysis-copy">${esc(plain(summary))}</p>` : ''}
${dataQuality ? `<div class="notice"><strong>Datenbasis:</strong> ${esc(plain(dataQuality))}</div>` : ''}
${metrics.length ? `<div class="metric-grid">${metrics.map(metric=>`<div class="metric"><span>${esc(plain(metric.label))}</span><strong>${esc(plain(metric.value))}</strong>${metric.detail?`<small>${esc(plain(metric.detail))}</small>`:''}</div>`).join('')}</div>` : ''}
${(Array.isArray(visuals.charts) ? visuals.charts : []).map(renderChart).join('')}
${(Array.isArray(visuals.flowcharts) ? visuals.flowcharts : []).map(renderFlowchart).join('')}
${exercises.length ? `<section class="analysis-section"><div class="eyebrow">Übungsstand</div>${exercises.map(ex=>`<article class="analysis-exercise"><div class="row between"><h3>${esc(plain(ex.name))}</h3><span class="trend ${esc(ex.trend||'unclear')}">${esc(({up:'↗ aufwärts',stable:'→ stabil',down:'↘ rückläufig',unclear:'? unklar'})[ex.trend] || 'unklar')}</span></div><div class="tiny"><strong>Stand:</strong> ${esc(plain(ex.current_level || 'nicht sicher ableitbar'))}</div>${ex.evidence?`<p class="muted">${esc(plain(Array.isArray(ex.evidence) ? ex.evidence.join(' · ') : ex.evidence))}</p>`:''}${(ex.next_action||ex.next_step)?`<div class="next-step"><strong>Nächster Schritt:</strong> ${esc(plain(ex.next_action || ex.next_step))}${ex.criterion?`<br><strong>Kriterium:</strong> ${esc(plain(ex.criterion))}`:''}</div>`:''}</article>`).join('')}</section>` : ''}
${adjustments.length ? `<section class="analysis-section"><div class="eyebrow">Vorschläge für die nächste Woche</div><div class="notice tiny">Diese Änderungen werden nicht automatisch übernommen. Du kannst sie im Trainingsplan-Modul manuell eintragen.</div>${adjustments.map(item=>`<article class="analysis-adjustment"><strong>${esc(plain(item.target || 'Plananpassung'))}</strong><p>${esc(plain(item.suggested_change || ''))}</p>${item.reason?`<p class="muted"><strong>Warum:</strong> ${esc(plain(item.reason))}</p>`:''}${item.manual_step?`<p class="manual"><strong>Im Trainingsplan-Modul:</strong> ${esc(plain(item.manual_step))}</p>`:''}${item.condition?`<p class="tiny muted"><strong>Bedingung:</strong> ${esc(plain(item.condition))}</p>`:''}</article>`).join('')}</section>` : ''}
${warnings.length ? `<section class="warning-box"><strong>Hinweise</strong><ul>${warnings.map(v=>`<li>${esc(plain(v))}</li>`).join('')}</ul></section>` : ''}
${result.conclusion || result.closing ? `<p class="analysis-copy closing">${esc(plain(result.conclusion || result.closing))}</p>` : ''}
</div>`;
}
function analysisRequest() {
const match = /^week:(\d+)$/.exec(state.analysisSelection || '');
return match ? {scope:'week', week:Number(match[1])} : {scope:'overall'};
}
function manageAnalysisPolling() {
clearTimeout(state.analysisPollTimer);
state.analysisPollTimer = null;
if (!state.analyzing || !state.selectedId) return;
state.analysisPollTimer = setTimeout(refreshAnalysisStatus, 1800);
}
async function refreshAnalysisStatus() {
if (!state.selectedId) return;
try {
const data = await fetchJSON(api(`/api/plans/${encodeURIComponent(state.selectedId)}/analysis/status`));
state.tracker.analysis_state = data.analysis_state || {status:'idle'};
state.tracker.analysis_cache = data.analysis_cache || {weeks:{},overall:null};
state.analysisCatalog = data.analysis_catalog || {weeks:[],overall:{}};
state.analyzing = state.tracker.analysis_state.status === 'running';
if (state.tracker.analysis_state.status === 'error') state.analysisError = state.tracker.analysis_state.error || 'Analyse fehlgeschlagen.';
if (state.tracker.analysis_state.status === 'done' && state.tracker.analysis_state.selection) {
state.analysisSelection = state.tracker.analysis_state.selection;
state.analysisError = '';
persistUiState();
setSaveState('Analyse gespeichert', 'saved');
}
ensureAnalysisSelection();
renderProgress();
} catch (error) {
state.analysisError = error?.message || String(error);
state.analyzing = false;
renderProgress();
} finally {
manageAnalysisPolling();
}
}
async function runProgressAnalysis() {
if (state.analyzing) return;
state.analysisError = '';
await flushSave();
if (state.dirty) {
state.analysisError = 'Die aktuellen Sessiondaten konnten nicht gespeichert werden. Analyse wurde nicht gestartet.';
renderProgress();
return;
}
try {
const data = await fetchJSON(api(`/api/plans/${encodeURIComponent(state.selectedId)}/analysis`), {
method:'POST', body: JSON.stringify(analysisRequest())
});
if (data.analysis_cache) state.tracker.analysis_cache = data.analysis_cache;
if (data.analysis_catalog) state.analysisCatalog = data.analysis_catalog;
if (data.analysis_state) state.tracker.analysis_state = data.analysis_state;
if (data.selection) state.analysisSelection = data.selection;
state.analyzing = state.tracker.analysis_state?.status === 'running';
persistUiState();
renderProgress();
manageAnalysisPolling();
} catch (error) {
state.analysisError = error?.message || String(error);
await refreshAnalysisStatus().catch(()=>{});
renderProgress();
}
}
function analysisButtonLabel(entry, job) {
if (state.analyzing) return job?.message || 'Analyse läuft …';
if (entry.status === 'current') return entry.analysis_kind==='final' ? 'Wochenabschlussanalyse ist aktuell' : 'Analyse entspricht dem aktuellen Datenstand';
if (state.analysisSelection === 'overall') {
const count = entry.pending_weeks?.length || 0;
return count ? `${count} Woche${count===1?'':'n'} aktualisieren + Gesamtanalyse` : 'Gesamtanalyse erstellen';
}
const week = catalogForSelection().week;
return entry.status === 'stale' ? `Woche ${week} neu analysieren` : `Woche ${week} analysieren`;
}
function renderProgress() {
ensureAnalysisSelection();
const latest = latestEntriesByProgression();
const completed = Object.entries(state.tracker.sessions || {})
.filter(([,value]) => value?.status === 'completed')
.sort(([a],[b]) => sessionOrdinal(b)-sessionOrdinal(a));
const populated = Object.values(state.tracker.sessions || {}).filter(session => session && (
['in_progress','stopped','completed'].includes(session.status) || session.note || session.warmup_note || session.cooldown_note || session.stretch_note ||
Object.values(session.items || {}).some(item => item && (['completed','partial','skipped'].includes(itemCompletionStatus(item)) || item.done || item.result || item.note || item.progression || item.skip_reason))
)).length;
const currentRows = uniqueExercises().map(exercise => ({exercise, entry:latest.get(exercise.progression_id)})).filter(row => row.entry);
const entry = catalogForSelection();
const weekMatch=/^week:(\d+)$/.exec(state.analysisSelection||'');
const selectedAnalysisWeek=weekMatch?Number(weekMatch[1]):null;
const selectedWeekState=selectedAnalysisWeek ? (state.tracker.week_statuses?.[String(selectedAnalysisWeek)]?.status||'open') : '';
const record = analysisRecord();
const job = state.tracker.analysis_state || {status:'idle'};
const progress = job.total_steps ? Math.round((Number(job.step || 0) / Number(job.total_steps || 1)) * 100) : 8;
const options = [
`<option value="overall" ${state.analysisSelection==='overall'?'selected':''}>Gesamtanalyse · ${esc(statusText(state.analysisCatalog?.overall?.status))}</option>`,
...(state.analysisCatalog?.weeks || []).map(item => `<option value="week:${item.week}" ${state.analysisSelection===`week:${item.week}`?'selected':''}>Woche ${item.week} · ${item.week_status==='closed'?'abgeschlossen':'laufend'} · ${esc(statusText(item.status))} · ${item.sessions} Sessions</option>`)
].join('');
const disableRun = state.analyzing || !state.capabilities.openai || !populated || entry.status === 'current';
$('#progressView').innerHTML = `
<section class="card hero"><div class="eyebrow">Aus Sessiondaten abgeleitet</div><h1>Fortschritt</h1><p class="muted">Wochen werden getrennt analysiert. Je Woche und für den Gesamtplan existiert genau eine aktuelle Auswertung; eine neue Analyse überschreibt die vorherige. Die Gesamtanalyse verwendet Wochenzusammenfassungen und kompakte Kennzahlen.</p></section>
<section class="card">
<div class="section-head"><div><div class="eyebrow">Manuell & planbewusst</div><h2>Progressionsanalyse</h2></div><span class="status-badge">${populated} Sessions mit Daten</span></div>
<div class="section-body analysis-controls">
<label class="field">Auswertung auswählen<select class="analysis-picker" id="analysisPicker">${options}</select></label>
${selectedAnalysisWeek ? `<div class="row between" style="align-items:center"><div class="tiny muted"><strong>Wochenstatus:</strong> ${selectedWeekState==='closed'?'abgeschlossen':'laufend'}</div><button class="btn secondary" id="toggleWeekStatus">${selectedWeekState==='closed'?'Woche wieder öffnen':'Woche abschließen'}</button></div>` : ''}
<p class="muted tiny">Der Plan selbst fließt mit Sessionformat, Timer, Phasen, Übungen und Progressionsregeln ein. Bei Tabata bleiben Arbeits- und Pausenintervalle unverändert. API-Aufrufe erfolgen nur durch diesen Button; unveränderte Analysen kommen aus dem Cache.</p>
${state.analyzing ? `<div class="analysis-status running"><strong>${esc(job.label || 'Analyse läuft')}</strong><div class="tiny muted" style="margin-top:4px">${esc(job.message || 'OpenAI wertet die Daten aus …')}</div><div class="job-progress"><span style="width:${Math.max(6,progress)}%"></span></div></div>` : `<div class="analysis-status"><strong>Status: ${esc(statusText(entry.status))}</strong>${entry.created_at?`<div class="tiny muted" style="margin-top:4px">Letzte Erstellung: ${formatTimestamp(entry.created_at)}</div>`:''}</div>`}
<button class="btn primary full" id="runAnalysis" ${disableRun ? 'disabled' : ''}>${esc(analysisButtonLabel(entry, job))}</button>
${!state.capabilities.openai ? '<div class="notice">OpenAI-Key oder Modell fehlt in den boehmitools-Einstellungen.</div>' : ''}
${state.analysisError ? `<div id="analysisError" class="error-card">${esc(state.analysisError)}</div>` : ''}
</div>
</section>
<section class="card card-pad"><div class="eyebrow">Ausgewählte Auswertung</div>${renderAnalysis(record)}</section>
<section class="card">
<div class="section-head"><div><div class="eyebrow">Automatisch übernommen</div><h2>Letzte dokumentierte Stufen</h2></div></div>
<div class="section-body history">${currentRows.map(({exercise,entry})=>{const parsed=parseSessionKey(entry.key); return `<div class="history-item"><button data-history-week="${parsed?.week||1}" data-history-day="${parsed?.dayNum||1}"><strong>${esc(plain(exercise.name))}</strong><div class="muted tiny">${esc(entry.item.progression || 'ohne Stufenangabe')}${entry.item.result ? ` · ${esc(entry.item.result)}` : ''} · W${parsed?.week||'?'} / Tag ${parsed?.dayNum||'?'}</div></button><span>↗</span></div>`;}).join('') || '<div class="empty">Noch keine Übungsdaten dokumentiert.</div>'}</div>
</section>
<section class="card">
<div class="section-head"><div><div class="eyebrow">Historie</div><h2>Abgeschlossene Sessions</h2></div><span class="status-badge completed">${completed.length}</span></div>
<div class="section-body history">${completed.map(([key,entry]) => {
const parsed = parseSessionKey(key); const week=parsed?.week||1; const dayNum=parsed?.dayNum||1; const day = state.plan.days.find(d => d.num === dayNum);
return `<div class="history-item"><button data-history-week="${week}" data-history-day="${dayNum}"><strong>W${week} · Tag ${dayNum}</strong><div class="muted tiny">${esc(plain(day?.focus || ''))} · ${formatDate(sessionDate(week,dayNum))}</div></button><span>✓</span></div>`;
}).join('') || '<div class="empty">Noch keine Session abgeschlossen.</div>'}</div>
</section>`;
$('#analysisPicker')?.addEventListener('change', event => { state.analysisSelection = event.target.value; persistUiState(); renderProgress(); });
$('#runAnalysis')?.addEventListener('click', runProgressAnalysis);
$('#toggleWeekStatus')?.addEventListener('click',async()=>{
if(!selectedAnalysisWeek)return; state.tracker.week_statuses ||= {};
const next=selectedWeekState==='closed'?'open':'closed';
if(next==='closed'){
const planned=state.plan.days.length;
const sessions=state.plan.days.map(day=>state.tracker.sessions?.[sessionKey(selectedAnalysisWeek,day.num)]).filter(Boolean);
const finished=sessions.filter(session=>['completed','stopped'].includes(session?.status)).length;
const message=`Woche ${selectedAnalysisWeek} als abgeschlossen markieren? Dokumentiert beendet: ${finished} von ${planned} geplanten Sessions. Die nächste Wochenanalyse wird als Abschlussanalyse erstellt und überschreibt die bisherige Analyse.`;
if(!confirm(message))return;
}
state.tracker.week_statuses[String(selectedAnalysisWeek)]={status:next,closed_at:next==='closed'?new Date().toISOString():''};
markDirty(); await flushSave(); renderProgress();
});
$$('[data-history-week]').forEach(btn => btn.addEventListener('click', () => { state.week = Number(btn.dataset.historyWeek); state.dayNum = Number(btn.dataset.historyDay); switchView('session'); persistUiState(); renderEverything(); window.scrollTo({top:0,behavior:'smooth'}); }));
}
function renderPlan() {
const front = state.plan.front;
$('#planView').innerHTML = `
<section class="card hero"><div class="eyebrow">Read-only Quelle</div><h1>${esc(plain(state.plan.title))}</h1><p class="muted">${esc(state.plan.subtitle)}</p><div class="tiny muted">Datei: <code>${esc(state.plan.source_file)}</code> · Planrevision R${Number(state.plan.published_revision||1)} · Schema ${Number(state.plan.schema_version||1)}/${Number(state.plan.contract_version||1)}</div></section>
${state.plan.compatibility?.warnings?.length ? `<section class="card card-pad"><div class="eyebrow">Kompatibilität</div><h2 style="margin-top:4px">Legacy-Plan erkannt</h2>${state.plan.compatibility.warnings.map(text=>`<div class="notice tiny" style="margin-top:8px">${esc(text)}</div>`).join('')}</section>` : ''}
<section class="card">
<div class="section-head"><div><div class="eyebrow">Zeitplan</div><h2>Plan einrichten</h2></div></div>
<div class="section-body">
<label class="field">Startdatum von Woche 1, Tag 1<input id="startDate" type="date" value="${esc(state.tracker.profile.start_date || '')}"></label>
<label class="field" style="margin-top:10px">Eigener Planname<input id="displayName" type="text" value="${esc(state.tracker.profile.display_name || '')}" placeholder="Optional"></label>
<label class="field" style="margin-top:10px">Planweite Notizen<textarea id="planNotes" placeholder="Equipment, Einschränkungen, allgemeine Ziele …">${esc(state.tracker.profile.plan_notes || '')}</textarea></label>
<button class="btn ghost full" id="todayBtn" style="margin-top:10px">Zur heutigen Session springen</button>
</div>
</section>
${front.session_how_body ? `<section class="card card-pad"><div class="eyebrow">${esc(plain(front.session_how_head) || 'Ablauf')}</div><div class="rich" style="margin-top:8px">${safeRich(front.session_how_body)}</div></section>` : ''}
${front.goals_box ? `<section class="card card-pad"><div class="eyebrow">${esc(plain(front.goals_head) || 'Ziele')}</div><div class="rich" style="margin-top:8px">${safeRich(front.goals_box)}</div>${front.goals_note ? `<div class="notice rich" style="margin-top:12px">${safeRich(front.goals_note)}</div>` : ''}</section>` : ''}
${front.reminders?.length ? `<section class="card card-pad"><div class="eyebrow">${esc(plain(front.reminder_head) || 'Reminder')}</div><div class="checklist" style="margin-top:10px">${front.reminders.map(reminder => `<div class="check-item" style="grid-template-columns:1fr"><div class="rich tiny">${safeRich(reminder)}</div></div>`).join('')}</div></section>` : ''}
<section class="card card-pad"><div class="eyebrow">Datentrennung</div><h2 style="margin-top:4px">Deine Daten bleiben separat</h2><p class="muted tiny">Der Plan wird nur aus <code>data/trainingsplan/plans/${esc(state.plan.source_file)}</code> gelesen. Tracking und Notizen werden unter <code>data/trainingstracker/sessions/${esc(state.plan.source_file)}</code> gespeichert.</p><a class="btn secondary full" style="display:grid;place-items:center;text-decoration:none" href="${api(`/api/plans/${encodeURIComponent(state.selectedId)}/tracker/export`)}">Tracking-JSON exportieren</a></section>`;
$('#startDate')?.addEventListener('change', event => { state.tracker.profile.start_date = event.target.value; markDirty(); renderSession(); renderProgress(); });
$('#displayName')?.addEventListener('input', event => { state.tracker.profile.display_name = event.target.value; markDirty(); });
$('#planNotes')?.addEventListener('input', event => { state.tracker.profile.plan_notes = event.target.value; markDirty(); });
$('#todayBtn')?.addEventListener('click', () => { chooseInitialSession(); switchView('session'); persistUiState(); renderEverything(); window.scrollTo({top:0,behavior:'smooth'}); });
}
function renderFAQ() {
const guide = state.equivalenceGuide || {tables:[],sources:[]};
const tables = (guide.tables || []).map((table, index) => `
<details class="faq-block" ${index < 2 ? 'open' : ''}>
<summary>${esc(table.label)} · ${table.mode === 'seconds' ? 'isometrisch' : 'dynamisch'}</summary>
<div class="faq-content">
<p class="tiny muted">${esc(table.reference || '')}</p>
<div class="table-scroll"><table class="eq-table">
<thead><tr><th>Variante</th><th>Faktor</th><th>Beispiel</th></tr></thead>
<tbody>${(table.variants || []).map(row => `<tr><td>${esc(row.label)}</td><td>${Number(row.factor || 1).toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2})}</td><td>${esc(row.example)}</td></tr>`).join('')}</tbody>
</table></div>
</div>
</details>`).join('');
const planCatalog = (guide.plan_catalog || []).map((entry,index) => `
<details class="faq-block" ${index < 2 ? 'open' : ''}>
<summary>${esc(entry.name || entry.progression_id)} · ${esc(entry.movement_label || entry.movement_cluster)}</summary>
<div class="faq-content">
<p class="tiny muted">Progressions-ID: ${esc(entry.progression_id || '')} · Cluster: ${esc(entry.movement_cluster || 'general')}</p>
<div class="table-scroll"><table class="eq-table"><thead><tr><th>Stufe</th><th>Messart</th><th>Faktor</th></tr></thead><tbody>
${(entry.variants || []).map(row=>`<tr><td>${esc(row.name || row.id)}</td><td>${esc(row.result_schema?.mode || 'auto')}</td><td>${Number(row.factor || 1).toLocaleString('de-DE',{minimumFractionDigits:2,maximumFractionDigits:2})}</td></tr>`).join('') || '<tr><td colspan="3">Keine Stufen hinterlegt.</td></tr>'}
</tbody></table></div>
</div>
</details>`).join('');
const sources = (guide.sources || []).map(source => {
const href = safeUrl(source.url);
return `<li>${href ? `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer"><strong>${esc(source.title)}</strong></a>` : `<strong>${esc(source.title)}</strong>`}${source.note ? `<div class="muted tiny">${esc(source.note)}</div>` : ''}</li>`;
}).join('');
const bodyweight = Number(guide.bodyweight_kg || 0);
$('#faqView').innerHTML = `
<section class="card hero"><div class="eyebrow">Nachvollziehbare Normalisierung</div><h1>FAQ & Umrechnung</h1><p class="muted">Hier stehen exakt die Faktoren, mit denen Varianten zu gemeinsamen Progressionskurven gebündelt werden.</p></section>
<section class="card card-pad">
<div class="eyebrow">Wie wird gerechnet?</div><h2 style="margin-top:4px">Referenzwerte statt Varianten-Chaos</h2>
<p class="tiny muted">${esc(guide.method || '')}</p>
${bodyweight ? `<div class="notice">Im aktiven Plan erkanntes Körpergewicht: <strong>${bodyweight.toLocaleString('de-DE')} kg</strong>. Nur deshalb kann Zusatzlast bei Bodyweight-Clustern relativ einfließen.</div>` : '<div class="notice">Im aktiven Plan ist kein Körpergewicht angegeben. Zusatzlast wird bei Bodyweight-Clustern deshalb nicht mit einer geratenen Körpermasse verrechnet.</div>'}
<p class="tiny"><strong>Dynamische Übungen:</strong> Gesamt-Reps × Variantenfaktor = Referenz-Reps.<br><strong>Isometrische Übungen:</strong> Gesamt-Haltezeit × Variantenfaktor = Referenzsekunden.<br><strong>Reine Gewichtsübungen:</strong> kg × Gesamt-Reps beziehungsweise kg × Sekunden.</p>
<p class="tiny muted">${esc(guide.caveat || '')}</p>
</section>
<section class="card card-pad"><div class="eyebrow">Planbibliothek</div><h2 style="margin-top:4px">Stufen des aktiven Plans</h2><p class="tiny muted">Diese IDs, Cluster, Messarten und Faktoren stammen direkt aus der veröffentlichten Planrevision und haben Vorrang vor der Texterkennung.</p>${planCatalog || '<div class="empty">Dieser Plan enthält noch keine strukturierte Übungsbibliothek.</div>'}</section>
<section class="card card-pad"><div class="eyebrow">Fallback-Entsprechungstabellen</div>${tables || '<div class="empty">Keine Umrechnungstabellen verfügbar.</div>'}</section>
<section class="card card-pad"><div class="eyebrow">Quellen & Kalibrierung</div><h2 style="margin-top:4px">Woher kommen die Faktoren?</h2><ul class="source-list">${sources}</ul><p class="tiny muted">Die Push-up-Werte besitzen Messanker. Zwischenstufen und andere Bewegungsmuster sind bewusst konservative Heuristiken. Pro Woche und für den Gesamtplan existiert jeweils nur eine aktuelle Analyse; eine Neuberechnung überschreibt die vorherige Datei.</p></section>`;
}
function switchView(view) {
state.view = view;
$('#sessionView').classList.toggle('hidden', view !== 'session');
$('#progressView').classList.toggle('hidden', view !== 'progress');
$('#planView').classList.toggle('hidden', view !== 'plan');
$('#faqView').classList.toggle('hidden', view !== 'faq');
$$('.nav-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.view === view));
persistUiState();
if (view === 'progress') refreshAnalysisStatus().catch(()=>{});
}
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.title || plan.name))}</strong><span>${esc(plan.filename)} · ${plan.weeks} Wochen · ${plan.days} Tage${plan.completed_sessions ? ` · ${plan.completed_sessions} erledigt` : ''}</span></button>`).join('');
$('#planModal').classList.remove('hidden');
$$('[data-plan-choice]').forEach(btn => btn.addEventListener('click', async () => {
$('#planModal').classList.add('hidden');
setSaveState('Plan wird geladen …', 'saving');
try { await selectPlan(btn.dataset.planChoice, true); setSaveState('Bereit'); }
catch (error) { showFatal(error); }
}));
}
function showFatal(error) {
const el = $('#fatalError');
el.textContent = error?.message || String(error);
el.classList.remove('hidden');
setSaveState('Fehler', 'error');
}
$$('.nav-btn').forEach(btn => btn.addEventListener('click', () => { switchView(btn.dataset.view); window.scrollTo({top:0,behavior:'smooth'}); }));
$('#choosePlanBtn').addEventListener('click', openPlanModal);
$('#closePlanModal').addEventListener('click', () => $('#planModal').classList.add('hidden'));
$('#planModal').addEventListener('click', event => { if (event.target === $('#planModal')) $('#planModal').classList.add('hidden'); });
window.addEventListener('beforeunload', event => { if (state.dirty) { event.preventDefault(); event.returnValue = ''; } });
document.addEventListener('visibilitychange', () => { if (document.hidden) flushSave(); else if (state.analyzing) refreshAnalysisStatus(); });
loadPlans(false).catch(showFatal);
})();
</script>
</body>
</html>