mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The docked panels' strips now run from the top edge of the window to the bottom, and the top bar ends left of them. The panel header no longer carries a fold button in docked mode: the strip opens and closes the day and, on /coach, the conversations; opening moves focus to the panel's heading and closing returns it to the strip. The sheets keep a close button, the day's now an X at the end of its header. The command palette's door is a magnifier at every width, named Search, with the shortcut in its tooltip. g p opens or closes the day like a click on its strip, and below the docking width opens the sheet on the last day or closes it; the shortcuts list shows it under Day.
240 lines
7.3 KiB
TypeScript
240 lines
7.3 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);
|
|
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));
|
|
}
|