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:
Marc-André Bombeck authored and GitHub committed 2026-07-11 12:16:24 +02:00
commit 14a517387e
11 files changed
+268 -8

No files matched your search

+14
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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