From 5778dd0be0c38551c37a0540a87ab2e00753c7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 11:01:22 +0200 Subject: [PATCH] fix(release): ssr-safe add-param, noon-anchored compliance days, localized wizard assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measurements page consumed its add parameter during render, which crashed server rendering now that every route renders dynamically with real search params. Compliance day buckets anchored to the current wall-clock minus twelve hours, so before local noon the current day was missing from every bucket series — they anchor on local noon now. The wizard spec accepts the localized start date its summary deliberately shows, and the generated contract picks up the version bump. --- docs/api/openapi.yaml | 2 +- e2e/medications-wizard-oneshot.spec.ts | 4 ++- src/app/api/medications/intake/route.ts | 12 ++++++-- src/app/measurements/page.tsx | 39 ++++++++++++++++++------- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5e97c78b7..84cfcc443 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.16.3 + version: 1.16.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/e2e/medications-wizard-oneshot.spec.ts b/e2e/medications-wizard-oneshot.spec.ts index c90974342..a5506caa3 100644 --- a/e2e/medications-wizard-oneshot.spec.ts +++ b/e2e/medications-wizard-oneshot.spec.ts @@ -78,7 +78,9 @@ test.describe("medication wizard — one-shot", () => { await expectStep(page, 5, 5); const summary = page.locator('[data-slot="wizard-summary"]'); await expect(summary).toContainText(/Einmaldosis/i); - await expect(summary).toContainText(/2026-10-15/); + // The summary renders the date through the locale-aware formatter + // (DE UI → "15.10.2026"), not the ISO value the input carries. + await expect(summary).toContainText(/15\.10\.2026/); await clickSave(page); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index e70c56ad3..ff9ab8407 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -278,12 +278,18 @@ async function buildScheduleAnchoredComplianceBuckets( }); // Pre-compute each local day's [start, end) bounds + key, oldest → newest. + // Each day's representative instant anchors on the user's LOCAL NOON of + // the current day and steps back in 24-hour increments: noon stays inside + // the intended local day across DST shifts (23/25-hour days), and — + // unlike the previous `now − 12 h` anchor — it cannot slip into + // yesterday when the user's local time is before noon, which silently + // dropped TODAY from the buckets for every pre-noon read. + const { start: todayStart } = getUserTodayBounds(now, userTz); + const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000; const dayKeys: string[] = []; const dayBounds = new Map(); for (let i = days - 1; i >= 0; i--) { - const representative = new Date( - nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000, - ); + const representative = new Date(todayNoonMs - i * 86_400_000); const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds( representative, userTz, diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 497b52d85..d22e405e9 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -27,15 +27,27 @@ export default function MeasurementsPage() { // v1.4.27 MB6 — read the `?add=` query param during render so // the dialog opens on the first paint (no flash of the page behind - // it). The state-from-prop pattern follows `account-section.tsx`: - // store the "addToken we acted on" sentinel and let render kick off - // the open + replace transition once per param value. The lint rule + // it). The first-load param is consumed in the `useState` lazy + // initializers: they run identically on the server and the hydration + // render (pure token resolution, no router call), so SSR — which sees + // real search params now that the root layout renders every route + // dynamically — stays side-effect free. Calling `router.replace` + // during a server render throws (`location is not defined`); the + // query-strip lives in the effect below instead. The render-driven + // block then only handles LATER client-side navigations back to + // `?add=` (e.g. the insights empty-state CTAs), following the + // state-from-prop pattern in `account-section.tsx` — the lint rule // `react-hooks/set-state-in-effect` rejects setState from inside an - // effect, so the open-on-param work is render-driven instead. + // effect, so that path stays render-driven. const addParam = searchParams.get("add"); - const [dialogOpen, setDialogOpen] = useState(false); - const [defaultType, setDefaultType] = useState(undefined); - const [consumedAddParam, setConsumedAddParam] = useState(null); + const initialAdd = addParam ? resolveAddToken(addParam) : null; + const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null); + const [defaultType, setDefaultType] = useState( + () => initialAdd ?? undefined, + ); + const [consumedAddParam, setConsumedAddParam] = useState( + () => addParam, + ); // v1.4.27 R4 RC2 — DOM handle the form portals its action row into so // the Sheet branch can sticky-pin Save / Cancel. const [footerEl, setFooterEl] = useState(null); @@ -53,6 +65,9 @@ export default function MeasurementsPage() { disabled: dialogOpen, }); + // Later client-side navigation landed on `?add=` again (the first + // load is consumed by the initializers above, so on the server this + // is always a no-op: `consumedAddParam` starts as the param itself). if (addParam && addParam !== consumedAddParam) { setConsumedAddParam(addParam); // v1.4.34 IW-G — `resolveAddToken` is the single source of truth @@ -64,11 +79,15 @@ export default function MeasurementsPage() { setDefaultType(resolved); setDialogOpen(true); } - // Drop the query string so the back-button leaves the user on - // `/measurements` rather than re-opening the dialog. - router.replace("/measurements"); } + // Drop the query string so the back-button leaves the user on + // `/measurements` rather than re-opening the dialog. Effect-driven + // (client-only) because `router.replace` is not callable during SSR. + useEffect(() => { + if (addParam) router.replace("/measurements"); + }, [addParam, router]); + useEffect(() => { if (!isLoading && !isAuthenticated) { router.push("/auth/login");