debug line test
This commit is contained in:
@@ -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?“
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
data/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
.DS_Store
|
||||
*.egg-info/
|
||||
+18
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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 <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.
|
||||
@@ -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
@@ -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
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 228 KiB |
@@ -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
@@ -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
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 228 KiB |
@@ -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>
|
||||
@@ -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 }) }),
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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" }));
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"buildTime": "2026-06-09T19:02:13.248375Z",
|
||||
"buildHash": "92521fc3cbd964bd"
|
||||
}
|
||||
@@ -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"]
|
||||
+103
@@ -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
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user