commit eaa019087e37fc754b3255db21c03a74411f6387 Author: Frank Schwenk Date: Tue Jun 9 21:21:52 2026 +0200 debug line test diff --git a/.cursor/rules/plan-mode-discipline.mdc b/.cursor/rules/plan-mode-discipline.mdc new file mode 100644 index 0000000..2a73c72 --- /dev/null +++ b/.cursor/rules/plan-mode-discipline.mdc @@ -0,0 +1,39 @@ +--- +description: Plan Mode strikt einhalten — nie ohne explizite Freigabe implementieren +alwaysApply: true +--- + +# Plan Mode Disziplin + +Wenn **Plan Mode** aktiv ist (``: Plan mode is active), gilt: + +## Verboten ohne explizite Freigabe + +- Keine Code-Änderungen (auch nicht per Shell/Heredoc/Python-Skript) +- Kein `SwitchMode` zu Agent Mode anbieten oder erzwingen +- Keine Commits, Deploys, Package-Installs die Projektdateien ändern +- Keine Umsetzung starten, nur weil der User Features beschreibt oder den Plan verfeinert + +## Erlaubt in Plan Mode + +- Codebase lesen (readonly) +- Plan erstellen/aktualisieren (Markdown, `.plan.md`) +- Fragen klären (`AskQuestion`) +- Architektur und Trade-offs erklären + +## Wann implementieren? + +Nur bei **expliziter** Ausführungsanweisung, z. B.: + +- „Implementiere den Plan“ / „execute the plan“ / „setz um“ / „go ahead“ +- „Ok, mach es“ / „ship it“ — **wenn** klar ist, dass der Plan abgeschlossen ist + +## Nicht als Freigabe werten + +- Feature-Wünsche oder Plan-Iteration („übernimm Feature X“, „nutz anderen Stack“) +- „Umsetzung bitte soviel wie möglich“ **in derselben Nachricht** wie Plan-Feedback — erst Plan finalisieren, dann **nochmal** explizit um Freigabe bitten oder auf Bestätigung warten +- Mode-Switch rejected → **stoppen**, User informieren, nicht über Shell ausweichen + +## Bei Unklarheit + +Immer konservativ: **Plan iterieren**, nicht implementieren. Einmal nachfragen: „Plan steht — soll ich jetzt implementieren?“ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..87b2cc6 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# PIN für Login (4–6 Ziffern empfohlen) +APP_PIN=1234 + +# JWT — zufälligen langen String generieren (openssl rand -hex 32) +JWT_SECRET=change-me-to-a-long-random-string +JWT_EXPIRE_DAYS=90 + +# 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 + KI-Orakel) +OPENROUTER_API_KEY= +OPENROUTER_MODEL=google/gemini-2.0-flash-001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b070237 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +data/ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.DS_Store +*.egg-info/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c5e4e1e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY pyproject.toml /app/pyproject.toml +COPY server /app/server +RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir . + +COPY public /app/public +COPY meds.yaml messages.yaml /app/ +COPY tools /app/tools + +EXPOSE 8000 + +CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..294e804 --- /dev/null +++ b/README.md @@ -0,0 +1,233 @@ +# TakeYourMeds + +Persönliche Medikamenten-PWA mit dezenten Push-Erinnerungen, Einnahme-Logging und sarkastischem Humor. + +**Domain:** [medis.schwenk.online](https://medis.schwenk.online) + +![App Icon](icon.png) + +--- + +## Features + +### Kern +- **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 + +### Spaß +- **Dopamin-Drop** — Animation + Erfolgsspruch beim Loggen +- **Roast-of-the-Day** — täglicher sarkastischer Spruch (OpenRouter, gecacht) +- **KI-Orakel** — wöchentlicher Compliance-Report (OpenRouter, gecacht) +- **Easter Eggs** — Meilenstein-Badges (50/100 Logs, Streak 7/30/100) +- **~95 Humor-Texte** in `messages.yaml` (Reminder, Success, Missed, …) + +### Nützlich +- **Snooze-Kette** — +15 / +30 Min (UI + Notification-Actions) +- **90-Tage Compliance-Heatmap** +- **Manifest Shortcuts** — „Genommen (Morgens/Mittags)" vom Homescreen +- **Badge API** — App-Icon zeigt Streak-Zahl +- **Offline-Queue** — Logs in IndexedDB, Sync bei Verbindung + +--- + +## Stack + +| Komponente | Technologie | +|------------|-------------| +| Backend | FastAPI + uvicorn | +| DB | SQLite (`data/medis.sqlite`) | +| Frontend | Vanilla JS (kein Build-Step) | +| Push | Web Push (pywebpush + VAPID) | +| Scheduler | APScheduler (8:00, 12:00, 21:00) | +| KI (optional) | OpenRouter | +| Deployment | Docker Compose + Traefik | + +**KISS-Entscheidung:** Ein Container statt nginx+api — FastAPI serviert API und statische Dateien. Traefik-Labels aus `compose.example.yml` übernommen. + +--- + +## Schnellstart (lokal) + +```bash +# 1. venv + Dependencies +python3 -m venv .venv +source .venv/bin/activate +pip install -e . + +# 2. Konfiguration +cp .env.example .env +# APP_PIN und JWT_SECRET anpassen + +# 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 +``` + +App: http://localhost:8000 + +--- + +## Deployment (Docker + Traefik) + +```bash +cp .env.example .env +# .env ausfüllen (PIN, JWT_SECRET, VAPID keys, optional OPENROUTER_API_KEY) + +docker compose up -d --build +``` + +Traefik routet `medis.schwenk.online` → Container Port 8000 (TLS via `myresolver`). + +Volumes: +- `./data` — SQLite-Datenbank +- `./meds.yaml` — Medikamenten-Zeitplan (read-only) +- `./messages.yaml` — Humor-Texte (read-only) + +--- + +## Konfiguration + +### `.env` + +| Variable | Beschreibung | +|----------|--------------| +| `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) | +| `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 + Orakel | +| `OPENROUTER_MODEL` | Default: `google/gemini-2.0-flash-001` | + +### `meds.yaml` + +```yaml +timezone: Europe/Berlin +slots: + - id: morning + time: "08:00" + label: Morgens + meds: + - name: Elvanse + dose: "30mg" + reminder_window_minutes: 90 # Karenzzeit — siehe unten +``` + +**`reminder_window_minutes`:** Zeit nach der Soll-Uhrzeit, in der eine Dosis noch als „offen" gilt. +Beispiel: 08:00 + 90 Min → bis 09:30 Status `pending`/`overdue`, erst danach wird sie abends (~21:00) automatisch als `missed` markiert. + +### `messages.yaml` + +Kategorien: `reminder`, `success`, `missed`, `streak`, `snooze`, `easter_egg`, `roast_fallback`. +Texte frei editierbar — kein Neustart nötig (wird beim ersten Request geladen; Container-Neustart lädt neu). + +--- + +## API + +Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer ` (außer `/api/auth/pin` und `/api/vapid-public-key`). + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| POST | `/api/auth/pin` | `{ "pin": "1234" }` → `{ "token": "..." }` | +| 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?" }` | +| POST | `/api/snooze` | `{ "slot_id", "minutes": 15\|30 }` | +| GET | `/api/history?days=90` | Heatmap-Daten + Stats | +| GET | `/api/stats` | Streak, Compliance, Meilensteine | +| GET | `/api/roast` | Roast-of-the-Day | +| GET | `/api/oracle` | Wöchentliches KI-Orakel | +| POST | `/api/push/subscribe` | Web-Push Subscription speichern | + +--- + +## PWA / Push einrichten + +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: **Genommen ✓**, **+15 Min**, **+30 Min** + +--- + +## Icons + +- `icon.png` / `icon.svg` — Quelldateien im Repo-Root +- Werden nach `public/` kopiert für PWA (192, 512, 72 px) +- Manifest `theme_color`: `#F12F12` + +--- + +## Entscheidungsprotokoll + +| Entscheidung | Begründung | +|--------------|------------| +| **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 | +| **Web Push vom Server** statt Client-Timer | Zuverlässig auch bei geschlossener App; APScheduler im Container | +| **OpenRouter nur für Roast + Orakel** | 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 | +| **JWT 90 Tage** | Lange Session auf persönlichem Gerät, kein ständiges PIN-Eingeben | +| **`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 Push | +| **Offline-Queue via IndexedDB** | Einfacher als SW-only; Flush bei App-Start + Background-Sync | + +--- + +## Projektstruktur + +``` +takeyourmeds/ +├── compose.yml # Docker + Traefik +├── Dockerfile +├── meds.yaml # Medikamenten-Zeitplan +├── messages.yaml # Humor-Pool +├── icon.png / icon.svg # App-Icons (Quelle) +├── server/ # FastAPI Backend +├── public/ # PWA Frontend +├── tools/ # schema.sql, gen_vapid.py +└── data/ # SQLite (gitignored) +``` + +--- + +## Entwicklung + +```bash +source .venv/bin/activate +uvicorn server.app:app --reload --port 8000 +``` + +Service Worker cached aggressiv — für SW-Änderungen: DevTools → Application → Clear storage, oder Inkognito. + +--- + +## Troubleshooting + +| Problem | Lösung | +|---------|--------| +| 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 | + +--- + +## Lizenz + +Privates Projekt — persönliche Nutzung. diff --git a/compose.example.yml b/compose.example.yml new file mode 100644 index 0000000..f604e4b --- /dev/null +++ b/compose.example.yml @@ -0,0 +1,45 @@ +services: + api: + build: + context: . + dockerfile: server/Dockerfile + restart: unless-stopped + environment: + - FESTIVAL_ID=${FESTIVAL_ID} + - DB_PATH=/data/festival.sqlite + volumes: + - ./data:/data + - ./tools:/app/tools:ro + # Private, per-project network only. Keeping the API off the shared `traefik` + # network prevents its `api` DNS alias from colliding with other stacks' + # API containers (which caused requests to be served by the wrong backend). + networks: + - internal + + web: + build: + context: . + dockerfile: nginx/Dockerfile + restart: unless-stopped + depends_on: + - api + volumes: + - ./public:/usr/share/nginx/html:ro + # `internal` to reach the API privately; `traefik` for public ingress only. + networks: + - internal + - traefik + labels: + - traefik.enable=true + # Pin the ingress IP to the traefik network (web is multi-homed). + - traefik.docker.network=traefik + - traefik.http.routers.affenschwenkonline.rule=Host(`affen.schwenk.online`) + - traefik.http.routers.affenschwenkonline.entrypoints=websecure + - traefik.http.routers.affenschwenkonline.tls.certresolver=myresolver + - traefik.http.services.affenschwenkonline.loadbalancer.server.port=80 + +networks: + # Private network created per compose project; isolates api<->web traffic. + internal: + traefik: + external: true diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..b70a315 --- /dev/null +++ b/compose.yml @@ -0,0 +1,34 @@ +services: + app: + build: . + restart: unless-stopped + environment: + - APP_PIN=${APP_PIN} + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRE_DAYS=${JWT_EXPIRE_DAYS:-90} + - 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 + - MEDS_PATH=/app/meds.yaml + - MESSAGES_PATH=/app/messages.yaml + - TZ=Europe/Berlin + volumes: + - ./data:/data + - ./meds.yaml:/app/meds.yaml:ro + - ./messages.yaml:/app/messages.yaml:ro + networks: + - traefik + labels: + - traefik.enable=true + - traefik.docker.network=traefik + - traefik.http.routers.takeyourmeds.rule=Host(`medis.schwenk.online`) + - traefik.http.routers.takeyourmeds.entrypoints=websecure + - traefik.http.routers.takeyourmeds.tls.certresolver=myresolver + - traefik.http.services.takeyourmeds.loadbalancer.server.port=8000 + +networks: + traefik: + external: true diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..ffd6a66 Binary files /dev/null and b/icon.png differ diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..d50d4be --- /dev/null +++ b/icon.svg @@ -0,0 +1,56 @@ + + + + diff --git a/meds.yaml b/meds.yaml new file mode 100644 index 0000000..fcdc98e --- /dev/null +++ b/meds.yaml @@ -0,0 +1,16 @@ +timezone: Europe/Berlin +slots: + - id: morning + time: "08:00" + label: Morgens + meds: + - name: Elvanse + dose: "30mg" + reminder_window_minutes: 90 + - id: noon + time: "12:00" + label: Mittags + meds: + - name: Elvanse + dose: "30mg" + reminder_window_minutes: 90 diff --git a/messages.yaml b/messages.yaml new file mode 100644 index 0000000..3eb7180 --- /dev/null +++ b/messages.yaml @@ -0,0 +1,105 @@ +reminder: +- 'Dein Gehirn hat dich gerade angelächelt und gefragt, ob du heute schon an Elvanse + gedacht hast. Spoiler: Nein.' +- Med-Time! Deine Neuronen stehen auf Streik bis du die Kapsel nimmst. +- 'Kleine Erinnerung: Du hast ADHS, nicht ein Superhelden-Gehirn. Nimm die Medis.' +- Es ist {label}-Zeit. Dein zukünftiges Ich bedankt sich. Dein jetziges Ich rollt + mit den Augen. +- Ping! {med} ruft an. Du gehst nicht ran. Typisch. +- 'Reminder: Ohne Medis bist du ein Browser mit 847 offenen Tabs.' +- Zeit für {med}. Oder wir machen weiter mit dem Chaos-Modus? +- Dein Therapeut würde stolz sein. Deine Prokrastination auch — aber aus anderen Gründen. +- Hallo, hier spricht deine Verantwortung. Kurz vorbeigeschaut. +- Medikamenten-Alarm. Aber dezent. Kein Drama. Nur leichtes existenzielles Schuldgefühl. +- Elvanse wartet. Du wartest auch — aber auf die falschen Dinge. +- 'Ding-dong: Gehirn braucht Treibstoff. Nicht Kaffee. Wieder nicht nur Kaffee.' +- 'Morgens/Mittags-Routine: Medis → Funktionieren → Vergessen dass du funktionierst.' +- 'Dein ADHS-Daemon sendet freundliche Grüße: NIMM ES JETZT.' +- 'Reminder #{random}: Ja, du musst das wirklich jeden Tag machen. Willkommen im Erwachsenenleben.' +- 'Kurzer Reality-Check: Hast du schon genommen? Nein? Dann ist das hier dein Zeichen.' +- Dein Gehirn ist wie ein Hamster auf Red Bull. Elvanse ist der Hamster-Rad-Fix. +- 'Notifcation incoming: Du bist verantwortlich für dein Gehirn. Sorry.' +- Zeit! Oder gleich Zeit. Oder vor 20 Minuten war Zeit. ADHS-Zeit ist kompliziert. +- Psst. Medis. Jetzt. Ich beobachte dich. Nicht wirklich. Aber trotzdem. +success: +- Genommen! Dein Gehirn gibt dir einen langsamen High-Five. +- Dopamin-Drop incoming! Du hast was geschafft. Feier das. Kurz. +- 'Medis down. Achievement unlocked: Funktionierender Mensch.' +- Yes! Du gegen ADHS — 1:0. Heute zumindest. +- 'Eingenommen. Dein Gehirn: endlich mal Ruhe. Dein Körper: vielleicht auch.' +- Streak-Material! Du bist offiziell nicht komplett im Chaos. +- Genommen. Ich bin stolz. Dein Gehirn ist erleichtert. Deine Mitmenschen auch. +- 'Wunder geschehen: Du hast dran gedacht. Dokumentiert.' +- Med-Time erledigt. Du darfst jetzt 5 Minuten stolz sein. +- Einnahme geloggt. Dein zukünftiges Ich sendet ein Danke. +- 'Done! Dein ADHS: ''Oh, wir machen heute den Anständigen?''' +- Genommen. Das war fast zu einfach. Verdächtig. +- 'Kapsel: ✓ Gehirn: loading... Bitte warten.' +- Du hast es geschafft. Nicht feiern, du hast noch Stuff zu tun. +- Einnahme bestätigt. Dopamin-Express on the way. +- Hero move! Medis genommen ohne dass jemand dich erinnern musste. Warte, ich hab + dich erinnert. +- Logged. Dein Compliance-Score atmet auf. +- Genommen. Jetzt tu so als wär das normal für dich. +- Check! Dein Gehirn startet neu. Bitte nicht während des Bootens stören. +- 'Medis: erledigt. Chaos: pausiert. Vorübergehend.' +missed: +- Verpasst. Dein Gehirn ist enttäuscht aber nicht überrascht. +- Nicht genommen. Das Chaos hat wieder gewonnen. +- Missed. Dein ADHS schreibt das unter 'character development'. +- Ups. Die Dosis ist vorbeigeflogen wie deine Motivation. +- Verpasst. Morgen ist ein neuer Tag. Oder gleich, je nachdem. +- Nicht geloggt = nicht genommen. Mathe. Auch mit ADHS. +- 'Dein Gehirn: ''Ich wusste es.'' Dein Therapeut: ''Versuch''s morgen.''' +- Verpasst. Das ADHS-Bingo-Feld 'Medis vergessen' ist fast voll. +- 'Chaos-Modus: aktiviert. Medis: deaktiviert. Korrelation? Wahrscheinlich.' +- Missed. Kein Drama. Aber ein bisschen Drama schon. +- Nicht genommen. Deine Neuronen machen jetzt Feierabend. +- Verpasst. Das passiert. Leider häufiger als ideal. +- 'Oops. Dein Gehirn läuft jetzt im Energiesparmodus. Spoiler: nicht sparsam genug.' +- Dosis verpasst. Compliance-Score weint leise. +- Nicht genommen. Aber hey, du hast die App geöffnet. Das zählt. Nein, zählt nicht. +streak: +- 'Streak: {streak} Tage! Du bist offiziell verdächtig diszipliniert.' +- '{streak} Tage am Stück. ADHS.exe läuft stabil.' +- Streak {streak}! Dein Therapeut würde fast stolz sein. +- Wow, {streak} Tage. Bist du sicher, dass du ADHS hast? +- '{streak}-Tage-Streak. Das ist fast schon langweilig. Fast.' +- 'Streak: {streak}. Weiter so, Ausnahme vom ADHS-Gesetz.' +- '{streak} Tage! Dein Gehirn: ''Wer bist du und was hast du mit mir gemacht?''' +- Streak {streak}. Nicht schlecht für jemanden der vergisst wo die Schlüssel sind. +- '{streak} Tage am Stück. Ich bin beeindruckt. Leise.' +- 'Compliance-Streak: {streak}. Weiter. Oder nicht. Aber bitte weiter.' +snooze: +- Okay, noch {minutes} Min. Aber wirklich diesmal. +- 'Snooze aktiviert. Prokrastination: approved.' +- 15 Minuten Aufschub. Dein ADHS jubelt. Dein Therapeut seufzt. +- Später. Das Lieblingswort aller ADHSler. +- Snooze! Die Kunst des 'noch nicht jetzt'. +- Verstanden. Aber ich komme wieder. Immer. +- Aufgeschoben. Nicht aufgehoben. Merken. +- Snooze. Du gewinnst Zeit. Ich gewinne Geduld. Einer von uns lügt. +- +{minutes} Min. Nutze sie weise. Oder scroll Instagram. Typisch. +- Okay okay. Aber dann wirklich. Versprochen? ADHS-Versprechen zählen nicht. +easter_egg: +- Achievement unlocked! Du hast was Seltenes freigeschaltet. +- Easter Egg! Dein Gehirn hat gerade einen Dopamin-Bonus gefunden. +- Seltenes Achievement! Zeig es niemandem, die würden misstrauisch werden. +- Meilenstein! Du darfst 10 Sekunden stolz sein. Los, timer läuft. +- 'Achievement: Du hast länger durchgehalten als dein letztes Hobby.' +- Easter Egg freigeschaltet! Das passiert nicht oft. Genieß es. +- Neues Badge! Deine Sammlung wächst. Dein ADHS vergisst es morgen. +- Achievement! Wenn das dein Therapeut wüsste... +- Selten! Du hast einen Meilenstein erreicht. Dokumentiert für die Nachwelt. +- 'Badge unlocked! Sarkasmus-Level: Expert.' +roast_fallback: +- Dein Gehirn hat heute schon aufgegeben, bevor du die PIN eingegeben hast. +- Elvanse wartet. Du offenbar auch, aber im falschen Sinne. +- 'Compliance-Check: Es könnte schlimmer sein. Aber auch besser. Viel besser.' +- 'Dein ADHS und du: eine toxische Beziehung mit gelegentlichen Höhepunkten.' +- 'Roast-of-the-Day: Du bist der Grund warum Erinnerungs-Apps existieren.' +- Heute schon Medis genommen? Die App weiß es. Du vielleicht nicht. +- Dein Gehirn ist ein Browser mit 847 Tabs. Elvanse ist der Task-Manager. +- 'Fun Fact: Du hast diese App installiert. Das war schon mal was.' +- Dein Streak ist wie deine Motivation — manchmal da, manchmal nicht. +- 'Dark Humor des Tages: Wenigstens hast du die App geöffnet. Fortschritt?' diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..c4f9386 --- /dev/null +++ b/public/app.js @@ -0,0 +1,173 @@ +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, subscribePush } from "./lib/pwa.js"; +import { renderSlots, renderHeatmap, updateStats, showToast, showConfetti, showEasterEgg } from "./lib/ui.js"; + +let pinBuffer = ""; + +const STATUS = { upcoming: "Noch nicht", pending: "Jetzt!", overdue: "Überfällig", snoozed: "Snoozed", taken: "Genommen", missed: "Verpasst" }; + +function showScreen(id) { + document.querySelectorAll(".screen").forEach((s) => { s.hidden = s.id !== id; }); +} + +function wirePinPad() { + const pad = document.getElementById("pinPad"); + const keys = ["1","2","3","4","5","6","7","8","9","⌫","0","✓"]; + pad.innerHTML = keys.map((k) => + `` + ).join(""); + + pad.addEventListener("click", async (e) => { + const btn = e.target.closest("[data-key]"); + if (!btn) return; + const key = btn.dataset.key; + const errEl = document.getElementById("loginError"); + errEl.hidden = true; + + if (key === "⌫") { pinBuffer = pinBuffer.slice(0, -1); } + else if (key === "✓") { + if (pinBuffer.length < 4) return; + try { + const { token } = await api.login(pinBuffer); + setToken(token); + pinBuffer = ""; + await initApp(); + } catch { + errEl.textContent = "Falscher PIN. Dein Gehirn auch."; + errEl.hidden = false; + pinBuffer = ""; + } + } else if (pinBuffer.length < 6) { + pinBuffer += key; + } + document.getElementById("pinDisplay").textContent = "•".repeat(pinBuffer.length) || "••••"; + }); +} + +function wireTabs() { + document.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".tabPanel").forEach((p) => p.hidden = true); + tab.classList.add("active"); + const panel = document.getElementById(`tab-${tab.dataset.tab}`); + panel.hidden = false; + if (tab.dataset.tab === "history") loadHistory(); + }); + }); +} + +async function handleTake(slotId) { + const card = document.querySelector(`[data-slot="${slotId}"]`); + try { + if (!navigator.onLine) { + await enqueue({ slot_id: slotId, status: "taken" }); + showToast("Offline gespeichert — wird synchronisiert."); + return; + } + const result = await api.log({ slot_id: slotId, status: "taken" }); + card?.classList.add("dopamin"); + showConfetti(); + showToast(result.message); + if (result.new_milestones?.length) { + for (const m of result.new_milestones) { + showEasterEgg(m.title, "Achievement freigeschaltet!"); + } + } + await refreshDashboard(result.stats); + } catch (e) { + await enqueue({ slot_id: slotId, status: "taken" }); + showToast("Gespeichert — Sync folgt."); + } +} + +async function handleSnooze(slotId, minutes) { + try { + const result = await api.snooze(slotId, minutes); + showToast(result.message || `Snooze +${minutes} Min`); + await refreshDashboard(); + } catch (e) { + showToast("Snooze fehlgeschlagen."); + } +} + +async function refreshDashboard(stats) { + const [today, roast] = await Promise.all([ + api.today(), + api.roast().catch(() => ({ text: "Dein Gebrain wartet auf Koffein und Medis." })), + ]); + renderSlots(today.slots, handleTake, handleSnooze); + document.getElementById("roastText").textContent = roast.text; + + const s = stats || await api.stats(); + updateStats(s); + await setStreakBadge(s.streak); +} + +async function loadHistory() { + const data = await api.history(90); + renderHeatmap(data.days); + updateStats(data.stats); + const oracle = await api.oracle().catch(() => ({ text: "Das Orakel schweigt. Wahrscheinlich enttäuscht." })); + document.getElementById("oracleText").textContent = oracle.text; +} + +async function handleDeepLink() { + const params = new URLSearchParams(location.search); + const slot = params.get("slot"); + if (params.get("action") === "take" && slot) { + await handleTake(slot); + history.replaceState({}, "", "/"); + } else if (params.get("action") === "snooze" && slot) { + const minutes = parseInt(params.get("minutes") || "15", 10); + await handleSnooze(slot, minutes); + history.replaceState({}, "", "/"); + } +} + +async function initApp() { + showScreen("mainScreen"); + wireTabs(); + wirePwaInstall(); + document.getElementById("logoutBtn").addEventListener("click", () => { + clearToken(); + location.reload(); + }); + + 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."; + } + }); + + await flushQueue((data) => api.log(data)); + await refreshDashboard(); + await handleDeepLink(); +} + +navigator.serviceWorker?.addEventListener("message", (event) => { + if (event.data?.type === "SYNC_QUEUE") flushQueue((data) => api.log(data)); +}); + +async function boot() { + registerPwa(); + if (isLoggedIn()) { + try { await initApp(); return; } catch { clearToken(); } + } + showScreen("loginScreen"); + wirePinPad(); +} + +boot(); diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 0000000..ffd6a66 Binary files /dev/null and b/public/icon-192x192.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 0000000..ffd6a66 Binary files /dev/null and b/public/icon-512x512.png differ diff --git a/public/icon-72x72.png b/public/icon-72x72.png new file mode 100644 index 0000000..ffd6a66 Binary files /dev/null and b/public/icon-72x72.png differ diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000..ffd6a66 Binary files /dev/null and b/public/icon.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..d50d4be --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,56 @@ + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5042ae0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + TakeYourMeds + + +
+ +
+
+ +

TakeYourMeds

+

Dein Gehirn. Deine Medis. Unser Sarkasmus.

+
+
••••
+
+ +
+ + + +
+ + + + + + + + diff --git a/public/lib/api.js b/public/lib/api.js new file mode 100644 index 0000000..70c5d8b --- /dev/null +++ b/public/lib/api.js @@ -0,0 +1,34 @@ +import { getToken, clearToken } from "./auth.js"; + +async function request(path, options = {}) { + const headers = { "Content-Type": "application/json", ...(options.headers || {}) }; + const token = getToken(); + if (token) headers.Authorization = `Bearer ${token}`; + + const resp = await fetch(path, { ...options, headers }); + if (resp.status === 401) { + clearToken(); + window.location.reload(); + throw new Error("Unauthorized"); + } + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || resp.statusText); + } + return resp.json(); +} + +export const api = { + login: (pin) => request("/api/auth/pin", { method: "POST", body: JSON.stringify({ pin }) }), + config: () => request("/api/config"), + today: () => request("/api/today"), + log: (data) => request("/api/log", { method: "POST", body: JSON.stringify(data) }), + history: (days = 90) => request(`/api/history?days=${days}`), + stats: () => request("/api/stats"), + roast: () => request("/api/roast"), + oracle: () => request("/api/oracle"), + snooze: (slot_id, minutes) => request("/api/snooze", { method: "POST", body: JSON.stringify({ slot_id, minutes }) }), + vapidKey: () => request("/api/vapid-public-key"), + pushSubscribe: (subscription) => + request("/api/push/subscribe", { method: "POST", body: JSON.stringify({ subscription }) }), +}; diff --git a/public/lib/auth.js b/public/lib/auth.js new file mode 100644 index 0000000..ee628a0 --- /dev/null +++ b/public/lib/auth.js @@ -0,0 +1,17 @@ +const TOKEN_KEY = "tym_token"; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token) { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY); +} + +export function isLoggedIn() { + return !!getToken(); +} diff --git a/public/lib/badge.js b/public/lib/badge.js new file mode 100644 index 0000000..24b28eb --- /dev/null +++ b/public/lib/badge.js @@ -0,0 +1,12 @@ +export async function setStreakBadge(streak) { + if (!("setAppBadge" in navigator)) return; + try { + if (streak > 0) await navigator.setAppBadge(streak); + else await navigator.clearAppBadge(); + } catch { /* unsupported */ } +} + +export async function clearBadge() { + if (!("clearAppBadge" in navigator)) return; + try { await navigator.clearAppBadge(); } catch { /* noop */ } +} diff --git a/public/lib/offlineQueue.js b/public/lib/offlineQueue.js new file mode 100644 index 0000000..47abae4 --- /dev/null +++ b/public/lib/offlineQueue.js @@ -0,0 +1,44 @@ +const DB_NAME = "tym-offline"; +const STORE = "queue"; + +function openDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "id", autoIncrement: true }); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export async function enqueue(entry) { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).add({ ...entry, queued_at: Date.now() }); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => reject(tx.error); + }); +} + +export async function flushQueue(apiLog) { + const db = await openDb(); + const entries = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readonly"); + const req = tx.objectStore(STORE).getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + + for (const entry of entries) { + try { + await apiLog({ slot_id: entry.slot_id, status: entry.status, source: "offline" }); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE, "readwrite"); + tx.objectStore(STORE).delete(entry.id); + tx.oncomplete = resolve; + tx.onerror = () => reject(tx.error); + }); + } catch { break; } + } + db.close(); +} diff --git a/public/lib/pwa.js b/public/lib/pwa.js new file mode 100644 index 0000000..cbe9941 --- /dev/null +++ b/public/lib/pwa.js @@ -0,0 +1,68 @@ +let deferredInstallPrompt = null; + +function isStandalone() { + return window.matchMedia("(display-mode: standalone)").matches || window.navigator.standalone === true; +} + +export function wirePwaInstall({ sectionId = "pwaInstallSection", buttonId = "installPwa" } = {}) { + const section = document.getElementById(sectionId); + const button = document.getElementById(buttonId); + if (!section || !button) return; + + const hide = () => { section.hidden = true; }; + if (isStandalone()) { hide(); return; } + hide(); + + window.addEventListener("beforeinstallprompt", (e) => { + e.preventDefault(); + deferredInstallPrompt = e; + section.hidden = false; + }); + + window.addEventListener("appinstalled", () => { + deferredInstallPrompt = null; + hide(); + }); + + button.addEventListener("click", async () => { + if (!deferredInstallPrompt) return; + deferredInstallPrompt.prompt(); + await deferredInstallPrompt.userChoice; + deferredInstallPrompt = null; + hide(); + }); +} + +export async function registerPwa() { + if (!("serviceWorker" in navigator)) return; + try { + await navigator.serviceWorker.register("/sw.js"); + if ("sync" in ServiceWorkerRegistration.prototype) { + navigator.serviceWorker.ready.then((reg) => { + reg.sync?.register("sync-logs").catch(() => {}); + }); + } + } catch (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))); +} diff --git a/public/lib/ui.js b/public/lib/ui.js new file mode 100644 index 0000000..55db2d7 --- /dev/null +++ b/public/lib/ui.js @@ -0,0 +1,84 @@ +const STATUS_LABELS = { + upcoming: "Noch nicht", + pending: "Jetzt!", + overdue: "Überfällig", + snoozed: "Snoozed", + taken: "Genommen ✓", + missed: "Verpasst", +}; + +export function showToast(msg, ms = 3000) { + const el = document.getElementById("toast"); + el.textContent = msg; + el.hidden = false; + clearTimeout(el._timer); + el._timer = setTimeout(() => { el.hidden = true; }, ms); +} + +export function showConfetti() { + const el = document.getElementById("confetti"); + el.hidden = false; + el.classList.add("show"); + setTimeout(() => { el.hidden = true; el.classList.remove("show"); }, 650); +} + +export function showEasterEgg(title, text) { + const overlay = document.getElementById("easterEgg"); + overlay.innerHTML = ` +
+

🏆 ${title}

+

${text}

+ +
`; + overlay.hidden = false; + overlay.querySelector("#easterClose").addEventListener("click", () => { + overlay.hidden = true; + }); +} + +export function renderSlots(slots, onTake, onSnooze) { + const container = document.getElementById("slotCards"); + container.innerHTML = slots.map((slot) => { + const taken = slot.status === "taken"; + const canTake = ["pending", "overdue", "snoozed", "upcoming"].includes(slot.status); + return ` +
+
+ ${slot.label} + ${slot.time} +
+
${slot.meds.map((m) => `${m.name} ${m.dose}`).join(", ")}
+ ${STATUS_LABELS[slot.status] || slot.status} + ${canTake && !taken ? ` +
+ + + +
` : ""} +
`; + }).join(""); + + container.querySelectorAll("[data-action]").forEach((btn) => { + btn.addEventListener("click", () => { + const slotId = btn.dataset.slot; + const action = btn.dataset.action; + if (action === "take") onTake(slotId); + else if (action === "snooze15") onSnooze(slotId, 15); + else if (action === "snooze30") onSnooze(slotId, 30); + }); + }); +} + +export function renderHeatmap(days) { + const container = document.getElementById("heatmap"); + container.innerHTML = days.map((d) => + `
` + ).join(""); +} + +export function updateStats(stats) { + document.getElementById("statStreak").textContent = stats.streak; + document.getElementById("statCompliance").textContent = `${stats.compliance_percent}%`; + document.getElementById("statTaken").textContent = stats.total_taken; + document.getElementById("streakBadge").textContent = `🔥 ${stats.streak}`; +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..bbb96c1 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,48 @@ +{ + "name": "TakeYourMeds", + "short_name": "Medis", + "description": "Medikamenten-Erinnerung mit Sarkasmus und Dopamin", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#F12F12", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { + "name": "Genommen (Morgens)", + "short_name": "Morgens ✓", + "url": "/?action=take&slot=morning", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192" + } + ] + }, + { + "name": "Genommen (Mittags)", + "short_name": "Mittags ✓", + "url": "/?action=take&slot=noon", + "icons": [ + { + "src": "/icon-192x192.png", + "sizes": "192x192" + } + ] + } + ] +} \ No newline at end of file diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..3188fef --- /dev/null +++ b/public/styles.css @@ -0,0 +1,236 @@ + +:root { + --primary: #F12F12; + --primary-dark: #c4250e; + --accent-yellow: #FFD600; + --accent-cyan: #00E5FF; + --accent-purple: #7C4DFF; + --bg: #1a1a2e; + --bg-card: #252545; + --bg-card-hover: #2e2e55; + --text: #f0f0ff; + --text-muted: #9999bb; + --success: #00e676; + --warning: #FFD600; + --danger: #ff5252; + --radius: 20px; + --shadow: 0 8px 32px rgba(241, 47, 18, 0.25); +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + font-family: "Fredoka", system-ui, sans-serif; + background: var(--bg); + color: var(--text); + -webkit-tap-highlight-color: transparent; + overscroll-behavior: none; +} + +body { + background: linear-gradient(160deg, #1a1a2e 0%, #2a1040 50%, #1a1a2e 100%); + min-height: 100dvh; +} + +#app { max-width: 480px; margin: 0 auto; min-height: 100dvh; } + +.screen { padding: 1.5rem 1rem 2rem; min-height: 100dvh; } + +/* Login */ +.loginHero { text-align: center; margin: 2rem 0 1.5rem; } +.loginLogo { border-radius: 28px; box-shadow: var(--shadow); margin-bottom: 1rem; } +.loginHero h1 { font-size: 2rem; color: var(--primary); text-shadow: 0 2px 12px rgba(241,47,18,.5); } +.tagline { color: var(--text-muted); margin-top: .5rem; font-size: .95rem; } + +.pinDisplay { + text-align: center; font-size: 2rem; letter-spacing: .5rem; + margin: 1rem 0; color: var(--accent-cyan); min-height: 2.5rem; +} + +.pinPad { + display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; + max-width: 280px; margin: 0 auto; +} + +.pinKey { + aspect-ratio: 1; border: none; border-radius: var(--radius); + background: var(--bg-card); color: var(--text); + font-family: inherit; font-size: 1.5rem; font-weight: 600; + cursor: pointer; transition: transform .1s, background .15s; + box-shadow: 0 4px 12px rgba(0,0,0,.3); +} +.pinKey:active { transform: scale(.93); background: var(--primary); } +.pinKey.wide { grid-column: span 1; font-size: 1rem; } + +.loginError { text-align: center; color: var(--danger); margin-top: 1rem; } + +/* Top bar */ +.topBar { + display: flex; align-items: center; gap: .75rem; + padding: .5rem 0 1rem; +} +.topBar h1 { flex: 1; font-size: 1.5rem; color: var(--primary); } +.topIcon { border-radius: 10px; } +.streakBadge { + background: linear-gradient(135deg, var(--primary), var(--accent-yellow)); + padding: .35rem .75rem; border-radius: 999px; + font-weight: 700; font-size: .9rem; color: #1a1a2e; +} + +/* Tabs */ +.tabNav { + display: flex; gap: .5rem; margin-bottom: 1.25rem; + background: var(--bg-card); border-radius: var(--radius); padding: .35rem; +} +.tab { + flex: 1; border: none; background: transparent; color: var(--text-muted); + font-family: inherit; font-size: .95rem; font-weight: 600; + padding: .6rem; border-radius: calc(var(--radius) - 4px); cursor: pointer; + transition: background .15s, color .15s; +} +.tab.active { background: var(--primary); color: white; } + +.tabPanel { animation: fadeIn .25s ease; } +@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } + +/* Cards */ +.roastCard, .oracleCard { + background: linear-gradient(135deg, var(--bg-card) 0%, #3a1855 100%); + border: 2px solid var(--accent-purple); + border-radius: var(--radius); padding: 1rem 1.25rem; + margin-bottom: 1.25rem; box-shadow: 0 4px 20px rgba(124,77,255,.2); +} +.roastLabel, .oracleLabel { + font-size: .75rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--accent-purple); margin-bottom: .5rem; font-weight: 600; +} +.roastCard p, .oracleCard p { line-height: 1.5; font-size: .95rem; } + +.slotCards { display: flex; flex-direction: column; gap: 1rem; } + +.slotCard { + background: var(--bg-card); border-radius: var(--radius); + padding: 1.25rem; border-left: 5px solid var(--primary); + box-shadow: var(--shadow); transition: transform .2s; + position: relative; overflow: hidden; +} +.slotCard.status-taken { border-left-color: var(--success); opacity: .85; } +.slotCard.status-pending { border-left-color: var(--warning); animation: pulse 2s infinite; } +.slotCard.status-overdue { border-left-color: var(--danger); } +.slotCard.status-snoozed { border-left-color: var(--accent-cyan); } +.slotCard.status-upcoming { border-left-color: var(--text-muted); opacity: .7; } +.slotCard.status-missed { border-left-color: var(--danger); opacity: .7; } + +@keyframes pulse { + 0%, 100% { box-shadow: 0 8px 32px rgba(255,214,0,.15); } + 50% { box-shadow: 0 8px 32px rgba(255,214,0,.4); } +} + +.slotCard.dopamin { animation: dopaminDrop .6s ease; } +@keyframes dopaminDrop { + 0% { transform: scale(1); } + 30% { transform: scale(1.06); } + 60% { transform: scale(.98); } + 100% { transform: scale(1); } +} + +.slotHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; } +.slotLabel { font-size: 1.2rem; font-weight: 700; } +.slotTime { color: var(--text-muted); font-size: .9rem; } +.slotMeds { color: var(--accent-cyan); margin-bottom: .75rem; font-size: .95rem; } +.slotStatus { + display: inline-block; padding: .2rem .6rem; border-radius: 999px; + font-size: .75rem; font-weight: 600; text-transform: uppercase; +} +.status-taken .slotStatus { background: rgba(0,230,118,.2); color: var(--success); } +.status-pending .slotStatus { background: rgba(255,214,0,.2); color: var(--warning); } +.status-overdue .slotStatus { background: rgba(255,82,82,.2); color: var(--danger); } +.status-snoozed .slotStatus { background: rgba(0,229,255,.2); color: var(--accent-cyan); } +.status-upcoming .slotStatus { background: rgba(153,153,187,.2); color: var(--text-muted); } +.status-missed .slotStatus { background: rgba(255,82,82,.15); color: var(--danger); } + +.slotActions { display: flex; gap: .5rem; margin-top: .75rem; flex-wrap: wrap; } + +.btn { + border: none; border-radius: 999px; font-family: inherit; + font-weight: 600; font-size: .95rem; padding: .7rem 1.25rem; + cursor: pointer; transition: transform .1s, opacity .15s; +} +.btn:active { transform: scale(.95); } +.btn-primary { background: var(--primary); color: white; flex: 1; } +.btn-secondary { background: var(--bg-card-hover); color: var(--text); } +.btn-accent { background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); color: white; width: 100%; } +.btn-danger { background: transparent; color: var(--danger); border: 2px solid var(--danger); width: 100%; } +.btn-snooze { background: rgba(0,229,255,.15); color: var(--accent-cyan); font-size: .85rem; padding: .5rem .9rem; } + +/* Stats */ +.statsRow { display: grid; grid-template-columns: repeat(3, 1fr); gap: .75rem; margin-bottom: 1.25rem; } +.statBox { + background: var(--bg-card); border-radius: var(--radius); + padding: 1rem; text-align: center; + border-top: 3px solid var(--primary); +} +.statNum { font-size: 1.75rem; font-weight: 700; color: var(--accent-yellow); } +.statLabel { font-size: .75rem; color: var(--text-muted); margin-top: .25rem; } + +.sectionTitle { font-size: 1rem; color: var(--text-muted); margin-bottom: .75rem; } + +/* Heatmap */ +.heatmap { + display: grid; grid-template-columns: repeat(15, 1fr); gap: 3px; + margin-bottom: 2rem; +} +.heatCell { + aspect-ratio: 1; border-radius: 4px; background: rgba(255,255,255,.06); + cursor: default; transition: transform .1s; +} +.heatCell.good { background: var(--success); opacity: .85; } +.heatCell.partial { background: var(--warning); opacity: .7; } +.heatCell.bad { background: var(--danger); opacity: .7; } +.heatCell.none { background: rgba(255,255,255,.06); } +.heatCell:hover { transform: scale(1.3); z-index: 1; } + +/* Settings */ +.settingsGroup { margin-bottom: 1.5rem; } +.settingsGroup h2 { font-size: 1rem; margin-bottom: .5rem; color: var(--accent-cyan); } +.settingsHint { color: var(--text-muted); font-size: .85rem; margin-bottom: .75rem; line-height: 1.4; } +.settingsStatus { color: var(--text-muted); font-size: .85rem; margin-top: .5rem; } + +/* Toast */ +.toast { + position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); + background: var(--bg-card); border: 2px solid var(--primary); + border-radius: var(--radius); padding: .85rem 1.25rem; + max-width: 90%; z-index: 1000; font-size: .9rem; + box-shadow: var(--shadow); animation: slideUp .3s ease; +} +@keyframes slideUp { from { opacity: 0; transform: translateX(-50%) translateY(20px); } } + +/* Confetti */ +.confetti { + position: fixed; inset: 0; pointer-events: none; z-index: 999; + background: radial-gradient(circle at 20% 50%, var(--primary) 0%, transparent 50%), + radial-gradient(circle at 80% 30%, var(--accent-yellow) 0%, transparent 40%), + radial-gradient(circle at 50% 80%, var(--accent-cyan) 0%, transparent 45%); + opacity: 0; transition: opacity .3s; +} +.confetti.show { opacity: .6; animation: confettiFade .6s ease forwards; } +@keyframes confettiFade { + 0% { opacity: .7; } + 100% { opacity: 0; } +} + +/* Easter egg overlay */ +.easterEgg { + position: fixed; inset: 0; background: rgba(0,0,0,.7); + display: flex; align-items: center; justify-content: center; + z-index: 2000; animation: fadeIn .3s ease; +} +.easterEggInner { + background: linear-gradient(135deg, var(--primary), var(--accent-purple)); + border-radius: var(--radius); padding: 2rem; text-align: center; + max-width: 85%; box-shadow: 0 20px 60px rgba(0,0,0,.5); +} +.easterEggInner h2 { font-size: 1.5rem; margin-bottom: .75rem; } +.easterEggInner p { margin-bottom: 1.25rem; line-height: 1.5; } diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..30291fd --- /dev/null +++ b/public/sw.js @@ -0,0 +1,131 @@ +const CACHE = "tym-v1.0.0"; +const ASSETS = [ + "/", + "/index.html", + "/styles.css", + "/app.js", + "/manifest.json", + "/version.json", + "/icon.png", + "/icon-192x192.png", + "/icon-512x512.png", + "/lib/api.js", + "/lib/auth.js", + "/lib/badge.js", + "/lib/offlineQueue.js", + "/lib/pwa.js", + "/lib/ui.js", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((names) => + Promise.all(names.filter((n) => n !== CACHE).map((n) => caches.delete(n))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + if (url.pathname.startsWith("/api/")) { + event.respondWith(fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), { + status: 503, headers: { "Content-Type": "application/json" }, + }))); + return; + } + + if (request.mode === "navigate") { + event.respondWith( + fetch(request).catch(() => caches.match("/index.html")) + ); + return; + } + + event.respondWith( + caches.match(request, { ignoreSearch: true }).then((cached) => + cached || fetch(request).then((resp) => { + if (resp.ok) { + const clone = resp.clone(); + caches.open(CACHE).then((c) => c.put(request, clone)); + } + return resp; + }) + ) + ); +}); + +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( + self.clients.matchAll().then((list) => { + list.forEach((c) => c.postMessage({ type: "SYNC_QUEUE" })); + }) + ); + } +}); diff --git a/public/version.json b/public/version.json new file mode 100644 index 0000000..a20213f --- /dev/null +++ b/public/version.json @@ -0,0 +1,5 @@ +{ + "version": "1.0.0", + "buildTime": "2026-06-09T19:02:13.248375Z", + "buildHash": "92521fc3cbd964bd" +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4d9880c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "takeyourmeds" +version = "1.0.0" +description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "aiosqlite>=0.20", + "pydantic-settings>=2.2", + "python-dotenv>=1.0", + "pyjwt>=2.9", + "pyyaml>=6.0", + "httpx>=0.27", + "pywebpush>=2.0", + "apscheduler>=3.10", +] + +[build-system] +requires = ["setuptools>=70", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["server"] diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/ai.py b/server/ai.py new file mode 100644 index 0000000..c48e0be --- /dev/null +++ b/server/ai.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +import aiosqlite +import httpx + +from server.db import utc_now_iso +from server.messages import pick +from server.settings import settings +from server.stats import compute_stats + + +async def _get_cache(db: aiosqlite.Connection, key: str) -> str | None: + cur = await db.execute("SELECT content FROM ai_cache WHERE key = ?", (key,)) + row = await cur.fetchone() + await cur.close() + return row[0] if row else None + + +async def _set_cache(db: aiosqlite.Connection, key: str, content: str) -> None: + await db.execute( + "INSERT INTO ai_cache (key, content, created_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET content=excluded.content, created_at=excluded.created_at", + (key, content, utc_now_iso()), + ) + await db.commit() + + +async def _call_openrouter(prompt: str) -> str | None: + if not settings.openrouter_api_key: + return None + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {settings.openrouter_api_key}", + "HTTP-Referer": "https://medis.schwenk.online", + "X-Title": "TakeYourMeds", + }, + json={ + "model": settings.openrouter_model, + "messages": [ + { + "role": "system", + "content": "Du bist ein sarkastischer, dark-humor Medikamenten-Coach für jemanden mit ADHS. Kurz (max 2 Sätze), deutsch, witzig aber nicht gemein. Keine medizinischen Ratschläge.", + }, + {"role": "user", "content": prompt}, + ], + "max_tokens": 120, + }, + ) + resp.raise_for_status() + data = resp.json() + return data["choices"][0]["message"]["content"].strip() + except Exception: + return None + + +async def get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]: + day = datetime.now(tz).strftime("%Y-%m-%d") + key = f"roast:{day}" + cached = await _get_cache(db, key) + if cached: + return {"text": cached, "source": "cache", "day": day} + + stats = await compute_stats(db) + prompt = f"Roast-of-the-Day für jemanden mit ADHS. Streak: {stats['streak']} Tage, Compliance 90d: {stats['compliance_percent']}%. Ein sarkastischer Spruch." + text = await _call_openrouter(prompt) + if not text: + text = pick("roast_fallback") + source = "fallback" + else: + source = "ai" + await _set_cache(db, key, text) + + return {"text": text, "source": source, "day": day} + + +async def get_oracle(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]: + week = datetime.now(tz).strftime("%Y-W%W") + key = f"oracle:{week}" + cached = await _get_cache(db, key) + if cached: + return {"text": cached, "source": "cache", "week": week} + + stats = await compute_stats(db) + prompt = ( + f"Wöchentlicher KI-Orakel-Report (passiv-aggressiv, max 4 Sätze). " + f"Streak: {stats['streak']}, Compliance 90d: {stats['compliance_percent']}%, " + f"genommen gesamt: {stats['total_taken']}." + ) + text = await _call_openrouter(prompt) + if not text: + text = pick("streak", streak=stats["streak"]) + f" Compliance: {stats['compliance_percent']}%." + source = "fallback" + else: + source = "ai" + await _set_cache(db, key, text) + + return {"text": text, "source": source, "week": week} diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..730f516 --- /dev/null +++ b/server/app.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import pathlib +from contextlib import asynccontextmanager +from datetime import datetime +from typing import Any + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from server.ai import get_oracle, get_roast_of_the_day +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 +from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS + +NO_STORE = {"Cache-Control": "no-store"} + + +@asynccontextmanager +async def lifespan(app: FastAPI): + db = await get_db() + await ensure_schema(db) + await db.close() + start_scheduler() + yield + + +app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan) + + +@app.post("/api/auth/pin") +async def auth_pin(payload: dict[str, Any]) -> JSONResponse: + pin = str(payload.get("pin", "")) + if not verify_pin(pin): + raise HTTPException(status_code=401, detail="Falscher PIN") + return JSONResponse({"token": create_token()}, 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") +async def get_config(_: dict = Depends(require_auth)) -> JSONResponse: + config = load_meds_config() + return JSONResponse( + { + "timezone": str(config.timezone), + "slots": [slot_to_dict(s) for s in config.slots], + "version": settings.app_version, + }, + headers=NO_STORE, + ) + + +@app.get("/api/today") +async def get_today(_: dict = Depends(require_auth)) -> JSONResponse: + db = await get_db() + try: + data = await build_today(db) + finally: + await db.close() + return JSONResponse(data, headers=NO_STORE) + + +@app.post("/api/log") +async def post_log(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse: + slot_id = str(payload.get("slot_id", "")) + status = str(payload.get("status", "taken")) + source = str(payload.get("source", "app")) + + if status not in ("taken", "missed", "snoozed"): + raise HTTPException(status_code=400, detail="Invalid status") + + config = load_meds_config() + if slot_id not in {s.id for s in config.slots}: + raise HTTPException(status_code=400, detail="Unknown slot") + + day = payload.get("day") or today_str(config.timezone) + logged_at = payload.get("logged_at") or utc_now_iso() + + db = await get_db() + try: + await db.execute("DELETE FROM intake_log WHERE slot_id = ? AND day = ?", (slot_id, day)) + await db.execute( + "INSERT INTO intake_log (id, slot_id, day, status, logged_at, source) VALUES (?, ?, ?, ?, ?, ?)", + (new_id(), slot_id, day, status, logged_at, source), + ) + if status == "taken": + await db.execute("DELETE FROM snooze WHERE slot_id = ? AND day = ?", (slot_id, day)) + await db.commit() + stats = await compute_stats(db) + new_milestones = await check_milestones(db, stats["streak"]) + finally: + await db.close() + + message = pick("success") if status == "taken" else pick("missed") + return JSONResponse( + { + "ok": True, + "message": message, + "stats": stats, + "new_milestones": [ + {"id": m, "title": MILESTONE_DEFS.get(m, m)} for m in new_milestones + ], + }, + headers=NO_STORE, + ) + + +@app.get("/api/history") +async def get_history(days: int = 90, _: dict = Depends(require_auth)) -> JSONResponse: + db = await get_db() + try: + data = await build_history(db, days=min(days, 365)) + finally: + await db.close() + return JSONResponse(data, headers=NO_STORE) + + +@app.get("/api/stats") +async def get_stats(_: dict = Depends(require_auth)) -> JSONResponse: + db = await get_db() + try: + stats = await compute_stats(db) + finally: + await db.close() + 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", "")) + minutes = int(payload.get("minutes", 15)) + if minutes not in (15, 30): + raise HTTPException(status_code=400, detail="minutes must be 15 or 30") + + config = load_meds_config() + if slot_id not in {s.id for s in config.slots}: + raise HTTPException(status_code=400, detail="Unknown slot") + + until = await schedule_snooze(slot_id, minutes) + return JSONResponse( + {"ok": True, "snooze_until": until, "message": pick("snooze")}, + headers=NO_STORE, + ) + + +@app.get("/api/roast") +async def get_roast(_: dict = Depends(require_auth)) -> JSONResponse: + config = load_meds_config() + db = await get_db() + try: + data = await get_roast_of_the_day(db, config.timezone) + finally: + await db.close() + return JSONResponse(data, headers=NO_STORE) + + +@app.get("/api/oracle") +async def get_oracle_route(_: dict = Depends(require_auth)) -> JSONResponse: + config = load_meds_config() + db = await get_db() + try: + data = await get_oracle(db, config.timezone) + finally: + await db.close() + return JSONResponse(data, headers=NO_STORE) + + +# Static files — must be after API routes +PUBLIC = pathlib.Path(settings.public_dir) +if PUBLIC.exists(): + app.mount("/", StaticFiles(directory=str(PUBLIC), html=True), name="static") diff --git a/server/auth.py b/server/auth.py new file mode 100644 index 0000000..e21d195 --- /dev/null +++ b/server/auth.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import hmac +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt +from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from server.settings import settings + +_bearer = HTTPBearer(auto_error=False) +ALGORITHM = "HS256" + + +def verify_pin(pin: str) -> bool: + return hmac.compare_digest(pin.strip(), settings.app_pin.strip()) + + +def create_token() -> str: + expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_expire_days) + payload = {"sub": "user", "exp": expire} + return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict[str, Any]: + try: + return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM]) + except jwt.PyJWTError as exc: + raise HTTPException(status_code=401, detail="Invalid token") from exc + + +async def require_auth( + creds: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> dict[str, Any]: + if creds is None or creds.scheme.lower() != "bearer": + raise HTTPException(status_code=401, detail="Missing token") + return decode_token(creds.credentials) diff --git a/server/config_loader.py b/server/config_loader.py new file mode 100644 index 0000000..a37e7d2 --- /dev/null +++ b/server/config_loader.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import pathlib +from dataclasses import dataclass, field +from datetime import datetime, time +from typing import Any +from zoneinfo import ZoneInfo + +import yaml + +from server.settings import settings + + +@dataclass +class Med: + name: str + dose: str + + +@dataclass +class Slot: + id: str + time: time + label: str + meds: list[Med] + reminder_window_minutes: int = 90 + + +@dataclass +class MedsConfig: + timezone: ZoneInfo + slots: list[Slot] = field(default_factory=list) + + +_config: MedsConfig | None = None + + +def load_meds_config(path: str | None = None) -> MedsConfig: + global _config + if _config is not None: + return _config + p = pathlib.Path(path or settings.meds_path) + raw = yaml.safe_load(p.read_text(encoding="utf-8")) + tz = ZoneInfo(raw.get("timezone", "Europe/Berlin")) + slots: list[Slot] = [] + for item in raw.get("slots", []): + h, m = str(item["time"]).split(":") + slots.append( + Slot( + id=item["id"], + time=time(int(h), int(m)), + label=item.get("label", item["id"]), + meds=[Med(**m) for m in item.get("meds", [])], + reminder_window_minutes=int(item.get("reminder_window_minutes", 90)), + ) + ) + _config = MedsConfig(timezone=tz, slots=slots) + return _config + + +def reload_meds_config() -> MedsConfig: + global _config + _config = None + return load_meds_config() + + +def slot_datetime(day: datetime, slot: Slot, tz: ZoneInfo) -> datetime: + local = day.astimezone(tz) if day.tzinfo else day.replace(tzinfo=tz) + return local.replace(hour=slot.time.hour, minute=slot.time.minute, second=0, microsecond=0) + + +def today_str(tz: ZoneInfo) -> str: + return datetime.now(tz).strftime("%Y-%m-%d") + + +def slot_to_dict(slot: Slot) -> dict[str, Any]: + return { + "id": slot.id, + "time": slot.time.strftime("%H:%M"), + "label": slot.label, + "meds": [{"name": m.name, "dose": m.dose} for m in slot.meds], + "reminder_window_minutes": slot.reminder_window_minutes, + } diff --git a/server/db.py b/server/db.py new file mode 100644 index 0000000..b5ea68e --- /dev/null +++ b/server/db.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import pathlib +import uuid +from datetime import datetime, timezone + +import aiosqlite + +from server.settings import settings + +SCHEMA_PATH = str(pathlib.Path(__file__).resolve().parents[1] / "tools" / "schema.sql") + + +def new_id() -> str: + return str(uuid.uuid4()) + + +def utc_now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +async def get_db() -> aiosqlite.Connection: + pathlib.Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True) + db = await aiosqlite.connect(settings.db_path) + await db.execute("PRAGMA foreign_keys = ON;") + return db + + +async def ensure_schema(db: aiosqlite.Connection) -> None: + schema = pathlib.Path(SCHEMA_PATH).read_text(encoding="utf-8") + await db.executescript(schema) + await db.commit() diff --git a/server/messages.py b/server/messages.py new file mode 100644 index 0000000..4842aa4 --- /dev/null +++ b/server/messages.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pathlib +import random +from typing import Any + +import yaml + +from server.settings import settings + +_pool: dict[str, list[str]] | None = None + +DEFAULT_MESSAGES: dict[str, list[str]] = { + "reminder": ["Zeit für Medis. Dein Gehirn wartet.", "Med-Time. Nicht vergessen."], + "success": ["Genommen. Held des Tages.", "Dopamin incoming."], + "missed": ["Verpasst. Das Gehirn ist enttäuscht.", "Nächstes Mal vielleicht."], + "streak": ["Streak läuft!", "Weiter so, Ausnahme vom ADHS-Gesetz."], + "snooze": ["Okay, noch 15 Min. Aber wirklich.", "Snooze aktiviert. Prokrastination approved."], + "easter_egg": ["Achievement unlocked!", "Du hast was Seltenes freigeschaltet."], + "roast_fallback": [ + "Dein Gehirn hat heute schon aufgegeben, bevor du die PIN eingegeben hast.", + "Elvanse wartet. Du offenbar auch, aber im falschen Sinne.", + ], +} + + +def load_messages(path: str | None = None) -> dict[str, list[str]]: + global _pool + if _pool is not None: + return _pool + p = pathlib.Path(path or settings.messages_path) + if p.exists(): + raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + _pool = {**DEFAULT_MESSAGES, **{k: v for k, v in raw.items() if isinstance(v, list)}} + else: + _pool = DEFAULT_MESSAGES + return _pool + + +def pick(category: str, **fmt: Any) -> str: + pool = load_messages() + choices = pool.get(category) or pool.get("reminder", ["Medis."]) + text = random.choice(choices) + if fmt: + try: + text = text.format(**fmt) + except (KeyError, IndexError): + pass + return text diff --git a/server/push.py b/server/push.py new file mode 100644 index 0000000..78850a2 --- /dev/null +++ b/server/push.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import json +from typing import Any + +import aiosqlite +from pywebpush import WebPushException, webpush + +from server.db import utc_now_iso +from server.settings import settings + + +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} + + +async def send_push( + db: aiosqlite.Connection, + payload: dict[str, Any], +) -> int: + if not settings.vapid_private_key or not settings.vapid_public_key: + return 0 + + subs = await get_subscriptions(db) + sent = 0 + dead: list[str] = [] + + for sub in subs: + subscription = { + "endpoint": sub["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 + except WebPushException as exc: + if exc.response and exc.response.status_code in (404, 410): + dead.append(sub["endpoint"]) + + for endpoint in dead: + await db.execute("DELETE FROM push_subscription WHERE endpoint = ?", (endpoint,)) + if dead: + await db.commit() + + 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) diff --git a/server/scheduler.py b/server/scheduler.py new file mode 100644 index 0000000..16cd4d9 --- /dev/null +++ b/server/scheduler.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timedelta + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger + +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.push import send_slot_reminder +from server.slots import build_today, get_log_for_day, mark_missed_for_overdue + +logger = logging.getLogger(__name__) +scheduler = AsyncIOScheduler() + + +async def _remind_slot(slot_id: str) -> None: + config = load_meds_config() + tz = config.timezone + day = today_str(tz) + db = await get_db() + try: + logs = await get_log_for_day(db, day) + if slot_id in logs and logs[slot_id]["status"] == "taken": + return + slot = next((s for s in config.slots if s.id == slot_id), None) + if not slot: + return + title = f"{slot.label} — Med-Time!" + body = pick("reminder", label=slot.label, med=slot.meds[0].name if slot.meds else "Medis") + await send_slot_reminder(db, slot_id, title, body) + finally: + await db.close() + + +async def _evening_check() -> None: + db = await get_db() + try: + marked = await mark_missed_for_overdue(db) + for slot_id in marked: + body = pick("missed") + await send_slot_reminder(db, slot_id, "Verpasst?", body) + finally: + await db.close() + + +async def schedule_snooze(slot_id: str, minutes: int) -> str: + config = load_meds_config() + tz = config.timezone + now = datetime.now(tz) + until = now + timedelta(minutes=minutes) + day = today_str(tz) + + db = await get_db() + try: + await db.execute( + """ + INSERT INTO snooze (slot_id, day, snooze_until) VALUES (?, ?, ?) + ON CONFLICT(slot_id, day) DO UPDATE SET snooze_until=excluded.snooze_until + """, + (slot_id, day, until.isoformat()), + ) + await db.commit() + finally: + await db.close() + + run_at = until.astimezone(tz).replace(tzinfo=None) + scheduler.add_job( + _remind_slot, + trigger=DateTrigger(run_date=run_at), + args=[slot_id], + id=f"snooze-{slot_id}-{until.timestamp()}", + replace_existing=False, + ) + return until.isoformat() + + +def start_scheduler() -> None: + config = load_meds_config() + tz = config.timezone + + for slot in config.slots: + h, m = slot.time.hour, slot.time.minute + scheduler.add_job( + _remind_slot, + trigger=CronTrigger(hour=h, minute=m, timezone=tz), + args=[slot.id], + id=f"remind-{slot.id}", + replace_existing=True, + ) + + scheduler.add_job( + _evening_check, + trigger=CronTrigger(hour=21, minute=0, timezone=tz), + id="evening-check", + replace_existing=True, + ) + + if not scheduler.running: + scheduler.start() + logger.info("Scheduler started for timezone %s", tz) diff --git a/server/settings.py b/server/settings.py new file mode 100644 index 0000000..ab80bad --- /dev/null +++ b/server/settings.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + app_pin: str = "1234" + jwt_secret: str = "dev-secret-change-me" + jwt_expire_days: int = 90 + + 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" + + db_path: str = "data/medis.sqlite" + meds_path: str = "meds.yaml" + messages_path: str = "messages.yaml" + + public_dir: str = "public" + app_version: str = "1.0.0" + + +settings = Settings() # type: ignore[call-arg] diff --git a/server/slots.py b/server/slots.py new file mode 100644 index 0000000..ec8fbb6 --- /dev/null +++ b/server/slots.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +import aiosqlite + +from server.config_loader import MedsConfig, Slot, load_meds_config, slot_datetime, today_str +from server.db import utc_now_iso + + +async def get_log_for_day(db: aiosqlite.Connection, day: str) -> dict[str, dict[str, Any]]: + cur = await db.execute( + "SELECT slot_id, status, logged_at, source FROM intake_log WHERE day = ? ORDER BY logged_at DESC", + (day,), + ) + rows = await cur.fetchall() + await cur.close() + result: dict[str, dict[str, Any]] = {} + for slot_id, status, logged_at, source in rows: + if slot_id not in result: + result[slot_id] = {"status": status, "logged_at": logged_at, "source": source} + return result + + +async def get_snooze_for_day(db: aiosqlite.Connection, day: str) -> dict[str, str]: + cur = await db.execute("SELECT slot_id, snooze_until FROM snooze WHERE day = ?", (day,)) + rows = await cur.fetchall() + await cur.close() + return {slot_id: snooze_until for slot_id, snooze_until in rows} + + +def compute_status( + slot: Slot, + now: datetime, + tz: ZoneInfo, + log_entry: dict[str, Any] | None, + snooze_until: str | None, +) -> str: + if log_entry: + return log_entry["status"] + + slot_dt = slot_datetime(now, slot, tz) + window_end = slot_dt + timedelta(minutes=slot.reminder_window_minutes) + + if snooze_until: + try: + snooze_dt = datetime.fromisoformat(snooze_until) + if now < snooze_dt: + return "snoozed" + except ValueError: + pass + + if now < slot_dt: + return "upcoming" + + if now <= window_end: + return "pending" + + return "overdue" + + +async def build_today(db: aiosqlite.Connection, config: MedsConfig | None = None) -> dict[str, Any]: + config = config or load_meds_config() + tz = config.timezone + now = datetime.now(tz) + day = today_str(tz) + logs = await get_log_for_day(db, day) + snoozes = await get_snooze_for_day(db, day) + + slots_out: list[dict[str, Any]] = [] + for slot in config.slots: + log_entry = logs.get(slot.id) + snooze_until = snoozes.get(slot.id) + status = compute_status(slot, now, tz, log_entry, snooze_until) + slot_dt = slot_datetime(now, slot, tz) + window_end = slot_dt + timedelta(minutes=slot.reminder_window_minutes) + slots_out.append( + { + "id": slot.id, + "time": slot.time.strftime("%H:%M"), + "label": slot.label, + "meds": [{"name": m.name, "dose": m.dose} for m in slot.meds], + "status": status, + "logged_at": log_entry["logged_at"] if log_entry else None, + "window_end": window_end.isoformat(), + "snooze_until": snooze_until, + } + ) + + return {"day": day, "timezone": str(tz), "now": now.isoformat(), "slots": slots_out} + + +async def mark_missed_for_overdue(db: aiosqlite.Connection, config: MedsConfig | None = None) -> list[str]: + from server.db import new_id + + config = config or load_meds_config() + tz = config.timezone + now = datetime.now(tz) + day = today_str(tz) + logs = await get_log_for_day(db, day) + marked: list[str] = [] + + for slot in config.slots: + if slot.id in logs: + continue + status = compute_status(slot, now, tz, None, None) + if status == "overdue": + await db.execute( + "INSERT INTO intake_log (id, slot_id, day, status, logged_at, source) VALUES (?, ?, ?, ?, ?, ?)", + (new_id(), slot.id, day, "missed", utc_now_iso(), "system"), + ) + marked.append(slot.id) + + if marked: + await db.commit() + return marked diff --git a/server/stats.py b/server/stats.py new file mode 100644 index 0000000..c4bbc45 --- /dev/null +++ b/server/stats.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +import aiosqlite + +from server.config_loader import load_meds_config, today_str +from server.db import new_id, utc_now_iso + +MILESTONE_DEFS: dict[str, str] = { + "log_50": "Pharma-Intern: 50 Logs. Dein Arzt wäre stolz. Vielleicht.", + "log_100": "Century Club: 100 Logs. Du bist offiziell zuverlässiger als dein WLAN.", + "streak_7": "Wochenkrieger: 7 Tage Streak. ADHS.exe has stopped crashing.", + "streak_30": "Monatslegende: 30 Tage. Das ist fast schon verdächtig diszipliniert.", + "streak_100": "Unmöglichkeitsgrad: 100 Tage Streak. Cheater oder Heilung?", +} + + +async def count_logs(db: aiosqlite.Connection) -> int: + cur = await db.execute("SELECT COUNT(*) FROM intake_log WHERE status = 'taken'") + row = await cur.fetchone() + await cur.close() + return int(row[0]) if row else 0 + + +async def get_milestones(db: aiosqlite.Connection) -> list[dict[str, str]]: + cur = await db.execute("SELECT id, unlocked_at FROM milestones ORDER BY unlocked_at") + rows = await cur.fetchall() + await cur.close() + return [{"id": r[0], "title": MILESTONE_DEFS.get(r[0], r[0]), "unlocked_at": r[1]} for r in rows] + + +async def unlock_milestone(db: aiosqlite.Connection, milestone_id: str) -> bool: + cur = await db.execute("SELECT 1 FROM milestones WHERE id = ?", (milestone_id,)) + exists = await cur.fetchone() + await cur.close() + if exists: + return False + await db.execute( + "INSERT INTO milestones (id, unlocked_at) VALUES (?, ?)", + (milestone_id, utc_now_iso()), + ) + await db.commit() + return True + + +async def check_milestones(db: aiosqlite.Connection, streak: int) -> list[str]: + new: list[str] = [] + total = await count_logs(db) + checks = [] + if total >= 50: + checks.append("log_50") + if total >= 100: + checks.append("log_100") + if streak >= 7: + checks.append("streak_7") + if streak >= 30: + checks.append("streak_30") + if streak >= 100: + checks.append("streak_100") + for mid in checks: + if await unlock_milestone(db, mid): + new.append(mid) + return new + + +async def day_compliance(db: aiosqlite.Connection, day: str, slot_ids: list[str]) -> bool: + if not slot_ids: + return True + placeholders = ",".join("?" * len(slot_ids)) + cur = await db.execute( + f"SELECT COUNT(DISTINCT slot_id) FROM intake_log WHERE day = ? AND status = 'taken' AND slot_id IN ({placeholders})", + (day, *slot_ids), + ) + row = await cur.fetchone() + await cur.close() + return int(row[0]) >= len(slot_ids) if row else False + + +async def compute_streak(db: aiosqlite.Connection, tz: ZoneInfo) -> int: + config = load_meds_config() + slot_ids = [s.id for s in config.slots] + streak = 0 + day = datetime.now(tz).date() + while True: + day_str = day.isoformat() + ok = await day_compliance(db, day_str, slot_ids) + if not ok: + break + streak += 1 + day -= timedelta(days=1) + return streak + + +async def compute_stats(db: aiosqlite.Connection) -> dict[str, Any]: + config = load_meds_config() + tz = config.timezone + slot_ids = [s.id for s in config.slots] + streak = await compute_streak(db, tz) + + days = 90 + taken = 0 + total = 0 + today = datetime.now(tz).date() + for i in range(days): + d = (today - timedelta(days=i)).isoformat() + for sid in slot_ids: + total += 1 + cur = await db.execute( + "SELECT 1 FROM intake_log WHERE day = ? AND slot_id = ? AND status = 'taken' LIMIT 1", + (d, sid), + ) + if await cur.fetchone(): + taken += 1 + await cur.close() + + compliance = round((taken / total) * 100, 1) if total else 0.0 + milestones = await get_milestones(db) + + return { + "streak": streak, + "compliance_percent": compliance, + "total_taken": await count_logs(db), + "milestones": milestones, + } + + +async def build_history(db: aiosqlite.Connection, days: int = 90) -> dict[str, Any]: + config = load_meds_config() + tz = config.timezone + slot_ids = [s.id for s in config.slots] + today = datetime.now(tz).date() + days_out: list[dict[str, Any]] = [] + + for i in range(days - 1, -1, -1): + d = (today - timedelta(days=i)).isoformat() + cur = await db.execute( + "SELECT slot_id, status FROM intake_log WHERE day = ?", + (d,), + ) + rows = await cur.fetchall() + await cur.close() + by_slot = {sid: st for sid, st in rows} + slots_status = {} + all_taken = True + any_missed = False + for sid in slot_ids: + st = by_slot.get(sid) + slots_status[sid] = st or "none" + if st != "taken": + all_taken = False + if st == "missed": + any_missed = True + if all_taken and slot_ids: + level = "good" + elif any_missed: + level = "bad" + elif any(st != "none" for st in slots_status.values()): + level = "partial" + else: + level = "none" + days_out.append({"day": d, "level": level, "slots": slots_status}) + + stats = await compute_stats(db) + return {"days": days_out, "stats": stats} diff --git a/tools/gen_vapid.py b/tools/gen_vapid.py new file mode 100644 index 0000000..d36cd2d --- /dev/null +++ b/tools/gen_vapid.py @@ -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() diff --git a/tools/schema.sql b/tools/schema.sql new file mode 100644 index 0000000..7c91e9e --- /dev/null +++ b/tools/schema.sql @@ -0,0 +1,37 @@ +CREATE TABLE IF NOT EXISTS intake_log ( + id TEXT PRIMARY KEY, + slot_id TEXT NOT NULL, + day TEXT NOT NULL, + status TEXT NOT NULL, + logged_at TEXT NOT NULL, + source TEXT DEFAULT 'app' +); + +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, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS milestones ( + id TEXT PRIMARY KEY, + unlocked_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS snooze ( + slot_id TEXT NOT NULL, + day TEXT NOT NULL, + snooze_until TEXT NOT NULL, + PRIMARY KEY (slot_id, day) +);