Session 2026-04-19: Navigation, Kompass, Übungsfortschritt

Routen-Navigation:
- POI-Marker: farbige Kreise mit Phosphor-Icons (wie Hauptkarte)
- Screensaver: Navi-Pfeil dreht sich via DeviceOrientationEvent (iOS+Android)
- Pfeil-Dämpfung: EMA α=0.12 mit Wrap-Around
- GPS-Distanz-Bug: Fortschritt nur wenn <500m zur Route
- fitBounds: User-Position nur wenn <20km von Route
- Screensaver: "zur Route" vs "verbleibend" kontextabhängig
- Richtungspfeile entlang Route (blau, max 7 Stück)
- Umkehren ins Route-Detail verschoben, Detail-Map rebuildet sich
- rk-header z-index:10 (Leaflet-Tiles liefen drüber)
- 2-Sek. Screensaver-Entsperrung

km-Tracking:
- route_walks Tabelle
- POST /api/routes/{id}/walked (≥50%)
- total_km = erstellte Routes + gelaufene route_walks
- Toast bei neuem Badge

Übungsfortschritt:
- exercise_progress + training_plan_progress Tabellen
- GET/POST /api/training/progress, /plan-progress, /suggestions
- uebungen.js: API-first + localStorage-Fallback + Auto-Migration
- Empfehlungs-Banner (regelbasiert)
- Toast bei "sitzt"
This commit is contained in:
rene 2026-04-19 20:33:01 +02:00
parent 390176383f
commit 9a78121a3e
25 changed files with 2487 additions and 248 deletions

View file

@ -36,16 +36,26 @@ window.Page_uebungen = (() => {
return `ub_status_${tab}_${name.replace(/\s+/g, '_')}`;
}
// In-memory cache (loaded from API on init)
let _progressCache = {}; // key → statusId
function _progressKey(tab, name) {
return `${tab}_${name.replace(/[\s/]+/g, '_')}`;
}
function _getStatus(tab, name) {
return localStorage.getItem(_statusKey(tab, name)) || null;
const k = _progressKey(tab, name);
// Fallback to localStorage while API loads
return _progressCache[k] !== undefined
? _progressCache[k]
: localStorage.getItem(_statusKey(tab, name)) || null;
}
function _setStatus(tab, name, statusId) {
if (statusId === null) {
localStorage.removeItem(_statusKey(tab, name));
} else {
localStorage.setItem(_statusKey(tab, name), statusId);
}
const k = _progressKey(tab, name);
_progressCache[k] = statusId;
localStorage.setItem(_statusKey(tab, name), statusId || ''); // keep localStorage in sync
API.training.setProgress(k, statusId).catch(() => {});
}
function _nextStatus(currentId) {
@ -352,6 +362,31 @@ window.Page_uebungen = (() => {
_container = container;
_appState = appState;
_render();
// Progress vom Server laden
API.training.getProgress().then(rows => {
rows.forEach(r => { _progressCache[r.exercise_id] = r.status; });
// localStorage-Daten migrieren falls noch nicht im Backend
Object.keys(localStorage).filter(k => k.startsWith('ub_status_')).forEach(lsKey => {
const parts = lsKey.replace('ub_status_', '').split('_');
const tab = parts[0];
const name = parts.slice(1).join('_');
const apiKey = `${tab}_${name}`;
if (_progressCache[apiKey] === undefined) {
const val = localStorage.getItem(lsKey);
if (val) {
_progressCache[apiKey] = val;
API.training.setProgress(apiKey, val).catch(() => {});
}
}
});
_renderContent(); // Re-render with loaded progress
}).catch(() => {});
// Empfehlungen laden
API.training.getSuggestions().then(suggestions => {
if (suggestions.length) _showSuggestions(suggestions);
}).catch(() => {});
}
function refresh() {}
@ -364,6 +399,7 @@ window.Page_uebungen = (() => {
_container.innerHTML = `
<div id="ueb-wrap">
${_renderTabs()}
<div id="ueb-suggestions" style="padding:0 var(--space-4);display:flex;flex-direction:column;gap:var(--space-2);margin-bottom:var(--space-2)"></div>
<div id="ueb-content"></div>
</div>
`;
@ -384,6 +420,54 @@ window.Page_uebungen = (() => {
`;
}
function _showSuggestions(suggestions) {
const el = _container.querySelector('#ueb-suggestions');
if (!el || !suggestions.length) return;
const COLORS = {
help: { bg: '#fef2f2', border: '#fca5a5', text: '#dc2626' },
boost: { bg: '#fff7ed', border: '#fdba74', text: '#ea580c' },
next: { bg: '#f0fdf4', border: '#86efac', text: '#16a34a' },
start: { bg: 'var(--c-primary-subtle)', border: 'var(--c-primary-light)', text: 'var(--c-primary)' },
};
el.innerHTML = suggestions.map(s => {
const c = COLORS[s.type] || COLORS.start;
return `
<div style="background:${c.bg};border:1px solid ${c.border};border-radius:var(--radius-md);
padding:var(--space-3) var(--space-4);display:flex;gap:var(--space-3);align-items:flex-start;cursor:pointer"
data-action-tab="${_esc(s.action_tab || '')}"
data-action-name="${_esc(s.action_name || '')}"
class="ueb-suggestion-card">
<svg class="ph-icon" style="width:20px;height:20px;flex-shrink:0;color:${c.text};margin-top:2px" aria-hidden="true">
<use href="/icons/phosphor.svg#${_esc(s.icon)}"></use>
</svg>
<div style="min-width:0">
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:${c.text};margin-bottom:2px">
${_esc(s.title)}
</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
${_esc(s.text)}
</div>
</div>
</div>
`;
}).join('');
el.querySelectorAll('.ueb-suggestion-card').forEach(card => {
card.addEventListener('click', () => {
const tab = card.dataset.actionTab;
if (tab && tab !== _activeTab) {
_activeTab = tab;
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === tab)
);
_renderContent();
}
});
});
}
function _bindTabs() {
_container.querySelectorAll('#ueb-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => {
@ -551,6 +635,7 @@ window.Page_uebungen = (() => {
const cur = _getStatus(tab, name);
const next = _nextStatus(cur);
_setStatus(tab, name, next);
if (next === 'sitzt') UI.toast.success(`🏆 „${name}" sitzt! Gut gemacht!`);
// Update button in place (no full re-render)
const sm = _statusMeta(next);