From ff92ba46c2d215d13731bc7c5ccf2016e6bddbe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 13 Jun 2026 16:08:01 +0200 Subject: [PATCH] fix(medications): the cards hold one shape and the list refetches on return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The low-supply notice moves onto the adherence meta row beside the streak — a slot every card already reserves in every state — so a card that is low on stock no longer inserts a row that pushes its bars out of line with the card beside it. The gap between the drug class and the first line tightens at the same time. The medications list, compliance summary and supply reads now refetch on every mount. App Router unmounts the page on navigation away and remounts it on return; inside the client staleTime that return served a cache that could be minutes old, hiding a dose logged on another device or surface. A Playwright spec guards the return-navigation refetch. --- e2e/refetch-nav.spec.ts | 65 ++++++++++ src/app/medications/page.tsx | 9 ++ .../card-parts/medication-card-body.tsx | 53 ++++---- .../card-parts/medication-compliance-bars.tsx | 116 +++++++++++++----- .../use-medication-compliance-summary.ts | 13 +- 5 files changed, 197 insertions(+), 59 deletions(-) create mode 100644 e2e/refetch-nav.spec.ts diff --git a/e2e/refetch-nav.spec.ts b/e2e/refetch-nav.spec.ts new file mode 100644 index 000000000..72fb583bf --- /dev/null +++ b/e2e/refetch-nav.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * #316 follow-up — does the medications list refetch when you navigate + * back to the page? + * + * Reporter: "TanStack Query is not fetching data on the medications page + * or other areas when the data is stale, even when I navigate between + * pages." In Next.js App Router a route-level page UNMOUNTS on navigation + * away and remounts on return, so the query observer is recreated — but + * the global `staleTime: 5min` makes `refetchOnMount: true` skip the + * fetch while the cached data is still "fresh", so the page shows a + * potentially stale list with no network round-trip. + * + * This spec counts `/api/medications` requests across a client-side + * round-trip (medications → dashboard → medications). The DESIRED + * (post-fix) behaviour is a fresh fetch on return; pre-fix the count + * stays at 1, which is the reproduction. + * + * Desktop-only: the assertion is about query behaviour, and the sidebar + * nav links it clicks live behind a drawer on the mobile project. + */ +test.use({ storageState: STORAGE_STATE_PATH }); + +test("medications list refetches on client-side navigation back to the page", async ({ + page, +}, testInfo) => { + test.skip( + testInfo.project.name.includes("mobile"), + "sidebar nav links are drawer-gated on mobile; query behaviour is project-independent", + ); + let medsRequests = 0; + page.on("request", (req) => { + try { + const u = new URL(req.url()); + if (u.pathname === "/api/medications") medsRequests += 1; + } catch { + /* non-URL request — ignore */ + } + }); + + // Initial load — the list query mounts and fetches once. + await page.goto("/medications"); + await page.waitForResponse( + (r) => new URL(r.url()).pathname === "/api/medications", + ); + expect(medsRequests).toBe(1); + + // Client-side nav AWAY (dashboard) then BACK — App Router unmounts and + // remounts the medications page. A full page.goto() would reset the + // client cache and always refetch, so we click the sidebar links. + await page.locator('a[href="/"]').first().click(); + await page.waitForURL("**/"); + await page.locator('a[href="/medications"]').first().click(); + await page.waitForURL("**/medications"); + + // Give a returning refetch time to fire. + await page.waitForTimeout(1500); + + // Desired: the return mounts a fresh observer that refetches. Pre-fix + // the 5-min staleTime masks it and this stays at 1 (the bug). + expect(medsRequests).toBeGreaterThanOrEqual(2); +}); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index 7e18fd891..82eef3a4a 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -195,6 +195,15 @@ export default function MedicationsPage() { return apiGet("/api/medications"); }, enabled: isAuthenticated, + // v1.16.12 (#316) — refetch on every mount, even inside the global + // staleTime window. App Router unmounts this page on navigation away + // and remounts it on return; without this, a return within 5 min + // serves the cached list and shows nothing the user changed on + // another surface (the iOS app, a Withings sync, a second tab) — the + // client cache can't know to invalidate a cross-device write. "always" + // makes a return-visit fetch fresh; the per-user server cache keeps it + // cheap. Mutation-driven invalidation still covers same-tab writes. + refetchOnMount: "always", }); // v1.16.11 — the same reminder-thresholds read the cards make (shared diff --git a/src/components/medications/card-parts/medication-card-body.tsx b/src/components/medications/card-parts/medication-card-body.tsx index 4092349cb..58e883b47 100644 --- a/src/components/medications/card-parts/medication-card-body.tsx +++ b/src/components/medications/card-parts/medication-card-body.tsx @@ -124,10 +124,12 @@ export interface MedicationCardBodyProps { /** * v1.16.11 — projected supply runway in whole days, set ONLY while it * sits below the user's low-stock threshold (the variants gate it). - * Non-null renders one muted warning-toned "Vorrat: ≈ N Tage" line - * under the next/last slot; null keeps the card free of stock noise. - * Never set for as-needed medications (no consuming schedule, no - * runway). + * Non-null renders the muted warning-toned "Vorrat: ≈ N Tage" notice on + * the compliance bars' meta row (beside the streak flame); null keeps the + * card free of stock noise. Never set for as-needed medications (no + * consuming schedule, no runway). v1.16.12 moved it off the standalone + * line above the bars onto the reserved meta row to stop it shifting the + * two-bar baseline across a grid row. */ lowStockRunwayDays?: number | null; @@ -228,7 +230,13 @@ export function MedicationCardBody({ // The card surface is a constant neutral surface — dose status is never // expressed as a background / border tint (the maintainer, recurring): only the // discreet status line / pill below communicates take-now / overdue. - className={cn("h-full", active ? "" : "opacity-60")} + // + // v1.16.12 — the shadcn card primitive's airy `gap-4 md:gap-6` between + // header and content read as a wide gulf below the category badge before + // the first content line; tightened to a uniform `gap-3` here (the + // header keeps its own `pb-2.5`) so the name / class / next-intake block + // reads as one tight stack without touching the shared primitive. + className={cn("h-full gap-3 md:gap-3", active ? "" : "opacity-60")} > - {/* v1.16.11 — low-stock context, ONLY below the user's runway - threshold (the prop is pre-gated). One calm warning-toned - line, not a badge: the card stays quiet until the supply - actually needs attention. */} - {active && !asNeeded && lowStockRunwayDays != null && ( -

- {t("medications.cardLowStockRunway", { - days: lowStockRunwayDays, - })} -

- )} - {/* Compliance bars — always two rows; constant-height skeleton holds the slot while the query is in flight so the grid row stays even. A FAILED query swaps the skeleton for the same-footprint quiet error fallback (no bars, one notice line + retry) instead of - sitting on the skeleton forever. */} + sitting on the skeleton forever. + + v1.16.12 — the low-stock runway notice rides the bars' always- + mounted meta row (beside the streak flame, flush right) rather + than a standalone line above the bars. The former line inserted + a conditional flow row only on low-supply cards, pushing their + bars down and breaking the two-bar baseline across a grid row; + the meta row is reserved on every card in every state, so the + notice now costs zero layout shift. */} {active && !asNeeded && (compliance ? ( @@ -279,11 +280,17 @@ export function MedicationCardBody({ streak={compliance.streak} shortDays={compliance.shortDays} longDays={compliance.longDays} + lowStockRunwayDays={lowStockRunwayDays} /> ) : complianceError && onRetryCompliance ? ( - + ) : ( - + ))} {/* Open-cycle status line — calm, rate-decoupled. The slot reserves diff --git a/src/components/medications/card-parts/medication-compliance-bars.tsx b/src/components/medications/card-parts/medication-compliance-bars.tsx index d9ffd1393..5b0253085 100644 --- a/src/components/medications/card-parts/medication-compliance-bars.tsx +++ b/src/components/medications/card-parts/medication-compliance-bars.tsx @@ -18,6 +18,54 @@ interface MedicationComplianceBarsProps { shortDays?: number; /** v1.8.6 — the span of the long row in days. */ longDays?: number; + /** + * Projected supply runway in whole days, set ONLY while it sits below the + * user's low-stock threshold (the card variants gate it). Non-null renders + * the muted warning-toned "Vorrat: ≈ N Tage" notice on the meta row, flush + * right beside the streak flame; null keeps the row free of stock noise. + */ + lowStockRunwayDays?: number | null; +} + +/** + * The always-mounted `min-h-4` meta row beneath the two bars. It carries the + * streak flame (left) and, when supply has dropped below the user's runway + * threshold, the low-stock notice (right, `ml-auto`). Both ride the SAME + * reserved slot every compliance state already holds, so the low-stock line + * no longer inserts a flow row above the bars — the two bars stay aligned + * across a grid row whether or not a card is low on supply. The row stays + * mounted at `min-h-4` even when empty so a streak / low-stock state arriving + * or expiring never changes the card height. + */ +function ComplianceMetaRow({ + streak, + lowStockRunwayDays, +}: { + streak: number; + lowStockRunwayDays: number | null; +}) { + const { t } = useTranslations(); + return ( +
+ {streak > 0 && ( + + + {streak}{" "} + {streak === 1 + ? t("medications.dayStreakOne") + : t("medications.dayStreak")} + + )} + {lowStockRunwayDays != null && ( + + {t("medications.cardLowStockRunway", { days: lowStockRunwayDays })} + + )} +
+ ); } /** @@ -48,6 +96,7 @@ export function MedicationComplianceBars({ streak, shortDays = 7, longDays = 30, + lowStockRunwayDays = null, }: MedicationComplianceBarsProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -94,22 +143,12 @@ export function MedicationComplianceBars({ - {/* Streak flame — the row is ALWAYS mounted at `min-h-4` so a - streak arriving (or expiring) never changes the card height - and the action row below stays on one baseline across a grid - row. The flame itself still gates on `streak > 0`; the - skeleton and error fallbacks reserve the same slot. */} -
- {streak > 0 && ( - - - {streak}{" "} - {streak === 1 - ? t("medications.dayStreakOne") - : t("medications.dayStreak")} - - )} -
+ {/* Streak flame + low-stock notice — the shared meta row, always + mounted at `min-h-4` so a streak or low-stock state arriving (or + expiring) never changes the card height and the action row below + stays on one baseline across a grid row. The skeleton and error + fallbacks reserve the same slot through the same component. */} + ); } @@ -129,24 +168,34 @@ export function MedicationComplianceBars({ * exists, so the skeleton must too or a resolving card would grow by one * row and shift the grid. */ -export function MedicationComplianceSkeleton() { +export function MedicationComplianceSkeleton({ + lowStockRunwayDays = null, +}: { + lowStockRunwayDays?: number | null; +}) { return ( -
-
-
- - +
+ {/* Only the bars are a placeholder; the meta row below is real (the + low-stock runway is independent of the in-flight compliance query), + so a card that sits on the skeleton still surfaces low supply and + announces it. */} +
+
+
+ + +
+
-
-
-
-
- - +
+
+ + +
+
-
-
+
); } @@ -162,8 +211,10 @@ export function MedicationComplianceSkeleton() { */ export function MedicationComplianceError({ onRetry, + lowStockRunwayDays = null, }: { onRetry: () => void; + lowStockRunwayDays?: number | null; }) { const { t } = useTranslations(); return ( @@ -189,8 +240,9 @@ export function MedicationComplianceError({
- {/* Streak-slot parity with the loaded card + skeleton. */} -
+ {/* Meta-row parity with the loaded card + skeleton — still surfaces + low supply even when the compliance read failed. */} +
); } diff --git a/src/lib/queries/use-medication-compliance-summary.ts b/src/lib/queries/use-medication-compliance-summary.ts index 16990a9dc..9cc546cf3 100644 --- a/src/lib/queries/use-medication-compliance-summary.ts +++ b/src/lib/queries/use-medication-compliance-summary.ts @@ -64,11 +64,14 @@ export function useMedicationComplianceSummary(medicationId: string): { const { data, isError, refetch } = useQuery({ queryKey: queryKeys.medicationComplianceSummary(), queryFn: fetchComplianceSummary, - // Dose actions invalidate the key explicitly through - // `medicationDependentKeys`; a shorter window would only re-fire the - // batch on every list visit. Matches the reminder-thresholds query - // the cards mount alongside. + // Dose actions in this tab invalidate the key through + // `medicationDependentKeys`. v1.16.12 (#316) — but a dose logged on + // another surface (the iOS app) leaves this stale for up to the + // window, so refetch on every mount: returning to the page reflects + // cross-device intakes without a manual reload. The batched read is + // one request per visit, server-cached. staleTime: 5 * 60 * 1000, + refetchOnMount: "always", select: (rows: MedicationComplianceSummaryEntry[]) => rows.find((row) => row.medicationId === medicationId) ?? null, }); @@ -88,6 +91,8 @@ export function useMedicationComplianceSummaryAll(): { queryKey: queryKeys.medicationComplianceSummary(), queryFn: fetchComplianceSummary, staleTime: 5 * 60 * 1000, + // v1.16.12 (#316) — fresh on return-navigation (see the per-row hook). + refetchOnMount: "always", }); return { data }; }