Revert "feat: replace Web Push with ntfy for medication reminders"

This reverts commit 1cada42370.
This commit is contained in:
Frank Schwenk
2026-07-08 08:25:34 +02:00
parent 1cada42370
commit bff14167c9
17 changed files with 323 additions and 251 deletions
+4 -5
View File
@@ -5,11 +5,10 @@ APP_PIN=1234
JWT_SECRET=change-me-to-a-long-random-string JWT_SECRET=change-me-to-a-long-random-string
JWT_EXPIRE_DAYS=90 JWT_EXPIRE_DAYS=90
# ntfy — Erinnerungen an Topic publizieren (ntfy-App abonnieren) # VAPID keys für Web Push — generieren mit: python tools/gen_vapid.py
NTFY_URL=https://ntfy.schwenk.online VAPID_PRIVATE_KEY=
NTFY_TOPIC=takeyourmeds VAPID_PUBLIC_KEY=
NTFY_TOKEN= VAPID_CLAIMS_EMAIL=mailto:admin@schwenk.online
NTFY_CLICK_URL=https://medis.schwenk.online
# OpenRouter (optional — Roast-of-the-Day) # OpenRouter (optional — Roast-of-the-Day)
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
+27 -29
View File
@@ -1,6 +1,6 @@
# TakeYourMeds # TakeYourMeds
Persönliche Medikamenten-PWA mit dezenten ntfy-Erinnerungen, Einnahme-Logging und sarkastischem Humor. Persönliche Medikamenten-PWA mit dezenten Push-Erinnerungen, Einnahme-Logging und sarkastischem Humor.
**Domain:** [medis.schwenk.online](https://medis.schwenk.online) **Domain:** [medis.schwenk.online](https://medis.schwenk.online)
@@ -11,7 +11,7 @@ Persönliche Medikamenten-PWA mit dezenten ntfy-Erinnerungen, Einnahme-Logging u
## Features ## Features
### Kern ### Kern
- **ntfy-Erinnerungen** um 8:00 und 12:00 (`Europe/Berlin`) — dezent, kein Alarm - **Push-Erinnerungen** um 8:00 und 12:00 (`Europe/Berlin`) — dezent (`silent`), kein Alarm
- **Einnahme loggen:** genommen / verpasst / snooze - **Einnahme loggen:** genommen / verpasst / snooze
- **PIN-Login** mit langer JWT-Session (90 Tage) - **PIN-Login** mit langer JWT-Session (90 Tage)
- **PWA:** installierbar, offline-fähige App-Shell, Service Worker - **PWA:** installierbar, offline-fähige App-Shell, Service Worker
@@ -38,7 +38,7 @@ Persönliche Medikamenten-PWA mit dezenten ntfy-Erinnerungen, Einnahme-Logging u
| Backend | FastAPI + uvicorn | | Backend | FastAPI + uvicorn |
| DB | SQLite (`data/medis.sqlite`) | | DB | SQLite (`data/medis.sqlite`) |
| Frontend | Vanilla JS (kein Build-Step) | | Frontend | Vanilla JS (kein Build-Step) |
| Push | ntfy ([ntfy.schwenk.online](https://ntfy.schwenk.online/)) | | Push | Web Push (pywebpush + VAPID) |
| Scheduler | APScheduler (8:00, 12:00, 21:00) | | Scheduler | APScheduler (8:00, 12:00, 21:00) |
| KI (optional) | OpenRouter | | KI (optional) | OpenRouter |
| Deployment | Docker Compose + Traefik | | Deployment | Docker Compose + Traefik |
@@ -57,9 +57,13 @@ pip install -e .
# 2. Konfiguration # 2. Konfiguration
cp .env.example .env cp .env.example .env
# APP_PIN, JWT_SECRET, NTFY_TOKEN anpassen # APP_PIN und JWT_SECRET anpassen
# 3. Starten # 3. VAPID keys (für Push)
python tools/gen_vapid.py
# Output in .env eintragen
# 4. Starten
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000 uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
``` ```
@@ -71,7 +75,7 @@ App: http://localhost:8000
```bash ```bash
cp .env.example .env cp .env.example .env
# .env ausfüllen (PIN, JWT_SECRET, NTFY_TOKEN, optional OPENROUTER_API_KEY) # .env ausfüllen (PIN, JWT_SECRET, VAPID keys, optional OPENROUTER_API_KEY)
docker compose up -d --build docker compose up -d --build
``` ```
@@ -94,10 +98,9 @@ Volumes:
| `APP_PIN` | Login-PIN (46 Ziffern) | | `APP_PIN` | Login-PIN (46 Ziffern) |
| `JWT_SECRET` | Geheimer Key für JWT (min. 32 Zeichen empfohlen: `openssl rand -hex 32`) | | `JWT_SECRET` | Geheimer Key für JWT (min. 32 Zeichen empfohlen: `openssl rand -hex 32`) |
| `JWT_EXPIRE_DAYS` | Session-Laufzeit (Default: 90) | | `JWT_EXPIRE_DAYS` | Session-Laufzeit (Default: 90) |
| `NTFY_URL` | ntfy-Server (Default: `https://ntfy.schwenk.online`) | | `VAPID_PRIVATE_KEY` | Web-Push Private Key (PEM) |
| `NTFY_TOPIC` | Topic für Reminder (Default: `takeyourmeds`) | | `VAPID_PUBLIC_KEY` | Web-Push Public Key |
| `NTFY_TOKEN` | Access-Token zum Publizieren (Bearer) | | `VAPID_CLAIMS_EMAIL` | mailto:-Adresse für VAPID |
| `NTFY_CLICK_URL` | PWA-URL für Tap/Actions (Default: `https://medis.schwenk.online`) |
| `OPENROUTER_API_KEY` | Optional — für Roast-of-the-Day | | `OPENROUTER_API_KEY` | Optional — für Roast-of-the-Day |
| `OPENROUTER_MODEL` | Default: `google/gemini-2.0-flash-001` | | `OPENROUTER_MODEL` | Default: `google/gemini-2.0-flash-001` |
@@ -127,12 +130,12 @@ Texte frei editierbar — kein Neustart nötig (wird beim ersten Request geladen
## API ## API
Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer <token>` (außer `/api/auth/pin`). Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer <token>` (außer `/api/auth/pin` und `/api/vapid-public-key`).
| Methode | Pfad | Beschreibung | | Methode | Pfad | Beschreibung |
|---------|------|--------------| |---------|------|--------------|
| POST | `/api/auth/pin` | `{ "pin": "1234" }``{ "token": "..." }` | | POST | `/api/auth/pin` | `{ "pin": "1234" }``{ "token": "..." }` |
| GET | `/api/notify-config` | ntfy Topic + Subscribe-URL | | GET | `/api/vapid-public-key` | Public Key für Push-Subscription |
| GET | `/api/config` | Meds-Config | | GET | `/api/config` | Meds-Config |
| GET | `/api/today` | Heutige Slots + Status | | GET | `/api/today` | Heutige Slots + Status |
| POST | `/api/log` | `{ "slot_id", "status", "day?", "source?" }` | | POST | `/api/log` | `{ "slot_id", "status", "day?", "source?" }` |
@@ -140,23 +143,18 @@ Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer <token>` (außer
| GET | `/api/history?days=90` | Heatmap-Daten + Stats | | GET | `/api/history?days=90` | Heatmap-Daten + Stats |
| GET | `/api/stats` | Streak, Compliance, Meilensteine | | GET | `/api/stats` | Streak, Compliance, Meilensteine |
| GET | `/api/roast` | Roast-of-the-Day | | GET | `/api/roast` | Roast-of-the-Day |
| POST | `/api/push/subscribe` | Web-Push Subscription speichern |
--- ---
## ntfy einrichten ## PWA / Push einrichten
1. **Server:** `NTFY_TOKEN` in `.env` auf boka setzen, Container neu starten 1. App im Browser öffnen (HTTPS erforderlich — lokal ohne Push)
2. **Handy:** ntfy-App → Topic `takeyourmeds` abonnieren (oder Link unter **Einstellungen** in der PWA) 2. Einloggen → Tab **Einstellungen****Push aktivieren**
3. Optional: PWA auf Homescreen — für Loggen/Snooze; Reminder kommen über ntfy 3. Optional: **Auf Homescreen** installieren
4. Android-Shortcuts: Long-Press App-Icon → „Genommen (Morgens/Mittags)"
Notification-Actions in ntfy: **Genommen ✓**, **+15 Min**, **+30 Min** (öffnen PWA mit Deep-Link) Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
Test von der Shell:
```bash
curl -d "Test" -H "Title: Med-Time" -H "Authorization: Bearer $NTFY_TOKEN" \
https://ntfy.schwenk.online/takeyourmeds
```
--- ---
@@ -175,7 +173,7 @@ curl -d "Test" -H "Title: Med-Time" -H "Authorization: Bearer $NTFY_TOKEN" \
| **Ein FastAPI-Container** statt nginx+api | KISS — weniger Moving Parts, StaticFiles reicht für Single-User-PWA | | **Ein FastAPI-Container** statt nginx+api | KISS — weniger Moving Parts, StaticFiles reicht für Single-User-PWA |
| **Vanilla JS** statt React/Vue | Kein Build-Step, schnelle Iteration, passt zu persönlicher App | | **Vanilla JS** statt React/Vue | Kein Build-Step, schnelle Iteration, passt zu persönlicher App |
| **SQLite** statt Postgres | Single-User, eine Datei, Backup = `data/medis.sqlite` kopieren | | **SQLite** statt Postgres | Single-User, eine Datei, Backup = `data/medis.sqlite` kopieren |
| **ntfy vom Server** statt Web Push | Zuverlässig auf Android; eine Notification-Logik | | **Web Push vom Server** statt Client-Timer | Zuverlässig auch bei geschlossener App; APScheduler im Container |
| **OpenRouter nur für Roast** | Kosten/Latenz — Notifications nutzen statische `messages.yaml` | | **OpenRouter nur für Roast** | Kosten/Latenz — Notifications nutzen statische `messages.yaml` |
| **Model: gemini-2.0-flash** | Günstig, schnell, gut genug für kurze deutsche Roasts | | **Model: gemini-2.0-flash** | Günstig, schnell, gut genug für kurze deutsche Roasts |
| **PIN plain in .env** statt Hash | Single-User, unkritische Daten; `hmac.compare_digest` gegen Timing-Leaks | | **PIN plain in .env** statt Hash | Single-User, unkritische Daten; `hmac.compare_digest` gegen Timing-Leaks |
@@ -183,7 +181,7 @@ curl -d "Test" -H "Title: Med-Time" -H "Authorization: Bearer $NTFY_TOKEN" \
| **`reminder_window_minutes: 90`** | ADHS-realistisch — nicht sofort „verpasst" um 08:01 | | **`reminder_window_minutes: 90`** | ADHS-realistisch — nicht sofort „verpasst" um 08:01 |
| **Badge = Streak**, nicht offene Dosen | Streak ist motivierender Dopamin-Hook | | **Badge = Streak**, nicht offene Dosen | Streak ist motivierender Dopamin-Hook |
| **Icons: PNG für alle Größen** | 556×556 Quelle — Browser skaliert; kein ImageMagick nötig | | **Icons: PNG für alle Größen** | 556×556 Quelle — Browser skaliert; kein ImageMagick nötig |
| **Abend-Check 21:00** | Markiert überfällige Slots als `missed`, optional ntfy | | **Abend-Check 21:00** | Markiert überfällige Slots als `missed`, optional Push |
| **Offline-Queue via IndexedDB** | Einfacher als SW-only; Flush bei App-Start + Background-Sync | | **Offline-Queue via IndexedDB** | Einfacher als SW-only; Flush bei App-Start + Background-Sync |
--- ---
@@ -199,7 +197,7 @@ takeyourmeds/
├── icon.png / icon.svg # App-Icons (Quelle) ├── icon.png / icon.svg # App-Icons (Quelle)
├── server/ # FastAPI Backend ├── server/ # FastAPI Backend
├── public/ # PWA Frontend ├── public/ # PWA Frontend
├── tools/ # schema.sql ├── tools/ # schema.sql, gen_vapid.py
└── data/ # SQLite (gitignored) └── data/ # SQLite (gitignored)
``` ```
@@ -220,8 +218,8 @@ Service Worker cached aggressiv — für SW-Änderungen: DevTools → Applicatio
| Problem | Lösung | | Problem | Lösung |
|---------|--------| |---------|--------|
| Reminder kommt nicht | `NTFY_TOKEN` in `.env`? Topic in ntfy-App abonniert? | | Push kommt nicht | VAPID keys in `.env`? HTTPS? Push in Einstellungen aktiviert? |
| „ntfy nicht konfiguriert" | `NTFY_URL` + `NTFY_TOPIC` in `.env` setzen | | „VAPID keys nicht konfiguriert" | `python tools/gen_vapid.py` ausführen |
| KI-Roast zeigt Fallback | `OPENROUTER_API_KEY` fehlt oder API-Fehler — Fallback aus `messages.yaml` | | KI-Roast zeigt Fallback | `OPENROUTER_API_KEY` fehlt oder API-Fehler — Fallback aus `messages.yaml` |
| Streak = 0 trotz Log | Streak zählt nur Tage wo **alle** Slots genommen wurden | | Streak = 0 trotz Log | Streak zählt nur Tage wo **alle** Slots genommen wurden |
| SW zeigt alte Version | `version.json` bumpen + Cache leeren | | SW zeigt alte Version | `version.json` bumpen + Cache leeren |
-95
View File
@@ -1,95 +0,0 @@
# SOUL.md — TakeYourMeds
Persönliche Medikamenten-PWA — nicht der Chat-Mood (siehe `MOOD.md`).
---
## Agent Quick Start
| | |
|---|---|
| **Was** | Single-User PWA: Med-Erinnerungen, Einnahme-Log, Streak/Heatmap, sarkastischer Humor |
| **Domain** | [medis.schwenk.online](https://medis.schwenk.online) |
| **Stack** | FastAPI + SQLite + Vanilla JS PWA, ntfy, APScheduler |
| **Deploy** | Docker Compose auf `boka``/home/frank/medis.schwenk.online` (Fränky deployt) |
| **Secrets** | `.env` only — PIN, JWT, NTFY_TOKEN, OpenRouter |
| **Tests** | Pragmatisch — keine Pflicht-Suite; manuell ntfy + PWA prüfen |
| **Commit** | Nur auf explizite Anweisung |
---
## Product Name
**TakeYourMeds** (intern: medis)
## One-Liner
Persönliche Medikamenten-PWA mit dezenten Erinnerungen, Einnahme-Logging und sarkastischem Humor — für ADHS-realistische Routinen.
## Vision
Medikamente nicht vergessen, ohne Schuld-Trip oder Alarm-Hölle. Die App erinnert dezent, loggt Einnahmen, zeigt Fortschritt (Streak, Heatmap) und liefert kleine Dopamin-Hits — weil ADHS-Gehirne Belohnung brauchen, nicht noch eine To-do-Liste.
## Audience
**Fränky** — Single-User, persönliches Gerät (Android PWA + Desktop). Erwartet: zuverlässige Reminder, schnelle „genommen"-Aktion, ehrlicher Ton, kein Corporate-Bullshit.
## Tone & Wording
Wie in `messages.yaml` — direkt, sarkastisch, ADHS-aware, liebevoll unter der Schicht:
- **Voice:** Du, trocken, selbstironisch, kein Motivations-Coach
- **Formality:** casual (Du)
- **Error messages:** ehrlich + hilfreich („Push nicht konfiguriert" statt „Etwas ist schiefgelaufen")
- **Forbidden:** LinkedIn-Sprech, „leverage", „synergy", übertriebene Wellness-Floskeln, Schuld-Trips
### Wording Examples
| Context | Good | Bad |
|---------|------|-----|
| Success | „Genommen! Dein Gehirn gibt dir einen langsamen High-Five." | „Großartig! Du hast heute wieder Großes geleistet! 🎉" |
| Error | „Reminder kommt nicht — NTFY_TOKEN in .env?" | „Ein unerwarteter Fehler ist aufgetreten." |
| Empty state | „Noch nichts geloggt. Typisch." | „Beginne deine Wellness-Journey!" |
## Design
- **Palette:** `#F12F12` (theme_color), utilitarian PWA
- **Feel:** minimal, schnell, mobile-first — keine Deko-Orgie
## Non-Goals
- Kein Multi-User / keine Accounts für andere
- Kein SaaS, kein App-Store-Launch
- Keine Dark Patterns (Streak-Shaming, Paywall, Engagement-Bait)
- Kein Medizin-Ratgeber — nur Erinnerung & Logging
- Kein Build-Step-Frontend (Vanilla JS bleibt)
## Infrastructure (project-local)
| | |
|---|---|
| Server | `boka` (Debian 12) |
| Path | `/home/frank/medis.schwenk.online` |
| Domain | `medis.schwenk.online` |
| DB | `data/medis.sqlite` (Volume) |
| Config | `meds.yaml`, `messages.yaml` (read-only mounts) |
| Push | ntfy → [ntfy.schwenk.online](https://ntfy.schwenk.online/) Topic `takeyourmeds` |
## Project-Specific Rules
- **ntfy only** — Server publiziert Reminder; ntfy-App auf dem Handy empfängt. Kein Web Push/VAPID.
- **`reminder_window_minutes: 90`** — nicht sofort „verpasst" um 08:01; ADHS-realistisch
- **Abend-Check 21:00** — überfällige Slots → `missed`
- **Roast/Motivation** — OpenRouter optional; Notifications nutzen statische `messages.yaml`
- **PIN in .env** — Single-User, `hmac.compare_digest`; kein Over-Engineering
- **Kein nginx** — FastAPI serviert API + Static (KISS)
- **Service Worker** — aggressives Caching; bei SW-Änderungen `version.json` bumpen
---
## Agent Instructions
- `SOUL.md` > `STANDARDS.md` für UI/UX und Produkt-Ton
- Secrets nie committen — Token/Keys nur `.env`
- Deploy: Fränky, außer explizit anders gesagt
- Neue Regeln hier oder in `BOUNDARIES.md` — Agent schlägt vor, Fränky bestätigt
+3 -4
View File
@@ -6,10 +6,9 @@ services:
- APP_PIN=${APP_PIN} - APP_PIN=${APP_PIN}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRE_DAYS=${JWT_EXPIRE_DAYS:-90} - JWT_EXPIRE_DAYS=${JWT_EXPIRE_DAYS:-90}
- NTFY_URL=${NTFY_URL:-https://ntfy.schwenk.online} - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- NTFY_TOPIC=${NTFY_TOPIC:-takeyourmeds} - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- NTFY_TOKEN=${NTFY_TOKEN:-} - VAPID_CLAIMS_EMAIL=${VAPID_CLAIMS_EMAIL:-mailto:admin@schwenk.online}
- NTFY_CLICK_URL=${NTFY_CLICK_URL:-https://medis.schwenk.online}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-google/gemini-2.0-flash-001} - OPENROUTER_MODEL=${OPENROUTER_MODEL:-google/gemini-2.0-flash-001}
- DB_PATH=/data/medis.sqlite - DB_PATH=/data/medis.sqlite
+14 -14
View File
@@ -2,7 +2,7 @@ import { api } from "./lib/api.js";
import { getToken, setToken, clearToken, isLoggedIn } from "./lib/auth.js"; import { getToken, setToken, clearToken, isLoggedIn } from "./lib/auth.js";
import { setStreakBadge } from "./lib/badge.js"; import { setStreakBadge } from "./lib/badge.js";
import { enqueue, flushQueue } from "./lib/offlineQueue.js"; import { enqueue, flushQueue } from "./lib/offlineQueue.js";
import { registerPwa, wirePwaInstall } from "./lib/pwa.js"; import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.js";
import { renderSlots, renderHeatmap, updateStats, showToast, showConfetti, showEasterEgg } from "./lib/ui.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" }; const STATUS = { upcoming: "Noch nicht", pending: "Jetzt!", overdue: "Überfällig", snoozed: "Snoozed", taken: "Genommen", missed: "Verpasst" };
@@ -153,20 +153,20 @@ async function initApp() {
location.reload(); location.reload();
}); });
try { document.getElementById("enablePush").addEventListener("click", async () => {
const notify = await api.notifyConfig(); const statusEl = document.getElementById("pushStatus");
const topicEl = document.getElementById("notifyTopic"); try {
const linkEl = document.getElementById("notifySubscribeLink"); const perm = await Notification.requestPermission();
if (notify.configured && notify.subscribe_url) { if (perm !== "granted") { statusEl.textContent = "Permission verweigert."; return; }
topicEl.textContent = `Topic: ${notify.topic}`; const { key } = await api.vapidKey();
linkEl.href = notify.subscribe_url; if (!key) { statusEl.textContent = "VAPID keys nicht konfiguriert."; return; }
} else { const sub = await subscribePush(key);
topicEl.textContent = "ntfy nicht konfiguriert (NTFY_URL + NTFY_TOPIC in .env)."; await api.pushSubscribe(sub);
linkEl.hidden = true; statusEl.textContent = "Push aktiv! Du wirst dezent erinnert.";
} catch (e) {
statusEl.textContent = e.message || "Push-Setup fehlgeschlagen.";
} }
} catch { });
document.getElementById("notifyTopic").textContent = "Notify-Config konnte nicht geladen werden.";
}
await flushQueue((data) => api.log(data)); await flushQueue((data) => api.log(data));
await refreshDashboard(); await refreshDashboard();
+3 -4
View File
@@ -74,10 +74,9 @@
<div id="tab-settings" class="tabPanel" hidden> <div id="tab-settings" class="tabPanel" hidden>
<div class="settingsGroup"> <div class="settingsGroup">
<h2>Benachrichtigungen</h2> <h2>Benachrichtigungen</h2>
<p class="settingsHint">Erinnerungen um 8:00 und 12:00 per ntfy — dezent, kein Alarm.</p> <p class="settingsHint">Push-Erinnerungen um 8:00 und 12:00 — dezent, kein Alarm.</p>
<p id="notifyTopic" class="settingsStatus"></p> <button type="button" class="btn btn-accent" id="enablePush">Push aktivieren</button>
<a id="notifySubscribeLink" class="btn btn-accent" href="#" target="_blank" rel="noopener">In ntfy abonnieren</a> <p id="pushStatus" class="settingsStatus"></p>
<p class="settingsHint">ntfy-App installiert? Link tippen → Topic abonnieren. Danach kommen Reminder zuverlässig.</p>
</div> </div>
<div class="settingsGroup" id="pwaInstallSection" hidden> <div class="settingsGroup" id="pwaInstallSection" hidden>
<h2>App installieren</h2> <h2>App installieren</h2>
+3 -1
View File
@@ -27,5 +27,7 @@ export const api = {
stats: () => request("/api/stats"), stats: () => request("/api/stats"),
roast: () => request("/api/roast"), roast: () => request("/api/roast"),
snooze: (slot_id, minutes) => request("/api/snooze", { method: "POST", body: JSON.stringify({ slot_id, minutes }) }), snooze: (slot_id, minutes) => request("/api/snooze", { method: "POST", body: JSON.stringify({ slot_id, minutes }) }),
notifyConfig: () => request("/api/notify-config"), vapidKey: () => request("/api/vapid-public-key"),
pushSubscribe: (subscription) =>
request("/api/push/subscribe", { method: "POST", body: JSON.stringify({ subscription }) }),
}; };
+20
View File
@@ -46,3 +46,23 @@ export async function registerPwa() {
console.error("SW registration failed:", e); console.error("SW registration failed:", e);
} }
} }
export async function subscribePush(vapidPublicKey) {
if (!("PushManager" in window)) throw new Error("Push nicht unterstützt");
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
return sub.toJSON();
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(base64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
+57 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "tym-v1.1.0"; const CACHE = "tym-v1.0.1";
const ASSETS = [ const ASSETS = [
"/", "/",
"/index.html", "/index.html",
@@ -64,6 +64,62 @@ self.addEventListener("fetch", (event) => {
); );
}); });
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)
);
});
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}`)
);
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) => { self.addEventListener("sync", (event) => {
if (event.tag === "sync-logs") { if (event.tag === "sync-logs") {
event.waitUntil( event.waitUntil(
+2 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "takeyourmeds" name = "takeyourmeds"
version = "1.0.0" version = "1.0.0"
description = "Medikamenten-PWA mit ntfy-Erinnerungen und sarkastischem Humor" description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"fastapi>=0.115", "fastapi>=0.115",
@@ -12,6 +12,7 @@ dependencies = [
"pyjwt>=2.9", "pyjwt>=2.9",
"pyyaml>=6.0", "pyyaml>=6.0",
"httpx>=0.27", "httpx>=0.27",
"pywebpush>=2.0",
"apscheduler>=3.10", "apscheduler>=3.10",
] ]
+17 -12
View File
@@ -16,6 +16,7 @@ from server.auth import create_token, require_auth, verify_pin
from server.config_loader import load_meds_config, slot_to_dict, today_str from server.config_loader import load_meds_config, slot_to_dict, today_str
from server.db import ensure_schema, get_db, new_id, utc_now_iso from server.db import ensure_schema, get_db, new_id, utc_now_iso
from server.messages import pick from server.messages import pick
from server.push import save_subscription, send_slot_reminder
from server.scheduler import schedule_snooze, start_scheduler from server.scheduler import schedule_snooze, start_scheduler
from server.settings import settings from server.settings import settings
from server.slots import build_today from server.slots import build_today
@@ -72,18 +73,9 @@ async def auth_pin(payload: dict[str, Any]) -> JSONResponse:
return JSONResponse({"token": create_token()}, headers=NO_STORE) return JSONResponse({"token": create_token()}, headers=NO_STORE)
@app.get("/api/notify-config") @app.get("/api/vapid-public-key")
async def notify_config(_: dict = Depends(require_auth)) -> JSONResponse: async def vapid_public_key() -> JSONResponse:
base = settings.ntfy_url.rstrip("/") if settings.ntfy_url else "" return JSONResponse({"key": settings.vapid_public_key}, headers=NO_STORE)
topic = settings.ntfy_topic
return JSONResponse(
{
"topic": topic,
"subscribe_url": f"{base}/{topic}" if base and topic else "",
"configured": bool(base and topic),
},
headers=NO_STORE,
)
@app.get("/api/config") @app.get("/api/config")
@@ -174,6 +166,19 @@ async def get_stats(_: dict = Depends(require_auth)) -> JSONResponse:
return JSONResponse(stats, headers=NO_STORE) return JSONResponse(stats, headers=NO_STORE)
@app.post("/api/push/subscribe")
async def push_subscribe(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse:
sub = payload.get("subscription")
if not sub or not sub.get("endpoint"):
raise HTTPException(status_code=400, detail="Invalid subscription")
db = await get_db()
try:
await save_subscription(db, sub)
finally:
await db.close()
return JSONResponse({"ok": True}, headers=NO_STORE)
@app.post("/api/snooze") @app.post("/api/snooze")
async def post_snooze(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse: async def post_snooze(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse:
slot_id = str(payload.get("slot_id", "")) slot_id = str(payload.get("slot_id", ""))
-78
View File
@@ -1,78 +0,0 @@
from __future__ import annotations
import json
import logging
import httpx
from server.settings import settings
logger = logging.getLogger(__name__)
def _publish_url() -> str | None:
if not settings.ntfy_url or not settings.ntfy_topic:
return None
return f"{settings.ntfy_url.rstrip('/')}/{settings.ntfy_topic}"
def _click_base() -> str:
return settings.ntfy_click_url.rstrip("/")
async def send_slot_reminder(
slot_id: str,
title: str,
body: str,
*,
silent: bool = True,
) -> int:
url = _publish_url()
if not url:
logger.warning(
"Notify skipped slot=%s reason=ntfy_not_configured (NTFY_URL + NTFY_TOPIC)",
slot_id,
)
return 0
click_base = _click_base()
headers: dict[str, str] = {
"Title": title,
"Click": f"{click_base}/?slot={slot_id}",
"Priority": "2" if silent else "3",
"Tags": "pill",
"Actions": json.dumps(
[
{
"action": "view",
"label": "Genommen ✓",
"url": f"{click_base}/?action=take&slot={slot_id}",
},
{
"action": "view",
"label": "+15 Min",
"url": f"{click_base}/?action=snooze&slot={slot_id}&minutes=15",
},
{
"action": "view",
"label": "+30 Min",
"url": f"{click_base}/?action=snooze&slot={slot_id}&minutes=30",
},
]
),
}
if settings.ntfy_token:
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
logger.info("Notify start slot=%s title=%r topic=%s", slot_id, title, settings.ntfy_topic)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, content=body.encode("utf-8"), headers=headers)
resp.raise_for_status()
except httpx.HTTPError as exc:
logger.warning("Notify failed slot=%s error=%s", slot_id, exc)
return 0
logger.info("Notify ok slot=%s", slot_id)
return 1
+140
View File
@@ -0,0 +1,140 @@
from __future__ import annotations
import json
import logging
from typing import Any
import aiosqlite
from pywebpush import WebPushException, webpush
from server.db import utc_now_iso
from server.settings import settings
logger = logging.getLogger(__name__)
async def save_subscription(db: aiosqlite.Connection, sub: dict[str, Any]) -> None:
keys = sub.get("keys", {})
await db.execute(
"""
INSERT INTO push_subscription (endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET p256dh=excluded.p256dh, auth=excluded.auth
""",
(sub["endpoint"], keys.get("p256dh", ""), keys.get("auth", ""), utc_now_iso()),
)
await db.commit()
async def get_subscriptions(db: aiosqlite.Connection) -> list[dict[str, str]]:
cur = await db.execute("SELECT endpoint, p256dh, auth FROM push_subscription")
rows = await cur.fetchall()
await cur.close()
return [{"endpoint": r[0], "p256dh": r[1], "auth": r[2]} for r in rows]
def _vapid_claims() -> dict[str, str]:
return {"sub": settings.vapid_claims_email}
def _endpoint_label(endpoint: str) -> str:
return endpoint[:60] + ("..." if len(endpoint) > 60 else "")
async def send_push(
db: aiosqlite.Connection,
payload: dict[str, Any],
) -> int:
tag = payload.get("tag", "?")
slot_id = payload.get("slot_id", "?")
if not settings.vapid_private_key or not settings.vapid_public_key:
logger.warning("Push skipped slot=%s tag=%s reason=vapid_not_configured", slot_id, tag)
return 0
subs = await get_subscriptions(db)
if not subs:
logger.warning("Push skipped slot=%s tag=%s reason=no_subscriptions", slot_id, tag)
return 0
sent = 0
failed = 0
dead: list[str] = []
logger.info(
"Push start slot=%s tag=%s subscriptions=%d title=%r",
slot_id,
tag,
len(subs),
payload.get("title"),
)
for sub in subs:
endpoint = sub["endpoint"]
subscription = {
"endpoint": endpoint,
"keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]},
}
try:
webpush(
subscription_info=subscription,
data=json.dumps(payload),
vapid_private_key=settings.vapid_private_key,
vapid_claims=_vapid_claims(),
)
sent += 1
logger.info("Push ok slot=%s endpoint=%s", slot_id, _endpoint_label(endpoint))
except WebPushException as exc:
status = exc.response.status_code if exc.response else None
if status in (404, 410):
dead.append(endpoint)
logger.info(
"Push dead slot=%s endpoint=%s status=%s",
slot_id,
_endpoint_label(endpoint),
status,
)
else:
failed += 1
logger.warning(
"Push failed slot=%s endpoint=%s status=%s error=%s",
slot_id,
_endpoint_label(endpoint),
status,
exc,
)
for endpoint in dead:
await db.execute("DELETE FROM push_subscription WHERE endpoint = ?", (endpoint,))
if dead:
await db.commit()
logger.info("Push removed %d dead subscription(s)", len(dead))
logger.info(
"Push done slot=%s tag=%s sent=%d failed=%d dead=%d total=%d",
slot_id,
tag,
sent,
failed,
len(dead),
len(subs),
)
return sent
async def send_slot_reminder(
db: aiosqlite.Connection,
slot_id: str,
title: str,
body: str,
*,
silent: bool = True,
) -> int:
payload = {
"title": title,
"body": body,
"slot_id": slot_id,
"silent": silent,
"tag": f"med-{slot_id}",
}
return await send_push(db, payload)
+3 -3
View File
@@ -13,7 +13,7 @@ from server.ai import generate_daily_motivation, generate_roast_of_the_day
from server.config_loader import load_meds_config, slot_to_dict, today_str from server.config_loader import load_meds_config, slot_to_dict, today_str
from server.db import get_db, utc_now_iso from server.db import get_db, utc_now_iso
from server.messages import pick from server.messages import pick
from server.notify import send_slot_reminder from server.push import send_slot_reminder
from server.slots import build_today, get_log_for_day, mark_missed_for_overdue from server.slots import build_today, get_log_for_day, mark_missed_for_overdue
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,7 +37,7 @@ async def _remind_slot(slot_id: str) -> None:
return return
title = f"{slot.label} — Med-Time!" title = f"{slot.label} — Med-Time!"
body = pick("reminder", label=slot.label, med=slot.meds[0].name if slot.meds else "Medis") body = pick("reminder", label=slot.label, med=slot.meds[0].name if slot.meds else "Medis")
sent = await send_slot_reminder(slot_id, title, body) sent = await send_slot_reminder(db, slot_id, title, body)
logger.info("Reminder finished slot=%s day=%s sent=%d", slot_id, day, sent) logger.info("Reminder finished slot=%s day=%s sent=%d", slot_id, day, sent)
finally: finally:
await db.close() await db.close()
@@ -76,7 +76,7 @@ async def _evening_check() -> None:
logger.info("Evening check marked missed slots=%s", ",".join(marked)) logger.info("Evening check marked missed slots=%s", ",".join(marked))
for slot_id in marked: for slot_id in marked:
body = pick("missed") body = pick("missed")
sent = await send_slot_reminder(slot_id, "Verpasst?", body) sent = await send_slot_reminder(db, slot_id, "Verpasst?", body)
logger.info("Evening reminder slot=%s sent=%d", slot_id, sent) logger.info("Evening reminder slot=%s sent=%d", slot_id, sent)
finally: finally:
await db.close() await db.close()
+3 -4
View File
@@ -10,10 +10,9 @@ class Settings(BaseSettings):
jwt_secret: str = "dev-secret-change-me" jwt_secret: str = "dev-secret-change-me"
jwt_expire_days: int = 90 jwt_expire_days: int = 90
ntfy_url: str = "https://ntfy.schwenk.online" vapid_private_key: str = ""
ntfy_topic: str = "takeyourmeds" vapid_public_key: str = ""
ntfy_token: str = "" vapid_claims_email: str = "mailto:admin@schwenk.online"
ntfy_click_url: str = "https://medis.schwenk.online"
openrouter_api_key: str = "" openrouter_api_key: str = ""
openrouter_model: str = "google/gemini-2.0-flash-001" openrouter_model: str = "google/gemini-2.0-flash-001"
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Generate VAPID key pair for Web Push. Run: python tools/gen_vapid.py"""
from __future__ import annotations
from py_vapid import Vapid
def main() -> None:
vapid = Vapid()
vapid.generate_keys()
private = vapid.private_pem.decode().strip()
public = vapid.public_key.decode().strip() if hasattr(vapid.public_key, "decode") else str(vapid.public_key)
print("Add these to your .env:\n")
print(f"VAPID_PRIVATE_KEY={private}")
print(f"VAPID_PUBLIC_KEY={public}")
if __name__ == "__main__":
main()
+8
View File
@@ -10,6 +10,14 @@ CREATE TABLE IF NOT EXISTS intake_log (
CREATE INDEX IF NOT EXISTS idx_intake_day ON intake_log(day); CREATE INDEX IF NOT EXISTS idx_intake_day ON intake_log(day);
CREATE INDEX IF NOT EXISTS idx_intake_slot_day ON intake_log(slot_id, day); CREATE INDEX IF NOT EXISTS idx_intake_slot_day ON intake_log(slot_id, day);
CREATE TABLE IF NOT EXISTS push_subscription (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint TEXT UNIQUE NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ai_cache ( CREATE TABLE IF NOT EXISTS ai_cache (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
content TEXT NOT NULL, content TEXT NOT NULL,