feat(insights): GLP1 therapy timeline on /insights/medikamente

Phase 8 of W4d GLP-1 full integration. New TherapyTimeline component
mounts on /insights/medikamente below the compliance grid. Renders a
chronological merge of every GLP-1-relevant event the user has logged:
dose changes (with up/down arrow against the previous step),
injections (with optional site), inventory events, and side-effect
days collapsed from MoodEntry tags.

Self-hides for users without a GLP-1 medication (the API returns
`hasGlp1: false`), so /insights/medikamente keeps its v1.4.24 look
for everyone else.

New GET /api/insights/glp1-timeline aggregates the data. Caps at 60
entries by default (configurable to 200 via ?limit) to keep the list
readable; older events surface via the existing intake-history page.

DE + EN copy for the surface lives under insights.therapyTimeline.
The medikamente sub-page description gains a one-liner explaining
when the timeline appears.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:49:35 +02:00
1 parent edae56969f
commit 2c7c3cc9f3
5 files changed
+395

No files matched your search

+17
View File
@@ -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"
+18
View File
@@ -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": {
+169
View File
@@ -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<string, string[]>();
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) });
});
+6
View File
@@ -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. */}
<TherapyTimeline />
</SubPageShell>
);
}
@@ -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 (
<Card data-slot="therapy-timeline">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Syringe className="text-dracula-purple h-4 w-4" />
{t("insights.therapyTimeline.title")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.therapyTimeline.empty")}
</p>
</CardContent>
</Card>
);
}
return (
<Card data-slot="therapy-timeline">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Syringe className="text-dracula-purple h-4 w-4" />
{t("insights.therapyTimeline.title")}
</CardTitle>
<p className="text-muted-foreground text-xs">
{t("insights.therapyTimeline.description")}
</p>
</CardHeader>
<CardContent>
<ol className="border-border/60 relative space-y-3 border-l pl-4">
{data.entries.map((entry, idx) => (
<li key={idx} className="relative">
<span
className="border-background absolute top-1 -left-[18px] h-3 w-3 rounded-full border-2"
aria-hidden="true"
style={{ backgroundColor: colourForKind(entry.kind) }}
/>
<div className="space-y-0.5">
<p className="text-muted-foreground text-xs">
{fmt.dateWithWeekday(new Date(entry.date))}
</p>
<p className="text-foreground/90 text-sm">
{renderEntryLine(entry, t)}
</p>
{entry.note && (
<p className="text-muted-foreground text-xs italic">
{entry.note}
</p>
)}
</div>
</li>
))}
</ol>
</CardContent>
</Card>
);
}
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, string | number>) => string,
): React.ReactNode {
const drug = entry.medicationName ?? "";
switch (entry.kind) {
case "dose-change": {
const arrow = entry.doseDelta === "up" ? <ArrowUp className="inline h-3 w-3" /> : entry.doseDelta === "down" ? <ArrowDown className="inline h-3 w-3" /> : null;
return (
<span>
<strong>{drug}</strong> {t("insights.therapyTimeline.doseTo")}{" "}
<span className="tabular-nums font-medium">
{entry.doseValue} {entry.doseUnit}
</span>
{arrow && <> {arrow}</>}
</span>
);
}
case "injection": {
const site = entry.injectionSite
? ` · ${t(describeInjectionSite(entry.injectionSite))}`
: "";
return (
<span>
<Pill className="text-dracula-cyan mr-1 inline h-3 w-3" />
<strong>{drug}</strong>
{site}
</span>
);
}
case "inventory": {
const sign = entry.inventoryDelta && entry.inventoryDelta > 0 ? "+" : "";
return (
<span>
<strong>{drug}</strong> · {sign}
{entry.inventoryDelta} {t("insights.therapyTimeline.pens")} ({entry.reason})
</span>
);
}
case "side-effect": {
return (
<span>
{t("insights.therapyTimeline.sideEffects")}: {(entry.tags ?? []).join(", ")}
</span>
);
}
}
}