mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
edae56969f
commit
2c7c3cc9f3
5 files changed
+395
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user