diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c41aa958..cbb1b6c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +## [1.28.29] — 2026-07-11 — Dashboard edits show up on the way back; one scrollbar again + +Changing the dashboard tile selection now shows up immediately when you +navigate back — the third and final layer of this bug. Saving marked the +dashboard's cached data stale but, with the dashboard page unmounted, nothing +re-read it until the next poll or a window-focus flick. The save now refreshes +that cache directly, mounted or not. + +The settings pages with sortable lists (dashboard layout, medication order, +modules, mood tags) showed a second page scrollbar: an invisible +screen-reader hint below each list escaped its container and silently +lengthened the document. All five editors are fixed and the overscroll guard +now covers these routes. + ## [1.28.28] — 2026-07-11 — OpenAI-compatible gateways work; a re-keyed night can no longer vanish The user-level "Local (OpenAI-compatible)" provider now speaks the standard diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 03c3eebea..66b284554 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.28 + version: 1.28.29 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index 1b11d2cd6..544ecfb7b 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -27,7 +27,13 @@ const ROUTES = [ // Settings sub-shell — short hub, long sortable-list subpages, long form. "/settings/layout", "/settings/layout/dashboard", + // The other sortable-list subpages + the Modules hub: each hosts an + // order editor with the sr-only drag-hint paragraph (see the + // one-scroll-floor assertion below). "/settings/layout/insights", + "/settings/layout/medications", + "/settings/layout/mood", + "/settings/modules", "/settings/account", "/settings/notifications", // Admin sub-shell — short overview + a longer list page. Guards that the @@ -116,6 +122,27 @@ test.describe("settings + admin vertical over-scroll guard", () => { expect(dims, "main-content / wrapper present").not.toBeNull(); if (!dims) return; + // One-scroll-floor (UI-STANDARDS §9): `
` is the ONLY vertical + // scroll surface — the document itself must never become scrollable + // next to it (a second painted scrollbar + a dead dark band under + // the shell). The historic offender class: an absolutely-positioned + // sr-only element (the dnd drag-hint paragraph) whose containing + // block resolves to the initial containing block because no ancestor + // between it and the root is positioned — its static position below + // a long list then extends the DOCUMENT's scrollable overflow while + // `
` clips everything in normal flow. + const doc = await page.evaluate(() => ({ + scrollHeight: document.documentElement.scrollHeight, + clientHeight: document.documentElement.clientHeight, + })); + expect( + doc.scrollHeight, + `document scrollHeight=${doc.scrollHeight} > viewport ` + + `${doc.clientHeight} — a second vertical scroll surface exists ` + + `beside
(likely an absolutely-positioned element escaping ` + + `to the initial containing block)`, + ).toBeLessThanOrEqual(doc.clientHeight + 1); + // A page shorter than the viewport cannot over-scroll at all. if (dims.scrollHeight <= dims.clientHeight) return; diff --git a/package.json b/package.json index 4c630a11f..3bf1b91f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.28", + "version": "1.28.29", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 8af20350a..6021ae46b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.28"; + /* @sw-version-fallback */ "v1.28.29"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/components/medications/medication-order-editor.tsx b/src/components/medications/medication-order-editor.tsx index 6b0b5d3b5..262c8cf1a 100644 --- a/src/components/medications/medication-order-editor.tsx +++ b/src/components/medications/medication-order-editor.tsx @@ -147,7 +147,11 @@ export function MedicationOrderEditor({ const dirty = draftActive !== null || draftInactive !== null; return ( -
+ // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is + // absolutely positioned, and without a positioned ancestor its 1px box + // lands below the list in DOCUMENT coordinates and makes the document + // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). +
{medications.length === 0 && (

{t("medications.emptyTitle")} diff --git a/src/components/module-list/module-order-editor.tsx b/src/components/module-list/module-order-editor.tsx index ce12ae97d..a0490f237 100644 --- a/src/components/module-list/module-order-editor.tsx +++ b/src/components/module-list/module-order-editor.tsx @@ -104,7 +104,11 @@ export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) { } return ( -

+ // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is + // absolutely positioned, and without a positioned ancestor its 1px box + // lands below the list in DOCUMENT coordinates and makes the document + // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). +
c.custom).length; return ( -
+ // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is + // absolutely positioned, and without a positioned ancestor its 1px box + // lands below the list in DOCUMENT coordinates and makes the document + // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). +

{t("mood.manage.groupsDescription")} diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index 34f37066f..e51ba034d 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -250,7 +250,11 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { } return ( -

+ // `relative` anchors the sr-only drag-hint paragraph: `sr-only` is + // absolutely positioned, and without a positioned ancestor its 1px box + // lands below the list in DOCUMENT coordinates and makes the document + // itself scrollable (second scrollbar — UI-STANDARDS §9 one-scroll-floor). +

{t("mood.manage.tagsDescription")} diff --git a/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts new file mode 100644 index 000000000..8354baad1 --- /dev/null +++ b/src/components/settings/__tests__/dashboard-layout-snapshot-invalidation.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; + +import { queryKeys } from "@/lib/query-keys"; + +/** + * Saving the dashboard tile selection must surface on the dashboard on the + * very next SAME-TAB navigation — not "eventually" via the 120 s poll or a + * window-focus flick. + * + * The web dashboard (`src/app/page-client.tsx`) reads tile visibility/order + * from TWO cache cells: + * 1. the snapshot cell (`queryKeys.dashboardSnapshot(locale)` via + * `useDashboardSnapshot`) — `snapshotQuery.data.layout` is the layout + * source whenever snapshot mode is on (the default), and + * 2. the legacy `queryKeys.dashboardWidgets()` cell (snapshot mode off, plus + * the per-chart overlay-prefs hook). + * The settings save writes (2) directly via `setQueryData` with the server + * response, so its freshness is unconditional. (1) is only INVALIDATED — and + * that is where the same-tab staleness lived: + * + * While the user sits on /settings/layout/dashboard the dashboard page is + * unmounted, so the snapshot cell has no active observer. An + * `invalidateQueries` with the default `refetchType: "active"` merely marks + * the cell stale, and the snapshot query's deliberate `refetchOnMount: false` + * (`use-dashboard-snapshot.ts` — return-to-dashboard within a minute stays a + * free cache hit) then suppresses the mount-time refetch when the user + * navigates back. Result: "saved, but the dashboard shows the old tiles". + * The tests below pin that mechanism against the REAL TanStack QueryClient + * and the fix (`refetchType: "all"`), so neither half can silently regress. + */ + +/** Mirror of the layout-relevant options in `use-dashboard-snapshot.ts`. */ +function snapshotObserverOptions( + client: QueryClient, + queryFn: () => Promise, +) { + return new QueryObserver(client, { + queryKey: queryKeys.dashboardSnapshot("en"), + queryFn, + staleTime: 60_000, + refetchOnMount: false, + retry: false, + }); +} + +function makeClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +describe("dashboard snapshot invalidation — same-tab staleness mechanism", () => { + it("default refetchType leaves the unmounted snapshot cell stale across a remount (the defect)", async () => { + const client = makeClient(); + let fetchCount = 0; + const queryFn = async () => { + fetchCount += 1; + return { layout: `v${fetchCount}` }; + }; + + // Dashboard mounted — first fetch. Wait for the COMMITTED data, not just + // the queryFn call: an observer-less query that never committed a fetch + // reads as disabled and would be skipped by refetchQueries. + const observer = snapshotObserverOptions(client, queryFn); + const unsubscribe = observer.subscribe(() => {}); + await vi.waitFor(() => + expect(observer.getCurrentResult().data).toEqual({ layout: "v1" }), + ); + + // Navigate to Settings → the dashboard unmounts. + unsubscribe(); + + // The PRE-fix save-mutation call: zero-arg key (prefix-matches the + // locale-keyed cell), default refetchType ("active"). + await client.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + }); + expect(fetchCount).toBe(1); // inactive cell: marked stale, NOT refetched + + // Navigate back to the dashboard — remount. `refetchOnMount: false` + // suppresses the stale-triggered refetch: the OLD layout renders. + const observer2 = snapshotObserverOptions(client, queryFn); + const unsubscribe2 = observer2.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(fetchCount).toBe(1); + expect(observer2.getCurrentResult().data).toEqual({ layout: "v1" }); + unsubscribe2(); + client.clear(); + }); + + it('refetchType: "all" refetches the unmounted cell so the remount reads fresh layout (the fix)', async () => { + const client = makeClient(); + let fetchCount = 0; + const queryFn = async () => { + fetchCount += 1; + return { layout: `v${fetchCount}` }; + }; + + const observer = snapshotObserverOptions(client, queryFn); + const unsubscribe = observer.subscribe(() => {}); + await vi.waitFor(() => + expect(observer.getCurrentResult().data).toEqual({ layout: "v1" }), + ); + unsubscribe(); + + // The save-mutation call as shipped: zero-arg key still prefix-matches + // the locale-keyed cell, and `refetchType: "all"` fires the request + // immediately even though the dashboard is unmounted. + await client.invalidateQueries({ + queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", + }); + expect(fetchCount).toBe(2); + + // Remount: fresh data is already in the cache — no refetch needed. + const observer2 = snapshotObserverOptions(client, queryFn); + const unsubscribe2 = observer2.subscribe(() => {}); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(observer2.getCurrentResult().data).toEqual({ layout: "v2" }); + unsubscribe2(); + client.clear(); + }); +}); + +describe("dashboard layout save — every layout source is covered (source pins)", () => { + const sectionSrc = readFileSync( + join(process.cwd(), "src/components/settings/dashboard-layout-section.tsx"), + "utf8", + ); + + it("all three snapshot invalidations (save / reset / rings) refetch inactive cells", () => { + const calls = + sectionSrc.match( + /invalidateQueries\(\{\s*queryKey: queryKeys\.dashboardSnapshot\(\),\s*refetchType: "all",\s*\}\)/g, + ) ?? []; + expect(calls).toHaveLength(3); + // No leftover default-refetchType snapshot invalidation. + const anySnapshotInvalidation = + sectionSrc.match( + /invalidateQueries\(\{\s*queryKey: queryKeys\.dashboardSnapshot\(\)/g, + ) ?? []; + expect(anySnapshotInvalidation).toHaveLength(3); + }); + + it("the legacy dashboardWidgets cell is written directly with the server response", () => { + expect(sectionSrc).toMatch( + /setQueryData\(queryKeys\.dashboardWidgets\(\), saved\)/, + ); + }); + + it("the dashboard reads layout from the snapshot (or the dashboardWidgets cell) only", () => { + const pageSrc = readFileSync( + join(process.cwd(), "src/app/page-client.tsx"), + "utf8", + ); + // Snapshot path: layout comes off the snapshot payload… + expect(pageSrc).toContain("snapshotQuery.data?.layout"); + // …legacy path: the dashboardWidgets factory key (no bare literal). + expect(pageSrc).toContain("queryKeys.dashboardWidgets()"); + // The analytics slices carry tile DATA (summaries), never layout — if + // someone threads layout through them, this pin forces a rethink of the + // save-path invalidation set. + expect(pageSrc).not.toMatch(/analytics[A-Za-z]*\??\.(data\.)?layout/); + }); + + it("refetchOnMount stays false on the snapshot hook (the reason refetchType-all is required)", () => { + const hookSrc = readFileSync( + join(process.cwd(), "src/lib/queries/use-dashboard-snapshot.ts"), + "utf8", + ); + expect(hookSrc).toContain("refetchOnMount: false"); + // v1.28.28 — focus refetch stays on (cross-tab freshness); do not revert. + expect(hookSrc).toContain("refetchOnWindowFocus: true"); + }); +}); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index b4f165414..674af8433 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -241,8 +241,19 @@ export function DashboardLayoutSection({ id }: { id: string }) { // invalidation the snapshot keeps its cached layout and the dashboard // looks stale until a manual reload. Invalidate the snapshot so the // next visit (or open tab) re-reads the saved layout immediately. + // + // `refetchType: "all"` is load-bearing: while the user sits on this + // settings page the dashboard is UNMOUNTED, so its snapshot cell is + // inactive — the default `refetchType: "active"` only marks it stale, + // and the snapshot query's deliberate `refetchOnMount: false` + // (use-dashboard-snapshot.ts) then suppresses the mount-time refetch + // on a same-tab navigation back to `/`. The saved tile selection only + // surfaced on the 120 s poll or a window-focus flick ("saved but the + // dashboard shows old tiles"). Refetching ALL matching cells fires the + // request right here, so the dashboard mounts onto fresh data. queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", }); setDraft(null); toast.success(t("dashboard.layoutSaveSuccess")); @@ -258,8 +269,11 @@ export function DashboardLayoutSection({ id }: { id: string }) { queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); // v1.18.10 — same snapshot invalidation as the save path so a reset // to defaults reflects on the Startseite without a manual reload. + // `refetchType: "all"` for the same reason as the save path: the + // unmounted snapshot cell must refetch NOW, not on its next poll. queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", }); setDraft(null); toast.success(t("dashboard.layoutResetSuccess")); @@ -363,8 +377,11 @@ export function DashboardLayoutSection({ id }: { id: string }) { }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.dashboardWidgets(), saved); + // `refetchType: "all"` for the same reason as the save path: the + // unmounted snapshot cell must refetch NOW, not on its next poll. queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot(), + refetchType: "all", }); }, }); @@ -708,7 +725,15 @@ export function DashboardLayoutSection({ id }: { id: string }) { {t("common.loading")}

) : ( -
+ // `relative` anchors the sr-only drag-hint paragraph below: Tailwind's + // `sr-only` is `position: absolute`, and without a positioned ancestor + // its containing block is the initial containing block — the 1px box + // then sits at its static position (below this long list) in DOCUMENT + // coordinates and silently makes the document itself scrollable, a + // second vertical scrollbar next to the shell-owned `
` scroll + // (UI-STANDARDS §9 one-scroll-floor; guarded by + // e2e/settings-overscroll.spec.ts). +
{/* v1.4.15 Fix 5 — table-style header naming the two switches. The "tile" column controls the strip tile in the upper row; the "chart" column controls the line