From 2b3ff0e58f9dc9dd66c996e5c393ee8686b17a4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 00:35:19 +0200 Subject: [PATCH] fix(daily-loop): thread the overdue medication id into the digest's Log-dose link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Today digest's dose-window rail item linked to the bare /medications list even though the overdue medication's id was already resolved server-side alongside its name — a tap landed the user on a generic list they then had to scan themselves. buildMedsTodayBlock now carries nextDueMedicationId; the digest's "Log dose" action links to /medications?highlight= when the id is known, falling back to the bare list for an older cached block. The medications page reads the highlight param, scrolls the matching card into view, and rings it briefly (mirrors the vault's duplicate-upload highlight) in both the card grid and the table view. Regenerates docs/api/openapi.yaml for the new medsToday field. --- docs/api/openapi.yaml | 12 ++++-- e2e/utils/mock-dashboard-snapshot.ts | 1 + src/app/medications/page.tsx | 28 +++++++++++++ .../medications/__tests__/card-parts.test.tsx | 29 +++++++++++++- .../__tests__/medication-table.test.tsx | 39 +++++++++++++++++++ .../card-parts/medication-card-body.tsx | 28 ++++++++++++- .../medications/glp1-medication-card.tsx | 8 ++++ .../medications/medication-card.tsx | 8 ++++ .../medications/medication-table.tsx | 22 ++++++++++- src/lib/daily/__tests__/digest.test.ts | 20 ++++++++++ src/lib/daily/digest.ts | 8 +++- .../dashboard/__tests__/meds-today.test.ts | 4 ++ src/lib/dashboard/__tests__/verdict.test.ts | 1 + src/lib/dashboard/meds-today.ts | 10 +++++ src/lib/openapi/routes/insights/schemas.ts | 3 +- 15 files changed, 213 insertions(+), 8 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a25fdae25..6458702f8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -23987,6 +23987,10 @@ components: anyOf: - type: string - type: "null" + nextDueMedicationId: + anyOf: + - type: string + - type: "null" required: - activeCount - scheduledToday @@ -23995,12 +23999,14 @@ components: - nextDueAt - nextDueOverdue - nextDueMedicationName + - nextDueMedicationId additionalProperties: false description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot - (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a - `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — - render the plain day summary, never an overdue state." + (anchor passed, still inside its catch-up band). `nextDueMedicationId` deep-links a consumer straight to the + overdue medication's card. Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past + with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day + summary, never an overdue state." healthScore: anyOf: - type: object diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index 1edbc1e6a..a01771183 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -279,6 +279,7 @@ export function buildMockSnapshot( nextDueAt: null, nextDueOverdue: false, nextDueMedicationName: null, + nextDueMedicationId: null, }, healthScore: options.briefing ? { score: 84, band: "green", delta: -2, restMode: null } diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 6838d8f7a..6be2d586e 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -169,6 +169,14 @@ export default function MedicationsPage() { // the param from the URL so a manual close + refresh stays closed. const shouldOpenFromUrl = searchParams?.get("new") === "1"; const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl); + // v1.29.x — the Today digest's "Log dose" action deep-links straight to + // the overdue medication (`/medications?highlight=`) instead of the + // bare list. Read once on mount; a scroll effect below (once the list + // resolves) brings the target card into view and rings it briefly, then + // clears the state so the ring doesn't linger indefinitely. + const [highlightId, setHighlightId] = useState( + () => searchParams?.get("highlight") ?? null, + ); // v1.14.0 — the medications-page "Add" choice. The top button now offers // two paths: log an intake (incl. a backdated one) against an existing // medication, or create a new medication (the existing wizard). @@ -213,6 +221,21 @@ export default function MedicationsPage() { refetchOnMount: "always", }); + // v1.29.x — once the highlighted card is in the DOM, scroll it into view + // and clear the highlight state after a few seconds so the ring is a + // one-time "you tapped here" cue, not a permanent tint (mirrors the + // vault's upload-duplicate flash-highlight, `HIGHLIGHT_MS` in + // `use-document-upload.ts`). + useEffect(() => { + if (!highlightId || !medications?.length) return; + const card = document.querySelector( + `[data-medication-id="${highlightId}"]`, + ); + card?.scrollIntoView({ behavior: "smooth", block: "center" }); + const timer = setTimeout(() => setHighlightId(null), 3000); + return () => clearTimeout(timer); + }, [highlightId, medications]); + // v1.16.11 — the same reminder-thresholds read the cards make (shared // key + cache), so the header's due-set derivation tiers late / // very_late exactly like the card pills. Null on failure keeps the @@ -468,6 +491,7 @@ export default function MedicationsPage() { ) : (
@@ -485,6 +509,7 @@ export default function MedicationsPage() { medication={med} onEdit={openEdit} onOpenHistory={openHistory} + highlighted={highlightId === med.id} /> ) : ( ), )} @@ -513,6 +539,7 @@ export default function MedicationsPage() { medication={med} onEdit={openEdit} onOpenHistory={openHistory} + highlighted={highlightId === med.id} /> ) : ( ), )} diff --git a/src/components/medications/__tests__/card-parts.test.tsx b/src/components/medications/__tests__/card-parts.test.tsx index 12e01579f..f5721b897 100644 --- a/src/components/medications/__tests__/card-parts.test.tsx +++ b/src/components/medications/__tests__/card-parts.test.tsx @@ -420,9 +420,15 @@ describe("streak-token parity — generic vs GLP-1 card", () => { * structural identity that makes the two variants impossible to diverge. */ describe("medication card body — shared shell + dose-state presentation", () => { - function renderBody(doseStatus: DoseStatus, active = true) { + function renderBody( + doseStatus: DoseStatus, + active = true, + highlighted = false, + ) { return render( `) rings the target card, mirroring + // ``. Carries the id for the page's + // scroll-into-view lookup regardless of highlight state. + it("carries the medication id for the deep-link target lookup", () => { + const html = renderBody("upcoming"); + expect(html).toContain('data-medication-id="m1"'); + }); + + it("rings the card when it is the deep-link highlight target", () => { + const html = renderBody("upcoming", true, true); + expect(html).toContain("ring-primary"); + expect(html).toContain("ring-2"); + }); + + it("does not ring the card when it is not the highlight target", () => { + const html = renderBody("upcoming", true, false); + expect(html).not.toContain("ring-primary"); + }); + it("renders the decisive next + last lines exactly once each", () => { const html = renderBody("upcoming"); expect((html.match(/Next intake:/g) ?? []).length).toBe(1); @@ -605,6 +631,7 @@ describe("compliance error fallback (v1.16.8)", () => { }) { return render( — structure + shared payloads", () => { expect(html).not.toContain('aria-label="Take – Amoxicillin"'); }); + // v1.29.x — the Today digest's "Log dose" deep-link + // (`/medications?highlight=`) rings the matching row, mirroring the + // card grid's transient highlight. + it("carries the medication id and rings the row matching highlightId", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + seedCompliance(client, "m2", 90, 88); + const html = render( + , + client, + ); + + expect(html).toContain('data-medication-id="m1"'); + expect(html).toContain('data-medication-id="m2"'); + expect(html).toContain("ring-primary"); + }); + + it("rings no row when highlightId matches nothing", () => { + const client = makeClient(); + seedCompliance(client, "m1", 90, 88); + const html = render( + , + client, + ); + + expect(html).not.toContain("ring-primary"); + }); + it("renders a GLP-1 medication like any other row", () => { const client = makeClient(); seedCompliance(client, "g1", 100, 100); diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index 38e290699..1f0bee3c2 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -50,6 +50,12 @@ import type { MedicationWindowStatus } from "@/lib/medications/window-status"; * - `upcoming` / `taken_*` / `skipped` → no escalation; the card stays calm. */ export interface MedicationCardBodyProps { + /** + * Medication id. Rendered as `data-medication-id` so a deep-link (e.g. the + * Today digest's "Log dose" action, `/medications?highlight=`) can + * locate + scroll to the right card. + */ + id: string; /** Medication name shown on the header's line 1. */ name: string; /** Dose shown beside the name on line 1. */ @@ -140,9 +146,18 @@ export interface MedicationCardBodyProps { /** GLP-1 variant mounts its post-dose injection-site dialog here. */ children?: ReactNode; + + /** + * v1.29.x — briefly ringed when this card is the Today digest's + * "Log dose" deep-link target (`/medications?highlight=`), mirroring + * the vault's `` treatment. Transient — the + * page clears it a few seconds after scrolling the card into view. + */ + highlighted?: boolean; } export function MedicationCardBody({ + id, name, dose, categoryLabel, @@ -164,6 +179,7 @@ export function MedicationCardBody({ intakeLoading, onRecordIntake, children, + highlighted = false, }: MedicationCardBodyProps) { const { t, locale } = useTranslations(); @@ -227,6 +243,7 @@ export function MedicationCardBody({ return ( `) for the Today digest's deep-link + // target. Never a persistent tint — the neutral-card rule above still + // holds for dose STATUS; this is a one-time "you tapped here" cue. + className={cn( + "h-full gap-3 md:gap-3", + active ? "" : "opacity-60", + highlighted && "ring-primary ring-2", + )} > void; onLogSideEffect?: (med: Glp1Medication) => void; + /** + * v1.29.x — briefly ringed when this card is the Today digest's "Log + * dose" deep-link target (`/medications?highlight=`). + */ + highlighted?: boolean; } function diffDays(target: Date, from: Date): number { @@ -182,6 +187,7 @@ export function Glp1MedicationCard({ onEdit, onOpenHistory, onLogSideEffect, + highlighted = false, }: Glp1MedicationCardProps) { const queryClient = useQueryClient(); const { t } = useTranslations(); @@ -493,6 +499,8 @@ export function Glp1MedicationCard({ return ( void; + /** + * v1.29.x — briefly ringed when this card is the Today digest's "Log + * dose" deep-link target (`/medications?highlight=`). + */ + highlighted?: boolean; } export function MedicationCard({ medication, onEdit, onOpenHistory, + highlighted = false, }: MedicationCardProps) { const queryClient = useQueryClient(); const { t, locale } = useTranslations(); @@ -463,6 +469,8 @@ export function MedicationCard({ return ( `). The matching row rings briefly, + * mirroring the card grid's ``. + */ + highlightId?: string | null; } export function MedicationTable({ activeMedications, inactiveMedications, initialSort = null, + highlightId = null, }: MedicationTableProps) { const { t, locale } = useTranslations(); const { user } = useAuth(); @@ -350,6 +357,7 @@ export function MedicationTable({ lateMinutes={lateMinutes} missedMinutes={missedMinutes} lowStockRunwayDays={lowStockRunwayDays} + highlighted={highlightId === med.id} /> ))} {sortedInactive.map((med) => ( @@ -360,6 +368,7 @@ export function MedicationTable({ lateMinutes={lateMinutes} missedMinutes={missedMinutes} lowStockRunwayDays={lowStockRunwayDays} + highlighted={highlightId === med.id} /> ))} @@ -375,6 +384,8 @@ interface MedicationTableRowItemProps { missedMinutes: number; /** Per-user low-stock runway threshold in days; null = alert off. */ lowStockRunwayDays: number | null; + /** v1.29.x — the deep-link highlight target (see `MedicationTableProps`). */ + highlighted?: boolean; } function MedicationTableRowItem({ @@ -383,6 +394,7 @@ function MedicationTableRowItem({ lateMinutes, missedMinutes, lowStockRunwayDays, + highlighted = false, }: MedicationTableRowItemProps) { const { t, locale } = useTranslations(); const fmt = useFormatters(); @@ -637,7 +649,15 @@ function MedicationTableRowItem({ ); return ( - + {/* Sticky name column — opaque background so scrolled content passes underneath, not through. */} diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 1027776c3..124dabc4a 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -35,6 +35,7 @@ function meds(over: Partial = {}): MedsTodayBlock { nextDueAt: null, nextDueOverdue: false, nextDueMedicationName: null, + nextDueMedicationId: null, ...over, }; } @@ -201,6 +202,7 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "Ramipril", + nextDueMedicationId: "med-ramipril", }), }), t, @@ -213,6 +215,24 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(dose?.body).toBe("Ramipril is due today."); expect(dose?.actions).toHaveLength(1); expect(dose?.actions[0].intent).toBe("dose.log"); + // Deep-links straight to the overdue medication's card, not the bare + // list — the tap should land on the right med. + expect(dose?.actions[0].href).toBe("/medications?highlight=med-ramipril"); + }); + + it("falls back to the bare medications list when no id is known (older cached block)", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ + nextDueOverdue: true, + nextDueMedicationName: "Ramipril", + nextDueMedicationId: null, + }), + }), + t, + ); + const dose = d.worthALook.find((i) => i.kind === "dose_window"); + expect(dose?.actions[0].href).toBe("/medications"); }); it("does NOT emit a dose-window item when the medications module is off", () => { diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 64d01a904..66362f5cd 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -258,6 +258,7 @@ function buildDoseWindowItem( if (!moduleEnabled(modules, "medications")) return null; if (!meds.nextDueOverdue) return null; const name = meds.nextDueMedicationName; + const id = meds.nextDueMedicationId; return { kind: "dose_window", title: t("daily.item.doseWindow.title"), @@ -269,7 +270,12 @@ function buildDoseWindowItem( { labelKey: "daily.action.logDose", intent: "dose.log", - href: "/medications", + // Deep-link straight to the overdue medication's card so the tap + // lands on the right one instead of a generic list the user then + // has to scan (the id is known server-side whenever the name is). + // The bare list stays the honest fallback for an older cached + // block that predates the id field. + href: id ? `/medications?highlight=${id}` : "/medications", }, ], moduleKey: "medications", diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts index 8d738d11a..4c96d84d6 100644 --- a/src/lib/dashboard/__tests__/meds-today.test.ts +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -207,6 +207,7 @@ describe("buildMedsTodayBlock — tally", () => { nextDueAt: null, nextDueOverdue: false, nextDueMedicationName: null, + nextDueMedicationId: null, }); }); @@ -259,6 +260,7 @@ describe("buildMedsTodayBlock — next due (real engine)", () => { expect(block.nextDueAt).toBe("2026-06-10T16:00:00.000Z"); expect(block.nextDueOverdue).toBe(false); expect(block.nextDueMedicationName).toBe("Abendmittel"); + expect(block.nextDueMedicationId).toBe("med-evening"); }); it("surfaces an open overdue slot (anchor passed, band still open)", async () => { @@ -282,6 +284,7 @@ describe("buildMedsTodayBlock — next due (real engine)", () => { expect(block.nextDueAt).toBe("2026-06-10T09:00:00.000Z"); expect(block.nextDueOverdue).toBe(true); expect(block.nextDueMedicationName).toBe("Mittagsmittel"); + expect(block.nextDueMedicationId).toBe("med-1"); }); it("advances past a RESOLVED slot to the next future anchor", async () => { @@ -329,5 +332,6 @@ describe("buildMedsTodayBlock — next due (real engine)", () => { expect(block.nextDueAt).toBeNull(); expect(block.nextDueOverdue).toBe(false); expect(block.nextDueMedicationName).toBeNull(); + expect(block.nextDueMedicationId).toBeNull(); }); }); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index 8e12f1d52..49e64199b 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -53,6 +53,7 @@ function medsToday(partial: Partial = {}): MedsTodayBlock { nextDueAt: null, nextDueOverdue: false, nextDueMedicationName: null, + nextDueMedicationId: null, ...partial, }; } diff --git a/src/lib/dashboard/meds-today.ts b/src/lib/dashboard/meds-today.ts index cda9c4b9a..d550a5b35 100644 --- a/src/lib/dashboard/meds-today.ts +++ b/src/lib/dashboard/meds-today.ts @@ -68,6 +68,13 @@ export interface MedsTodayBlock { nextDueOverdue: boolean; /** Name of the medication carrying `nextDueAt`; null when none due. */ nextDueMedicationName: string | null; + /** + * Id of the medication carrying `nextDueAt`; null when none due. Threaded + * through so a consumer (the Today digest's dose-window rail item) can + * deep-link straight to the overdue medication's card instead of the bare + * list. + */ + nextDueMedicationId: string | null; } export async function buildMedsTodayBlock( @@ -182,6 +189,7 @@ export async function buildMedsTodayBlock( let nextDueAt: Date | null = null; let nextDueOverdue = false; let nextDueMedicationName: string | null = null; + let nextDueMedicationId: string | null = null; for (const m of medications) { const display = computeDisplayDue({ medication: { @@ -203,6 +211,7 @@ export async function buildMedsTodayBlock( nextDueAt = display.at; nextDueOverdue = display.overdue; nextDueMedicationName = m.name; + nextDueMedicationId = m.id; } } @@ -214,5 +223,6 @@ export async function buildMedsTodayBlock( nextDueAt: nextDueAt ? nextDueAt.toISOString() : null, nextDueOverdue, nextDueMedicationName, + nextDueMedicationId, }; } diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 98b90427f..28d6ebfc1 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -896,9 +896,10 @@ export const dashboardSnapshotResponse = z nextDueAt: z.string().nullable(), nextDueOverdue: z.boolean(), nextDueMedicationName: z.string().nullable(), + nextDueMedicationId: z.string().nullable(), }) .describe( - "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day summary, never an overdue state.", + "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band). `nextDueMedicationId` deep-links a consumer straight to the overdue medication's card. Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day summary, never an overdue state.", ), // Dashboard hero — health score (warm phase, nullable on a // rollup-coverage miss). Score + band + delta only; the per-pillar