From 76af8680647e82bcc113700ed47408709506913e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 08:25:47 +0200 Subject: [PATCH] fix(day): step the open day from the URL, not from the last rendered day A step read the day the layer had last published, which reaches the shortcut and the header arrows through useSearchParams, a render and an effect. Next 16.4 lets that chain trail a replaceState by more than a key repeat, so two quick presses of ] both stepped from the same day and one was lost. stepDay now reads ?day= from window.location, which the step itself writes, and refuses a step past today on that value too. --- .../__tests__/day-layer-controller.test.ts | 41 ++++++++++++++++--- src/components/day/day-layer-controller.ts | 26 ++++++++++-- src/components/day/day-layer.tsx | 3 +- src/components/layout/global-shortcuts.tsx | 10 ++--- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/components/day/__tests__/day-layer-controller.test.ts b/src/components/day/__tests__/day-layer-controller.test.ts index 32eef666c..f950e9d2d 100644 --- a/src/components/day/__tests__/day-layer-controller.test.ts +++ b/src/components/day/__tests__/day-layer-controller.test.ts @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; * * - opening adds exactly one entry, so Back closes the layer; * - opening another day while one is open swaps it in place; - * - stepping to the neighbouring day never adds an entry, however often; + * - stepping to the neighbouring day never adds an entry, however often, + * starts from the day in the URL and never passes today; * - closing a day the layer opened goes Back (the next Back leaves the * page); closing a deep link only drops the parameter and stays. */ @@ -57,6 +58,8 @@ function installFakeBrowser(initialUrl: string) { let browser: ReturnType; +const TODAY = "2026-01-10"; + beforeEach(() => { browser = installFakeBrowser("/insights/blood-pressure?range=90"); }); @@ -88,18 +91,44 @@ describe("day layer history", () => { it("steps without adding history entries", async () => { const { openDay, stepDay } = await import("../day-layer-controller"); openDay("2026-01-03"); - stepDay("2026-01-03", -1); - stepDay("2026-01-02", -1); - stepDay("2026-01-01", 1); + stepDay(-1, TODAY); + stepDay(-1, TODAY); + stepDay(1, TODAY); expect(browser.length()).toBe(2); expect(browser.current()).toContain("day=2026-01-02"); }); + it("steps from the day in the URL, so quick steps in a row all count", async () => { + // Nothing republishes the open day between these calls, which is what a + // fast key repeat looks like while the layer is still rendering the + // previous step. Each step must still move from where the last one went. + const { openDay, stepDay } = await import("../day-layer-controller"); + openDay("2026-01-03"); + expect(stepDay(1, TODAY)).toBe("2026-01-04"); + expect(stepDay(1, TODAY)).toBe("2026-01-05"); + expect(browser.current()).toContain("day=2026-01-05"); + }); + + it("never steps past today, however often it is asked", async () => { + const { openDay, stepDay } = await import("../day-layer-controller"); + openDay("2026-01-09"); + expect(stepDay(1, TODAY)).toBe(TODAY); + expect(stepDay(1, TODAY)).toBeNull(); + expect(stepDay(1, TODAY)).toBeNull(); + expect(browser.current()).toContain(`day=${TODAY}`); + }); + + it("does nothing while no day is open", async () => { + const { stepDay } = await import("../day-layer-controller"); + expect(stepDay(-1, TODAY)).toBeNull(); + expect(browser.current()).toBe("/insights/blood-pressure?range=90"); + }); + it("closes a pushed day with Back, landing on the page without it", async () => { const { closeDay, openDay, stepDay } = await import("../day-layer-controller"); openDay("2026-01-03"); - stepDay("2026-01-03", 1); + stepDay(1, TODAY); closeDay(); expect(browser.current()).toBe("/insights/blood-pressure?range=90"); }); @@ -107,7 +136,7 @@ describe("day layer history", () => { it("closes a deep link in place: the parameter goes, the page stays", async () => { browser = installFakeBrowser("/?day=2026-01-03"); const { closeDay, stepDay } = await import("../day-layer-controller"); - stepDay("2026-01-03", -1); + stepDay(-1, TODAY); expect(browser.current()).toBe("/?day=2026-01-02"); closeDay(); expect(browser.current()).toBe("/"); diff --git a/src/components/day/day-layer-controller.ts b/src/components/day/day-layer-controller.ts index 1431ce594..28f5dd195 100644 --- a/src/components/day/day-layer-controller.ts +++ b/src/components/day/day-layer-controller.ts @@ -2,11 +2,12 @@ import { useSyncExternalStore } from "react"; -import type { DateKey } from "@/lib/day/contract"; +import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract"; import { dayHistoryState, historyEntryOwnedByLayer, + parseDayParam, shiftDateKey, withDayHref, } from "./day-url"; @@ -155,8 +156,27 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void { } } -/** Move the open day by `delta` calendar days, without a new history entry. */ -export function stepDay(from: DateKey, delta: number): DateKey { +/** + * Move the open day by `delta` calendar days, without a new history entry. + * Null, and nothing written, when no day is open or the step would pass + * `today`. + * + * The step starts from the day in the URL at the moment of the step, not + * from the day a caller last rendered. The rendered day reaches a caller + * through Next's `useSearchParams`, a render of the layer and its + * `publishOpenDay`, and Next 16.4 lets that chain trail a `replaceState` by + * more than a key repeat: two quick `]` presses then both stepped from the + * same day and one of them was lost. `window.location` is written by the + * step itself, so it is never behind. + */ +export function stepDay(delta: number, today: DateKey): DateKey | null { + if (typeof window === "undefined") return null; + const from = parseDayParam( + new URLSearchParams(window.location.search).get(DAY_QUERY_PARAM), + today, + ); + if (from === null) return null; + if (delta > 0 && from >= today) return null; const next = shiftDateKey(from, delta); jumpDay(next); return next; diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index 8c75f2ea3..7a6676e14 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -348,8 +348,7 @@ function DayLayer() { const onStep = useCallback( (delta: number) => { if (date === null) return; - if (delta > 0 && date >= today) return; - stepDay(date, delta); + stepDay(delta, today); }, [date, today], ); diff --git a/src/components/layout/global-shortcuts.tsx b/src/components/layout/global-shortcuts.tsx index 71eb14229..1e5953c65 100644 --- a/src/components/layout/global-shortcuts.tsx +++ b/src/components/layout/global-shortcuts.tsx @@ -192,12 +192,12 @@ export function GlobalShortcuts() { setShortcutsHelpOpen(true); return; case "day": { - const date = state.openDay; - if (date === null) return; + if (state.openDay === null) return; event.preventDefault(); - // No day after today: the layer refuses it, so the step does too. - if (action.delta > 0 && date >= state.today) return; - stepDay(date, action.delta); + // The step reads the day from the URL, which is ahead of + // `openDay` while a previous step is still rendering, and refuses + // a day after today itself. + stepDay(action.delta, state.today); return; } case "day-toggle":