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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 08:25:47 +02:00
1 parent fdd0f50130
commit 76af868064
4 files changed
+64 -16

No files matched your search

@@ -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<typeof installFakeBrowser>;
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("/");
+23 -3
View File
@@ -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;
+1 -2
View File
@@ -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],
);
+5 -5
View File
@@ -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":