diff --git a/skills/datastore/assets/mila.webmanifest b/skills/datastore/assets/mila.webmanifest index 7cd7061..58fe1ae 100644 --- a/skills/datastore/assets/mila.webmanifest +++ b/skills/datastore/assets/mila.webmanifest @@ -1,4 +1,5 @@ { + "id": "/family/mila.html", "name": "Mila — Schlaf & Entwicklung", "short_name": "Mila", "description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf", @@ -13,13 +14,25 @@ "src": "/family/mila-icon-192.png", "sizes": "192x192", "type": "image/png", - "purpose": "any maskable" + "purpose": "any" + }, + { + "src": "/family/mila-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" }, { "src": "/family/mila-icon-512.png", "sizes": "512x512", "type": "image/png", - "purpose": "any maskable" + "purpose": "any" + }, + { + "src": "/family/mila-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" }, { "src": "/family/mila-apple-touch-icon.png", diff --git a/skills/datastore/assets/moritz.webmanifest b/skills/datastore/assets/moritz.webmanifest new file mode 100644 index 0000000..61b9109 --- /dev/null +++ b/skills/datastore/assets/moritz.webmanifest @@ -0,0 +1,20 @@ +{ + "id": "/family/moritz.html", + "name": "Moritz Health", + "short_name": "Health", + "description": "Persönliches Dashboard für Kalorienbilanz, Ernährung und Garmin-Fitness von Moritz.", + "start_url": "/family/moritz.html", + "scope": "/family/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#f8fafc", + "theme_color": "#ffffff", + "icons": [ + { + "src": "data:image/svg+xml;utf8,", + "sizes": "192x192 512x512", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/skills/datastore/scripts/update_moritz_website.py b/skills/datastore/scripts/update_moritz_website.py index 20ac5bb..3d30cde 100644 --- a/skills/datastore/scripts/update_moritz_website.py +++ b/skills/datastore/scripts/update_moritz_website.py @@ -82,8 +82,8 @@ def get_s3_client(): ) -def publish_to_s3(html_content: str, key: str = "moritz.html") -> str: - """Uploads the rendered HTML to MinIO hosting bucket.""" +def publish_to_s3(html_content: str, key: str = "family/moritz.html", data_dir: Optional[Path] = None) -> str: + """Uploads the rendered HTML and webmanifest to MinIO hosting bucket.""" s3 = get_s3_client() s3.put_object( Bucket=DEFAULT_BUCKET, @@ -92,6 +92,22 @@ def publish_to_s3(html_content: str, key: str = "moritz.html") -> str: ContentType="text/html; charset=utf-8", CacheControl="no-cache, no-store, must-revalidate", ) + # Also upload moritz.webmanifest if present + assets_dir = SCRIPT_DIR.parent / "assets" + manifest_file = assets_dir / "moritz.webmanifest" + if not manifest_file.is_file() and data_dir: + manifest_file = data_dir.parent / "public" / "family" / "moritz.webmanifest" + if manifest_file.is_file(): + try: + s3.put_object( + Bucket=DEFAULT_BUCKET, + Key="family/moritz.webmanifest", + Body=manifest_file.read_bytes(), + ContentType="application/manifest+json; charset=utf-8", + CacheControl="no-cache, no-store, must-revalidate", + ) + except Exception as ex: + print(f"Warning: could not upload family/moritz.webmanifest: {ex}", file=sys.stderr) return f"{DEFAULT_BASE_URL}/{key}" @@ -546,7 +562,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: - + @@ -1231,10 +1247,10 @@ def main(): # 2. Upload strictly to protected S3 hosting under /family/ if not args.skip_upload: try: - url = publish_to_s3(html, key="family/moritz.html") + url = publish_to_s3(html, key="family/moritz.html", data_dir=data_dir) print(f"🚀 Published successfully to: {url}") # Also publish to /family/nutrition.html for backwards compatibility - publish_to_s3(html, key="family/nutrition.html") + publish_to_s3(html, key="family/nutrition.html", data_dir=data_dir) except Exception as e: print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr) diff --git a/skills/datastore/scripts/update_website.py b/skills/datastore/scripts/update_website.py index d073ebc..66f58ae 100644 --- a/skills/datastore/scripts/update_website.py +++ b/skills/datastore/scripts/update_website.py @@ -464,9 +464,9 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: """ - # PWA Manifest (RFC-compliant base64 data URI + external webmanifest) - import base64 + # PWA Manifest manifest_dict = { + "id": "/family/mila.html", "name": "Mila — Schlaf & Entwicklung", "short_name": "Mila", "description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf", @@ -481,13 +481,25 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: "src": "/family/mila-icon-192.png", "sizes": "192x192", "type": "image/png", - "purpose": "any maskable" + "purpose": "any" + }, + { + "src": "/family/mila-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" }, { "src": "/family/mila-icon-512.png", "sizes": "512x512", "type": "image/png", - "purpose": "any maskable" + "purpose": "any" + }, + { + "src": "/family/mila-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" }, { "src": "/family/mila-apple-touch-icon.png", @@ -501,8 +513,6 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: } ] } - manifest_json = json.dumps(manifest_dict) - manifest_uri = f"data:application/manifest+json;base64,{base64.b64encode(manifest_json.encode('utf-8')).decode('utf-8')}" # Full HTML Document html = f""" @@ -523,8 +533,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: - - + @@ -615,9 +624,13 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: - +
{'
Schl\u00e4ft (' + active_sleep_duration_str + ')
' if is_sleeping else '
Wach (' + wake_window_str + ')
'} + @@ -775,44 +788,54 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: lucide.createIcons(); }}); - // Service Worker Registration for PWA installability + // Service Worker Registration for PWA installability & offline support if ('serviceWorker' in navigator) {{ window.addEventListener('load', () => {{ - navigator.serviceWorker.register('/family/sw.js').catch(() => {{ - navigator.serviceWorker.register('sw.js').catch(console.error); - }}); + 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)); }}); }} - // Chrome/Android PWA BeforeInstallPrompt + // Chrome/Android/Desktop PWA BeforeInstallPrompt & iOS Detection let deferredInstallPrompt = null; + const isIos = (/iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) && !window.MSStream; + const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches; + window.addEventListener('beforeinstallprompt', (e) => {{ e.preventDefault(); deferredInstallPrompt = e; const banner = document.getElementById('pwa-install-banner'); - if (banner) banner.classList.remove('hidden'); + if (banner && !isStandalone) banner.classList.remove('hidden'); + const headerBtn = document.getElementById('header-pwa-install-btn'); + if (headerBtn && !isStandalone) headerBtn.classList.remove('hidden'); + }}); + + window.addEventListener('appinstalled', () => {{ + console.log('[PWA] App successfully installed'); + const banner = document.getElementById('pwa-install-banner'); + if (banner) banner.classList.add('hidden'); + const headerBtn = document.getElementById('header-pwa-install-btn'); + if (headerBtn) headerBtn.classList.add('hidden'); + deferredInstallPrompt = null; }}); const installBtn = document.getElementById('pwa-install-btn'); if (installBtn) {{ - installBtn.addEventListener('click', async () => {{ - if (!deferredInstallPrompt) return; - deferredInstallPrompt.prompt(); - const {{ outcome }} = await deferredInstallPrompt.userChoice; - if (outcome === 'accepted') {{ - const banner = document.getElementById('pwa-install-banner'); - if (banner) banner.classList.add('hidden'); - }} - deferredInstallPrompt = null; + installBtn.addEventListener('click', () => {{ + triggerPwaInstall(); }}); }} // iOS Safari Detection & Guidance Banner - const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; - const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches; - if (isIos && !isStandalone && !localStorage.getItem('dismissed_ios_mila_pwa')) {{ - const iosBanner = document.getElementById('ios-install-banner'); - if (iosBanner) iosBanner.classList.remove('hidden'); + if (isIos && !isStandalone) {{ + const headerBtn = document.getElementById('header-pwa-install-btn'); + if (headerBtn) headerBtn.classList.remove('hidden'); + + if (!localStorage.getItem('dismissed_ios_mila_pwa')) {{ + const iosBanner = document.getElementById('ios-install-banner'); + if (iosBanner) iosBanner.classList.remove('hidden'); + }} }} function dismissIosBanner() {{ @@ -821,6 +844,31 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: if (iosBanner) iosBanner.classList.add('hidden'); }} + window.triggerPwaInstall = function() {{ + if (deferredInstallPrompt) {{ + deferredInstallPrompt.prompt(); + deferredInstallPrompt.userChoice.then(({{ outcome }}) => {{ + if (outcome === 'accepted') {{ + const banner = document.getElementById('pwa-install-banner'); + if (banner) banner.classList.add('hidden'); + const headerBtn = document.getElementById('header-pwa-install-btn'); + if (headerBtn) headerBtn.classList.add('hidden'); + }} + deferredInstallPrompt = null; + }}); + }} else if (isIos) {{ + const iosBanner = document.getElementById('ios-install-banner'); + if (iosBanner) {{ + iosBanner.classList.remove('hidden'); + iosBanner.scrollIntoView({{ behavior: 'smooth' }}); + }} else {{ + alert("Als App nutzen: Im Safari-Menü auf 'Teilen' [↑] tippen und 'Zum Home-Bildschirm' (+) wählen."); + }} + }} else {{ + alert("Zum Installieren: Im Browser-Menü auf 'App installieren' oder das Installations-Symbol in der Adressleiste tippen."); + }} + }}; + // ============================================================================== // Self-Updating Engine & Live Tickers // ==============================================================================