Files
HealthLog/src/components/day/day-layer-controller.ts
T
Marc-André Bombeck c496e390be fix(day): hold a step until the layer shows the previous one
A step writes ?day= with replaceState, past Next's router, which adopts
the write a render later. Since 16.4 that render can land after a second
quick step and write the first step's URL back, so [ then ] pressed in a
row ended a day short. A step that finds the layer still showing another
day than the URL now waits, and publishOpenDay makes it in one write as
soon as the layer catches up. Never past today still holds for the sum.
2026-10-22 11:32:56 +02:00

289 lines
9.2 KiB
TypeScript

"use client";
import { useSyncExternalStore } from "react";
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
import {
dayHistoryState,
historyEntryOwnedByLayer,
parseDayParam,
shiftDateKey,
withDayHref,
} 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;
}
function currentLocation() {
return { pathname: window.location.pathname, search: window.location.search };
}
/** 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();
const { pathname, search } = currentLocation();
const href = withDayHref(pathname, search, date);
if (historyEntryOwnedByLayer(window.history.state)) {
window.history.replaceState(dayHistoryState(date), "", href);
} else {
window.history.pushState(dayHistoryState(date), "", href);
}
}
/**
* 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(
new URLSearchParams(window.location.search).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 { pathname, search } = currentLocation();
const owned = historyEntryOwnedByLayer(window.history.state);
window.history.replaceState(
// 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.
owned ? dayHistoryState(next) : null,
"",
withDayHref(pathname, search, next),
);
}
/**
* 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;
}
const { pathname, search } = currentLocation();
window.history.replaceState(null, "", withDayHref(pathname, search, null));
}