Compare commits

...

5 Commits

Author SHA1 Message Date
Frank Schwenk 7cec3030c1 feat: PWA update strategy with hashed assets and cache control
Move frontend sources to frontend/, build content-hashed assets at deploy,
network-first SW for shell/JS, immutable cache for /assets/, version.json
update banner, and Cache-Control middleware. Docs in docs/PWA-STRATEGY.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 08:51:12 +02:00
Frank Schwenk bff14167c9 Revert "feat: replace Web Push with ntfy for medication reminders"
This reverts commit 1cada42370.
2026-07-08 08:25:34 +02:00
Frank Schwenk 1cada42370 feat: replace Web Push with ntfy for medication reminders
Single notification path via ntfy HTTP publish for reliable Android delivery;
remove VAPID, push subscriptions, and SW push handlers. PWA settings show
topic subscribe link; humor texts and deep-link actions unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 10:42:58 +02:00
Frank Schwenk 24ac7f2f48 feat: restore roast-of-the-day with 14-day prompt history
Separate roast generation from motivation again and pass cached roasts
from the last 14 days into the OpenRouter prompt to reduce repetition.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 08:45:09 +02:00
Frank Schwenk c59c3069c4 refactor: remove KI-Orakel from history page and backend
Drop oracle UI, API endpoint, scheduled generation, and related docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 08:32:28 +02:00
33 changed files with 844 additions and 176 deletions
+1 -1
View File
@@ -10,6 +10,6 @@ VAPID_PRIVATE_KEY=
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_CLAIMS_EMAIL=mailto:admin@schwenk.online VAPID_CLAIMS_EMAIL=mailto:admin@schwenk.online
# OpenRouter (optional — Roast-of-the-Day + KI-Orakel) # OpenRouter (optional — Roast-of-the-Day)
OPENROUTER_API_KEY= OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.0-flash-001 OPENROUTER_MODEL=google/gemini-2.0-flash-001
+4
View File
@@ -1,5 +1,7 @@
.env .env
data/ data/
public/assets/*
!public/assets/.gitkeep
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*.egg-info/ *.egg-info/
@@ -7,3 +9,5 @@ __pycache__/
venv/ venv/
.DS_Store .DS_Store
*.egg-info/ *.egg-info/
public/assets/*
!public/assets/.gitkeep
+4 -2
View File
@@ -9,9 +9,11 @@ COPY pyproject.toml /app/pyproject.toml
COPY server /app/server COPY server /app/server
RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir . RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir .
COPY public /app/public COPY frontend /app/frontend
COPY meds.yaml messages.yaml /app/
COPY tools /app/tools COPY tools /app/tools
RUN python tools/build_frontend.py
COPY meds.yaml messages.yaml /app/
EXPOSE 8000 EXPOSE 8000
+14 -9
View File
@@ -19,7 +19,6 @@ Persönliche Medikamenten-PWA mit dezenten Push-Erinnerungen, Einnahme-Logging u
### Spaß ### Spaß
- **Dopamin-Drop** — Animation + Erfolgsspruch beim Loggen - **Dopamin-Drop** — Animation + Erfolgsspruch beim Loggen
- **Roast-of-the-Day** — täglicher sarkastischer Spruch (OpenRouter, gecacht) - **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) - **Easter Eggs** — Meilenstein-Badges (50/100 Logs, Streak 7/30/100)
- **~95 Humor-Texte** in `messages.yaml` (Reminder, Success, Missed, …) - **~95 Humor-Texte** in `messages.yaml` (Reminder, Success, Missed, …)
@@ -60,11 +59,14 @@ pip install -e .
cp .env.example .env cp .env.example .env
# APP_PIN und JWT_SECRET anpassen # APP_PIN und JWT_SECRET anpassen
# 3. VAPID keys (für Push) # 3. Frontend bauen (gehashte Assets)
python tools/build_frontend.py
# 4. VAPID keys (für Push)
python tools/gen_vapid.py python tools/gen_vapid.py
# Output in .env eintragen # Output in .env eintragen
# 4. Starten # 5. Starten
uvicorn server.app:app --reload --host 0.0.0.0 --port 8000 uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
``` ```
@@ -102,7 +104,7 @@ Volumes:
| `VAPID_PRIVATE_KEY` | Web-Push Private Key (PEM) | | `VAPID_PRIVATE_KEY` | Web-Push Private Key (PEM) |
| `VAPID_PUBLIC_KEY` | Web-Push Public Key | | `VAPID_PUBLIC_KEY` | Web-Push Public Key |
| `VAPID_CLAIMS_EMAIL` | mailto:-Adresse für VAPID | | `VAPID_CLAIMS_EMAIL` | mailto:-Adresse für VAPID |
| `OPENROUTER_API_KEY` | Optional — für Roast + Orakel | | `OPENROUTER_API_KEY` | Optional — für Roast-of-the-Day |
| `OPENROUTER_MODEL` | Default: `google/gemini-2.0-flash-001` | | `OPENROUTER_MODEL` | Default: `google/gemini-2.0-flash-001` |
### `meds.yaml` ### `meds.yaml`
@@ -144,7 +146,6 @@ Alle Endpunkte unter `/api/*`. Auth via `Authorization: Bearer <token>` (außer
| GET | `/api/history?days=90` | Heatmap-Daten + Stats | | GET | `/api/history?days=90` | Heatmap-Daten + Stats |
| GET | `/api/stats` | Streak, Compliance, Meilensteine | | GET | `/api/stats` | Streak, Compliance, Meilensteine |
| GET | `/api/roast` | Roast-of-the-Day | | GET | `/api/roast` | Roast-of-the-Day |
| GET | `/api/oracle` | Wöchentliches KI-Orakel |
| POST | `/api/push/subscribe` | Web-Push Subscription speichern | | POST | `/api/push/subscribe` | Web-Push Subscription speichern |
--- ---
@@ -176,7 +177,7 @@ Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
| **Vanilla JS** statt React/Vue | Kein Build-Step, schnelle Iteration, passt zu persönlicher App | | **Vanilla JS** statt React/Vue | Kein Build-Step, schnelle Iteration, passt zu persönlicher App |
| **SQLite** statt Postgres | Single-User, eine Datei, Backup = `data/medis.sqlite` kopieren | | **SQLite** statt Postgres | Single-User, eine Datei, Backup = `data/medis.sqlite` kopieren |
| **Web Push vom Server** statt Client-Timer | Zuverlässig auch bei geschlossener App; APScheduler im Container | | **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` | | **OpenRouter nur für Roast** | Kosten/Latenz — Notifications nutzen statische `messages.yaml` |
| **Model: gemini-2.0-flash** | Günstig, schnell, gut genug für kurze deutsche Roasts | | **Model: gemini-2.0-flash** | Günstig, schnell, gut genug für kurze deutsche Roasts |
| **PIN plain in .env** statt Hash | Single-User, unkritische Daten; `hmac.compare_digest` gegen Timing-Leaks | | **PIN plain in .env** statt Hash | Single-User, unkritische Daten; `hmac.compare_digest` gegen Timing-Leaks |
| **JWT 90 Tage** | Lange Session auf persönlichem Gerät, kein ständiges PIN-Eingeben | | **JWT 90 Tage** | Lange Session auf persönlichem Gerät, kein ständiges PIN-Eingeben |
@@ -192,14 +193,15 @@ Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
``` ```
takeyourmeds/ takeyourmeds/
├── frontend/ # PWA-Quellen (JS, CSS, Templates)
├── compose.yml # Docker + Traefik ├── compose.yml # Docker + Traefik
├── Dockerfile ├── Dockerfile
├── meds.yaml # Medikamenten-Zeitplan ├── meds.yaml # Medikamenten-Zeitplan
├── messages.yaml # Humor-Pool ├── messages.yaml # Humor-Pool
├── icon.png / icon.svg # App-Icons (Quelle) ├── icon.png / icon.svg # App-Icons (Quelle)
├── server/ # FastAPI Backend ├── server/ # FastAPI Backend
├── public/ # PWA Frontend ├── public/ # Build-Output (index.html, sw.js, assets/)
├── tools/ # schema.sql, gen_vapid.py ├── tools/ # schema.sql, gen_vapid.py, build_frontend.py
└── data/ # SQLite (gitignored) └── data/ # SQLite (gitignored)
``` ```
@@ -209,10 +211,13 @@ takeyourmeds/
```bash ```bash
source .venv/bin/activate source .venv/bin/activate
python tools/build_frontend.py # nach Änderungen in frontend/
uvicorn server.app:app --reload --port 8000 uvicorn server.app:app --reload --port 8000
``` ```
Service Worker cached aggressiv — für SW-Änderungen: DevTools → Application → Clear storage, oder Inkognito. Quellen liegen in `frontend/`; Build-Output in `public/` (gehashte JS/CSS unter `public/assets/`). PWA-Update-Strategie: `docs/PWA-STRATEGY.md`.
Service Worker: bei Deploy neuer `CACHE`-Name automatisch via Build. Update-Banner erscheint bei veralteter Client-Version.
--- ---
+56
View File
@@ -0,0 +1,56 @@
# PWA Update-Strategie
Wiederverwendbare Strategie für Fränkys PWAs — damit Nutzer:innen **ohne Cache leeren** immer die aktuelle Version sehen.
> Gitea-Wiki: manuell anlegen unter [PWA-Update-Strategie](https://gitea.schwenk.online/froxxxy/takeyourmeds/wiki/_pages) (MCP-Token hatte keine Wiki-Schreibrechte).
## Warum?
- Viele Nutzer:innen wissen nicht, was „Cache leeren" bedeutet (besonders auf dem Handy / als installierte PWA).
- Service Worker können alte Dateien **monatelang** vorhalten — ein Deploy reicht nicht.
- Ziel: Updates **sichtbar und kontrolliert** (Banner + Reload), nicht stilles Veralten.
## Drei Ebenen (Tiers)
### Tier 1 — Service Worker & Client
| Element | Strategie |
|---------|-----------|
| `sw.js` | Nicht vom SW intercepten; Server: `Cache-Control: no-cache` |
| `index.html` | Network-first |
| Unversioniertes JS/CSS | Network-first |
| Cache-Name | Pro Build: `tym-v{version}-{buildHash}` |
| `version.json` | Network-only im SW; Client-Check beim Start/Focus |
| Update-Banner | `buildHash``meta app-build` → „Aktualisieren" |
### Tier 2 — Server & Lifecycle
| Element | Maßnahme |
|---------|----------|
| HTTP-Header | `server/static_cache.py` Middleware |
| Focus | `registration.update()` + `version.json` Check |
| `controllerchange` | Einmalig `location.reload()` |
### Tier 3 — Build
| Element | Maßnahme |
|---------|----------|
| Quellen | `frontend/` |
| Output | `public/assets/*.{hash}.js/css` |
| Build | `python tools/build_frontend.py` (läuft im Dockerfile) |
| Gehashte Assets | `Cache-Control: immutable` |
## Deploy-Checkliste
- [ ] Version in `pyproject.toml` bumpen (optional)
- [ ] `python tools/build_frontend.py` (Docker macht das automatisch)
- [ ] `docker compose up -d --build`
- [ ] Inkognito: `sw.js` neue CACHE-Konstante, `version.json` neuer `buildHash`
- [ ] Alte PWA-Installation: Update-Banner nach App-Öffnung
## Anti-Patterns
- Cache-first für unversioniertes `app.js`
- `sw.js` im Precache
- Stub-Endpoints für fehlende APIs
- „Cache leeren" als einziger Fix in der Doku
+2 -2
View File
@@ -3,6 +3,7 @@ import { getToken, setToken, clearToken, isLoggedIn } from "./lib/auth.js";
import { setStreakBadge } from "./lib/badge.js"; import { setStreakBadge } from "./lib/badge.js";
import { enqueue, flushQueue } from "./lib/offlineQueue.js"; import { enqueue, flushQueue } from "./lib/offlineQueue.js";
import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.js"; import { registerPwa, wirePwaInstall, subscribePush } from "./lib/pwa.js";
import { wireAppUpdates } from "./lib/updates.js";
import { renderSlots, renderHeatmap, updateStats, showToast, showConfetti, showEasterEgg } from "./lib/ui.js"; import { renderSlots, renderHeatmap, updateStats, showToast, showConfetti, showEasterEgg } from "./lib/ui.js";
const STATUS = { upcoming: "Noch nicht", pending: "Jetzt!", overdue: "Überfällig", snoozed: "Snoozed", taken: "Genommen", missed: "Verpasst" }; const STATUS = { upcoming: "Noch nicht", pending: "Jetzt!", overdue: "Überfällig", snoozed: "Snoozed", taken: "Genommen", missed: "Verpasst" };
@@ -129,8 +130,6 @@ async function loadHistory() {
const data = await api.history(90); const data = await api.history(90);
renderHeatmap(data.days); renderHeatmap(data.days);
updateStats(data.stats); 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() { async function handleDeepLink() {
@@ -181,6 +180,7 @@ navigator.serviceWorker?.addEventListener("message", (event) => {
async function boot() { async function boot() {
registerPwa(); registerPwa();
wireAppUpdates();
if (isLoggedIn()) { if (isLoggedIn()) {
try { await initApp(); return; } catch { clearToken(); } try { await initApp(); return; } catch { clearToken(); }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+104
View File
@@ -0,0 +1,104 @@
<!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">
<meta name="app-build" content="{{BUILD_HASH}}">
<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_HREF}}">
<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>
<form id="pinForm" class="pinForm">
<input type="number" id="pinInput" class="pinInput" inputmode="numeric" min="0" max="999999" step="1" placeholder="PIN" autocomplete="off" required>
<button type="submit" class="btn btn-primary pinSubmit">Anmelden</button>
</form>
<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="motivationCard" id="motivationCard">
<div class="motivationLabel">ADHD live, laugh, toaster bath motivational</div>
<p id="motivationText"></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" id="statComplianceLabel">90 Tage</div>
</div>
<div class="statBox">
<div class="statNum" id="statTaken">0</div>
<div class="statLabel">Genommen</div>
</div>
</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="updateBanner" class="updateBanner" hidden>
<span>Neue Version verfügbar</span>
<button type="button" class="btn btn-primary" id="updateBannerBtn">Aktualisieren</button>
</div>
<div id="confetti" class="confetti" hidden></div>
<div id="easterEgg" class="easterEgg" hidden></div>
<script type="module" src="{{APP_SCRIPT_SRC}}"></script>
</body>
</html>
@@ -26,7 +26,6 @@ export const api = {
history: (days = 90) => request(`/api/history?days=${days}`), history: (days = 90) => request(`/api/history?days=${days}`),
stats: () => request("/api/stats"), stats: () => request("/api/stats"),
roast: () => request("/api/roast"), roast: () => request("/api/roast"),
oracle: () => request("/api/oracle"),
snooze: (slot_id, minutes) => request("/api/snooze", { method: "POST", body: JSON.stringify({ slot_id, minutes }) }), snooze: (slot_id, minutes) => request("/api/snooze", { method: "POST", body: JSON.stringify({ slot_id, minutes }) }),
vapidKey: () => request("/api/vapid-public-key"), vapidKey: () => request("/api/vapid-public-key"),
pushSubscribe: (subscription) => pushSubscribe: (subscription) =>
+4 -3
View File
@@ -36,12 +36,13 @@ export function wirePwaInstall({ sectionId = "pwaInstallSection", buttonId = "in
export async function registerPwa() { export async function registerPwa() {
if (!("serviceWorker" in navigator)) return; if (!("serviceWorker" in navigator)) return;
try { try {
await navigator.serviceWorker.register("/sw.js"); const reg = await navigator.serviceWorker.register("/sw.js");
if ("sync" in ServiceWorkerRegistration.prototype) { if ("sync" in ServiceWorkerRegistration.prototype) {
navigator.serviceWorker.ready.then((reg) => { navigator.serviceWorker.ready.then((readyReg) => {
reg.sync?.register("sync-logs").catch(() => {}); readyReg.sync?.register("sync-logs").catch(() => {});
}); });
} }
return reg;
} catch (e) { } catch (e) {
console.error("SW registration failed:", e); console.error("SW registration failed:", e);
} }
+62
View File
@@ -0,0 +1,62 @@
let bannerVisible = false;
function showUpdateBanner() {
if (bannerVisible) return;
const banner = document.getElementById("updateBanner");
if (!banner) return;
banner.hidden = false;
bannerVisible = true;
}
async function applyUpdate() {
const reg = await navigator.serviceWorker?.getRegistration();
if (reg?.waiting) {
reg.waiting.postMessage({ type: "SKIP_WAITING" });
} else {
await reg?.update();
location.reload();
}
}
export function wireAppUpdates() {
if (!("serviceWorker" in navigator)) return;
let refreshing = false;
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (refreshing) return;
refreshing = true;
location.reload();
});
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data?.type === "UPDATE_AVAILABLE") showUpdateBanner();
});
const button = document.getElementById("updateBannerBtn");
button?.addEventListener("click", () => applyUpdate());
const checkVersion = async () => {
try {
const reg = await navigator.serviceWorker.getRegistration();
await reg?.update();
const built = document.querySelector('meta[name="app-build"]')?.content;
if (!built) return;
const res = await fetch("/version.json", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
if (data.buildHash && data.buildHash !== built) {
showUpdateBanner();
}
} catch {
/* ignore — offline or transient */
}
};
window.addEventListener("focus", checkVersion);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") checkVersion();
});
checkVersion();
}
+48
View File
@@ -0,0 +1,48 @@
{
"name": "TakeYourMeds",
"short_name": "Medis",
"description": "Medikamenten-Erinnerung mit Sarkasmus und Dopamin",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#F12F12",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Genommen (Morgens)",
"short_name": "Morgens ✓",
"url": "/?action=take&slot=morning",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192"
}
]
},
{
"name": "Genommen (Mittags)",
"short_name": "Mittags ✓",
"url": "/?action=take&slot=noon",
"icons": [
{
"src": "/icon-192x192.png",
"sizes": "192x192"
}
]
}
]
}
+15 -3
View File
@@ -97,18 +97,18 @@ body {
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } @keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* Cards */ /* Cards */
.roastCard, .oracleCard, .motivationCard { .roastCard, .motivationCard {
background: linear-gradient(135deg, var(--bg-card) 0%, #3a1855 100%); background: linear-gradient(135deg, var(--bg-card) 0%, #3a1855 100%);
border: 2px solid var(--accent-purple); border: 2px solid var(--accent-purple);
border-radius: var(--radius); padding: 1rem 1.25rem; border-radius: var(--radius); padding: 1rem 1.25rem;
margin-bottom: 1.25rem; box-shadow: 0 4px 20px rgba(124,77,255,.2); margin-bottom: 1.25rem; box-shadow: 0 4px 20px rgba(124,77,255,.2);
} }
.roastLabel, .oracleLabel, .motivationLabel { .roastLabel, .motivationLabel {
font-size: .65rem; text-transform: uppercase; letter-spacing: .06em; font-size: .65rem; text-transform: uppercase; letter-spacing: .06em;
line-height: 1.35; line-height: 1.35;
color: var(--accent-purple); margin-bottom: .5rem; font-weight: 600; color: var(--accent-purple); margin-bottom: .5rem; font-weight: 600;
} }
.roastCard p, .oracleCard p, .motivationCard p { line-height: 1.5; font-size: .95rem; } .roastCard p, .motivationCard p { line-height: 1.5; font-size: .95rem; }
.slotCards { display: flex; flex-direction: column; gap: 1rem; } .slotCards { display: flex; flex-direction: column; gap: 1rem; }
@@ -244,3 +244,15 @@ body {
} }
.easterEggInner h2 { font-size: 1.5rem; margin-bottom: .75rem; } .easterEggInner h2 { font-size: 1.5rem; margin-bottom: .75rem; }
.easterEggInner p { margin-bottom: 1.25rem; line-height: 1.5; } .easterEggInner p { margin-bottom: 1.25rem; line-height: 1.5; }
/* PWA update banner */
.updateBanner {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 1500;
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
padding: .85rem 1rem calc(.85rem + env(safe-area-inset-bottom));
background: linear-gradient(135deg, var(--primary), #c41f1f);
color: #fff; box-shadow: 0 -4px 20px rgba(0,0,0,.35);
font-weight: 500;
}
.updateBanner[hidden] { display: none !important; }
.updateBanner .btn { flex-shrink: 0; padding: .5rem 1rem; font-size: .9rem; }
+152
View File
@@ -0,0 +1,152 @@
const CACHE = "{{CACHE_VERSION}}";
const ASSETS = {{ASSETS_JSON}};
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("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
function isHashedAsset(pathname) {
return pathname.startsWith("/assets/");
}
async function networkOnly(request) {
return fetch(request);
}
async function networkFirst(request, fallbackPath) {
try {
const response = await fetch(request);
if (response.ok) return response;
} catch {
/* offline */
}
const cached = await caches.match(request);
if (cached) return cached;
if (fallbackPath) {
const fallback = await caches.match(fallbackPath);
if (fallback) return fallback;
}
return new Response("Offline", { status: 503, statusText: "Offline" });
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE);
await cache.put(request, response.clone());
}
return response;
}
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;
// sw.js: never intercept — browser must revalidate with server
if (url.pathname === "/sw.js") 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 (url.pathname === "/version.json") {
event.respondWith(networkOnly(request));
return;
}
if (request.mode === "navigate") {
event.respondWith(networkFirst(request, "/index.html"));
return;
}
if (isHashedAsset(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
if (url.pathname === "/index.html" || url.pathname === "/manifest.json") {
event.respondWith(networkFirst(request));
return;
}
// Icons and other static root files
event.respondWith(networkFirst(request));
});
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)
);
});
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" }));
})
);
}
});
View File
+7 -6
View File
@@ -5,12 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#F12F12"> <meta name="theme-color" content="#F12F12">
<meta name="description" content="Medikamenten-Erinnerung mit Sarkasmus"> <meta name="description" content="Medikamenten-Erinnerung mit Sarkasmus">
<meta name="app-build" content="914619f3">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icon.png" type="image/png"> <link rel="icon" href="/icon.png" type="image/png">
<link rel="apple-touch-icon" href="/icon-192x192.png"> <link rel="apple-touch-icon" href="/icon-192x192.png">
<link rel="preconnect" href="https://fonts.googleapis.com"> <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 href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css"> <link rel="stylesheet" href="/assets/styles.993b74f1.css">
<title>TakeYourMeds</title> <title>TakeYourMeds</title>
</head> </head>
<body> <body>
@@ -67,10 +68,6 @@
<div class="statLabel">Genommen</div> <div class="statLabel">Genommen</div>
</div> </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> <h2 class="sectionTitle">90-Tage Heatmap</h2>
<div id="heatmap" class="heatmap"></div> <div id="heatmap" class="heatmap"></div>
</div> </div>
@@ -95,9 +92,13 @@
</div> </div>
<div id="toast" class="toast" hidden></div> <div id="toast" class="toast" hidden></div>
<div id="updateBanner" class="updateBanner" hidden>
<span>Neue Version verfügbar</span>
<button type="button" class="btn btn-primary" id="updateBannerBtn">Aktualisieren</button>
</div>
<div id="confetti" class="confetti" hidden></div> <div id="confetti" class="confetti" hidden></div>
<div id="easterEgg" class="easterEgg" hidden></div> <div id="easterEgg" class="easterEgg" hidden></div>
<script type="module" src="/app.js"></script> <script type="module" src="/assets/app.58f3e95c.js"></script>
</body> </body>
</html> </html>
+82 -43
View File
@@ -1,20 +1,22 @@
const CACHE = "tym-v1.0.1"; const CACHE = "tym-v1.1.0-914619f3";
const ASSETS = [ const ASSETS = [
"/", "/",
"/index.html", "/index.html",
"/styles.css", "/assets/api.89363e2a.js",
"/app.js", "/assets/app.58f3e95c.js",
"/assets/auth.a6c28cdb.js",
"/assets/badge.30848c28.js",
"/assets/offlineQueue.0a9bbfdd.js",
"/assets/pwa.b6a6d909.js",
"/assets/styles.993b74f1.css",
"/assets/ui.47fc1625.js",
"/assets/updates.57073981.js",
"/manifest.json", "/manifest.json",
"/version.json",
"/icon.png", "/icon.png",
"/icon-72x72.png",
"/icon-192x192.png", "/icon-192x192.png",
"/icon-512x512.png", "/icon-512x512.png",
"/lib/api.js", "/version.json"
"/lib/auth.js",
"/lib/badge.js",
"/lib/offlineQueue.js",
"/lib/pwa.js",
"/lib/ui.js",
]; ];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
@@ -31,37 +33,86 @@ self.addEventListener("activate", (event) => {
); );
}); });
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
function isHashedAsset(pathname) {
return pathname.startsWith("/assets/");
}
async function networkOnly(request) {
return fetch(request);
}
async function networkFirst(request, fallbackPath) {
try {
const response = await fetch(request);
if (response.ok) return response;
} catch {
/* offline */
}
const cached = await caches.match(request);
if (cached) return cached;
if (fallbackPath) {
const fallback = await caches.match(fallbackPath);
if (fallback) return fallback;
}
return new Response("Offline", { status: 503, statusText: "Offline" });
}
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE);
await cache.put(request, response.clone());
}
return response;
}
self.addEventListener("fetch", (event) => { self.addEventListener("fetch", (event) => {
const { request } = event; const { request } = event;
if (request.method !== "GET") return; if (request.method !== "GET") return;
const url = new URL(request.url); const url = new URL(request.url);
if (url.origin !== self.location.origin) return; if (url.origin !== self.location.origin) return;
if (url.pathname.startsWith("/api/")) { // sw.js: never intercept — browser must revalidate with server
event.respondWith(fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), { if (url.pathname === "/sw.js") return;
status: 503, headers: { "Content-Type": "application/json" },
})));
return;
}
if (request.mode === "navigate") { if (url.pathname.startsWith("/api/")) {
event.respondWith( event.respondWith(
fetch(request).catch(() => caches.match("/index.html")) fetch(request).catch(() => new Response(JSON.stringify({ offline: true }), {
status: 503,
headers: { "Content-Type": "application/json" },
}))
); );
return; return;
} }
event.respondWith( if (url.pathname === "/version.json") {
caches.match(request, { ignoreSearch: true }).then((cached) => event.respondWith(networkOnly(request));
cached || fetch(request).then((resp) => { return;
if (resp.ok) { }
const clone = resp.clone();
caches.open(CACHE).then((c) => c.put(request, clone)); if (request.mode === "navigate") {
} event.respondWith(networkFirst(request, "/index.html"));
return resp; return;
}) }
)
); if (isHashedAsset(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
if (url.pathname === "/index.html" || url.pathname === "/manifest.json") {
event.respondWith(networkFirst(request));
return;
}
// Icons and other static root files
event.respondWith(networkFirst(request));
}); });
self.addEventListener("push", (event) => { self.addEventListener("push", (event) => {
@@ -89,31 +140,19 @@ self.addEventListener("push", (event) => {
); );
}); });
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) => { self.addEventListener("notificationclick", (event) => {
event.notification.close(); event.notification.close();
const slotId = event.notification.data?.slot_id; const slotId = event.notification.data?.slot_id;
const action = event.action; const action = event.action;
if (action === "take" && slotId) { if (action === "take" && slotId) {
event.waitUntil( event.waitUntil(self.clients.openWindow(`/?action=take&slot=${slotId}`));
self.clients.openWindow(`/?action=take&slot=${slotId}`)
);
return; return;
} }
if ((action === "snooze15" || action === "snooze30") && slotId) { if ((action === "snooze15" || action === "snooze30") && slotId) {
const minutes = action === "snooze15" ? 15 : 30; const minutes = action === "snooze15" ? 15 : 30;
event.waitUntil( event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`));
self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`)
);
return; return;
} }
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.0.0", "version": "1.1.0",
"buildTime": "2026-06-09T19:02:13.248375Z", "buildTime": "2026-07-08T06:50:31.880953Z",
"buildHash": "92521fc3cbd964bd" "buildHash": "914619f3"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "takeyourmeds" name = "takeyourmeds"
version = "1.0.0" version = "1.1.0"
description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor" description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+42 -53
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -21,11 +21,13 @@ MOTIVATION_SYSTEM = (
"Genau ein Satz auf Deutsch, max 25 Wörter. Keine Anführungszeichen um den Spruch." "Genau ein Satz auf Deutsch, max 25 Wörter. Keine Anführungszeichen um den Spruch."
) )
ORACLE_SYSTEM = ( ROAST_SYSTEM = (
"Du bist das tägliche KI-Orakel einer ADHS-Medikamenten-App. " "Du bist ein sarkastischer, dark-humor Medikamenten-Coach für jemanden mit ADHS. "
"Passiv-aggressiv, trocken, max 4 Sätze auf Deutsch. Keine medizinischen Ratschläge." "Kurz (max 2 Sätze), deutsch, witzig aber nicht gemein. Keine medizinischen Ratschläge. "
"Keine Anführungszeichen um den Spruch. Wiederhole keine Formulierungen oder Ideen aus der Historie."
) )
ROAST_HISTORY_DAYS = 14
async def _get_cache(db: aiosqlite.Connection, key: str) -> str | None: async def _get_cache(db: aiosqlite.Connection, key: str) -> str | None:
cur = await db.execute("SELECT content FROM ai_cache WHERE key = ?", (key,)) cur = await db.execute("SELECT content FROM ai_cache WHERE key = ?", (key,))
@@ -93,16 +95,24 @@ def _motivation_key(day: str) -> str:
return f"motivation:v2:{day}" return f"motivation:v2:{day}"
def _oracle_key(day: str) -> str: def _roast_key(day: str) -> str:
return f"oracle:v3:{day}" return f"roast:{day}"
async def _cached_oracle(db: aiosqlite.Connection, day: str) -> str | None: async def _recent_roasts(
cached = await _get_cache(db, _oracle_key(day)) db: aiosqlite.Connection,
if cached: *,
return cached before_day: str,
week = datetime.strptime(day, "%Y-%m-%d").strftime("%Y-W%W") days: int = ROAST_HISTORY_DAYS,
return await _get_cache(db, f"oracle:v2:{week}") ) -> list[str]:
end = datetime.strptime(before_day, "%Y-%m-%d").date()
roasts: list[str] = []
for offset in range(1, days + 1):
day = (end - timedelta(days=offset)).isoformat()
text = await _get_cache(db, _roast_key(day))
if text:
roasts.append(text)
return roasts
async def _cached_motivation(db: aiosqlite.Connection, day: str) -> str | None: async def _cached_motivation(db: aiosqlite.Connection, day: str) -> str | None:
@@ -152,11 +162,7 @@ async def get_daily_motivation(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[s
return {"text": pick("motivation_fallback"), "source": "fallback", "day": day} return {"text": pick("motivation_fallback"), "source": "fallback", "day": day}
async def get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]: async def generate_roast_of_the_day(
return await get_daily_motivation(db, tz)
async def generate_oracle(
db: aiosqlite.Connection, db: aiosqlite.Connection,
tz: ZoneInfo, tz: ZoneInfo,
day: str | None = None, day: str | None = None,
@@ -164,58 +170,41 @@ async def generate_oracle(
force: bool = False, force: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
day = day or datetime.now(tz).strftime("%Y-%m-%d") day = day or datetime.now(tz).strftime("%Y-%m-%d")
key = _oracle_key(day) key = _roast_key(day)
if not force: if not force:
cached = await _cached_oracle(db, day) cached = await _get_cache(db, key)
if cached: if cached:
return {"text": cached, "source": "cache", "day": day} return {"text": cached, "source": "cache", "day": day}
stats = await compute_stats(db) stats = await compute_stats(db)
days_active = stats["days_active"] recent = await _recent_roasts(db, before_day=day)
window = stats["compliance_window_days"] history = ""
young_app = days_active < 90 if recent:
context = ( lines = "\n".join(f"- {text}" for text in recent)
f"App aktiv seit {days_active} Tag(en), erste Einnahme am {stats['first_day']}. " history = (
if young_app f"\n\nDiese Roasts der letzten {ROAST_HISTORY_DAYS} Tage wurden bereits verwendet "
else "" f"(nicht wiederholen, neue Idee):\n{lines}"
) )
caveat = (
"Die App ist noch jung — lange Streaks oder 90-Tage-Compliance sind noch nicht realistisch erreichbar. "
"Bewerte nur die verfügbaren Daten, sei fair aber sarkastisch. "
if young_app
else ""
)
prompt = ( prompt = (
f"Täglicher Orakel-Report für heute. {context}{caveat}" "Schreibe den Roast-of-the-Day. "
f"Streak: {stats['streak']} Tage. " f"Streak: {stats['streak']} Tage. "
f"Compliance über {window} Tag(e): {stats['compliance_percent']}%. " f"Compliance über {stats['compliance_window_days']} aktive Tage: {stats['compliance_percent']}%. "
f"Genommen gesamt: {stats['total_taken']}." f"Ein sarkastischer Spruch im dark-humor Medikamenten-Coach Stil.{history}"
) )
text = await _call_openrouter(prompt, system=ORACLE_SYSTEM) text = await _call_openrouter(prompt, system=ROAST_SYSTEM)
if not text: if not text:
if young_app: text = pick("roast_fallback")
text = (
f"Tag {days_active} deiner Medis-Karriere. "
f"Streak: {stats['streak']}. Compliance ({window} Tage): {stats['compliance_percent']}%. "
"Das Orakel ist beeindruckt — oder gelangweilt. Grenzwertig."
)
else:
text = pick("streak", streak=stats["streak"]) + f" Compliance: {stats['compliance_percent']}%."
source = "fallback" source = "fallback"
else: else:
source = "ai" source = "ai"
await _set_cache(db, key, text) await _set_cache(db, key, text)
logger.info("Generated daily oracle for %s (%s)", day, source) logger.info("Generated roast of the day for %s (%s)", day, source)
return {"text": text, "source": source, "day": day} return {"text": text, "source": source, "day": day}
async def get_oracle(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]: async def get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
day = datetime.now(tz).strftime("%Y-%m-%d") day = datetime.now(tz).strftime("%Y-%m-%d")
cached = await _cached_oracle(db, day) cached = await _get_cache(db, _roast_key(day))
if cached: if cached:
return {"text": cached, "source": "cache", "day": day} return {"text": cached, "source": "cache", "day": day}
return { return {"text": pick("roast_fallback"), "source": "fallback", "day": day}
"text": "Das Orakel bereitet sich vor. Schau später nochmal rein.",
"source": "pending",
"day": day,
}
+7 -27
View File
@@ -11,7 +11,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from server.ai import generate_daily_motivation, generate_oracle, get_oracle, get_roast_of_the_day from server.ai import generate_daily_motivation, generate_roast_of_the_day, get_roast_of_the_day
from server.auth import create_token, require_auth, verify_pin from server.auth import create_token, require_auth, verify_pin
from server.config_loader import load_meds_config, slot_to_dict, today_str from server.config_loader import load_meds_config, slot_to_dict, today_str
from server.db import ensure_schema, get_db, new_id, utc_now_iso from server.db import ensure_schema, get_db, new_id, utc_now_iso
@@ -20,6 +20,7 @@ from server.push import save_subscription, send_slot_reminder
from server.scheduler import schedule_snooze, start_scheduler from server.scheduler import schedule_snooze, start_scheduler
from server.settings import settings from server.settings import settings
from server.slots import build_today from server.slots import build_today
from server.static_cache import CacheControlMiddleware
from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS
NO_STORE = {"Cache-Control": "no-store"} NO_STORE = {"Cache-Control": "no-store"}
@@ -40,24 +41,14 @@ _configure_logging()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def _ensure_today_motivation() -> None: async def _ensure_today_ai_texts() -> None:
config = load_meds_config() config = load_meds_config()
db = await get_db() db = await get_db()
try: try:
await generate_daily_motivation(db, config.timezone) await generate_daily_motivation(db, config.timezone)
await generate_roast_of_the_day(db, config.timezone)
except Exception: except Exception:
logger.exception("Startup motivation generation failed") logger.exception("Startup AI text generation failed")
finally:
await db.close()
async def _ensure_today_oracle() -> None:
config = load_meds_config()
db = await get_db()
try:
await generate_oracle(db, config.timezone)
except Exception:
logger.exception("Startup oracle generation failed")
finally: finally:
await db.close() await db.close()
@@ -68,12 +59,12 @@ async def lifespan(app: FastAPI):
await ensure_schema(db) await ensure_schema(db)
await db.close() await db.close()
start_scheduler() start_scheduler()
asyncio.create_task(_ensure_today_motivation()) asyncio.create_task(_ensure_today_ai_texts())
asyncio.create_task(_ensure_today_oracle())
yield yield
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan) app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
app.add_middleware(CacheControlMiddleware)
@app.post("/api/auth/pin") @app.post("/api/auth/pin")
@@ -219,17 +210,6 @@ async def get_roast(_: dict = Depends(require_auth)) -> JSONResponse:
return JSONResponse(data, headers=NO_STORE) 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 # Static files — must be after API routes
PUBLIC = pathlib.Path(settings.public_dir) PUBLIC = pathlib.Path(settings.public_dir)
if PUBLIC.exists(): if PUBLIC.exists():
+6 -6
View File
@@ -9,7 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from server.ai import generate_daily_motivation, generate_oracle from server.ai import generate_daily_motivation, generate_roast_of_the_day
from server.config_loader import load_meds_config, slot_to_dict, today_str from server.config_loader import load_meds_config, slot_to_dict, today_str
from server.db import get_db, utc_now_iso from server.db import get_db, utc_now_iso
from server.messages import pick from server.messages import pick
@@ -54,13 +54,13 @@ async def _generate_daily_motivation() -> None:
await db.close() await db.close()
async def _generate_daily_oracle() -> None: async def _generate_daily_roast() -> None:
config = load_meds_config() config = load_meds_config()
db = await get_db() db = await get_db()
try: try:
await generate_oracle(db, config.timezone) await generate_roast_of_the_day(db, config.timezone)
except Exception: except Exception:
logger.exception("Daily oracle generation failed") logger.exception("Daily roast generation failed")
finally: finally:
await db.close() await db.close()
@@ -159,9 +159,9 @@ def start_scheduler() -> None:
) )
scheduler.add_job( scheduler.add_job(
_generate_daily_oracle, _generate_daily_roast,
trigger=CronTrigger(hour=3, minute=5, timezone=tz), trigger=CronTrigger(hour=3, minute=5, timezone=tz),
id="daily-oracle", id="daily-roast",
replace_existing=True, replace_existing=True,
) )
+1 -1
View File
@@ -22,7 +22,7 @@ class Settings(BaseSettings):
messages_path: str = "messages.yaml" messages_path: str = "messages.yaml"
public_dir: str = "public" public_dir: str = "public"
app_version: str = "1.0.0" app_version: str = "1.1.0"
settings = Settings() # type: ignore[call-arg] settings = Settings() # type: ignore[call-arg]
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from starlette.types import ASGIApp, Receive, Scope, Send
NO_CACHE = b"no-cache, must-revalidate"
NO_STORE = b"no-store"
IMMUTABLE = b"public, max-age=31536000, immutable"
def _cache_control_for_path(path: str) -> bytes | None:
if path in ("/sw.js", "/index.html", "/manifest.json"):
return NO_CACHE
if path == "/version.json":
return NO_STORE
if path.startswith("/assets/"):
return IMMUTABLE
if path.endswith((".js", ".css")) and not path.startswith("/assets/"):
return NO_CACHE
return None
class CacheControlMiddleware:
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
cache_control = _cache_control_for_path(path)
async def send_wrapper(message: dict) -> None:
if message["type"] == "http.response.start" and cache_control is not None:
headers = list(message.get("headers", []))
headers = [(k, v) for k, v in headers if k.lower() != b"cache-control"]
headers.append((b"cache-control", cache_control))
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_wrapper)
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Build frontend assets with content hashes for cache-immutable PWA deploy."""
from __future__ import annotations
import hashlib
import json
import re
import shutil
import tomllib
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND = ROOT / "frontend"
PUBLIC = ROOT / "public"
ASSETS_DIR = PUBLIC / "assets"
HASH_LEN = 8
IMPORT_RE = re.compile(r'''from\s+["'](\./(?:lib/)?[^"']+\.js)["']''')
STATIC_COPY = ("manifest.json",)
STATIC_ICONS = (
"icon.png",
"icon-72x72.png",
"icon-192x192.png",
"icon-512x512.png",
)
def short_hash(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()[:HASH_LEN]
def js_sources() -> list[tuple[str, str]]:
sources: list[tuple[str, str]] = [("app.js", "app")]
lib_dir = FRONTEND / "lib"
if lib_dir.is_dir():
for path in sorted(lib_dir.glob("*.js")):
sources.append((f"lib/{path.name}", path.stem))
return sources
def read_version() -> str:
pyproject = ROOT / "pyproject.toml"
with pyproject.open("rb") as fh:
data = tomllib.load(fh)
return str(data["project"]["version"])
def rewrite_imports(source: str, stem_to_filename: dict[str, str]) -> str:
def replace(match: re.Match[str]) -> str:
import_path = match.group(1)
stem = Path(import_path).stem
hashed = stem_to_filename.get(stem)
if not hashed:
raise ValueError(f"Unknown import stem {stem!r} in {import_path!r}")
quote = match.group(0).split("from", 1)[1].strip()[0]
return f'from {quote}./{hashed}{quote}'
return IMPORT_RE.sub(replace, source)
def clean_assets_dir() -> None:
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
for path in ASSETS_DIR.iterdir():
if path.name == ".gitkeep":
continue
if path.is_file():
path.unlink()
def copy_static_files() -> list[str]:
copied: list[str] = []
for name in STATIC_COPY:
src = FRONTEND / name
if not src.is_file():
raise FileNotFoundError(f"Missing frontend source: {src}")
shutil.copy2(src, PUBLIC / name)
copied.append(f"/{name}")
for name in STATIC_ICONS:
src = FRONTEND / name
if src.is_file():
shutil.copy2(src, PUBLIC / name)
copied.append(f"/{name}")
return copied
def build() -> dict[str, object]:
if not FRONTEND.is_dir():
raise FileNotFoundError(f"Frontend source directory not found: {FRONTEND}")
PUBLIC.mkdir(parents=True, exist_ok=True)
clean_assets_dir()
hashed_assets: dict[str, str] = {}
stem_to_filename: dict[str, str] = {}
file_hashes: list[str] = []
styles_src = FRONTEND / "styles.css"
if not styles_src.is_file():
raise FileNotFoundError(f"Missing frontend source: {styles_src}")
styles_bytes = styles_src.read_bytes()
styles_hash = short_hash(styles_bytes)
styles_filename = f"styles.{styles_hash}.css"
(ASSETS_DIR / styles_filename).write_bytes(styles_bytes)
hashed_assets["styles"] = f"/assets/{styles_filename}"
file_hashes.append(styles_hash)
for rel_path, stem in js_sources():
src = FRONTEND / rel_path
if not src.is_file():
raise FileNotFoundError(f"Missing frontend source: {src}")
content = src.read_text(encoding="utf-8")
content_hash = short_hash(content.encode("utf-8"))
filename = f"{stem}.{content_hash}.js"
stem_to_filename[stem] = filename
file_hashes.append(content_hash)
for rel_path, stem in js_sources():
src = FRONTEND / rel_path
content = rewrite_imports(src.read_text(encoding="utf-8"), stem_to_filename)
filename = stem_to_filename[stem]
(ASSETS_DIR / filename).write_text(content, encoding="utf-8")
hashed_assets[stem] = f"/assets/{filename}"
build_hash = short_hash("".join(file_hashes).encode("utf-8"))
version = read_version()
build_time = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
cache_version = f"tym-v{version}-{build_hash}"
static_paths = copy_static_files()
asset_paths = sorted(hashed_assets.values())
sw_assets = ["/", "/index.html", *asset_paths, *static_paths, "/version.json"]
index_template = (FRONTEND / "index.template.html").read_text(encoding="utf-8")
index_html = (
index_template.replace("{{STYLES_HREF}}", hashed_assets["styles"])
.replace("{{APP_SCRIPT_SRC}}", hashed_assets["app"])
.replace("{{BUILD_HASH}}", build_hash)
)
(PUBLIC / "index.html").write_text(index_html, encoding="utf-8")
sw_template = (FRONTEND / "sw.template.js").read_text(encoding="utf-8")
sw_js = (
sw_template.replace("{{CACHE_VERSION}}", cache_version)
.replace("{{ASSETS_JSON}}", json.dumps(sw_assets, indent=2))
)
(PUBLIC / "sw.js").write_text(sw_js, encoding="utf-8")
version_json = {
"version": version,
"buildTime": build_time,
"buildHash": build_hash,
}
(PUBLIC / "version.json").write_text(
json.dumps(version_json, indent=2) + "\n",
encoding="utf-8",
)
return {
"version": version,
"buildHash": build_hash,
"cacheVersion": cache_version,
"assets": hashed_assets,
"static": static_paths,
}
def main() -> None:
result = build()
print(f"Built frontend v{result['version']} ({result['buildHash']})")
print(f"Cache: {result['cacheVersion']}")
for stem, url in sorted(result["assets"].items()):
print(f" {stem}: {url}")
if __name__ == "__main__":
main()
+6 -14
View File
@@ -1,36 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Regenerate cached AI texts (motivation + oracle).""" """Regenerate cached AI texts (motivation, roast)."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
from server.ai import generate_daily_motivation, generate_oracle from server.ai import generate_daily_motivation, generate_roast_of_the_day
from server.config_loader import load_meds_config from server.config_loader import load_meds_config
from server.db import get_db from server.db import get_db
async def main() -> None: async def main() -> None:
parser = argparse.ArgumentParser(description="Regenerate cached AI texts") parser = argparse.ArgumentParser(description="Regenerate cached AI texts")
parser.add_argument("--motivation", action="store_true", help="Regenerate today's motivation")
parser.add_argument("--oracle", action="store_true", help="Regenerate today's oracle")
parser.add_argument("--force", action="store_true", help="Regenerate even if cache exists") parser.add_argument("--force", action="store_true", help="Regenerate even if cache exists")
args = parser.parse_args() args = parser.parse_args()
if not args.motivation and not args.oracle:
args.motivation = True
args.oracle = True
config = load_meds_config() config = load_meds_config()
db = await get_db() db = await get_db()
try: try:
if args.motivation: motivation = await generate_daily_motivation(db, config.timezone, force=args.force)
result = await generate_daily_motivation(db, config.timezone, force=args.force) roast = await generate_roast_of_the_day(db, config.timezone, force=args.force)
print("motivation:", result) print("motivation:", motivation)
if args.oracle: print("roast:", roast)
result = await generate_oracle(db, config.timezone, force=args.force)
print("oracle:", result)
finally: finally:
await db.close() await db.close()