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");