Revert "feat: replace Web Push with ntfy for medication reminders"
This reverts commit 1cada42370.
This commit is contained in:
+4
-5
@@ -5,11 +5,10 @@ APP_PIN=1234
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
JWT_EXPIRE_DAYS=90
|
||||
|
||||
# ntfy — Erinnerungen an Topic publizieren (ntfy-App abonnieren)
|
||||
NTFY_URL=https://ntfy.schwenk.online
|
||||
NTFY_TOPIC=takeyourmeds
|
||||
NTFY_TOKEN=
|
||||
NTFY_CLICK_URL=https://medis.schwenk.online
|
||||
# VAPID keys für Web Push — generieren mit: python tools/gen_vapid.py
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_CLAIMS_EMAIL=mailto:admin@schwenk.online
|
||||
|
||||
# OpenRouter (optional — Roast-of-the-Day)
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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)
|
||||
|
||||
@@ -11,7 +11,7 @@ Persönliche Medikamenten-PWA mit dezenten ntfy-Erinnerungen, Einnahme-Logging u
|
||||
## Features
|
||||
|
||||
### 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
|
||||
- **PIN-Login** mit langer JWT-Session (90 Tage)
|
||||
- **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 |
|
||||
| DB | SQLite (`data/medis.sqlite`) |
|
||||
| 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) |
|
||||
| KI (optional) | OpenRouter |
|
||||
| Deployment | Docker Compose + Traefik |
|
||||
@@ -57,9 +57,13 @@ pip install -e .
|
||||
|
||||
# 2. Konfiguration
|
||||
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
|
||||
```
|
||||
|
||||
@@ -71,7 +75,7 @@ App: http://localhost:8000
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
@@ -94,10 +98,9 @@ Volumes:
|
||||
| `APP_PIN` | Login-PIN (4–6 Ziffern) |
|
||||
| `JWT_SECRET` | Geheimer Key für JWT (min. 32 Zeichen empfohlen: `openssl rand -hex 32`) |
|
||||
| `JWT_EXPIRE_DAYS` | Session-Laufzeit (Default: 90) |
|
||||
| `NTFY_URL` | ntfy-Server (Default: `https://ntfy.schwenk.online`) |
|
||||
| `NTFY_TOPIC` | Topic für Reminder (Default: `takeyourmeds`) |
|
||||
| `NTFY_TOKEN` | Access-Token zum Publizieren (Bearer) |
|
||||
| `NTFY_CLICK_URL` | PWA-URL für Tap/Actions (Default: `https://medis.schwenk.online`) |
|
||||
| `VAPID_PRIVATE_KEY` | Web-Push Private Key (PEM) |
|
||||
| `VAPID_PUBLIC_KEY` | Web-Push Public Key |
|
||||
| `VAPID_CLAIMS_EMAIL` | mailto:-Adresse für VAPID |
|
||||
| `OPENROUTER_API_KEY` | Optional — für Roast-of-the-Day |
|
||||
| `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
|
||||
|
||||
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 |
|
||||
|---------|------|--------------|
|
||||
| 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/today` | Heutige Slots + Status |
|
||||
| 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/stats` | Streak, Compliance, Meilensteine |
|
||||
| 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
|
||||
2. **Handy:** ntfy-App → Topic `takeyourmeds` abonnieren (oder Link unter **Einstellungen** in der PWA)
|
||||
3. Optional: PWA auf Homescreen — für Loggen/Snooze; Reminder kommen über ntfy
|
||||
1. App im Browser öffnen (HTTPS erforderlich — lokal ohne Push)
|
||||
2. Einloggen → Tab **Einstellungen** → **Push aktivieren**
|
||||
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)
|
||||
|
||||
Test von der Shell:
|
||||
|
||||
```bash
|
||||
curl -d "Test" -H "Title: Med-Time" -H "Authorization: Bearer $NTFY_TOKEN" \
|
||||
https://ntfy.schwenk.online/takeyourmeds
|
||||
```
|
||||
Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
|
||||
|
||||
---
|
||||
|
||||
@@ -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 |
|
||||
| **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 |
|
||||
| **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` |
|
||||
| **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 |
|
||||
@@ -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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
---
|
||||
@@ -199,7 +197,7 @@ takeyourmeds/
|
||||
├── icon.png / icon.svg # App-Icons (Quelle)
|
||||
├── server/ # FastAPI Backend
|
||||
├── public/ # PWA Frontend
|
||||
├── tools/ # schema.sql
|
||||
├── tools/ # schema.sql, gen_vapid.py
|
||||
└── data/ # SQLite (gitignored)
|
||||
```
|
||||
|
||||
@@ -220,8 +218,8 @@ Service Worker cached aggressiv — für SW-Änderungen: DevTools → Applicatio
|
||||
|
||||
| Problem | Lösung |
|
||||
|---------|--------|
|
||||
| Reminder kommt nicht | `NTFY_TOKEN` in `.env`? Topic in ntfy-App abonniert? |
|
||||
| „ntfy nicht konfiguriert" | `NTFY_URL` + `NTFY_TOPIC` in `.env` setzen |
|
||||
| Push kommt nicht | VAPID keys in `.env`? HTTPS? Push in Einstellungen aktiviert? |
|
||||
| „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` |
|
||||
| Streak = 0 trotz Log | Streak zählt nur Tage wo **alle** Slots genommen wurden |
|
||||
| SW zeigt alte Version | `version.json` bumpen + Cache leeren |
|
||||
|
||||
@@ -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
@@ -6,10 +6,9 @@ services:
|
||||
- APP_PIN=${APP_PIN}
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- JWT_EXPIRE_DAYS=${JWT_EXPIRE_DAYS:-90}
|
||||
- NTFY_URL=${NTFY_URL:-https://ntfy.schwenk.online}
|
||||
- NTFY_TOPIC=${NTFY_TOPIC:-takeyourmeds}
|
||||
- NTFY_TOKEN=${NTFY_TOKEN:-}
|
||||
- NTFY_CLICK_URL=${NTFY_CLICK_URL:-https://medis.schwenk.online}
|
||||
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_CLAIMS_EMAIL=${VAPID_CLAIMS_EMAIL:-mailto:admin@schwenk.online}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
- OPENROUTER_MODEL=${OPENROUTER_MODEL:-google/gemini-2.0-flash-001}
|
||||
- DB_PATH=/data/medis.sqlite
|
||||
|
||||
+14
-14
@@ -2,7 +2,7 @@ import { api } from "./lib/api.js";
|
||||
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 } from "./lib/pwa.js";
|
||||
import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.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" };
|
||||
@@ -153,20 +153,20 @@ async function initApp() {
|
||||
location.reload();
|
||||
});
|
||||
|
||||
try {
|
||||
const notify = await api.notifyConfig();
|
||||
const topicEl = document.getElementById("notifyTopic");
|
||||
const linkEl = document.getElementById("notifySubscribeLink");
|
||||
if (notify.configured && notify.subscribe_url) {
|
||||
topicEl.textContent = `Topic: ${notify.topic}`;
|
||||
linkEl.href = notify.subscribe_url;
|
||||
} else {
|
||||
topicEl.textContent = "ntfy nicht konfiguriert (NTFY_URL + NTFY_TOPIC in .env).";
|
||||
linkEl.hidden = true;
|
||||
document.getElementById("enablePush").addEventListener("click", async () => {
|
||||
const statusEl = document.getElementById("pushStatus");
|
||||
try {
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== "granted") { statusEl.textContent = "Permission verweigert."; return; }
|
||||
const { key } = await api.vapidKey();
|
||||
if (!key) { statusEl.textContent = "VAPID keys nicht konfiguriert."; return; }
|
||||
const sub = await subscribePush(key);
|
||||
await api.pushSubscribe(sub);
|
||||
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 refreshDashboard();
|
||||
|
||||
+3
-4
@@ -74,10 +74,9 @@
|
||||
<div id="tab-settings" class="tabPanel" hidden>
|
||||
<div class="settingsGroup">
|
||||
<h2>Benachrichtigungen</h2>
|
||||
<p class="settingsHint">Erinnerungen um 8:00 und 12:00 per ntfy — dezent, kein Alarm.</p>
|
||||
<p id="notifyTopic" class="settingsStatus"></p>
|
||||
<a id="notifySubscribeLink" class="btn btn-accent" href="#" target="_blank" rel="noopener">In ntfy abonnieren</a>
|
||||
<p class="settingsHint">ntfy-App installiert? Link tippen → Topic abonnieren. Danach kommen Reminder zuverlässig.</p>
|
||||
<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>
|
||||
|
||||
+3
-1
@@ -27,5 +27,7 @@ export const api = {
|
||||
stats: () => request("/api/stats"),
|
||||
roast: () => request("/api/roast"),
|
||||
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 }) }),
|
||||
};
|
||||
|
||||
@@ -46,3 +46,23 @@ export async function registerPwa() {
|
||||
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
@@ -1,4 +1,4 @@
|
||||
const CACHE = "tym-v1.1.0";
|
||||
const CACHE = "tym-v1.0.1";
|
||||
const ASSETS = [
|
||||
"/",
|
||||
"/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) => {
|
||||
if (event.tag === "sync-logs") {
|
||||
event.waitUntil(
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "takeyourmeds"
|
||||
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"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"pyjwt>=2.9",
|
||||
"pyyaml>=6.0",
|
||||
"httpx>=0.27",
|
||||
"pywebpush>=2.0",
|
||||
"apscheduler>=3.10",
|
||||
]
|
||||
|
||||
|
||||
+17
-12
@@ -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.db import ensure_schema, get_db, new_id, utc_now_iso
|
||||
from server.messages import pick
|
||||
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
|
||||
@@ -72,18 +73,9 @@ async def auth_pin(payload: dict[str, Any]) -> JSONResponse:
|
||||
return JSONResponse({"token": create_token()}, headers=NO_STORE)
|
||||
|
||||
|
||||
@app.get("/api/notify-config")
|
||||
async def notify_config(_: dict = Depends(require_auth)) -> JSONResponse:
|
||||
base = settings.ntfy_url.rstrip("/") if settings.ntfy_url else ""
|
||||
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/vapid-public-key")
|
||||
async def vapid_public_key() -> JSONResponse:
|
||||
return JSONResponse({"key": settings.vapid_public_key}, headers=NO_STORE)
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
@@ -174,6 +166,19 @@ async def get_stats(_: dict = Depends(require_auth)) -> JSONResponse:
|
||||
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")
|
||||
async def post_snooze(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse:
|
||||
slot_id = str(payload.get("slot_id", ""))
|
||||
|
||||
@@ -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
@@ -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
@@ -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.db import get_db, utc_now_iso
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -37,7 +37,7 @@ async def _remind_slot(slot_id: str) -> None:
|
||||
return
|
||||
title = f"{slot.label} — Med-Time!"
|
||||
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)
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -76,7 +76,7 @@ async def _evening_check() -> None:
|
||||
logger.info("Evening check marked missed slots=%s", ",".join(marked))
|
||||
for slot_id in marked:
|
||||
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)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
+3
-4
@@ -10,10 +10,9 @@ class Settings(BaseSettings):
|
||||
jwt_secret: str = "dev-secret-change-me"
|
||||
jwt_expire_days: int = 90
|
||||
|
||||
ntfy_url: str = "https://ntfy.schwenk.online"
|
||||
ntfy_topic: str = "takeyourmeds"
|
||||
ntfy_token: str = ""
|
||||
ntfy_click_url: str = "https://medis.schwenk.online"
|
||||
vapid_private_key: str = ""
|
||||
vapid_public_key: str = ""
|
||||
vapid_claims_email: str = "mailto:admin@schwenk.online"
|
||||
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_model: str = "google/gemini-2.0-flash-001"
|
||||
|
||||
@@ -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()
|
||||
@@ -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_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 (
|
||||
key TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user