Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
27 changes: 27 additions & 0 deletions e2e/settings-overscroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/*`
Expand Down
6 changes: 5 additions & 1 deletion src/components/medications/medication-order-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand Down
6 changes: 5 additions & 1 deletion src/components/module-list/module-order-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
6 changes: 5 additions & 1 deletion src/components/mood/manage/tag-groups-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand Down
6 changes: 5 additions & 1 deletion src/components/mood/manage/tag-manager-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
});
27 changes: 26 additions & 1 deletion src/components/settings/dashboard-layout-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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"));
Expand Down Expand Up @@ -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",
});
},
});
Expand Down Expand Up @@ -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
Expand Down
Loading