diff --git a/skills/datastore/assets/mila-apple-touch-icon.png b/skills/datastore/assets/mila-apple-touch-icon.png new file mode 100644 index 0000000..04807c6 Binary files /dev/null and b/skills/datastore/assets/mila-apple-touch-icon.png differ diff --git a/skills/datastore/assets/mila-icon-192.png b/skills/datastore/assets/mila-icon-192.png new file mode 100644 index 0000000..81ce8cc Binary files /dev/null and b/skills/datastore/assets/mila-icon-192.png differ diff --git a/skills/datastore/assets/mila-icon-512.png b/skills/datastore/assets/mila-icon-512.png new file mode 100644 index 0000000..ed8f9f1 Binary files /dev/null and b/skills/datastore/assets/mila-icon-512.png differ diff --git a/skills/datastore/assets/mila-icon.svg b/skills/datastore/assets/mila-icon.svg new file mode 100644 index 0000000..8d0b57b --- /dev/null +++ b/skills/datastore/assets/mila-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + 🧸 + diff --git a/skills/datastore/assets/mila.webmanifest b/skills/datastore/assets/mila.webmanifest new file mode 100644 index 0000000..f7e492d --- /dev/null +++ b/skills/datastore/assets/mila.webmanifest @@ -0,0 +1,35 @@ +{ + "name": "Mila — Schlaf & Entwicklung", + "short_name": "Mila", + "description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf", + "start_url": "/family/mila.html", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#090A0F", + "theme_color": "#090A0F", + "icons": [ + { + "src": "/family/mila-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/family/mila-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/family/mila-apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "/family/mila-icon.svg", + "sizes": "any", + "type": "image/svg+xml" + } + ] +} diff --git a/skills/datastore/assets/sw.js b/skills/datastore/assets/sw.js new file mode 100644 index 0000000..903d71f --- /dev/null +++ b/skills/datastore/assets/sw.js @@ -0,0 +1,26 @@ +// Minimal service worker for Mila PWA installability & offline resilience +const CACHE_NAME = 'mila-v1'; + +self.addEventListener('install', (event) => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.map((key) => { + if (key !== CACHE_NAME) return caches.delete(key); + }) + ) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') return; + // Network-first strategy: Always fetch live real-time updates from MinIO + event.respondWith( + fetch(event.request).catch(() => caches.match(event.request)) + ); +}); diff --git a/skills/datastore/scripts/update_website.py b/skills/datastore/scripts/update_website.py index 6722673..3c769af 100644 --- a/skills/datastore/scripts/update_website.py +++ b/skills/datastore/scripts/update_website.py @@ -447,23 +447,45 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: """ - # PWA Manifest as inline Data URI + # PWA Manifest (RFC-compliant base64 data URI + external webmanifest) + import base64 manifest_dict = { - "name": "Mila Tracker", + "name": "Mila — Schlaf & Entwicklung", "short_name": "Mila", + "description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf", "start_url": "/family/mila.html", + "scope": "/", "display": "standalone", + "orientation": "portrait-primary", "background_color": "#090A0F", "theme_color": "#090A0F", "icons": [ { - "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='48' fill='%237C3AED'/%3E%3Ctext x='50' y='65' font-size='42' font-family='sans-serif' text-anchor='middle' fill='white'%3E%F0%9F%A7%B8%3C/text%3E%3C/svg%3E", - "sizes": "192x192 512x512", + "src": "/family/mila-icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/family/mila-icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/family/mila-apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "/family/mila-icon.svg", + "sizes": "any", "type": "image/svg+xml" } ] } - manifest_uri = "data:application/manifest+json," + json.dumps(manifest_dict) + 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""" @@ -475,11 +497,20 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: + + + + + - + + + + + @@ -505,6 +536,42 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: + + + + + +
@@ -683,6 +750,52 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: document.addEventListener('DOMContentLoaded', () => {{ lucide.createIcons(); }}); + + // Service Worker Registration for PWA installability + if ('serviceWorker' in navigator) {{ + window.addEventListener('load', () => {{ + navigator.serviceWorker.register('/family/sw.js').catch(() => {{ + navigator.serviceWorker.register('sw.js').catch(console.error); + }}); + }}); + }} + + // Chrome/Android PWA BeforeInstallPrompt + let deferredInstallPrompt = null; + window.addEventListener('beforeinstallprompt', (e) => {{ + e.preventDefault(); + deferredInstallPrompt = e; + const banner = document.getElementById('pwa-install-banner'); + if (banner) banner.classList.remove('hidden'); + }}); + + 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; + }}); + }} + + // 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'); + }} + + function dismissIosBanner() {{ + localStorage.setItem('dismissed_ios_mila_pwa', 'true'); + const iosBanner = document.getElementById('ios-install-banner'); + if (iosBanner) iosBanner.classList.add('hidden'); + }} @@ -690,19 +803,54 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: return html -def publish_to_s3(html_content: str) -> str: - """Uploads the rendered HTML to MinIO hosting bucket at family/mila.html.""" +def publish_to_s3(html_content: str, data_dir: Optional[Path] = None) -> str: + """Uploads the rendered HTML, webmanifest, service worker and icons to MinIO hosting bucket.""" s3 = get_s3_client() - key = "family/mila.html" + assets_dir = SCRIPT_DIR.parent / "assets" - s3.put_object( - Bucket=DEFAULT_BUCKET, - Key=key, - Body=html_content.encode("utf-8"), - ContentType="text/html; charset=utf-8", - CacheControl="no-cache, no-store, must-revalidate", - ) - url = f"{DEFAULT_BASE_URL}/{key}" + # HTML files (both in family/mila.html and mila.html) + for key in ["family/mila.html", "mila.html"]: + s3.put_object( + Bucket=DEFAULT_BUCKET, + Key=key, + Body=html_content.encode("utf-8"), + ContentType="text/html; charset=utf-8", + CacheControl="no-cache, no-store, must-revalidate", + ) + + # Static PWA Assets to upload + static_assets = [ + ("mila.webmanifest", "application/manifest+json; charset=utf-8", "no-cache, no-store, must-revalidate"), + ("sw.js", "application/javascript; charset=utf-8", "no-cache, no-store, must-revalidate"), + ("mila-icon-192.png", "image/png", "public, max-age=604800"), + ("mila-icon-512.png", "image/png", "public, max-age=604800"), + ("mila-apple-touch-icon.png", "image/png", "public, max-age=604800"), + ("mila-icon.svg", "image/svg+xml", "public, max-age=604800"), + ] + + for fname, ctype, cache_ctrl in static_assets: + candidates = [assets_dir / fname] + if data_dir: + candidates.extend([data_dir.parent / "public" / "family" / fname, data_dir.parent / "public" / fname]) + body_bytes = None + for candidate in candidates: + if candidate.is_file(): + body_bytes = candidate.read_bytes() + break + if body_bytes: + for prefix in ["family/", ""]: + try: + s3.put_object( + Bucket=DEFAULT_BUCKET, + Key=f"{prefix}{fname}", + Body=body_bytes, + ContentType=ctype, + CacheControl=cache_ctrl, + ) + except Exception as ex: + print(f"Warning: could not upload {prefix}{fname}: {ex}", file=sys.stderr) + + url = f"{DEFAULT_BASE_URL}/family/mila.html" return url @@ -719,17 +867,25 @@ def main(): print("Building Mila Dashboard from DuckDB...") html = generate_dashboard_html(engine) - # 1. Save locally to knowledge-wiki/public/family/mila.html (or specified file) - wiki_public = data_dir.parent / "public" / "family" - wiki_public.mkdir(parents=True, exist_ok=True) - out_file = Path(args.output_file) if args.output_file else (wiki_public / "mila.html") + # 1. Save locally to knowledge-wiki/public/family/mila.html AND public/mila.html + wiki_public_family = data_dir.parent / "public" / "family" + wiki_public_family.mkdir(parents=True, exist_ok=True) + out_file = Path(args.output_file) if args.output_file else (wiki_public_family / "mila.html") out_file.write_text(html, encoding="utf-8") print(f"✅ Saved locally: {out_file}") + # Mirror to public/mila.html + root_mila = data_dir.parent / "public" / "mila.html" + try: + root_mila.write_text(html, encoding="utf-8") + print(f"✅ Synced locally: {root_mila}") + except Exception as e: + print(f"Warning: could not sync to {root_mila}: {e}", file=sys.stderr) + # 2. Upload to S3 hosting if not args.skip_upload: try: - url = publish_to_s3(html) + url = publish_to_s3(html, data_dir) print(f"🚀 Published successfully to: {url}") except Exception as e: print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr)