mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
insights(medications): drop the GLP-1 dose-listing timeline and densify the compliance tiles
Remove the chronological GLP-1 therapy-event list from the medications insight page — it duplicated data already on the per-medication detail page and added no read on this surface. The page spine collapses to compliance grid -> target -> assessment. Route each per-medication compliance card header through the canonical TileHeader (Pill glyph + med name as the heading) and fold the dose, category, and streak into one compact muted line. Tighten the card gap, header padding, progress-bar height, and content spacing so the same data (7d/30d bars, taken/skipped/missed, heatmap, per-med sentence) reads in noticeably less vertical space, consistent with the rest of the harmonised Insights tiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
424d6ee5e1
commit
18e36e3226
2 files changed
+29
-244
No files matched your search
@@ -10,7 +10,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
|
||||
@@ -18,8 +18,7 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
|
||||
import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — `/insights/medications`.
|
||||
@@ -203,33 +202,37 @@ export default function InsightsMedikamentePage() {
|
||||
>
|
||||
{medications.map((med) => {
|
||||
const medicationSummary = medicationSummaryById.get(med.id);
|
||||
// UI-H3 — route every medication-list row (medications page
|
||||
// + insights/medications) through `<MedicationCardHeader>`
|
||||
// so the surface stays one shape: `{name} {dose}` on line 1
|
||||
// + outline category badge on line 2. The streak chip rides
|
||||
// the `stateBadges` slot so a strong streak still surfaces.
|
||||
// v1.12.6 — the per-medication compliance card harmonises with
|
||||
// the rest of the Insights tiles: its header runs through the
|
||||
// canonical `<TileHeader>` (Pill glyph + med name as the white
|
||||
// heading), and the dose + category + streak fold into one
|
||||
// compact muted line below it. Every datum the previous, taller
|
||||
// card carried (7d/30d bars, taken/skipped/missed, heatmap,
|
||||
// per-med assistant sentence) stays — only the vertical rhythm
|
||||
// tightens.
|
||||
const categoryLabel =
|
||||
med.category === "BLOOD_PRESSURE"
|
||||
? t("medications.categoryBloodPressure")
|
||||
: med.category === "VITAMIN"
|
||||
? t("medications.categoryVitamin")
|
||||
: t("medications.categoryOther");
|
||||
const streakBadge =
|
||||
med.streak > 0 ? (
|
||||
<Badge variant="outline" className="shrink-0 text-xs">
|
||||
{t("insights.dayStreak", { count: med.streak })}
|
||||
</Badge>
|
||||
) : null;
|
||||
return (
|
||||
<Card key={med.id}>
|
||||
<MedicationCardHeader
|
||||
name={med.name}
|
||||
dose={med.dose}
|
||||
categoryLabel={categoryLabel}
|
||||
stateBadges={streakBadge}
|
||||
/>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Card key={med.id} className="gap-2 py-4 md:gap-3 md:py-5">
|
||||
<CardHeader className="pb-0">
|
||||
<TileHeader icon={Pill} title={med.name} />
|
||||
<div className="text-muted-foreground mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
|
||||
<span className="font-medium tabular-nums">{med.dose}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{categoryLabel}</span>
|
||||
{med.streak > 0 ? (
|
||||
<Badge variant="outline" className="ml-auto shrink-0 text-xs">
|
||||
{t("insights.dayStreak", { count: med.streak })}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2.5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>{t("insights.compliance7d")}</span>
|
||||
<span className="font-medium">{med.compliance7}%</span>
|
||||
@@ -238,18 +241,18 @@ export default function InsightsMedikamentePage() {
|
||||
accessible name (Lighthouse a11y 91/94). */}
|
||||
<Progress
|
||||
value={med.compliance7}
|
||||
className="h-2"
|
||||
className="h-1.5"
|
||||
aria-label={t("insights.compliance7d")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>{t("insights.compliance30d")}</span>
|
||||
<span className="font-medium">{med.compliance30}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={med.compliance30}
|
||||
className="h-2"
|
||||
className="h-1.5"
|
||||
aria-label={t("insights.compliance30d")}
|
||||
/>
|
||||
</div>
|
||||
@@ -283,11 +286,6 @@ export default function InsightsMedikamentePage() {
|
||||
|
||||
<MetricTargetSummary slug="medications" />
|
||||
|
||||
{/* 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 />
|
||||
|
||||
{/* v1.12.2 — the assessment is the LAST block on every bespoke
|
||||
metric-detail page, matching the canonical spine the generic
|
||||
scaffold renders. This card reads the medication-compliance
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"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/medications.
|
||||
*
|
||||
* 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) => (
|
||||
<li
|
||||
key={`${entry.kind}-${entry.date}-${entry.medicationName ?? ""}`}
|
||||
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">
|
||||
{/* v1.4.27 B7 / L4 — sr-only drug-name heading per
|
||||
entry so screen-reader users can jump between
|
||||
medication blocks; the visible row keeps the
|
||||
inline <strong> rendering.
|
||||
v1.4.33 IW9 — promoted from h4 -> h2 so the page
|
||||
outline stays sequential. The parent SubPageShell
|
||||
ships an <h1>; CardTitle is a <div>, so the timeline
|
||||
is the only heading-bearing surface here and h2
|
||||
nests directly under h1 without skipping levels. */}
|
||||
{entry.medicationName && (
|
||||
<h2 className="sr-only">{entry.medicationName}</h2>
|
||||
)}
|
||||
<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" aria-hidden="true" />
|
||||
) : entry.doseDelta === "down" ? (
|
||||
<ArrowDown className="inline h-3 w-3" aria-hidden="true" />
|
||||
) : null;
|
||||
return (
|
||||
<span>
|
||||
<strong>{drug}</strong> {t("insights.therapyTimeline.doseTo")}{" "}
|
||||
<span className="font-medium tabular-nums">
|
||||
{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"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<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