Files
HealthLog/src/components/insights/chart-tooltip-observer.ts
T
Marc-André Bombeck 7bef199039 v1.4.33 — polish and reliability
Quality-leap release between two HealthKit milestones. The headline is a P0 hotfix for the `/api/analytics` 500 that broke the Insights mother page for any account with more than a few thousand measurements: the six per-metric status helpers were spreading the entire numeric history into `Math.min` / `Math.max`, which trips a stack overflow once a single argument list exceeds the V8 spread cap. Both the surfaced symptom and the latent risk in five sibling helpers were folded onto a reduce-based min/max path.

Around that hotfix landed a deep polish pass: the Insights mother page now defers three below-the-fold blocks behind `next/dynamic` to trim the cold mount cost, the analytics endpoint gained a `?slice=summaries` slim slice that the per-metric sub-pages now ride, the Coach snapshot builder picked up a 60 s LRU keyed on `(userId, scope)`, the Settings shell consolidated three duplicate copies of the about / notifications / about-dropdown surfaces, the navigation strip dropped a redundant "Home" group label and a colliding "Notifications" sibling, every icon-only button on the medications and admin surfaces gained an accessible name, every Progress bar gained one too, three pages had their heading hierarchy repaired to a sequential h1 → h2 → h3, the mobile Coach FAB now hides while a chart tooltip is open so it cannot occlude the read-out, and the auth shell normalised every authenticated route to a single `max-w-screen-xl` container.

Also lands the Apple App Site Association handler at `/.well-known/apple-app-site-association` on every host that fronts the app, advertising the iOS bundle's App ID prefix (S8WDX4W5KX.dev.healthlog.app) under `webcredentials.apps` so the passkey ceremony shares cleanly between the web origin and the iOS app.

The release closes nineteen issues from a five-surface audit + a runtime bug-hunt pass, plus six follow-up tracks of polish across the affected surfaces.
2026-05-16 19:46:43 +02:00

182 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.4.33 (F15) — chart-tooltip active observer.
*
* The mobile Coach FAB at `bottom-right` of every routed `/insights`
* sub-page used to overlay the Recharts tooltip bubble when a user
* tapped a data point near the bottom-right of the chart. We can't
* route a `tooltipActive` boolean directly from the chart to the FAB
* because the chart sits inside the routed page subtree while the FAB
* is mounted at the layout level — and the chart component itself is
* out of this work-item's touch surface.
*
* Instead, listen at the DOM level. Recharts paints every active
* tooltip into a div whose class always carries `recharts-tooltip-
* wrapper`. The `<TooltipBoundingBox>` upstream component flips the
* inline `visibility` from `hidden` (idle) to `visible` (cursor over
* a point or long-press on touch). A single shared `MutationObserver`
* watches every such wrapper for `style`-attribute changes and feeds
* a tiny external store; consumers subscribe via `useSyncExternalStore`
* to avoid prop-drilling or react-context coupling between unrelated
* subtrees.
*
* The store is module-scoped so the listener stays a singleton — the
* observer connects on the first subscriber and disconnects on the
* last unmount. SSR-safe: every browser API touch is gated on
* `typeof document`.
*/
let activeCount = 0;
const listeners = new Set<() => void>();
let observer: MutationObserver | null = null;
let listening = false;
// Track wrappers we've already seen so we don't double-count their
// visibility transitions. A `WeakSet` lets the GC reclaim removed
// wrapper elements naturally.
const knownWrappers = new WeakSet<HTMLElement>();
function isVisible(wrapper: HTMLElement): boolean {
// Recharts surfaces `visibility: visible` via inline style when the
// tooltip should paint, and clears it otherwise. We mirror that exact
// contract — `getComputedStyle()` is not required because the inline
// style is always set.
return wrapper.style.visibility === "visible";
}
function emit() {
for (const fn of listeners) fn();
}
function syncWrapperState(wrapper: HTMLElement, nextVisible: boolean) {
const flagged = wrapper.dataset.coachTooltipActive === "1";
if (nextVisible && !flagged) {
wrapper.dataset.coachTooltipActive = "1";
activeCount += 1;
emit();
} else if (!nextVisible && flagged) {
delete wrapper.dataset.coachTooltipActive;
activeCount = Math.max(0, activeCount - 1);
emit();
}
}
function adoptWrapper(wrapper: HTMLElement) {
if (knownWrappers.has(wrapper)) return;
knownWrappers.add(wrapper);
// Seed the active-count with whatever state the wrapper carried on
// first sight so a tooltip already painted before our observer
// attached still counts.
syncWrapperState(wrapper, isVisible(wrapper));
}
function scanForWrappers(root: ParentNode) {
// `querySelectorAll` returns every existing wrapper inside the
// mutated subtree on every mutation — cheap because the DOM
// typically holds 0–1 wrappers at a time.
const wrappers = root.querySelectorAll<HTMLElement>(
'[class*="recharts-tooltip-wrapper"]',
);
for (const wrapper of wrappers) adoptWrapper(wrapper);
}
function handleMutations(mutations: MutationRecord[]) {
for (const mutation of mutations) {
if (mutation.type === "childList") {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
if (node.matches?.('[class*="recharts-tooltip-wrapper"]')) {
adoptWrapper(node);
} else {
scanForWrappers(node);
}
}
for (const node of mutation.removedNodes) {
if (!(node instanceof HTMLElement)) continue;
// Removed wrappers cannot contribute to active count anymore;
// drop the flag so a re-added wrapper re-counts cleanly.
if (node.dataset?.coachTooltipActive === "1") {
delete node.dataset.coachTooltipActive;
activeCount = Math.max(0, activeCount - 1);
emit();
}
}
} else if (mutation.type === "attributes" && mutation.target instanceof HTMLElement) {
const target = mutation.target;
if (target.matches('[class*="recharts-tooltip-wrapper"]')) {
syncWrapperState(target, isVisible(target));
}
}
}
}
function start() {
if (listening) return;
if (typeof document === "undefined") return;
listening = true;
observer = new MutationObserver(handleMutations);
observer.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["style"],
});
// Seed with wrappers that already exist in the DOM at subscribe-time.
scanForWrappers(document.body);
}
function stop() {
if (!listening) return;
listening = false;
observer?.disconnect();
observer = null;
// Reset the counter — re-seeding on the next subscriber re-scans
// the DOM anyway.
activeCount = 0;
}
/**
* Subscribe-API used by `useSyncExternalStore`. Connects the singleton
* observer on the first subscriber and disconnects on the last.
*/
export function subscribeChartTooltipActive(listener: () => void): () => void {
listeners.add(listener);
if (listeners.size === 1) start();
return () => {
listeners.delete(listener);
if (listeners.size === 0) stop();
};
}
/**
* Snapshot getter for `useSyncExternalStore`. Returns `true` while at
* least one Recharts tooltip wrapper is currently visible.
*/
export function getChartTooltipActive(): boolean {
return activeCount > 0;
}
/**
* SSR snapshot — no chart tooltip can be active on the server, so we
* always return `false`. Keeps the hook safe on the initial render
* before hydration.
*/
export function getChartTooltipActiveServer(): boolean {
return false;
}
/**
* Test-only reset hook. Vitest tears the DOM down between tests but
* the module-scoped counter survives the teardown; exposing a reset
* keeps cross-test state from bleeding.
*/
export function __resetChartTooltipObserverForTests(): void {
activeCount = 0;
listeners.clear();
if (observer) {
observer.disconnect();
observer = null;
}
listening = false;
}