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_CLAIMS_EMAIL=mailto:admin@schwenk.online
# OpenRouter (optional — Roast-of-the-Day + KI-Orakel)
# OpenRouter (optional — Roast-of-the-Day)
OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.0-flash-001
+4
View File
@@ -1,5 +1,7 @@
.env
data/
public/assets/*
!public/assets/.gitkeep
__pycache__/
*.py[cod]
*.egg-info/
@@ -7,3 +9,5 @@ __pycache__/
venv/
.DS_Store
*.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
RUN pip install --no-cache-dir -U pip && pip install --no-cache-dir .
COPY public /app/public
COPY meds.yaml messages.yaml /app/
COPY frontend /app/frontend
COPY tools /app/tools
RUN python tools/build_frontend.py
COPY meds.yaml messages.yaml /app/
EXPOSE 8000
+14 -9
View File
@@ -19,7 +19,6 @@ Persönliche Medikamenten-PWA mit dezenten Push-Erinnerungen, Einnahme-Logging u
### 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, …)
@@ -60,11 +59,14 @@ pip install -e .
cp .env.example .env
# 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
# Output in .env eintragen
# 4. Starten
# 5. Starten
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_PUBLIC_KEY` | Web-Push Public Key |
| `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` |
### `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/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 |
---
@@ -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 |
| **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` |
| **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 |
| **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 |
@@ -192,14 +193,15 @@ Notification-Actions: **Genommen ✓**, **+15 Min**, **+30 Min**
```
takeyourmeds/
├── frontend/ # PWA-Quellen (JS, CSS, Templates)
├── 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
├── public/ # Build-Output (index.html, sw.js, assets/)
├── tools/ # schema.sql, gen_vapid.py, build_frontend.py
└── data/ # SQLite (gitignored)
```
@@ -209,10 +211,13 @@ takeyourmeds/
```bash
source .venv/bin/activate
python tools/build_frontend.py # nach Änderungen in frontend/
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 { enqueue, flushQueue } from "./lib/offlineQueue.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";
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);
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() {
@@ -181,6 +180,7 @@ navigator.serviceWorker?.addEventListener("message", (event) => {
async function boot() {
registerPwa();
wireAppUpdates();
if (isLoggedIn()) {
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}`),
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) =>
+4 -3
View File
@@ -36,12 +36,13 @@ export function wirePwaInstall({ sectionId = "pwaInstallSection", buttonId = "in
export async function registerPwa() {
if (!("serviceWorker" in navigator)) return;
try {
await navigator.serviceWorker.register("/sw.js");
const reg = await navigator.serviceWorker.register("/sw.js");
if ("sync" in ServiceWorkerRegistration.prototype) {
navigator.serviceWorker.ready.then((reg) => {
reg.sync?.register("sync-logs").catch(() => {});
navigator.serviceWorker.ready.then((readyReg) => {
readyReg.sync?.register("sync-logs").catch(() => {});
});
}
return reg;
} catch (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; } }
/* Cards */
.roastCard, .oracleCard, .motivationCard {
.roastCard, .motivationCard {
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, .motivationLabel {
.roastLabel, .motivationLabel {
font-size: .65rem; text-transform: uppercase; letter-spacing: .06em;
line-height: 1.35;
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; }
@@ -244,3 +244,15 @@ body {
}
.easterEggInner h2 { font-size: 1.5rem; margin-bottom: .75rem; }
.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="theme-color" content="#F12F12">
<meta name="description" content="Medikamenten-Erinnerung mit Sarkasmus">
<meta name="app-build" content="914619f3">
<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">
<link rel="stylesheet" href="/assets/styles.993b74f1.css">
<title>TakeYourMeds</title>
</head>
<body>
@@ -67,10 +68,6 @@
<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>
@@ -95,9 +92,13 @@
</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.js"></script>
<script type="module" src="/assets/app.58f3e95c.js"></script>
</body>
</html>
+82 -43
View File
@@ -1,20 +1,22 @@
const CACHE = "tym-v1.0.1";
const CACHE = "tym-v1.1.0-914619f3";
const ASSETS = [
"/",
"/index.html",
"/styles.css",
"/app.js",
"/assets/api.89363e2a.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",
"/version.json",
"/icon.png",
"/icon-72x72.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",
"/version.json"
];
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) => {
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;
}
// sw.js: never intercept — browser must revalidate with server
if (url.pathname === "/sw.js") return;
if (request.mode === "navigate") {
if (url.pathname.startsWith("/api/")) {
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;
}
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;
})
)
);
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) => {
@@ -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) => {
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}`)
);
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}`)
);
event.waitUntil(self.clients.openWindow(`/?action=snooze&slot=${slotId}&minutes=${minutes}`));
return;
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.0",
"buildTime": "2026-06-09T19:02:13.248375Z",
"buildHash": "92521fc3cbd964bd"
"version": "1.1.0",
"buildTime": "2026-07-08T06:50:31.880953Z",
"buildHash": "914619f3"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "takeyourmeds"
version = "1.0.0"
version = "1.1.0"
description = "Medikamenten-PWA mit Push-Erinnerungen und sarkastischem Humor"
requires-python = ">=3.11"
dependencies = [
+42 -53
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from datetime import datetime
from datetime import datetime, timedelta
from typing import Any
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."
)
ORACLE_SYSTEM = (
"Du bist das tägliche KI-Orakel einer ADHS-Medikamenten-App. "
"Passiv-aggressiv, trocken, max 4 Sätze auf Deutsch. Keine medizinischen Ratschläge."
ROAST_SYSTEM = (
"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. "
"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:
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}"
def _oracle_key(day: str) -> str:
return f"oracle:v3:{day}"
def _roast_key(day: str) -> str:
return f"roast:{day}"
async def _cached_oracle(db: aiosqlite.Connection, day: str) -> str | None:
cached = await _get_cache(db, _oracle_key(day))
if cached:
return cached
week = datetime.strptime(day, "%Y-%m-%d").strftime("%Y-W%W")
return await _get_cache(db, f"oracle:v2:{week}")
async def _recent_roasts(
db: aiosqlite.Connection,
*,
before_day: str,
days: int = ROAST_HISTORY_DAYS,
) -> 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:
@@ -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}
async def get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
return await get_daily_motivation(db, tz)
async def generate_oracle(
async def generate_roast_of_the_day(
db: aiosqlite.Connection,
tz: ZoneInfo,
day: str | None = None,
@@ -164,58 +170,41 @@ async def generate_oracle(
force: bool = False,
) -> dict[str, Any]:
day = day or datetime.now(tz).strftime("%Y-%m-%d")
key = _oracle_key(day)
key = _roast_key(day)
if not force:
cached = await _cached_oracle(db, day)
cached = await _get_cache(db, key)
if cached:
return {"text": cached, "source": "cache", "day": day}
stats = await compute_stats(db)
days_active = stats["days_active"]
window = stats["compliance_window_days"]
young_app = days_active < 90
context = (
f"App aktiv seit {days_active} Tag(en), erste Einnahme am {stats['first_day']}. "
if young_app
else ""
)
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 ""
)
recent = await _recent_roasts(db, before_day=day)
history = ""
if recent:
lines = "\n".join(f"- {text}" for text in recent)
history = (
f"\n\nDiese Roasts der letzten {ROAST_HISTORY_DAYS} Tage wurden bereits verwendet "
f"(nicht wiederholen, neue Idee):\n{lines}"
)
prompt = (
f"Täglicher Orakel-Report für heute. {context}{caveat}"
"Schreibe den Roast-of-the-Day. "
f"Streak: {stats['streak']} Tage. "
f"Compliance über {window} Tag(e): {stats['compliance_percent']}%. "
f"Genommen gesamt: {stats['total_taken']}."
f"Compliance über {stats['compliance_window_days']} aktive Tage: {stats['compliance_percent']}%. "
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 young_app:
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']}%."
text = pick("roast_fallback")
source = "fallback"
else:
source = "ai"
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}
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")
cached = await _cached_oracle(db, day)
cached = await _get_cache(db, _roast_key(day))
if cached:
return {"text": cached, "source": "cache", "day": day}
return {
"text": "Das Orakel bereitet sich vor. Schau später nochmal rein.",
"source": "pending",
"day": day,
}
return {"text": pick("roast_fallback"), "source": "fallback", "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.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.config_loader import load_meds_config, slot_to_dict, today_str
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.settings import settings
from server.slots import build_today
from server.static_cache import CacheControlMiddleware
from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS
NO_STORE = {"Cache-Control": "no-store"}
@@ -40,24 +41,14 @@ _configure_logging()
logger = logging.getLogger(__name__)
async def _ensure_today_motivation() -> None:
async def _ensure_today_ai_texts() -> None:
config = load_meds_config()
db = await get_db()
try:
await generate_daily_motivation(db, config.timezone)
await generate_roast_of_the_day(db, config.timezone)
except Exception:
logger.exception("Startup motivation 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")
logger.exception("Startup AI text generation failed")
finally:
await db.close()
@@ -68,12 +59,12 @@ async def lifespan(app: FastAPI):
await ensure_schema(db)
await db.close()
start_scheduler()
asyncio.create_task(_ensure_today_motivation())
asyncio.create_task(_ensure_today_oracle())
asyncio.create_task(_ensure_today_ai_texts())
yield
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
app.add_middleware(CacheControlMiddleware)
@app.post("/api/auth/pin")
@@ -219,17 +210,6 @@ async def get_roast(_: dict = Depends(require_auth)) -> JSONResponse:
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():
+6 -6
View File
@@ -9,7 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
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.db import get_db, utc_now_iso
from server.messages import pick
@@ -54,13 +54,13 @@ async def _generate_daily_motivation() -> None:
await db.close()
async def _generate_daily_oracle() -> None:
async def _generate_daily_roast() -> None:
config = load_meds_config()
db = await get_db()
try:
await generate_oracle(db, config.timezone)
await generate_roast_of_the_day(db, config.timezone)
except Exception:
logger.exception("Daily oracle generation failed")
logger.exception("Daily roast generation failed")
finally:
await db.close()
@@ -159,9 +159,9 @@ def start_scheduler() -> None:
)
scheduler.add_job(
_generate_daily_oracle,
_generate_daily_roast,
trigger=CronTrigger(hour=3, minute=5, timezone=tz),
id="daily-oracle",
id="daily-roast",
replace_existing=True,
)
+1 -1
View File
@@ -22,7 +22,7 @@ class Settings(BaseSettings):
messages_path: str = "messages.yaml"
public_dir: str = "public"
app_version: str = "1.0.0"
app_version: str = "1.1.0"
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
"""Regenerate cached AI texts (motivation + oracle)."""
"""Regenerate cached AI texts (motivation, roast)."""
from __future__ import annotations
import argparse
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.db import get_db
async def main() -> None:
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")
args = parser.parse_args()
if not args.motivation and not args.oracle:
args.motivation = True
args.oracle = True
config = load_meds_config()
db = await get_db()
try:
if args.motivation:
result = await generate_daily_motivation(db, config.timezone, force=args.force)
print("motivation:", result)
if args.oracle:
result = await generate_oracle(db, config.timezone, force=args.force)
print("oracle:", result)
motivation = 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:", motivation)
print("roast:", roast)
finally:
await db.close()