Files
HealthLog/src/components/day/day-layer-controller.ts
T
Marc-André Bombeck fee6088e08 feat: open the day with one tap and from every place a day is shown
A tap on a point, a bar, a calendar cell or a date now opens the day at
once, as a click does; the tooltip's second step is gone and the tapped
value heads the day. The dashboard's charts (not its today area), the
adherence chart, the rhythm events, the ECG list and detail and the rest
mode notes open their days too.

The day's header carries the close control top left, the Coach panel's
own toggle and glyph, the date on one centred line and the arrows on the
right. While the day is docked, the Coach button and the documents bar
move clear of it.

Smooth the layer: the tooltip no longer glides into place on touch, the
neighbouring day is read before a step, the sheet animates in 300 ms and
not at all under reduced motion, and the opener stays put while the page
beside the docked day reflows.
2026-10-08 15:56:40 +02:00

190 lines
6.0 KiB
TypeScript

"use client";
import { useSyncExternalStore } from "react";
import type { DateKey } from "@/lib/day/contract";
import {
dayHistoryState,
historyEntryOwnedByLayer,
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 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);
}
}
/** Move the open day by `delta` calendar days, without a new history entry. */
export function stepDay(from: DateKey, delta: number): DateKey {
const next = shiftDateKey(from, delta);
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),
);
return next;
}
/**
* 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));
}