From 02f49e55f8324d96811cd3ef860484af11cc9534 Mon Sep 17 00:00:00 2001 From: Nate Date: Mon, 16 Mar 2026 10:12:23 -0400 Subject: [PATCH] Add PWA support (manifest, service worker, icons, safe-area CSS) --- app/public/icons/icon.svg | 12 +++++++ app/public/index.html | 15 ++++++++- app/public/manifest.json | 20 ++++++++++++ app/public/style.css | 34 +++++++++++++++++++- app/public/sw.js | 66 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 app/public/icons/icon.svg create mode 100644 app/public/manifest.json create mode 100644 app/public/sw.js diff --git a/app/public/icons/icon.svg b/app/public/icons/icon.svg new file mode 100644 index 0000000..7481f76 --- /dev/null +++ b/app/public/icons/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/public/index.html b/app/public/index.html index 0040086..6e06197 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -2,8 +2,16 @@ - + Weather + + + + + + + + @@ -134,5 +142,10 @@ + diff --git a/app/public/manifest.json b/app/public/manifest.json new file mode 100644 index 0000000..d402ecc --- /dev/null +++ b/app/public/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "Weather", + "short_name": "Weather", + "description": "Self-hosted weather app with animated radar. No API keys required.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#191510", + "theme_color": "#1a1a1a", + "orientation": "portrait-primary", + "categories": ["weather", "utilities"], + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/app/public/style.css b/app/public/style.css index b905f45..295f0bb 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -454,6 +454,38 @@ a:hover { text-decoration: underline; } padding-top: 0.75rem; } +/* ── PWA: safe area insets (iPhone notch / home bar) ── */ +@supports (padding: env(safe-area-inset-bottom)) { + .app { + padding-left: max(1.5rem, calc(1rem + env(safe-area-inset-left))); + padding-right: max(1.5rem, calc(1rem + env(safe-area-inset-right))); + padding-bottom: max(4rem, calc(2rem + env(safe-area-inset-bottom))); + } + .radar-map-full { + height: calc(100vh - 280px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + } +} + +/* ── PWA: status bar area when running standalone ── */ +@media (display-mode: standalone) { + .masthead { padding-top: env(safe-area-inset-top); } +} + +/* ── Touch-friendly tap targets (min 44 × 44 px) ── */ +.tab { + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.search-row input, +.search-row button { min-height: 44px; } +.radar-btn { width: 44px; height: 44px; font-size: 0.8rem; } +.tl-tick { min-height: 36px; } + +/* iOS momentum scrolling */ +.hourly-scroll { -webkit-overflow-scrolling: touch; } + /* ── Leaflet overrides ── */ .leaflet-container { background: #1a1f2e; } .leaflet-control-zoom a { @@ -464,7 +496,7 @@ a:hover { text-decoration: underline; } /* ── Responsive ── */ @media (max-width: 640px) { - .app { padding: 1.25rem 1rem 3rem; } + .app { padding: 1.25rem 1rem max(3rem, calc(1.5rem + env(safe-area-inset-bottom, 0px))); } .masthead { grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; } .masthead h1 { font-size: 1.6rem; } .tagline { display: none; } diff --git a/app/public/sw.js b/app/public/sw.js new file mode 100644 index 0000000..8019805 --- /dev/null +++ b/app/public/sw.js @@ -0,0 +1,66 @@ +'use strict'; + +const CACHE = 'weather-v1'; + +// App shell — cached on install for offline use +const PRECACHE = [ + '/', + '/app.js', + '/style.css', + '/manifest.json', + '/icons/icon.svg' +]; + +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CACHE) + .then(cache => cache.addAll(PRECACHE)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all( + keys.filter(k => k !== CACHE).map(k => caches.delete(k)) + )) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', event => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET, API calls, and map/radar tile requests + if ( + request.method !== 'GET' || + url.pathname.startsWith('/api/') || + url.hostname.includes('rainviewer') || + url.hostname.includes('openstreetmap') || + url.hostname.includes('cartodb') || + url.hostname.includes('basemaps') + ) { + return; + } + + event.respondWith( + caches.match(request).then(cached => { + if (cached) return cached; + return fetch(request).then(response => { + // Only cache successful same-origin and trusted CDN responses + if (response.ok && ( + url.origin === self.location.origin || + url.hostname.includes('googleapis') || + url.hostname.includes('gstatic') || + url.hostname.includes('unpkg.com') + )) { + const clone = response.clone(); + caches.open(CACHE).then(cache => cache.put(request, clone)); + } + return response; + }); + }) + ); +});