mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge pull request #475 from MBombeck/release/v1.28.29
release/v1.28.29 — dashboard edits show up on the way back; one scrollbar again
This commit is contained in:
11 files changed
+268
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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): `<main>` 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
|
||||
// `<main>` 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 <main> (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;
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -147,7 +147,11 @@ export function MedicationOrderEditor({
|
||||
const dirty = draftActive !== null || draftInactive !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="medication-order-editor">
|
||||
// `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).
|
||||
<div className="relative space-y-4" data-slot="medication-order-editor">
|
||||
{medications.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.emptyTitle")}
|
||||
|
||||
@@ -104,7 +104,11 @@ export function ModuleOrderEditor({ items, onChange }: ModuleOrderEditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="module-order-editor">
|
||||
// `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).
|
||||
<div className="relative space-y-2" data-slot="module-order-editor">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
|
||||
@@ -189,7 +189,11 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) {
|
||||
const customGroupCount = catalog.categories.filter((c) => c.custom).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="mood-tag-groups-card">
|
||||
// `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).
|
||||
<div className="relative space-y-3" data-slot="mood-tag-groups-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground min-w-0 text-xs">
|
||||
{t("mood.manage.groupsDescription")}
|
||||
|
||||
@@ -250,7 +250,11 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="mood-tag-manager-card">
|
||||
// `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).
|
||||
<div className="relative space-y-4" data-slot="mood-tag-manager-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-muted-foreground min-w-0 text-xs">
|
||||
{t("mood.manage.tagsDescription")}
|
||||
|
||||
@@ -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<unknown>,
|
||||
) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
// `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 `<main>` scroll
|
||||
// (UI-STANDARDS §9 one-scroll-floor; guarded by
|
||||
// e2e/settings-overscroll.spec.ts).
|
||||
<div className="relative space-y-2">
|
||||
{/* 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
|
||||
|
||||
Reference in new issue
Block a user