feat(pwa): add auto-update on reopen, live tickers, and manual refresh to dashboards
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user