debug line test

This commit is contained in:
Frank Schwenk
2026-06-09 21:21:52 +02:00
commit eaa019087e
43 changed files with 2666 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
---
description: Plan Mode strikt einhalten — nie ohne explizite Freigabe implementieren
alwaysApply: true
---
# Plan Mode Disziplin
Wenn **Plan Mode** aktiv ist (`<system_reminder>`: 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?“
+15
View File
@@ -0,0 +1,15 @@
# PIN für Login (46 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
+9
View File
@@ -0,0 +1,9 @@
.env
data/
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.DS_Store
*.egg-info/
+18
View File
@@ -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"]
+233
View File
@@ -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 (46 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 <token>` (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.
+45
View File
@@ -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
+34
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+56
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 228 KiB

+16
View File
@@ -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
+105
View File
@@ -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?'
+173
View File
@@ -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) =>
`<button type="button" class="pinKey${k.length > 1 ? " wide" : ""}" data-key="${k}">${k}</button>`
).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();
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+56
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 228 KiB

+101
View File
@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#F12F12">
<meta name="description" content="Medikamenten-Erinnerung mit Sarkasmus">
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icon.png" type="image/png">
<link rel="apple-touch-icon" href="/icon-192x192.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
<title>TakeYourMeds</title>
</head>
<body>
<div id="app">
<!-- Login -->
<section id="loginScreen" class="screen">
<div class="loginHero">
<img src="/icon-192x192.png" alt="TakeYourMeds" class="loginLogo" width="120" height="120">
<h1>TakeYourMeds</h1>
<p class="tagline">Dein Gehirn. Deine Medis. Unser Sarkasmus.</p>
</div>
<div class="pinDisplay" id="pinDisplay">••••</div>
<div class="pinPad" id="pinPad"></div>
<p class="loginError" id="loginError" hidden></p>
</section>
<!-- Main -->
<section id="mainScreen" class="screen" hidden>
<header class="topBar">
<img src="/icon.png" alt="" class="topIcon" width="36" height="36">
<h1>Medis</h1>
<div class="streakBadge" id="streakBadge" title="Streak">🔥 0</div>
</header>
<nav class="tabNav">
<button type="button" class="tab active" data-tab="dashboard">Heute</button>
<button type="button" class="tab" data-tab="history">Verlauf</button>
<button type="button" class="tab" data-tab="settings"></button>
</nav>
<main class="tabContent">
<div id="tab-dashboard" class="tabPanel active">
<div class="roastCard" id="roastCard">
<div class="roastLabel">Roast of the Day</div>
<p id="roastText">Lade Roast…</p>
</div>
<div id="slotCards" class="slotCards"></div>
</div>
<div id="tab-history" class="tabPanel" hidden>
<div class="statsRow">
<div class="statBox">
<div class="statNum" id="statStreak">0</div>
<div class="statLabel">Streak</div>
</div>
<div class="statBox">
<div class="statNum" id="statCompliance">0%</div>
<div class="statLabel">90 Tage</div>
</div>
<div class="statBox">
<div class="statNum" id="statTaken">0</div>
<div class="statLabel">Genommen</div>
</div>
</div>
<div class="oracleCard" id="oracleCard">
<div class="oracleLabel">🔮 KI-Orakel</div>
<p id="oracleText">Lade Orakel…</p>
</div>
<h2 class="sectionTitle">90-Tage Heatmap</h2>
<div id="heatmap" class="heatmap"></div>
</div>
<div id="tab-settings" class="tabPanel" hidden>
<div class="settingsGroup">
<h2>Benachrichtigungen</h2>
<p class="settingsHint">Push-Erinnerungen um 8:00 und 12:00 — dezent, kein Alarm.</p>
<button type="button" class="btn btn-accent" id="enablePush">Push aktivieren</button>
<p id="pushStatus" class="settingsStatus"></p>
</div>
<div class="settingsGroup" id="pwaInstallSection" hidden>
<h2>App installieren</h2>
<button type="button" class="btn btn-secondary" id="installPwa">Auf Homescreen</button>
</div>
<div class="settingsGroup">
<button type="button" class="btn btn-danger" id="logoutBtn">Abmelden</button>
</div>
</div>
</main>
</section>
</div>
<div id="toast" class="toast" hidden></div>
<div id="confetti" class="confetti" hidden></div>
<div id="easterEgg" class="easterEgg" hidden></div>
<script type="module" src="/app.js"></script>
</body>
</html>
+34
View File
@@ -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 }) }),
};
+17
View File
@@ -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();
}
+12
View File
@@ -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 */ }
}
+44
View File
@@ -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();
}
+68
View File
@@ -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)));
}
+84
View File
@@ -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 = `
<div class="easterEggInner">
<h2>🏆 ${title}</h2>
<p>${text}</p>
<button type="button" class="btn btn-primary" id="easterClose">Nice</button>
</div>`;
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 `
<div class="slotCard status-${slot.status}" data-slot="${slot.id}">
<div class="slotHeader">
<span class="slotLabel">${slot.label}</span>
<span class="slotTime">${slot.time}</span>
</div>
<div class="slotMeds">${slot.meds.map((m) => `${m.name} ${m.dose}`).join(", ")}</div>
<span class="slotStatus">${STATUS_LABELS[slot.status] || slot.status}</span>
${canTake && !taken ? `
<div class="slotActions">
<button type="button" class="btn btn-primary" data-action="take" data-slot="${slot.id}">Genommen ✓</button>
<button type="button" class="btn btn-snooze" data-action="snooze15" data-slot="${slot.id}">+15 Min</button>
<button type="button" class="btn btn-snooze" data-action="snooze30" data-slot="${slot.id}">+30 Min</button>
</div>` : ""}
</div>`;
}).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) =>
`<div class="heatCell ${d.level}" title="${d.day}"></div>`
).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}`;
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "TakeYourMeds",
"short_name": "Medis",
"description": "Medikamenten-Erinnerung mit Sarkasmus und Dopamin",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#F12F12",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Genommen (Morgens)",
"short_name": "Morgens ✓",
"url": "/?action=take&slot=morning",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192"
}
]
},
{
"name": "Genommen (Mittags)",
"short_name": "Mittags ✓",
"url": "/?action=take&slot=noon",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192"
}
]
}
]
}
+236
View File
@@ -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; }
+131
View File
@@ -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" }));
})
);
}
});
+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.0.0",
"buildTime": "2026-06-09T19:02:13.248375Z",
"buildHash": "92521fc3cbd964bd"
}
+24
View File
@@ -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"]
View File
+103
View File
@@ -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}
+195
View File
@@ -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")
+39
View File
@@ -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)
+83
View File
@@ -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,
}
+32
View File
@@ -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()
+49
View File
@@ -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
+88
View File
@@ -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)
+105
View File
@@ -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)
+28
View File
@@ -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]
+118
View File
@@ -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
+167
View File
@@ -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}
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Generate VAPID key pair for Web Push. Run: python tools/gen_vapid.py"""
from __future__ import annotations
from py_vapid import Vapid
def main() -> None:
vapid = Vapid()
vapid.generate_keys()
private = vapid.private_pem.decode().strip()
public = vapid.public_key.decode().strip() if hasattr(vapid.public_key, "decode") else str(vapid.public_key)
print("Add these to your .env:\n")
print(f"VAPID_PRIVATE_KEY={private}")
print(f"VAPID_PUBLIC_KEY={public}")
if __name__ == "__main__":
main()
+37
View File
@@ -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)
);