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