feat(pwa): add auto-update on reopen, live tickers, and manual refresh to dashboards

This commit is contained in:
Moritz Graf
2026-10-11 22:25:37 +02:00
parent 63497765b0
commit e9d2c59eed
3 changed files with 225 additions and 9 deletions
+12 -3
View File
@@ -1,5 +1,5 @@
// Minimal service worker for Mila PWA installability & offline resilience
const CACHE_NAME = 'mila-v1';
// Minimal service worker for Family PWAs (Mila & Moritz) with instant cache-bypassing navigation
const CACHE_NAME = 'family-pwa-v2';
self.addEventListener('install', (event) => {
self.skipWaiting();
@@ -19,7 +19,16 @@ self.addEventListener('activate', (event) => {
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
// Network-first strategy: Always fetch live real-time updates from MinIO
// For HTML documents/navigations: ALWAYS bypass HTTP cache to get latest live server rendering
if (event.request.destination === 'document' || event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' }).catch(() => caches.match(event.request))
);
return;
}
// Assets (images, fonts, scripts): network-first with offline cache fallback
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
@@ -537,6 +537,8 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="build-time" content="{now_berlin.isoformat()}">
<meta name="build-epoch" content="{int(now_berlin.timestamp())}">
<title>Moritz Health — Energiebilanz & Sport</title>
<!-- PWA & Mobile Web App Meta Tags -->
@@ -587,6 +589,9 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
</div>
</div>
<div class="flex items-center gap-2">
<button id="manual-refresh-btn" onclick="triggerManualRefresh()" title="Aktualisieren" class="inline-flex items-center justify-center p-2 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-600 hover:text-slate-900 border border-slate-200/80 transition active:scale-95 shadow-xs">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
</button>
<button onclick="openModal('modal-today-meals')" class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-sky-50 text-sky-700 hover:bg-sky-100 border border-sky-200/70 text-xs font-semibold shadow-xs transition">
<i data-lucide="utensils" class="w-3.5 h-3.5"></i>
<span>Heute ({today_count})</span>
@@ -1106,6 +1111,91 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
document.addEventListener('DOMContentLoaded', () => {{
lucide.createIcons();
}});
// Service Worker Registration for PWA caching & offline support
if ('serviceWorker' in navigator) {{
window.addEventListener('load', () => {{
navigator.serviceWorker.register('/family/sw.js', {{ scope: '/family/' }})
.then(reg => console.log('[PWA] Service Worker registered:', reg.scope))
.catch(err => console.warn('[PWA] Service Worker registration failed:', err));
}});
}}
// ==============================================================================
// Self-Updating Engine
// ==============================================================================
(function() {{
const currentBuildEpoch = parseInt(document.querySelector('meta[name="build-epoch"]')?.content || '0', 10);
let lastCheckMs = Date.now();
let isChecking = false;
// 1. Server Update Checker
async function checkForUpdates() {{
if (isChecking) return;
isChecking = true;
try {{
const res = await fetch(window.location.pathname + '?_poll=' + Date.now(), {{
cache: 'no-store',
headers: {{ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }}
}});
if (res.ok) {{
const html = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const remoteEpoch = parseInt(doc.querySelector('meta[name="build-epoch"]')?.content || '0', 10);
if (remoteEpoch && currentBuildEpoch && remoteEpoch > currentBuildEpoch) {{
console.log('[AutoUpdate] Server has newer build (' + remoteEpoch + ' > ' + currentBuildEpoch + '). Reloading...');
window.location.reload();
return;
}}
}}
}} catch (err) {{
console.warn('[AutoUpdate] Check failed:', err);
}} finally {{
isChecking = false;
lastCheckMs = Date.now();
}}
}}
// 2. Auto-update on reopen / foreground resume (PWA & Mobile Safari)
document.addEventListener('visibilitychange', () => {{
if (document.visibilityState === 'visible') {{
if (Date.now() - lastCheckMs > 10000) {{
checkForUpdates();
}}
}}
}});
window.addEventListener('pageshow', (e) => {{
if (e.persisted || Date.now() - lastCheckMs > 10000) {{
checkForUpdates();
}}
}});
window.addEventListener('focus', () => {{
if (Date.now() - lastCheckMs > 15000) {{
checkForUpdates();
}}
}});
// 3. Background check every 30 seconds when visible
setInterval(() => {{
if (document.visibilityState === 'visible') {{
checkForUpdates();
}}
}}, 30000);
// 4. Manual refresh trigger
window.triggerManualRefresh = function() {{
const btn = document.getElementById('manual-refresh-btn');
if (btn) {{
const icon = btn.querySelector('svg') || btn.querySelector('i');
if (icon) icon.classList.add('animate-spin');
}}
window.location.reload();
}};
}})();
</script>
</body>
</html>
+123 -6
View File
@@ -118,8 +118,10 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
is_sleeping = open_sleep is not None
active_sleep_duration_str = ""
active_sleep_start_time = ""
active_sleep_iso = ""
wake_window_str = ""
last_wake_time_str = ""
last_wake_iso = ""
if is_sleeping:
dur = max(0, open_sleep[6] or 0)
@@ -127,6 +129,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
active_sleep_duration_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min"
s_dt = datetime.datetime.fromisoformat(str(open_sleep[2]))
active_sleep_start_time = s_dt.strftime("%H:%M")
active_sleep_iso = s_dt.isoformat()
else:
# Calculate current wake window from last closed sleep session
last_closed = con.execute(
@@ -143,6 +146,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
wake_window_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min"
e_dt = datetime.datetime.fromisoformat(str(last_closed[1]))
last_wake_time_str = e_dt.strftime("%H:%M")
last_wake_iso = e_dt.isoformat()
# 2. Today's sleep details
today_sessions = con.execute(
@@ -390,6 +394,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
dur_m = n[6] or 0
dh, dm = divmod(dur_m, 60)
dur_str = f"{dh} h {dm:02d} min" if dh > 0 else f"{dm} min"
dur_display = f'<span class="live-sleep-timer" data-start-iso="{active_sleep_iso}">{dur_str}</span>' if n[7] else f'<span>{dur_str}</span>'
note_str = f"· {n[5]}" if n[5] else ""
today_nights_html.append(f"""
<div class="flex items-center justify-between p-3 rounded-xl bg-zinc-900/60 border border-zinc-800/60">
@@ -402,7 +407,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
<div class="text-xs text-zinc-400">{note_str}</div>
</div>
</div>
<div class="text-right font-mono font-bold text-sky-400 text-sm">{dur_str}</div>
<div class="text-right font-mono font-bold text-sky-400 text-sm">{dur_display}</div>
</div>
""")
else:
@@ -505,6 +510,8 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="build-time" content="{now_berlin.isoformat()}">
<meta name="build-epoch" content="{int(now_berlin.timestamp())}">
<title>Mila — Schlaf & Entwicklung</title>
<!-- PWA & Mobile Web Capabilities -->
@@ -606,9 +613,12 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
</div>
</div>
<!-- Live State Badge -->
<div>
{'<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-purple-500/15 border border-purple-500/30 text-purple-300 text-xs font-semibold shadow-sm shadow-purple-500/10"><span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span><span>Schl\u00e4ft (' + active_sleep_duration_str + ')</span></div>' if is_sleeping else '<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs font-semibold shadow-sm shadow-emerald-500/10"><span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span><span>Wach (' + wake_window_str + ')</span></div>'}
<!-- Live State Badge & Manual Refresh Button -->
<div class="flex items-center gap-2">
{'<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-purple-500/15 border border-purple-500/30 text-purple-300 text-xs font-semibold shadow-sm shadow-purple-500/10"><span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span><span>Schl\u00e4ft (<span class="live-sleep-timer" data-start-iso="' + active_sleep_iso + '">' + active_sleep_duration_str + '</span>)</span></div>' if is_sleeping else '<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs font-semibold shadow-sm shadow-emerald-500/10"><span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span><span>Wach (<span class="live-wake-timer" data-end-iso="' + last_wake_iso + '">' + wake_window_str + '</span>)</span></div>'}
<button id="manual-refresh-btn" onclick="triggerManualRefresh()" title="Neu laden" class="p-2 rounded-xl bg-zinc-900/80 border border-zinc-800 text-zinc-400 hover:text-white hover:border-purple-500/40 hover:bg-zinc-800 transition">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
</div>
</div>
</header>
@@ -625,7 +635,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
<span>{'💤 Status: Schläft gerade' if is_sleeping else '☀️ Status: Wach & Munter'}</span>
</div>
<div class="text-3xl sm:text-4xl font-extrabold tracking-tight text-white font-heading">
{f'Seit {active_sleep_start_time} Uhr ({active_sleep_duration_str})' if is_sleeping else f'Wachfenster: {wake_window_str}'}
{f'Seit {active_sleep_start_time} Uhr (<span class="live-sleep-timer" data-start-iso="{active_sleep_iso}">{active_sleep_duration_str}</span>)' if is_sleeping else f'Wachfenster: <span class="live-wake-timer" data-end-iso="{last_wake_iso}">{wake_window_str}</span>'}
</div>
<p class="text-sm text-zinc-400">
{f'Eingeschlafen im Bett. Schlafintervall l\u00e4uft aktuell.' if is_sleeping else f'Letztes Schläfchen beendet um {last_wake_time_str} Uhr.'}
@@ -754,7 +764,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
<!-- Floating Refresh Footer -->
<footer class="max-w-4xl mx-auto px-4 text-center text-xs text-zinc-500 space-y-1">
<p>Aktualisiert: {now_berlin.strftime('%d.%m.%Y um %H:%M:%S')} Uhr · Single Source of Truth via DuckDB Datastore</p>
<p>Aktualisiert: <span id="build-time-display">{now_berlin.strftime('%d.%m.%Y um %H:%M:%S')} Uhr</span> · <button onclick="triggerManualRefresh()" class="text-purple-400 hover:text-purple-300 underline font-mono transition">Live Sync</button></p>
<p class="text-[11px]">Tipp: Im Safari-Menü auf „Zum Home-Bildschirm" tippen für die native App-Nutzung.</p>
</footer>
@@ -808,6 +818,113 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
const iosBanner = document.getElementById('ios-install-banner');
if (iosBanner) iosBanner.classList.add('hidden');
}}
// ==============================================================================
// Self-Updating Engine & Live Tickers
// ==============================================================================
(function() {{
const currentBuildEpoch = parseInt(document.querySelector('meta[name="build-epoch"]')?.content || '0', 10);
let lastCheckMs = Date.now();
let isChecking = false;
// 1. Live Running Sleep/Wake Timer (increments live in DOM every 5 seconds)
function tickLiveTimers() {{
document.querySelectorAll('.live-sleep-timer').forEach(el => {{
const iso = el.dataset.startIso;
if (!iso) return;
const startMs = new Date(iso).getTime();
if (isNaN(startMs)) return;
const diffMs = Math.max(0, Date.now() - startMs);
const mins = Math.floor(diffMs / 60000);
const h = Math.floor(mins / 60);
const m = mins % 60;
el.textContent = h > 0 ? (h + ' h ' + String(m).padStart(2, '0') + ' min') : (m + ' min');
}});
document.querySelectorAll('.live-wake-timer').forEach(el => {{
const iso = el.dataset.endIso;
if (!iso) return;
const endMs = new Date(iso).getTime();
if (isNaN(endMs)) return;
const diffMs = Math.max(0, Date.now() - endMs);
const mins = Math.floor(diffMs / 60000);
const h = Math.floor(mins / 60);
const m = mins % 60;
el.textContent = h > 0 ? (h + ' h ' + String(m).padStart(2, '0') + ' min') : (m + ' min');
}});
}}
setInterval(tickLiveTimers, 5000);
tickLiveTimers();
// 2. Server Update Checker
async function checkForUpdates() {{
if (isChecking) return;
isChecking = true;
try {{
const res = await fetch(window.location.pathname + '?_poll=' + Date.now(), {{
cache: 'no-store',
headers: {{ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }}
}});
if (res.ok) {{
const html = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const remoteEpoch = parseInt(doc.querySelector('meta[name="build-epoch"]')?.content || '0', 10);
if (remoteEpoch && currentBuildEpoch && remoteEpoch > currentBuildEpoch) {{
console.log('[AutoUpdate] Server has newer build (' + remoteEpoch + ' > ' + currentBuildEpoch + '). Reloading...');
window.location.reload();
return;
}}
}}
}} catch (err) {{
console.warn('[AutoUpdate] Check failed:', err);
}} finally {{
isChecking = false;
lastCheckMs = Date.now();
}}
}}
// 3. Auto-update on reopen / foreground resume (PWA & Mobile Safari)
document.addEventListener('visibilitychange', () => {{
if (document.visibilityState === 'visible') {{
tickLiveTimers();
if (Date.now() - lastCheckMs > 10000) {{
checkForUpdates();
}}
}}
}});
window.addEventListener('pageshow', (e) => {{
tickLiveTimers();
if (e.persisted || Date.now() - lastCheckMs > 10000) {{
checkForUpdates();
}}
}});
window.addEventListener('focus', () => {{
tickLiveTimers();
if (Date.now() - lastCheckMs > 15000) {{
checkForUpdates();
}}
}});
// 4. Background check every 30 seconds when visible
setInterval(() => {{
if (document.visibilityState === 'visible') {{
checkForUpdates();
}}
}}, 30000);
// 5. Manual refresh trigger
window.triggerManualRefresh = function() {{
const btn = document.getElementById('manual-refresh-btn');
if (btn) {{
const icon = btn.querySelector('svg') || btn.querySelector('i');
if (icon) icon.classList.add('animate-spin');
}}
window.location.reload();
}};
}})();
</script>
</body>
</html>