diff --git a/.gitignore b/.gitignore
index b070237..a669939 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
.env
data/
+public/assets/*
+!public/assets/.gitkeep
__pycache__/
*.py[cod]
*.egg-info/
@@ -7,3 +9,5 @@ __pycache__/
venv/
.DS_Store
*.egg-info/
+public/assets/*
+!public/assets/.gitkeep
diff --git a/Dockerfile b/Dockerfile
index c5e4e1e..3382e66 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,9 +9,11 @@ COPY pyproject.toml /app/pyproject.toml
COPY server /app/server
RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir .
-COPY public /app/public
-COPY meds.yaml messages.yaml /app/
+COPY frontend /app/frontend
COPY tools /app/tools
+RUN python tools/build_frontend.py
+
+COPY meds.yaml messages.yaml /app/
EXPOSE 8000
diff --git a/README.md b/README.md
index cbe17dc..5ebb04e 100644
--- a/README.md
+++ b/README.md
@@ -59,11 +59,14 @@ pip install -e .
cp .env.example .env
# APP_PIN und JWT_SECRET anpassen
-# 3. VAPID keys (für Push)
+# 3. Frontend bauen (gehashte Assets)
+python tools/build_frontend.py
+
+# 4. VAPID keys (für Push)
python tools/gen_vapid.py
# Output in .env eintragen
-# 4. Starten
+# 5. Starten
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
```
@@ -190,14 +193,15 @@ Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
```
takeyourmeds/
+├── frontend/ # PWA-Quellen (JS, CSS, Templates)
├── compose.yml # Docker + Traefik
├── Dockerfile
├── meds.yaml # Medikamenten-Zeitplan
├── messages.yaml # Humor-Pool
├── icon.png / icon.svg # App-Icons (Quelle)
├── server/ # FastAPI Backend
-├── public/ # PWA Frontend
-├── tools/ # schema.sql, gen_vapid.py
+├── public/ # Build-Output (index.html, sw.js, assets/)
+├── tools/ # schema.sql, gen_vapid.py, build_frontend.py
└── data/ # SQLite (gitignored)
```
@@ -207,10 +211,13 @@ takeyourmeds/
```bash
source .venv/bin/activate
+python tools/build_frontend.py # nach Änderungen in frontend/
uvicorn server.app:app --reload --port 8000
```
-Service Worker cached aggressiv — für SW-Änderungen: DevTools → Application → Clear storage, oder Inkognito.
+Quellen liegen in `frontend/`; Build-Output in `public/` (gehashte JS/CSS unter `public/assets/`). PWA-Update-Strategie: `docs/PWA-STRATEGY.md`.
+
+Service Worker: bei Deploy neuer `CACHE`-Name automatisch via Build. Update-Banner erscheint bei veralteter Client-Version.
---
diff --git a/docs/PWA-STRATEGY.md b/docs/PWA-STRATEGY.md
new file mode 100644
index 0000000..4628bfe
--- /dev/null
+++ b/docs/PWA-STRATEGY.md
@@ -0,0 +1,56 @@
+# PWA Update-Strategie
+
+Wiederverwendbare Strategie für Fränkys PWAs — damit Nutzer:innen **ohne Cache leeren** immer die aktuelle Version sehen.
+
+> Gitea-Wiki: manuell anlegen unter [PWA-Update-Strategie](https://gitea.schwenk.online/froxxxy/takeyourmeds/wiki/_pages) (MCP-Token hatte keine Wiki-Schreibrechte).
+
+## Warum?
+
+- Viele Nutzer:innen wissen nicht, was „Cache leeren" bedeutet (besonders auf dem Handy / als installierte PWA).
+- Service Worker können alte Dateien **monatelang** vorhalten — ein Deploy reicht nicht.
+- Ziel: Updates **sichtbar und kontrolliert** (Banner + Reload), nicht stilles Veralten.
+
+## Drei Ebenen (Tiers)
+
+### Tier 1 — Service Worker & Client
+
+| Element | Strategie |
+|---------|-----------|
+| `sw.js` | Nicht vom SW intercepten; Server: `Cache-Control: no-cache` |
+| `index.html` | Network-first |
+| Unversioniertes JS/CSS | Network-first |
+| Cache-Name | Pro Build: `tym-v{version}-{buildHash}` |
+| `version.json` | Network-only im SW; Client-Check beim Start/Focus |
+| Update-Banner | `buildHash` ≠ `meta app-build` → „Aktualisieren" |
+
+### Tier 2 — Server & Lifecycle
+
+| Element | Maßnahme |
+|---------|----------|
+| HTTP-Header | `server/static_cache.py` Middleware |
+| Focus | `registration.update()` + `version.json` Check |
+| `controllerchange` | Einmalig `location.reload()` |
+
+### Tier 3 — Build
+
+| Element | Maßnahme |
+|---------|----------|
+| Quellen | `frontend/` |
+| Output | `public/assets/*.{hash}.js/css` |
+| Build | `python tools/build_frontend.py` (läuft im Dockerfile) |
+| Gehashte Assets | `Cache-Control: immutable` |
+
+## Deploy-Checkliste
+
+- [ ] Version in `pyproject.toml` bumpen (optional)
+- [ ] `python tools/build_frontend.py` (Docker macht das automatisch)
+- [ ] `docker compose up -d --build`
+- [ ] Inkognito: `sw.js` neue CACHE-Konstante, `version.json` neuer `buildHash`
+- [ ] Alte PWA-Installation: Update-Banner nach App-Öffnung
+
+## Anti-Patterns
+
+- Cache-first für unversioniertes `app.js`
+- `sw.js` im Precache
+- Stub-Endpoints für fehlende APIs
+- „Cache leeren" als einziger Fix in der Doku
diff --git a/public/app.js b/frontend/app.js
similarity index 98%
rename from public/app.js
rename to frontend/app.js
index a305067..4dfb952 100644
--- a/public/app.js
+++ b/frontend/app.js
@@ -3,6 +3,7 @@ import { getToken, setToken, clearToken, isLoggedIn } from "./lib/auth.js";
import { setStreakBadge } from "./lib/badge.js";
import { enqueue, flushQueue } from "./lib/offlineQueue.js";
import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.js";
+import { wireAppUpdates } from "./lib/updates.js";
import { renderSlots, renderHeatmap, updateStats, showToast, showConfetti, showEasterEgg } from "./lib/ui.js";
const STATUS = { upcoming: "Noch nicht", pending: "Jetzt!", overdue: "Überfällig", snoozed: "Snoozed", taken: "Genommen", missed: "Verpasst" };
@@ -179,6 +180,7 @@ navigator.serviceWorker?.addEventListener("message", (event) => {
async function boot() {
registerPwa();
+ wireAppUpdates();
if (isLoggedIn()) {
try { await initApp(); return; } catch { clearToken(); }
}
diff --git a/frontend/icon-192x192.png b/frontend/icon-192x192.png
new file mode 100644
index 0000000..ffd6a66
Binary files /dev/null and b/frontend/icon-192x192.png differ
diff --git a/frontend/icon-512x512.png b/frontend/icon-512x512.png
new file mode 100644
index 0000000..ffd6a66
Binary files /dev/null and b/frontend/icon-512x512.png differ
diff --git a/frontend/icon-72x72.png b/frontend/icon-72x72.png
new file mode 100644
index 0000000..ffd6a66
Binary files /dev/null and b/frontend/icon-72x72.png differ
diff --git a/frontend/icon.png b/frontend/icon.png
new file mode 100644
index 0000000..ffd6a66
Binary files /dev/null and b/frontend/icon.png differ
diff --git a/frontend/index.template.html b/frontend/index.template.html
new file mode 100644
index 0000000..31e438d
--- /dev/null
+++ b/frontend/index.template.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TakeYourMeds
+
+
+
+
+
+
+

+
TakeYourMeds
+
Dein Gehirn. Deine Medis. Unser Sarkasmus.
+
+
+
+
+
+
+
+
+
+ Medis
+ 🔥 0
+
+
+
+
+
+
+
+
ADHD live, laugh, toaster bath motivational
+
…
+
+
+
+
+
+
+
+
+
Benachrichtigungen
+
Push-Erinnerungen um 8:00 und 12:00 — dezent, kein Alarm.
+
+
+
+
+
App installieren
+
+
+
+
+
+
+
+
+
+
+
+
+ Neue Version verfügbar
+
+
+
+
+
+
+
+
diff --git a/public/lib/api.js b/frontend/lib/api.js
similarity index 100%
rename from public/lib/api.js
rename to frontend/lib/api.js
diff --git a/public/lib/auth.js b/frontend/lib/auth.js
similarity index 100%
rename from public/lib/auth.js
rename to frontend/lib/auth.js
diff --git a/public/lib/badge.js b/frontend/lib/badge.js
similarity index 100%
rename from public/lib/badge.js
rename to frontend/lib/badge.js
diff --git a/public/lib/offlineQueue.js b/frontend/lib/offlineQueue.js
similarity index 100%
rename from public/lib/offlineQueue.js
rename to frontend/lib/offlineQueue.js
diff --git a/public/lib/pwa.js b/frontend/lib/pwa.js
similarity index 90%
rename from public/lib/pwa.js
rename to frontend/lib/pwa.js
index cbe9941..d3803e2 100644
--- a/public/lib/pwa.js
+++ b/frontend/lib/pwa.js
@@ -36,12 +36,13 @@ export function wirePwaInstall({ sectionId = "pwaInstallSection", buttonId = "in
export async function registerPwa() {
if (!("serviceWorker" in navigator)) return;
try {
- await navigator.serviceWorker.register("/sw.js");
+ const reg = await navigator.serviceWorker.register("/sw.js");
if ("sync" in ServiceWorkerRegistration.prototype) {
- navigator.serviceWorker.ready.then((reg) => {
- reg.sync?.register("sync-logs").catch(() => {});
+ navigator.serviceWorker.ready.then((readyReg) => {
+ readyReg.sync?.register("sync-logs").catch(() => {});
});
}
+ return reg;
} catch (e) {
console.error("SW registration failed:", e);
}
diff --git a/public/lib/ui.js b/frontend/lib/ui.js
similarity index 100%
rename from public/lib/ui.js
rename to frontend/lib/ui.js
diff --git a/frontend/lib/updates.js b/frontend/lib/updates.js
new file mode 100644
index 0000000..1c3ad4c
--- /dev/null
+++ b/frontend/lib/updates.js
@@ -0,0 +1,62 @@
+let bannerVisible = false;
+
+function showUpdateBanner() {
+ if (bannerVisible) return;
+ const banner = document.getElementById("updateBanner");
+ if (!banner) return;
+ banner.hidden = false;
+ bannerVisible = true;
+}
+
+async function applyUpdate() {
+ const reg = await navigator.serviceWorker?.getRegistration();
+ if (reg?.waiting) {
+ reg.waiting.postMessage({ type: "SKIP_WAITING" });
+ } else {
+ await reg?.update();
+ location.reload();
+ }
+}
+
+export function wireAppUpdates() {
+ if (!("serviceWorker" in navigator)) return;
+
+ let refreshing = false;
+ navigator.serviceWorker.addEventListener("controllerchange", () => {
+ if (refreshing) return;
+ refreshing = true;
+ location.reload();
+ });
+
+ navigator.serviceWorker.addEventListener("message", (event) => {
+ if (event.data?.type === "UPDATE_AVAILABLE") showUpdateBanner();
+ });
+
+ const button = document.getElementById("updateBannerBtn");
+ button?.addEventListener("click", () => applyUpdate());
+
+ const checkVersion = async () => {
+ try {
+ const reg = await navigator.serviceWorker.getRegistration();
+ await reg?.update();
+
+ const built = document.querySelector('meta[name="app-build"]')?.content;
+ if (!built) return;
+
+ const res = await fetch("/version.json", { cache: "no-store" });
+ if (!res.ok) return;
+ const data = await res.json();
+ if (data.buildHash && data.buildHash !== built) {
+ showUpdateBanner();
+ }
+ } catch {
+ /* ignore — offline or transient */
+ }
+ };
+
+ window.addEventListener("focus", checkVersion);
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "visible") checkVersion();
+ });
+ checkVersion();
+}
diff --git a/frontend/manifest.json b/frontend/manifest.json
new file mode 100644
index 0000000..bbb96c1
--- /dev/null
+++ b/frontend/manifest.json
@@ -0,0 +1,48 @@
+{
+ "name": "TakeYourMeds",
+ "short_name": "Medis",
+ "description": "Medikamenten-Erinnerung mit Sarkasmus und Dopamin",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#1a1a2e",
+ "theme_color": "#F12F12",
+ "orientation": "portrait-primary",
+ "icons": [
+ {
+ "src": "/icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "/icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Genommen (Morgens)",
+ "short_name": "Morgens ✓",
+ "url": "/?action=take&slot=morning",
+ "icons": [
+ {
+ "src": "/icon-192x192.png",
+ "sizes": "192x192"
+ }
+ ]
+ },
+ {
+ "name": "Genommen (Mittags)",
+ "short_name": "Mittags ✓",
+ "url": "/?action=take&slot=noon",
+ "icons": [
+ {
+ "src": "/icon-192x192.png",
+ "sizes": "192x192"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/public/styles.css b/frontend/styles.css
similarity index 94%
rename from public/styles.css
rename to frontend/styles.css
index 3bcf6fe..3dcd7a4 100644
--- a/public/styles.css
+++ b/frontend/styles.css
@@ -244,3 +244,15 @@ body {
}
.easterEggInner h2 { font-size: 1.5rem; margin-bottom: .75rem; }
.easterEggInner p { margin-bottom: 1.25rem; line-height: 1.5; }
+
+/* PWA update banner */
+.updateBanner {
+ position: fixed; left: 0; right: 0; bottom: 0; z-index: 1500;
+ display: flex; align-items: center; justify-content: space-between; gap: 1rem;
+ padding: .85rem 1rem calc(.85rem + env(safe-area-inset-bottom));
+ background: linear-gradient(135deg, var(--primary), #c41f1f);
+ color: #fff; box-shadow: 0 -4px 20px rgba(0,0,0,.35);
+ font-weight: 500;
+}
+.updateBanner[hidden] { display: none !important; }
+.updateBanner .btn { flex-shrink: 0; padding: .5rem 1rem; font-size: .9rem; }
diff --git a/frontend/sw.template.js b/frontend/sw.template.js
new file mode 100644
index 0000000..dcc0759
--- /dev/null
+++ b/frontend/sw.template.js
@@ -0,0 +1,152 @@
+const CACHE = "{{CACHE_VERSION}}";
+const ASSETS = {{ASSETS_JSON}};
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches.keys().then((names) =>
+ Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n)))
+ ).then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener("message", (event) => {
+ if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
+});
+
+function isHashedAsset(pathname) {
+ return pathname.startsWith("/assets/");
+}
+
+async function networkOnly(request) {
+ return fetch(request);
+}
+
+async function networkFirst(request, fallbackPath) {
+ try {
+ const response = await fetch(request);
+ if (response.ok) return response;
+ } catch {
+ /* offline */
+ }
+ const cached = await caches.match(request);
+ if (cached) return cached;
+ if (fallbackPath) {
+ const fallback = await caches.match(fallbackPath);
+ if (fallback) return fallback;
+ }
+ return new Response("Offline", { status: 503, statusText: "Offline" });
+}
+
+async function cacheFirst(request) {
+ const cached = await caches.match(request);
+ if (cached) return cached;
+ const response = await fetch(request);
+ if (response.ok) {
+ const cache = await caches.open(CACHE);
+ await cache.put(request, response.clone());
+ }
+ return response;
+}
+
+self.addEventListener("fetch", (event) => {
+ const { request } = event;
+ if (request.method !== "GET") return;
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin) return;
+
+ // sw.js: never intercept — browser must revalidate with server
+ if (url.pathname === "/sw.js") return;
+
+ if (url.pathname.startsWith("/api/")) {
+ event.respondWith(
+ fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), {
+ status: 503,
+ headers: { "Content-Type": "application/json" },
+ }))
+ );
+ return;
+ }
+
+ if (url.pathname === "/version.json") {
+ event.respondWith(networkOnly(request));
+ return;
+ }
+
+ if (request.mode === "navigate") {
+ event.respondWith(networkFirst(request, "/index.html"));
+ return;
+ }
+
+ if (isHashedAsset(url.pathname)) {
+ event.respondWith(cacheFirst(request));
+ return;
+ }
+
+ if (url.pathname === "/index.html" || url.pathname === "/manifest.json") {
+ event.respondWith(networkFirst(request));
+ return;
+ }
+
+ // Icons and other static root files
+ event.respondWith(networkFirst(request));
+});
+
+self.addEventListener("push", (event) => {
+ if (!event.data) return;
+ let data = {};
+ try { data = event.data.json(); } catch { data = { body: event.data.text() }; }
+
+ const options = {
+ body: data.body || "Med-Time!",
+ icon: "/icon-192x192.png",
+ badge: "/icon-72x72.png",
+ silent: data.silent !== false,
+ tag: data.tag || "med-reminder",
+ renotify: true,
+ data: { slot_id: data.slot_id },
+ actions: [
+ { action: "take", title: "Genommen ✓" },
+ { action: "snooze15", title: "+15 Min" },
+ { action: "snooze30", title: "+30 Min" },
+ ],
+ };
+
+ event.waitUntil(
+ self.registration.showNotification(data.title || "TakeYourMeds", options)
+ );
+});
+
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close();
+ const slotId = event.notification.data?.slot_id;
+ const action = event.action;
+
+ if (action === "take" && slotId) {
+ event.waitUntil(self.clients.openWindow(`/?action=take&slot=${slotId}`));
+ return;
+ }
+
+ if ((action === "snooze15" || action === "snooze30") && slotId) {
+ const minutes = action === "snooze15" ? 15 : 30;
+ event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`));
+ return;
+ }
+
+ event.waitUntil(self.clients.openWindow("/"));
+});
+
+self.addEventListener("sync", (event) => {
+ if (event.tag === "sync-logs") {
+ event.waitUntil(
+ self.clients.matchAll().then((list) => {
+ list.forEach((c) => c.postMessage({ type: "SYNC_QUEUE" }));
+ })
+ );
+ }
+});
diff --git a/public/assets/.gitkeep b/public/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/public/index.html b/public/index.html
index 9f81e31..0156c15 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,12 +5,13 @@
+
-
+
TakeYourMeds
@@ -91,9 +92,13 @@
+
+ Neue Version verfügbar
+
+
-
+