diff --git a/messages/de.json b/messages/de.json index f6027cbe2..c6dc64916 100644 --- a/messages/de.json +++ b/messages/de.json @@ -560,6 +560,15 @@ "siteThighRight": "Rechter Oberschenkel", "siteUpperArmLeft": "Linker Oberarm", "siteUpperArmRight": "Rechter Oberarm", + "sideEffectTagsLabel": "Häufige GLP-1-Nebenwirkungen", + "sideEffectTagsHelp": "Tippe einen Chip, um ihn zu den Mood-Tags hinzuzufügen.", + "sideEffectTagNausea": "Übelkeit", + "sideEffectTagConstipation": "Verstopfung", + "sideEffectTagDiarrhea": "Durchfall", + "sideEffectTagFatigue": "Müdigkeit", + "sideEffectTagAppetiteLoss": "Appetitlosigkeit", + "sideEffectTagHeartburn": "Sodbrennen", + "sideEffectTagHeadache": "Kopfschmerzen", "newSchedule": "Neues Zeitfenster", "removeSchedule": "Zeitfenster entfernen", "advancedEditing": "Erweiterte Bearbeitung", @@ -879,6 +888,14 @@ "emptyAction": "Briefing erstellen", "loadingLabel": "Briefing wird zusammengestellt" }, + "therapyTimeline": { + "title": "GLP-1-Therapieverlauf", + "description": "Chronologische Liste: Dosis-Änderungen, Injektionen, Bestand und Nebenwirkungstage.", + "empty": "Keine GLP-1-Ereignisse in den letzten 90 Tagen.", + "doseTo": "Dosis auf", + "pens": "Pens", + "sideEffects": "Nebenwirkungen" + }, "trendsRow": { "title": "Trends", "subtitle": "Die letzten 30 Tage auf einen Blick" diff --git a/messages/en.json b/messages/en.json index ffc180018..b901cdf1c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -560,6 +560,15 @@ "siteThighRight": "Right thigh", "siteUpperArmLeft": "Left upper arm", "siteUpperArmRight": "Right upper arm", + "sideEffectTagsLabel": "Common GLP-1 side effects", + "sideEffectTagsHelp": "Tap a chip to add it to your mood-entry tags.", + "sideEffectTagNausea": "nausea", + "sideEffectTagConstipation": "constipation", + "sideEffectTagDiarrhea": "diarrhea", + "sideEffectTagFatigue": "fatigue", + "sideEffectTagAppetiteLoss": "appetite-loss", + "sideEffectTagHeartburn": "heartburn", + "sideEffectTagHeadache": "headache", "newSchedule": "New schedule", "removeSchedule": "Remove schedule", "advancedEditing": "Advanced editing", @@ -879,6 +888,14 @@ "emptyAction": "Generate briefing", "loadingLabel": "Synthesising today's briefing" }, + "therapyTimeline": { + "title": "GLP-1 therapy timeline", + "description": "Chronological log of dose changes, injections, inventory events, and side-effect days.", + "empty": "No GLP-1 therapy events logged in the last 90 days.", + "doseTo": "dose set to", + "pens": "pens", + "sideEffects": "Side effects" + }, "trendsRow": { "title": "Trends", "subtitle": "Last 30 days at a glance" @@ -1016,6 +1033,7 @@ "medikamenteEmptyTitle": "Add your first medication", "medikamenteEmptyDescription": "Compliance insights need at least one tracked medication.", "medikamenteEmptyAction": "Open medications", + "therapyTimelineDescription": "Chronological GLP-1 therapy log — dose changes, injections, inventory, side effects.", "bmiDescription": "BMI derived from your weight and height, with a written AI assessment." }, "sleep": { diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts new file mode 100644 index 000000000..73fd9b852 --- /dev/null +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -0,0 +1,169 @@ +/** + * v1.4.25 W4d — GLP-1 therapy-timeline aggregator. + * + * Returns a chronological merge of every GLP-1-relevant event the user + * has logged: dose changes, injections (with site if recorded), + * inventory events, and side-effect-tagged mood entries from the + * trailing 90 days. Used by /insights/medikamente's TherapyTimeline + * component. Web-only users without a GLP-1 medication get + * `hasGlp1: false` so the component hides cleanly. + */ + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { NextRequest } from "next/server"; + +const DEFAULT_LIMIT = 60; +const SIDE_EFFECT_TAGS = new Set([ + "nausea", + "constipation", + "diarrhea", + "fatigue", + "appetite-loss", + "heartburn", + "headache", + "übelkeit", + "verstopfung", + "durchfall", + "müdigkeit", + "appetitlosigkeit", + "sodbrennen", + "kopfschmerzen", +]); + +interface TimelineEntry { + date: string; + kind: "dose-change" | "injection" | "inventory" | "side-effect"; + medicationName?: string; + doseValue?: number; + doseUnit?: string; + doseDelta?: "up" | "down" | null; + note?: string | null; + injectionSite?: string | null; + inventoryDelta?: number; + reason?: string; + tags?: string[]; +} + +function parseTagList(raw: string | null): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter(Boolean); + } + } catch { + /* fall through */ + } + return raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean); +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const url = new URL(request.url); + const rawLimit = Number(url.searchParams.get("limit")); + const limit = + Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= 200 + ? rawLimit + : DEFAULT_LIMIT; + + const meds = await prisma.medication.findMany({ + where: { userId: user.id, treatmentClass: "GLP1" }, + include: { + doseChanges: { orderBy: { effectiveFrom: "asc" } }, + intakeEvents: { + where: { takenAt: { not: null } }, + orderBy: { takenAt: "desc" }, + take: 50, + }, + inventoryEvents: { orderBy: { occurredAt: "desc" }, take: 20 }, + }, + }); + + if (meds.length === 0) { + return apiSuccess({ hasGlp1: false, entries: [] }); + } + + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + const moods = await prisma.moodEntry.findMany({ + where: { userId: user.id, moodLoggedAt: { gte: ninetyDaysAgo } }, + select: { moodLoggedAt: true, tags: true }, + orderBy: { moodLoggedAt: "desc" }, + }); + + const entries: TimelineEntry[] = []; + + for (const med of meds) { + const sortedChanges = [...med.doseChanges]; + for (let i = 0; i < sortedChanges.length; i += 1) { + const dc = sortedChanges[i]; + const prev = i > 0 ? sortedChanges[i - 1] : null; + let delta: "up" | "down" | null = null; + if (prev) { + if (dc.doseValue > prev.doseValue) delta = "up"; + else if (dc.doseValue < prev.doseValue) delta = "down"; + } + entries.push({ + date: dc.effectiveFrom.toISOString(), + kind: "dose-change", + medicationName: med.name, + doseValue: dc.doseValue, + doseUnit: dc.doseUnit, + doseDelta: delta, + note: dc.note, + }); + } + for (const intake of med.intakeEvents) { + if (!intake.takenAt) continue; + entries.push({ + date: intake.takenAt.toISOString(), + kind: "injection", + medicationName: med.name, + injectionSite: intake.injectionSite, + }); + } + for (const inv of med.inventoryEvents) { + entries.push({ + date: inv.occurredAt.toISOString(), + kind: "inventory", + medicationName: med.name, + inventoryDelta: inv.delta, + reason: inv.reason, + }); + } + } + + // Side-effect days — collapse to one entry per day (most recent + // first) listing every recognised tag picked up that day. + const sideEffectByDay = new Map(); + for (const mood of moods) { + const tags = parseTagList(mood.tags).filter((t) => + SIDE_EFFECT_TAGS.has(t.toLowerCase()), + ); + if (tags.length === 0) continue; + const dayKey = mood.moodLoggedAt.toISOString().slice(0, 10); + const existing = sideEffectByDay.get(dayKey) ?? []; + for (const t of tags) { + if (!existing.includes(t)) existing.push(t); + } + sideEffectByDay.set(dayKey, existing); + } + for (const [day, tags] of sideEffectByDay) { + entries.push({ + date: `${day}T12:00:00Z`, + kind: "side-effect", + tags, + }); + } + + entries.sort((a, b) => b.date.localeCompare(a.date)); + + return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) }); +}); diff --git a/src/app/insights/medikamente/page.tsx b/src/app/insights/medikamente/page.tsx index 7a6a87843..d77a1d309 100644 --- a/src/app/insights/medikamente/page.tsx +++ b/src/app/insights/medikamente/page.tsx @@ -14,6 +14,7 @@ import { Progress } from "@/components/ui/progress"; import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { TherapyTimeline } from "@/components/insights/therapy-timeline"; /** * v1.4.25 W4 — `/insights/medikamente`. @@ -214,6 +215,11 @@ export default function InsightsMedikamentePage() { updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} /> + + {/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users + without an active GLP-1 medication, so the page collapses + back to the compliance grid for everyone else. */} + ); } diff --git a/src/components/insights/therapy-timeline.tsx b/src/components/insights/therapy-timeline.tsx new file mode 100644 index 000000000..9ca296e78 --- /dev/null +++ b/src/components/insights/therapy-timeline.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { ArrowDown, ArrowUp, Pill, Syringe } from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { describeInjectionSite, type InjectionSiteKey } from "@/lib/medications/injection-sites"; + +/** + * v1.4.25 W4d — therapy-timeline view for /insights/medikamente. + * + * Renders a chronological list of GLP-1 therapy events: dose changes, + * injections (with site if recorded), inventory events, side-effect tag + * occurrences. Pure list view — no chart — so the medications-page + * "no chart on the medication card" rule stays consistent across all + * GLP-1 surfaces; the dose curve lives on the Dashboard tile only. + * + * Self-hides for users with no GLP-1 medications (the parent route does + * the gating via a non-200 from the API endpoint). + */ + +interface TimelineEntry { + date: string; + kind: "dose-change" | "injection" | "inventory" | "side-effect"; + medicationName?: string; + doseValue?: number; + doseUnit?: string; + doseDelta?: "up" | "down" | null; + note?: string | null; + injectionSite?: InjectionSiteKey | null; + inventoryDelta?: number; + reason?: string; + tags?: string[]; +} + +interface TherapyTimelineResponse { + hasGlp1: boolean; + entries: TimelineEntry[]; +} + +interface TherapyTimelineProps { + /** Optional cap — defaults to the API's default (60 entries). */ + limit?: number; +} + +export function TherapyTimeline({ limit }: TherapyTimelineProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const { data, isLoading } = useQuery({ + queryKey: ["insights", "glp1-timeline", limit ?? "default"], + queryFn: async () => { + const url = limit + ? `/api/insights/glp1-timeline?limit=${limit}` + : "/api/insights/glp1-timeline"; + const res = await fetch(url); + if (!res.ok) return null; + const json = await res.json(); + return json.data as TherapyTimelineResponse; + }, + staleTime: 60 * 1000, + }); + + if (isLoading || !data || !data.hasGlp1) return null; + if (data.entries.length === 0) { + return ( + + + + + {t("insights.therapyTimeline.title")} + + + +

+ {t("insights.therapyTimeline.empty")} +

+
+
+ ); + } + + return ( + + + + + {t("insights.therapyTimeline.title")} + +

+ {t("insights.therapyTimeline.description")} +

+
+ +
    + {data.entries.map((entry, idx) => ( +
  1. +
  2. + ))} +
+
+
+ ); +} + +function colourForKind(kind: TimelineEntry["kind"]): string { + switch (kind) { + case "dose-change": + return "var(--dracula-purple)"; + case "injection": + return "var(--dracula-cyan)"; + case "inventory": + return "var(--dracula-yellow)"; + case "side-effect": + return "var(--dracula-orange)"; + } +} + +function renderEntryLine( + entry: TimelineEntry, + t: (key: string, params?: Record) => string, +): React.ReactNode { + const drug = entry.medicationName ?? ""; + switch (entry.kind) { + case "dose-change": { + const arrow = entry.doseDelta === "up" ? : entry.doseDelta === "down" ? : null; + return ( + + {drug} {t("insights.therapyTimeline.doseTo")}{" "} + + {entry.doseValue} {entry.doseUnit} + + {arrow && <> {arrow}} + + ); + } + case "injection": { + const site = entry.injectionSite + ? ` · ${t(describeInjectionSite(entry.injectionSite))}` + : ""; + return ( + + + {drug} + {site} + + ); + } + case "inventory": { + const sign = entry.inventoryDelta && entry.inventoryDelta > 0 ? "+" : ""; + return ( + + {drug} · {sign} + {entry.inventoryDelta} {t("insights.therapyTimeline.pens")} ({entry.reason}) + + ); + } + case "side-effect": { + return ( + + {t("insights.therapyTimeline.sideEffects")}: {(entry.tags ?? []).join(", ")} + + ); + } + } +}