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 }; }