Compare commits

..

3 Commits

Author SHA1 Message Date
Frank Schwenk 7cec3030c1 feat: PWA update strategy with hashed assets and cache control
Move frontend sources to frontend/, build content-hashed assets at deploy,
network-first SW for shell/JS, immutable cache for /assets/, version.json
update banner, and Cache-Control middleware. Docs in docs/PWA-STRATEGY.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 08:51:12 +02:00
Frank Schwenk bff14167c9 Revert "feat: replace Web Push with ntfy for medication reminders"
This reverts commit 1cada42370.
2026-07-08 08:25:34 +02:00
Frank Schwenk 1cada42370 feat: replace Web Push with ntfy for medication reminders
Single notification path via ntfy HTTP publish for reliable Android delivery;
remove VAPID, push subscriptions, and SW push handlers. PWA settings show
topic subscribe link; humor texts and deep-link actions unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 10:42:58 +02:00
29 changed files with 779 additions and 61 deletions
+4
View File
@@ -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
+4 -2
View File
@@ -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
+12 -5
View File
@@ -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.
---
+56
View File
@@ -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
+2
View File
@@ -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(); }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#F12F12">
<meta name="description" content="Medikamenten-Erinnerung mit Sarkasmus">
<meta name="app-build" content="{{BUILD_HASH}}">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icon.png" type="image/png">
<link rel="apple-touch-icon" href="/icon-192x192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{STYLES_HREF}}">
<title>TakeYourMeds</title>
</head>
<body>
<div id="app">
<!-- Login -->
<section id="loginScreen" class="screen">
<div class="loginHero">
<img src="/icon-192x192.png" alt="TakeYourMeds" class="loginLogo" width="120" height="120">
<h1>TakeYourMeds</h1>
<p class="tagline">Dein Gehirn. Deine Medis. Unser Sarkasmus.</p>
</div>
<form id="pinForm" class="pinForm">
<input type="number" id="pinInput" class="pinInput" inputmode="numeric" min="0" max="999999" step="1" placeholder="PIN" autocomplete="off" required>
<button type="submit" class="btn btn-primary pinSubmit">Anmelden</button>
</form>
<p class="loginError" id="loginError" hidden></p>
</section>
<!-- Main -->
<section id="mainScreen" class="screen" hidden>
<header class="topBar">
<img src="/icon.png" alt="" class="topIcon" width="36" height="36">
<h1>Medis</h1>
<div class="streakBadge" id="streakBadge" title="Streak">🔥 0</div>
</header>
<nav class="tabNav">
<button type="button" class="tab active" data-tab="dashboard">Heute</button>
<button type="button" class="tab" data-tab="history">Verlauf</button>
<button type="button" class="tab" data-tab="settings"></button>
</nav>
<main class="tabContent">
<div id="tab-dashboard" class="tabPanel active">
<div class="motivationCard" id="motivationCard">
<div class="motivationLabel">ADHD live, laugh, toaster bath motivational</div>
<p id="motivationText"></p>
</div>
<div id="slotCards" class="slotCards"></div>
</div>
<div id="tab-history" class="tabPanel" hidden>
<div class="statsRow">
<div class="statBox">
<div class="statNum" id="statStreak">0</div>
<div class="statLabel">Streak</div>
</div>
<div class="statBox">
<div class="statNum" id="statCompliance">0%</div>
<div class="statLabel" id="statComplianceLabel">90 Tage</div>
</div>
<div class="statBox">
<div class="statNum" id="statTaken">0</div>
<div class="statLabel">Genommen</div>
</div>
</div>
<h2 class="sectionTitle">90-Tage Heatmap</h2>
<div id="heatmap" class="heatmap"></div>
</div>
<div id="tab-settings" class="tabPanel" hidden>
<div class="settingsGroup">
<h2>Benachrichtigungen</h2>
<p class="settingsHint">Push-Erinnerungen um 8:00 und 12:00 — dezent, kein Alarm.</p>
<button type="button" class="btn btn-accent" id="enablePush">Push aktivieren</button>
<p id="pushStatus" class="settingsStatus"></p>
</div>
<div class="settingsGroup" id="pwaInstallSection" hidden>
<h2>App installieren</h2>
<button type="button" class="btn btn-secondary" id="installPwa">Auf Homescreen</button>
</div>
<div class="settingsGroup">
<button type="button" class="btn btn-danger" id="logoutBtn">Abmelden</button>
</div>
</div>
</main>
</section>
</div>
<div id="toast" class="toast" hidden></div>
<div id="updateBanner" class="updateBanner" hidden>
<span>Neue Version verfügbar</span>
<button type="button" class="btn btn-primary" id="updateBannerBtn">Aktualisieren</button>
</div>
<div id="confetti" class="confetti" hidden></div>
<div id="easterEgg" class="easterEgg" hidden></div>
<script type="module" src="{{APP_SCRIPT_SRC}}"></script>
</body>
</html>
+4 -3
View File
@@ -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);
}
+62
View File
@@ -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();
}
+48
View File
@@ -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"
}
]
}
]
}
+12
View File
@@ -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; }
+152
View File
@@ -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" }));
})
);
}
});
View File
+7 -2
View File
@@ -5,12 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#F12F12">
<meta name="description" content="Medikamenten-Erinnerung mit Sarkasmus">
<meta name="app-build" content="914619f3">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icon.png" type="image/png">
<link rel="apple-touch-icon" href="/icon-192x192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
<link rel="stylesheet" href="/assets/styles.993b74f1.css">
<title>TakeYourMeds</title>
</head>
<body>
@@ -91,9 +92,13 @@
</div>
<div id="toast" class="toast" hidden></div>
<div id="updateBanner" class="updateBanner" hidden>
<span>Neue Version verfügbar</span>
<button type="button" class="btn btn-primary" id="updateBannerBtn">Aktualisieren</button>
</div>
<div id="confetti" class="confetti" hidden></div>
<div id="easterEgg" class="easterEgg" hidden></div>
<script type="module" src="/app.js"></script>
<script type="module" src="/assets/app.58f3e95c.js"></script>
</body>
</html>
+79 -40
View File
@@ -1,20 +1,22 @@
const CACHE = "tym-v1.0.1";
const CACHE = "tym-v1.1.0-914619f3";
const ASSETS = [
"/",
"/index.html",
"/styles.css",
"/app.js",
"/assets/api.89363e2a.js",
"/assets/app.58f3e95c.js",
"/assets/auth.a6c28cdb.js",
"/assets/badge.30848c28.js",
"/assets/offlineQueue.0a9bbfdd.js",
"/assets/pwa.b6a6d909.js",
"/assets/styles.993b74f1.css",
"/assets/ui.47fc1625.js",
"/assets/updates.57073981.js",
"/manifest.json",
"/version.json",
"/icon.png",
"/icon-72x72.png",
"/icon-192x192.png",
"/icon-512x512.png",
"/lib/api.js",
"/lib/auth.js",
"/lib/badge.js",
"/lib/offlineQueue.js",
"/lib/pwa.js",
"/lib/ui.js",
"/version.json"
];
self.addEventListener("install", (event) => {
@@ -31,37 +33,86 @@ self.addEventListener("activate", (event) => {
);
});
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" },
})));
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(
fetch(request).catch(() => caches.match("/index.html"))
);
event.respondWith(networkFirst(request, "/index.html"));
return;
}
event.respondWith(
caches.match(request, { ignoreSearch: true }).then((cached) =>
cached || fetch(request).then((resp) => {
if (resp.ok) {
const clone = resp.clone();
caches.open(CACHE).then((c) => c.put(request, clone));
if (isHashedAsset(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
return resp;
})
)
);
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) => {
@@ -89,31 +140,19 @@ self.addEventListener("push", (event) => {
);
});
async function apiPost(path, body) {
const clients = await self.clients.matchAll({ type: "window" });
for (const client of clients) {
client.postMessage({ type: "API_ACTION", path, body });
return;
}
}
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}`)
);
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}`)
);
event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`));
return;
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.0",
"buildTime": "2026-06-09T19:02:13.248375Z",
"buildHash": "92521fc3cbd964bd"
"version": "1.1.0",
"buildTime": "2026-07-08T06:50:31.880953Z",
"buildHash": "914619f3"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "takeyourmeds"
version = "1.0.0"
version = "1.1.0"
description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor"
requires-python = ">=3.11"
dependencies = [
+2
View File
@@ -20,6 +20,7 @@ from server.push import save_subscription, send_slot_reminder
from server.scheduler import schedule_snooze, start_scheduler
from server.settings import settings
from server.slots import build_today
from server.static_cache import CacheControlMiddleware
from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS
NO_STORE = {"Cache-Control": "no-store"}
@@ -63,6 +64,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
app.add_middleware(CacheControlMiddleware)
@app.post("/api/auth/pin")
+1 -1
View File
@@ -22,7 +22,7 @@ class Settings(BaseSettings):
messages_path: str = "messages.yaml"
public_dir: str = "public"
app_version: str = "1.0.0"
app_version: str = "1.1.0"
settings = Settings() # type: ignore[call-arg]
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from starlette.types import ASGIApp, Receive, Scope, Send
NO_CACHE = b"no-cache, must-revalidate"
NO_STORE = b"no-store"
IMMUTABLE = b"public, max-age=31536000, immutable"
def _cache_control_for_path(path: str) -> bytes | None:
if path in ("/sw.js", "/index.html", "/manifest.json"):
return NO_CACHE
if path == "/version.json":
return NO_STORE
if path.startswith("/assets/"):
return IMMUTABLE
if path.endswith((".js", ".css")) and not path.startswith("/assets/"):
return NO_CACHE
return None
class CacheControlMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
cache_control = _cache_control_for_path(path)
async def send_wrapper(message: dict) -> None:
if message["type"] == "http.response.start" and cache_control is not None:
headers = list(message.get("headers", []))
headers = [(k, v) for k, v in headers if k.lower() != b"cache-control"]
headers.append((b"cache-control", cache_control))
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_wrapper)
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Build frontend assets with content hashes for cache-immutable PWA deploy."""
from __future__ import annotations
import hashlib
import json
import re
import shutil
import tomllib
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND = ROOT / "frontend"
PUBLIC = ROOT / "public"
ASSETS_DIR = PUBLIC / "assets"
HASH_LEN = 8
IMPORT_RE = re.compile(r'''from\s+["'](\./(?:lib/)?[^"']+\.js)["']''')
STATIC_COPY = ("manifest.json",)
STATIC_ICONS = (
"icon.png",
"icon-72x72.png",
"icon-192x192.png",
"icon-512x512.png",
)
def short_hash(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()[:HASH_LEN]
def js_sources() -> list[tuple[str, str]]:
sources: list[tuple[str, str]] = [("app.js", "app")]
lib_dir = FRONTEND / "lib"
if lib_dir.is_dir():
for path in sorted(lib_dir.glob("*.js")):
sources.append((f"lib/{path.name}", path.stem))
return sources
def read_version() -> str:
pyproject = ROOT / "pyproject.toml"
with pyproject.open("rb") as fh:
data = tomllib.load(fh)
return str(data["project"]["version"])
def rewrite_imports(source: str, stem_to_filename: dict[str, str]) -> str:
def replace(match: re.Match[str]) -> str:
import_path = match.group(1)
stem = Path(import_path).stem
hashed = stem_to_filename.get(stem)
if not hashed:
raise ValueError(f"Unknown import stem {stem!r} in {import_path!r}")
quote = match.group(0).split("from", 1)[1].strip()[0]
return f'from {quote}./{hashed}{quote}'
return IMPORT_RE.sub(replace, source)
def clean_assets_dir() -> None:
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
for path in ASSETS_DIR.iterdir():
if path.name == ".gitkeep":
continue
if path.is_file():
path.unlink()
def copy_static_files() -> list[str]:
copied: list[str] = []
for name in STATIC_COPY:
src = FRONTEND / name
if not src.is_file():
raise FileNotFoundError(f"Missing frontend source: {src}")
shutil.copy2(src, PUBLIC / name)
copied.append(f"/{name}")
for name in STATIC_ICONS:
src = FRONTEND / name
if src.is_file():
shutil.copy2(src, PUBLIC / name)
copied.append(f"/{name}")
return copied
def build() -> dict[str, object]:
if not FRONTEND.is_dir():
raise FileNotFoundError(f"Frontend source directory not found: {FRONTEND}")
PUBLIC.mkdir(parents=True, exist_ok=True)
clean_assets_dir()
hashed_assets: dict[str, str] = {}
stem_to_filename: dict[str, str] = {}
file_hashes: list[str] = []
styles_src = FRONTEND / "styles.css"
if not styles_src.is_file():
raise FileNotFoundError(f"Missing frontend source: {styles_src}")
styles_bytes = styles_src.read_bytes()
styles_hash = short_hash(styles_bytes)
styles_filename = f"styles.{styles_hash}.css"
(ASSETS_DIR / styles_filename).write_bytes(styles_bytes)
hashed_assets["styles"] = f"/assets/{styles_filename}"
file_hashes.append(styles_hash)
for rel_path, stem in js_sources():
src = FRONTEND / rel_path
if not src.is_file():
raise FileNotFoundError(f"Missing frontend source: {src}")
content = src.read_text(encoding="utf-8")
content_hash = short_hash(content.encode("utf-8"))
filename = f"{stem}.{content_hash}.js"
stem_to_filename[stem] = filename
file_hashes.append(content_hash)
for rel_path, stem in js_sources():
src = FRONTEND / rel_path
content = rewrite_imports(src.read_text(encoding="utf-8"), stem_to_filename)
filename = stem_to_filename[stem]
(ASSETS_DIR / filename).write_text(content, encoding="utf-8")
hashed_assets[stem] = f"/assets/{filename}"
build_hash = short_hash("".join(file_hashes).encode("utf-8"))
version = read_version()
build_time = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
cache_version = f"tym-v{version}-{build_hash}"
static_paths = copy_static_files()
asset_paths = sorted(hashed_assets.values())
sw_assets = ["/", "/index.html", *asset_paths, *static_paths, "/version.json"]
index_template = (FRONTEND / "index.template.html").read_text(encoding="utf-8")
index_html = (
index_template.replace("{{STYLES_HREF}}", hashed_assets["styles"])
.replace("{{APP_SCRIPT_SRC}}", hashed_assets["app"])
.replace("{{BUILD_HASH}}", build_hash)
)
(PUBLIC / "index.html").write_text(index_html, encoding="utf-8")
sw_template = (FRONTEND / "sw.template.js").read_text(encoding="utf-8")
sw_js = (
sw_template.replace("{{CACHE_VERSION}}", cache_version)
.replace("{{ASSETS_JSON}}", json.dumps(sw_assets, indent=2))
)
(PUBLIC / "sw.js").write_text(sw_js, encoding="utf-8")
version_json = {
"version": version,
"buildTime": build_time,
"buildHash": build_hash,
}
(PUBLIC / "version.json").write_text(
json.dumps(version_json, indent=2) + "\n",
encoding="utf-8",
)
return {
"version": version,
"buildHash": build_hash,
"cacheVersion": cache_version,
"assets": hashed_assets,
"static": static_paths,
}
def main() -> None:
result = build()
print(f"Built frontend v{result['version']} ({result['buildHash']})")
print(f"Cache: {result['cacheVersion']}")
for stem, url in sorted(result["assets"].items()):
print(f" {stem}: {url}")
if __name__ == "__main__":
main()