Files
HealthLog/src/components/day/day-layer-controller.ts
T
Marc-André Bombeck d8e4cf6000 fix(url): write page state into the query string through one serialised helper
Next 16.4 adopts a native history write a render later. A second write
inside that gap was undone when the router adopted the first, and a
write computed from useSearchParams started from the URL before the
last one. Clearing the vault's filters could therefore leave
?encounter= behind: the debounced search wrote the filters it had
rendered back over the clear.

@/lib/url-state is now the one way a page writes its own query: each
write starts from the live address bar, at most one waits for the
router at a time, and later updates queue on top of it and go together
once the router has it (or after a short grace). useUrlSearch reads the
same live value and reports each adoption. The filter hook (documents,
measurements, mood), the open document, the day layer, the timeline's
zoom and range, the medication tabs, the AI provider choice, the
Coach's ?c= and the one-shot parameter strips all go through it; the
filter changes merge into the live filters. A guard refuses raw
history writes and router writes built from the rendered params
outside the helper.

The timeline spec now waits for the values menu to hand focus back
before scrolling to the last row; it measured mid-animation and the
focus return scrolled the page back.
2026-10-23 02:04:21 +02:00

279 lines
8.9 KiB
TypeScript

"use client";
import { useSyncExternalStore } from "react";
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
import { currentUrlSearch, writeUrlState } from "@/lib/url-state";
import {
dayHistoryState,
historyEntryOwnedByLayer,
parseDayParam,
shiftDateKey,
} from "./day-url";
/**
* Opening, stepping and closing the day layer from anywhere in the app.
*
* The URL is the state. `openDay` writes `?day=` with `history.pushState`, so
* the browser's Back closes the layer and Forward opens it again; Next keeps
* `useSearchParams` in step with those writes, and the layer mounted in the
* shell reads the parameter and nothing else. Stepping to the neighbouring day
* replaces the entry instead of adding one, so five steps still need a single
* Back to leave. Opening another day while one is open (a second chart point
* beside the docked panel) swaps the day the same way.
*
* Two things do not belong in the URL and live here for the length of one
* open: the value the person tapped to get here (the focus card at the top of
* the day) and the element that opened the layer, which gets the focus back
* when it closes.
*/
/**
* What the person was looking at when they opened the day: a chart point or a
* list row. Shown at the top of the day with the comparison the opening
* surface already knows. Formatted by that surface, in its own words.
*/
export interface DayFocus {
date: DateKey;
/** The `MeasurementType` the value is, to mark its tile and its notes. */
types?: readonly string[];
/** What it is and where it came from, e.g. "Blood pressure · 07:12". */
label: string;
/** The value, formatted. */
value: string;
unit?: string;
/** The comparison the opening surface knows (an earlier result). */
compare?: { label: string; value: string };
}
interface LayerMemory {
focus: DayFocus | null;
trigger: HTMLElement | null;
/** The day the layer shows, as the mounted layer resolved it. */
open: DateKey | null;
/** The trigger's top edge on screen when it opened the layer. */
triggerTop: number | null;
}
const memory: LayerMemory = {
focus: null,
trigger: null,
open: null,
triggerTop: null,
};
const listeners = new Set<() => void>();
function emit() {
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** The focus of the open day, or null when the day was opened without one. */
export function useDayFocus(date: DateKey | null): DayFocus | null {
const focus = useSyncExternalStore(
subscribe,
() => memory.focus,
() => null,
);
return focus !== null && focus.date === date ? focus : null;
}
/**
* The open day, for the surfaces that mark it (the dashed line in a chart,
* the highlighted row in a list). Written by the mounted layer only, so a
* surface never reads `useSearchParams` and needs no Suspense boundary.
*/
export function useOpenDay(): DateKey | null {
return useSyncExternalStore(
subscribe,
() => memory.open,
() => null,
);
}
/** Called by the mounted layer whenever the day it shows changes. */
export function publishOpenDay(date: DateKey | null): void {
if (memory.open === date) return;
memory.open = date;
emit();
// The layer has caught up with the URL: the steps that waited for it go
// now, in one write, from the day it shows.
const waiting = pendingStep;
pendingStep = null;
if (waiting !== null && date !== null) {
stepDay(waiting.delta, waiting.today);
}
}
/**
* The element that opened the layer and where it stood on screen at that
* moment, without handing it over: the docked column keeps it there while
* the page beside it reflows.
*/
export function peekDayTriggerAt(): { el: HTMLElement; top: number } | null {
return memory.trigger && memory.triggerTop !== null
? { el: memory.trigger, top: memory.triggerTop }
: null;
}
/** The element focus returns to when the layer closes. */
export function takeDayTrigger(): HTMLElement | null {
const trigger = memory.trigger;
memory.trigger = null;
return trigger;
}
export interface OpenDayOptions {
focus?: DayFocus | null;
/** Defaults to the element that has focus now. */
trigger?: HTMLElement | null;
}
/** Open `date` over the current page. */
export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
if (typeof window === "undefined") return;
memory.focus = options.focus?.date === date ? options.focus : null;
const active = document.activeElement;
const focused =
typeof HTMLElement !== "undefined" &&
active instanceof HTMLElement &&
active !== document.body
? active
: null;
memory.trigger = options.trigger ?? focused ?? memory.trigger;
memory.triggerTop = memory.trigger
? memory.trigger.getBoundingClientRect().top
: null;
emit();
writeUrlState((params) => params.set(DAY_QUERY_PARAM, date), {
mode: historyEntryOwnedByLayer(window.history.state) ? "replace" : "push",
state: dayHistoryState(date),
});
}
/**
* Steps asked for while the layer had not yet shown the previous one. Kept
* as one sum, with the `today` of the latest request.
*/
let pendingStep: { delta: number; today: DateKey } | null = null;
/**
* Move the open day by `delta` calendar days, without a new history entry,
* and never past `today`. Null when nothing is written now: no day is open,
* the step would pass today, or it waits for the previous one.
*
* Two things keep quick steps (a key held down, `]` pressed twice) from
* losing one.
*
* The step starts from the day in the URL, not from the day a caller last
* rendered, which reaches it through `useSearchParams`, a render and an
* effect and can be a step behind.
*
* And a step does not write while the layer still shows a different day
* than the URL. The write goes past Next's router, which adopts it a render
* later, and since Next 16.4 that render can land after a second write and
* put the first one's URL back: `[` then `]` pressed quickly ended a day
* short. So a step that finds the layer behind waits, and `publishOpenDay`
* makes it as soon as the layer shows the day the URL names.
*/
export function stepDay(delta: number, today: DateKey): DateKey | null {
if (typeof window === "undefined") return null;
const from = parseDayParam(
// The live address, a queued write included (`@/lib/url-state`).
new URLSearchParams(currentUrlSearch()).get(DAY_QUERY_PARAM),
today,
);
if (from === null) {
pendingStep = null;
return null;
}
const total = (pendingStep?.delta ?? 0) + delta;
if (memory.open !== null && memory.open !== from) {
pendingStep = { delta: total, today };
return null;
}
pendingStep = null;
const target = shiftDateKey(from, total);
const next = target > today ? today : target;
if (next === from) return null;
jumpDay(next);
return next;
}
/**
* Swap the open day for `next` (a day picked from the header's calendar),
* the way a step does: no new history entry, so one Back still leaves.
*/
export function jumpDay(next: DateKey): void {
memory.focus = null;
emit();
const owned = historyEntryOwnedByLayer(window.history.state);
writeUrlState((params) => params.set(DAY_QUERY_PARAM, next), {
// A deep link stays a deep link: marking it as pushed would make a later
// close go Back off the page the person arrived on.
state: owned ? dayHistoryState(next) : null,
});
}
/**
* Set while the docked day collapses to make room for another panel (the
* Coach's conversations below 1600 px): focus then stays with the control
* that asked for the room instead of moving to the day's edge.
*/
let dayToggle: (() => void) | null = null;
/**
* The mounted layer's open-or-close, the same as a click on the day's strip
* (a sheet with the strip's day below the docking width). The layer
* registers it; `g p` calls it through `toggleDay`.
*/
export function registerDayToggle(toggle: () => void): () => void {
dayToggle = toggle;
return () => {
if (dayToggle === toggle) dayToggle = null;
};
}
/** Open or close the day. False when no day layer is mounted. */
export function toggleDay(): boolean {
if (dayToggle === null) return false;
dayToggle();
return true;
}
let yielding = false;
/** Collapse the docked day to its edge for a neighbouring panel. */
export function yieldDay(): void {
yielding = true;
memory.trigger = null;
closeDay();
}
/** Read and clear the flag `yieldDay` set. */
export function takeDayYield(): boolean {
const was = yielding;
yielding = false;
return was;
}
/**
* Close the layer. An entry the layer pushed is consumed with Back, so the
* next Back reaches the page before; a deep link only loses its parameter.
*/
export function closeDay(): void {
if (typeof window === "undefined") return;
memory.focus = null;
emit();
if (historyEntryOwnedByLayer(window.history.state)) {
window.history.back();
return;
}
writeUrlState((params) => params.delete(DAY_QUERY_PARAM));
}