feat: add iOS-focused PWA shell with service worker and manifest

This commit is contained in:
Oliver 2026-02-15 16:12:04 +01:00
parent 56b8825ca5
commit fea5fe9cbb
No known key found for this signature in database
9 changed files with 176 additions and 3 deletions

View file

@ -10,6 +10,7 @@ Implementiert:
- API: `GET /spot/score`, `POST /spot/signal` - API: `GET /spot/score`, `POST /spot/signal`
- Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche - Kartenwahl via OpenStreetMap (Leaflet), inkl. Klickauswahl und Ortssuche
- API: `GET /geocode/search` (Nominatim-Proxy), `GET /map/tile/{z}/{x}/{y}.png` (OSM-Tile-Proxy) - API: `GET /geocode/search` (Nominatim-Proxy), `GET /map/tile/{z}/{x}/{y}.png` (OSM-Tile-Proxy)
- PWA-Basis (Manifest, Service Worker, iOS-Standalone-Meta, Offline-Shell)
- Admin-Bereich (Setup/Login, geschuetzt per User/Passwort + Session-Token) - Admin-Bereich (Setup/Login, geschuetzt per User/Passwort + Session-Token)
- Admin-API fuer Uebersicht und Event-Verwaltung (`/admin/*`) - Admin-API fuer Uebersicht und Event-Verwaltung (`/admin/*`)
- Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash - Anti-Spam ohne Account: lokaler Token + serverseitiger HMAC-Hash
@ -43,6 +44,9 @@ Nicht im MVP:
- `python3 -m http.server 8080` - `python3 -m http.server 8080`
7. App oeffnen: 7. App oeffnen:
- `http://localhost:8080` - `http://localhost:8080`
8. PWA-Test:
- iOS Safari: Seite aufrufen -> Teilen -> "Zum Home-Bildschirm"
- Danach aus Home-Screen starten (Standalone-Modus)
## OpenData Connector Config ## OpenData Connector Config

View file

@ -2,8 +2,15 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#006680" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="StaySense" />
<title>Datenschutz | StaySense</title> <title>Datenschutz | StaySense</title>
<link rel="manifest" href="manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="icons/icon.svg" />
<link rel="apple-touch-icon" href="icons/icon.svg" />
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body> <body>
@ -105,6 +112,7 @@
</section> </section>
</main> </main>
<script src="pwa.js"></script>
<script> <script>
document.getElementById("privacy-date").textContent = new Date().toLocaleDateString("de-DE"); document.getElementById("privacy-date").textContent = new Date().toLocaleDateString("de-DE");
</script> </script>

11
src/icons/icon.svg Normal file
View file

@ -0,0 +1,11 @@
<svg xmlns='http://www.w3.org/2000/svg' width='512' height='512' viewBox='0 0 512 512'>
<defs>
<linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>
<stop offset='0' stop-color='#006680'/>
<stop offset='1' stop-color='#1689a8'/>
</linearGradient>
</defs>
<rect width='512' height='512' rx='96' fill='url(#g)'/>
<circle cx='256' cy='256' r='132' fill='none' stroke='#ffffff' stroke-width='30'/>
<path d='M170 314 L232 206 L284 276 L342 182' fill='none' stroke='#ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='28'/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

View file

@ -2,8 +2,15 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#006680" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="StaySense" />
<title>StaySense NRW MVP</title> <title>StaySense NRW MVP</title>
<link rel="manifest" href="manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="icons/icon.svg" />
<link rel="apple-touch-icon" href="icons/icon.svg" />
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
<link rel="stylesheet" href="vendor/leaflet/leaflet.css" /> <link rel="stylesheet" href="vendor/leaflet/leaflet.css" />
</head> </head>
@ -14,6 +21,7 @@
<header class="top"> <header class="top">
<h1>StaySense</h1> <h1>StaySense</h1>
<p>Kreis Mettmann Pilot: ruhige Nacht in weniger als 10 Sekunden bewerten.</p> <p>Kreis Mettmann Pilot: ruhige Nacht in weniger als 10 Sekunden bewerten.</p>
<small id="ios-install-hint" class="small hidden"></small>
<small id="network-status">Netzwerkstatus wird geprüft ...</small> <small id="network-status">Netzwerkstatus wird geprüft ...</small>
<small id="data-status">Datenstand: -</small> <small id="data-status">Datenstand: -</small>
</header> </header>
@ -190,6 +198,7 @@
</section> </section>
</main> </main>
<script src="pwa.js"></script>
<script src="vendor/leaflet/leaflet.js"></script> <script src="vendor/leaflet/leaflet.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>

19
src/manifest.webmanifest Normal file
View file

@ -0,0 +1,19 @@
{
"name": "StaySense NRW",
"short_name": "StaySense",
"description": "Night Safety Score für ruhige Nächte (22-06 Uhr) in NRW.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#f3f7f6",
"theme_color": "#006680",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

16
src/pwa.js Normal file
View file

@ -0,0 +1,16 @@
(function () {
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js").catch(() => {});
});
}
const isIos = /iphone|ipad|ipod/i.test(window.navigator.userAgent);
const isStandalone =
window.matchMedia("(display-mode: standalone)").matches || Boolean(window.navigator.standalone);
const hintEl = document.getElementById("ios-install-hint");
if (hintEl && isIos && !isStandalone) {
hintEl.textContent = "Tipp: Über Teilen > Zum Home-Bildschirm hinzufügen für App-Modus auf iOS.";
hintEl.classList.remove("hidden");
}
})();

View file

@ -2,8 +2,15 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#006680" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="StaySense" />
<title>StaySense Quellen & Attribution</title> <title>StaySense Quellen & Attribution</title>
<link rel="manifest" href="manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="icons/icon.svg" />
<link rel="apple-touch-icon" href="icons/icon.svg" />
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body> <body>
@ -72,6 +79,7 @@
</section> </section>
</main> </main>
<script src="pwa.js"></script>
<script> <script>
const DEFAULT_API_BASE = const DEFAULT_API_BASE =
window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"

88
src/service-worker.js Normal file
View file

@ -0,0 +1,88 @@
const SW_VERSION = "staysense-v1.0.0";
const CORE_CACHE = `${SW_VERSION}-core`;
const RUNTIME_CACHE = `${SW_VERSION}-runtime`;
const CORE_ASSETS = [
"/",
"/index.html",
"/styles.css",
"/app.js",
"/pwa.js",
"/manifest.webmanifest",
"/icons/icon.svg",
"/vendor/leaflet/leaflet.css",
"/vendor/leaflet/leaflet.js",
"/datenschutz.html",
"/quellen.html",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CORE_CACHE).then((cache) => cache.addAll(CORE_ASSETS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CORE_CACHE && key !== RUNTIME_CACHE)
.map((key) => caches.delete(key))
)
).then(() => self.clients.claim())
);
});
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
const runtime = await caches.open(RUNTIME_CACHE);
runtime.put(request, response.clone());
return response;
}
async function networkFirst(request) {
try {
const response = await fetch(request);
const runtime = await caches.open(RUNTIME_CACHE);
runtime.put(request, response.clone());
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
throw new Error("offline");
}
}
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
// Cache map tiles and static assets aggressively.
if (url.pathname.startsWith("/api/map/tile/")) {
event.respondWith(cacheFirst(event.request));
return;
}
if (url.pathname.startsWith("/vendor/") || url.pathname.endsWith(".css") || url.pathname.endsWith(".js") || url.pathname.endsWith(".svg")) {
event.respondWith(cacheFirst(event.request));
return;
}
// Keep score/health API usable in flaky networks.
if (url.pathname === "/api/health" || url.pathname.startsWith("/api/spot/score")) {
event.respondWith(networkFirst(event.request));
return;
}
// App shell pages: network first with offline fallback.
if (url.pathname === "/" || url.pathname.endsWith(".html")) {
event.respondWith(
networkFirst(event.request).catch(async () => {
const fallback = await caches.match("/index.html");
return fallback || new Response("Offline", { status: 503, statusText: "Offline" });
})
);
}
});

View file

@ -20,6 +20,11 @@ body {
font-family: "Avenir Next", "Segoe UI", sans-serif; font-family: "Avenir Next", "Segoe UI", sans-serif;
color: var(--ink); color: var(--ink);
background: linear-gradient(160deg, #f7fbfa 0%, #edf3fb 100%); background: linear-gradient(160deg, #f7fbfa 0%, #edf3fb 100%);
-webkit-text-size-adjust: 100%;
padding-top: env(safe-area-inset-top);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
} }
.background { .background {
@ -72,6 +77,11 @@ body {
color: #486270; color: #486270;
} }
#ios-install-hint {
display: block;
color: #486270;
}
.grid { .grid {
max-width: 1120px; max-width: 1120px;
margin: 8px auto 32px; margin: 8px auto 32px;