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_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=
|
||||||
|
|||||||
@@ -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 (4–6 Ziffern) |
|
| `APP_PIN` | Login-PIN (4–6 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 |
|
||||||
|
|||||||
@@ -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}
|
- 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
@@ -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
@@ -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
@@ -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 }) }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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", ""))
|
||||||
|
|||||||
@@ -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.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
@@ -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"
|
||||||
|
|||||||
@@ -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_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,
|
||||||
|
|||||||
Reference in New Issue
Block a user