I hate this fucking language.

This commit is contained in:
Frank Schwenk
2026-06-10 09:43:50 +02:00
parent 66fa24872d
commit fef0454895
10 changed files with 193 additions and 43 deletions
+11
View File
@@ -103,3 +103,14 @@ roast_fallback:
- 'Fun Fact: Du hast diese App installiert. Das war schon mal was.' - 'Fun Fact: Du hast diese App installiert. Das war schon mal was.'
- Dein Streak ist wie deine Motivation — manchmal da, manchmal nicht. - Dein Streak ist wie deine Motivation — manchmal da, manchmal nicht.
- 'Dark Humor des Tages: Wenigstens hast du die App geöffnet. Fortschritt?' - 'Dark Humor des Tages: Wenigstens hast du die App geöffnet. Fortschritt?'
motivation_fallback:
- Dein Gehirn ist nicht kaputt. Es läuft nur auf einem anderen Betriebssystem — ohne Support.
- Du bist nicht faul. Dein Dopamin ist nur gerade in einem Meeting ohne Agenda.
- Heute ist ein guter Tag — oder zumindest einer, an dem Medis existieren.
- 'ADHS: where focus goes to die. Medis: der Respawn-Button.'
- Du schaffst das. Nicht alles, aber Medis. Das zählt.
- Dein Gehirn braucht keinen Fix — nur einen besseren Task-Manager. Hi.
- Motivation ist overrated. Medis nehmen ist unterschätzt. Mach das.
- Du bist kein Bug. Du bist ein Feature mit experimentellem UI.
- Heute existierst du. Medis nehmen ist Bonus-Level. Los geht's.
- 'Toaster bath? Nein. Medis. Einfacher, ähnlich effektiv fürs Funktionieren.'
+21 -10
View File
@@ -89,25 +89,36 @@ async function handleSnooze(slotId, minutes) {
} }
} }
const ROAST_CACHE_KEY = "medis-roast-day"; const MOTIVATION_CACHE_KEY = "medis-motivation-day";
async function loadRoast(day) { function showMotivationFromCache(day) {
const el = document.getElementById("motivationText");
const cached = JSON.parse(sessionStorage.getItem(MOTIVATION_CACHE_KEY) || "null");
if (cached?.day === day && cached?.text) {
el.textContent = cached.text;
return true;
}
return false;
}
async function loadMotivation(day) {
try { try {
const cached = JSON.parse(sessionStorage.getItem(ROAST_CACHE_KEY) || "null"); const motivation = await api.roast();
if (cached?.day === day && cached?.text) return cached; sessionStorage.setItem(MOTIVATION_CACHE_KEY, JSON.stringify({ day: motivation.day, text: motivation.text }));
const roast = await api.roast(); document.getElementById("motivationText").textContent = motivation.text;
sessionStorage.setItem(ROAST_CACHE_KEY, JSON.stringify({ day: roast.day, text: roast.text }));
return roast;
} catch { } catch {
return { text: "Dein Gebrain wartet auf Koffein und Medis." }; document.getElementById("motivationText").textContent = "Dein Gehirn ist nicht kaputt. Es wartet nur auf den richtigen Treiber.";
} }
} }
async function refreshDashboard(stats) { async function refreshDashboard(stats) {
const today = await api.today(); const today = await api.today();
const roast = await loadRoast(today.day);
renderSlots(today.slots, handleTake, handleSnooze); renderSlots(today.slots, handleTake, handleSnooze);
document.getElementById("roastText").textContent = roast.text;
if (!showMotivationFromCache(today.day)) {
document.getElementById("motivationText").textContent = "…";
loadMotivation(today.day);
}
const s = stats || await api.stats(); const s = stats || await api.stats();
updateStats(s); updateStats(s);
+4 -4
View File
@@ -45,9 +45,9 @@
<main class="tabContent"> <main class="tabContent">
<div id="tab-dashboard" class="tabPanel active"> <div id="tab-dashboard" class="tabPanel active">
<div class="roastCard" id="roastCard"> <div class="motivationCard" id="motivationCard">
<div class="roastLabel">Roast of the Day</div> <div class="motivationLabel">ADHD live, laugh, toaster bath motivational</div>
<p id="roastText">Lade Roast</p> <p id="motivationText"></p>
</div> </div>
<div id="slotCards" class="slotCards"></div> <div id="slotCards" class="slotCards"></div>
</div> </div>
@@ -60,7 +60,7 @@
</div> </div>
<div class="statBox"> <div class="statBox">
<div class="statNum" id="statCompliance">0%</div> <div class="statNum" id="statCompliance">0%</div>
<div class="statLabel">90 Tage</div> <div class="statLabel" id="statComplianceLabel">90 Tage</div>
</div> </div>
<div class="statBox"> <div class="statBox">
<div class="statNum" id="statTaken">0</div> <div class="statNum" id="statTaken">0</div>
+2
View File
@@ -80,6 +80,8 @@ export function renderHeatmap(days) {
export function updateStats(stats) { export function updateStats(stats) {
document.getElementById("statStreak").textContent = stats.streak; document.getElementById("statStreak").textContent = stats.streak;
document.getElementById("statCompliance").textContent = `${stats.compliance_percent}%`; document.getElementById("statCompliance").textContent = `${stats.compliance_percent}%`;
const windowDays = stats.compliance_window_days ?? 90;
document.getElementById("statComplianceLabel").textContent = windowDays === 1 ? "1 Tag" : `${windowDays} Tage`;
document.getElementById("statTaken").textContent = stats.total_taken; document.getElementById("statTaken").textContent = stats.total_taken;
document.getElementById("streakBadge").textContent = `🔥 ${stats.streak}`; document.getElementById("streakBadge").textContent = `🔥 ${stats.streak}`;
} }
+5 -4
View File
@@ -97,17 +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 { .roastCard, .oracleCard, .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 { .roastLabel, .oracleLabel, .motivationLabel {
font-size: .75rem; text-transform: uppercase; letter-spacing: .08em; font-size: .65rem; text-transform: uppercase; letter-spacing: .06em;
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 { line-height: 1.5; font-size: .95rem; } .roastCard p, .oracleCard 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; }
+84 -18
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -12,6 +13,19 @@ from server.messages import pick
from server.settings import settings from server.settings import settings
from server.stats import compute_stats from server.stats import compute_stats
logger = logging.getLogger(__name__)
MOTIVATION_SYSTEM = (
"Du schreibst Tages-Motivationssprüche für 'ADHD live, laugh, toaster bath motivational'. "
"Stil: dark humor, Sarkasmus, passiv-aggressiv, kreativ-motivierend — nie gemein, nie medizinisch. "
"Genau ein Satz auf Deutsch, max 25 Wörter. Keine Anführungszeichen um den Spruch."
)
ORACLE_SYSTEM = (
"Du bist das wöchentliche KI-Orakel einer ADHS-Medikamenten-App. "
"Passiv-aggressiv, trocken, max 4 Sätze auf Deutsch. Keine medizinischen Ratschläge."
)
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,))
@@ -28,7 +42,7 @@ async def _set_cache(db: aiosqlite.Connection, key: str, content: str) -> None:
await db.commit() await db.commit()
async def _call_openrouter(prompt: str) -> str | None: async def _call_openrouter(prompt: str, *, system: str) -> str | None:
if not settings.openrouter_api_key: if not settings.openrouter_api_key:
return None return None
try: try:
@@ -43,10 +57,7 @@ async def _call_openrouter(prompt: str) -> str | None:
json={ json={
"model": settings.openrouter_model, "model": settings.openrouter_model,
"messages": [ "messages": [
{ {"role": "system", "content": system},
"role": "system",
"content": "Du bist ein sarkastischer, dark-humor Medikamenten-Coach für jemanden mit ADHS. Kurz (max 2 Sätze), deutsch, witzig aber nicht gemein. Keine medizinischen Ratschläge.",
},
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
], ],
"max_tokens": 120, "max_tokens": 120,
@@ -56,29 +67,62 @@ async def _call_openrouter(prompt: str) -> str | None:
data = resp.json() data = resp.json()
return data["choices"][0]["message"]["content"].strip() return data["choices"][0]["message"]["content"].strip()
except Exception: except Exception:
logger.exception("OpenRouter request failed")
return None return None
async def get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]: def _motivation_key(day: str) -> str:
day = datetime.now(tz).strftime("%Y-%m-%d") return f"motivation:{day}"
key = f"roast:{day}"
cached = await _get_cache(db, key)
async def _cached_motivation(db: aiosqlite.Connection, day: str) -> str | None:
cached = await _get_cache(db, _motivation_key(day))
if cached:
return cached
return await _get_cache(db, f"roast:{day}")
async def generate_daily_motivation(
db: aiosqlite.Connection,
tz: ZoneInfo,
day: str | None = None,
) -> dict[str, Any]:
day = day or datetime.now(tz).strftime("%Y-%m-%d")
key = _motivation_key(day)
cached = await _cached_motivation(db, day)
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)
prompt = f"Roast-of-the-Day für jemanden mit ADHS. Streak: {stats['streak']} Tage, Compliance 90d: {stats['compliance_percent']}%. Ein sarkastischer Spruch." prompt = (
text = await _call_openrouter(prompt) "Schreibe den Motivationsspruch für heute. "
f"Streak: {stats['streak']} Tage. "
f"Compliance über {stats['compliance_window_days']} aktive Tage: {stats['compliance_percent']}%. "
"Beispiel-Stil: Dein Gehirn ist nicht kaputt. Es läuft nur auf einem anderen Betriebssystem — ohne Support."
)
text = await _call_openrouter(prompt, system=MOTIVATION_SYSTEM)
if not text: if not text:
text = pick("roast_fallback") text = pick("motivation_fallback")
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 motivation for %s (%s)", day, source)
return {"text": text, "source": source, "day": day} return {"text": text, "source": source, "day": day}
async def get_daily_motivation(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
day = datetime.now(tz).strftime("%Y-%m-%d")
cached = await _cached_motivation(db, day)
if cached:
return {"text": cached, "source": "cache", "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]:
return await get_daily_motivation(db, tz)
async def get_oracle(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]: async def get_oracle(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
week = datetime.now(tz).strftime("%Y-W%W") week = datetime.now(tz).strftime("%Y-W%W")
key = f"oracle:{week}" key = f"oracle:{week}"
@@ -87,13 +131,35 @@ async def get_oracle(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
return {"text": cached, "source": "cache", "week": week} return {"text": cached, "source": "cache", "week": week}
stats = await compute_stats(db) stats = await compute_stats(db)
prompt = ( days_active = stats["days_active"]
f"Wöchentlicher KI-Orakel-Report (passiv-aggressiv, max 4 Sätze). " window = stats["compliance_window_days"]
f"Streak: {stats['streak']}, Compliance 90d: {stats['compliance_percent']}%, " young_app = days_active < 90
f"genommen gesamt: {stats['total_taken']}." context = (
f"App aktiv seit {days_active} Tag(en), erste Einnahme am {stats['first_day']}. "
if young_app
else ""
) )
text = await _call_openrouter(prompt) 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 = (
f"Wöchentlicher Orakel-Report. {context}{caveat}"
f"Streak: {stats['streak']} Tage. "
f"Compliance über {window} Tag(e): {stats['compliance_percent']}%. "
f"Genommen gesamt: {stats['total_taken']}."
)
text = await _call_openrouter(prompt, system=ORACLE_SYSTEM)
if not text: 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("streak", streak=stats["streak"]) + f" Compliance: {stats['compliance_percent']}%."
source = "fallback" source = "fallback"
else: else:
+16 -1
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging
import pathlib import pathlib
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime from datetime import datetime
@@ -9,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 get_oracle, get_roast_of_the_day from server.ai import generate_daily_motivation, get_oracle, 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
@@ -21,6 +23,18 @@ from server.slots import build_today
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"}
logger = logging.getLogger(__name__)
async def _ensure_today_motivation() -> None:
config = load_meds_config()
db = await get_db()
try:
await generate_daily_motivation(db, config.timezone)
except Exception:
logger.exception("Startup motivation generation failed")
finally:
await db.close()
@asynccontextmanager @asynccontextmanager
@@ -29,6 +43,7 @@ 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())
yield yield
+8 -1
View File
@@ -21,6 +21,13 @@ DEFAULT_MESSAGES: dict[str, list[str]] = {
"Dein Gehirn hat heute schon aufgegeben, bevor du die PIN eingegeben hast.", "Dein Gehirn hat heute schon aufgegeben, bevor du die PIN eingegeben hast.",
"Elvanse wartet. Du offenbar auch, aber im falschen Sinne.", "Elvanse wartet. Du offenbar auch, aber im falschen Sinne.",
], ],
"motivation_fallback": [
"Dein Gehirn ist nicht kaputt. Es läuft nur auf einem anderen Betriebssystem — ohne Support.",
"Du bist nicht faul. Dein Dopamin ist nur gerade in einem Meeting ohne Agenda.",
"Heute ist ein guter Tag — oder zumindest einer, an dem Medis existieren.",
"ADHS: where focus goes to die. Medis: der Respawn-Button.",
"Du schaffst das. Nicht alles, aber Medis. Das zählt.",
],
} }
@@ -39,7 +46,7 @@ def load_messages(path: str | None = None) -> dict[str, list[str]]:
def pick(category: str, **fmt: Any) -> str: def pick(category: str, **fmt: Any) -> str:
pool = load_messages() pool = load_messages()
choices = pool.get(category) or pool.get("reminder", ["Medis."]) choices = pool.get(category) or pool.get("motivation_fallback") or pool.get("reminder", ["Medis."])
text = random.choice(choices) text = random.choice(choices)
if fmt: if fmt:
try: try:
+19
View File
@@ -8,6 +8,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
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
@@ -37,6 +38,17 @@ async def _remind_slot(slot_id: str) -> None:
await db.close() await db.close()
async def _generate_daily_motivation() -> None:
config = load_meds_config()
db = await get_db()
try:
await generate_daily_motivation(db, config.timezone)
except Exception:
logger.exception("Daily motivation generation failed")
finally:
await db.close()
async def _evening_check() -> None: async def _evening_check() -> None:
db = await get_db() db = await get_db()
try: try:
@@ -100,6 +112,13 @@ def start_scheduler() -> None:
replace_existing=True, replace_existing=True,
) )
scheduler.add_job(
_generate_daily_motivation,
trigger=CronTrigger(hour=3, minute=0, timezone=tz),
id="daily-motivation",
replace_existing=True,
)
if not scheduler.running: if not scheduler.running:
scheduler.start() scheduler.start()
logger.info("Scheduler started for timezone %s", tz) logger.info("Scheduler started for timezone %s", tz)
+22 -4
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from typing import Any from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
@@ -9,6 +9,15 @@ import aiosqlite
from server.config_loader import load_meds_config, today_str from server.config_loader import load_meds_config, today_str
from server.db import new_id, utc_now_iso from server.db import new_id, utc_now_iso
async def get_first_intake_day(db: aiosqlite.Connection, tz: ZoneInfo) -> str:
cur = await db.execute("SELECT MIN(day) FROM intake_log WHERE status = 'taken'")
row = await cur.fetchone()
await cur.close()
if row and row[0]:
return str(row[0])
return today_str(tz)
MILESTONE_DEFS: dict[str, str] = { MILESTONE_DEFS: dict[str, str] = {
"log_50": "Pharma-Intern: 50 Logs. Dein Arzt wäre stolz. Vielleicht.", "log_50": "Pharma-Intern: 50 Logs. Dein Arzt wäre stolz. Vielleicht.",
"log_100": "Century Club: 100 Logs. Du bist offiziell zuverlässiger als dein WLAN.", "log_100": "Century Club: 100 Logs. Du bist offiziell zuverlässiger als dein WLAN.",
@@ -100,12 +109,18 @@ async def compute_stats(db: aiosqlite.Connection) -> dict[str, Any]:
slot_ids = [s.id for s in config.slots] slot_ids = [s.id for s in config.slots]
streak = await compute_streak(db, tz) streak = await compute_streak(db, tz)
days = 90 first_day = await get_first_intake_day(db, tz)
first_date = date.fromisoformat(first_day)
today = datetime.now(tz).date()
days_active = max(1, (today - first_date).days + 1)
compliance_window_days = min(90, days_active)
taken = 0 taken = 0
total = 0 total = 0
today = datetime.now(tz).date() for i in range(compliance_window_days):
for i in range(days):
d = (today - timedelta(days=i)).isoformat() d = (today - timedelta(days=i)).isoformat()
if d < first_day:
break
for sid in slot_ids: for sid in slot_ids:
total += 1 total += 1
cur = await db.execute( cur = await db.execute(
@@ -122,6 +137,9 @@ async def compute_stats(db: aiosqlite.Connection) -> dict[str, Any]:
return { return {
"streak": streak, "streak": streak,
"compliance_percent": compliance, "compliance_percent": compliance,
"compliance_window_days": compliance_window_days,
"days_active": days_active,
"first_day": first_day,
"total_taken": await count_logs(db), "total_taken": await count_logs(db),
"milestones": milestones, "milestones": milestones,
} }