1638 lines
114 KiB
HTML
1638 lines
114 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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>
|