Merge pull request #1145 from MBombeck/perf/dashboard-client

perf: faster charts and insights, no sidebar scrollbar flash, Umami events delivered
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-04 17:33:51 +02:00
commit 006679ed9e
33 files changed
+1458 -393

No files matched your search

+9
View File
@@ -23,8 +23,17 @@
* "recharts" directly.
* - Types stay importable from the component files directly (type-only
* imports are value-free and don't drag the chunk in).
*
* Loading this module also moves Recharts' text-measurement span out of
* `<body>` before the first chart measures anything; see
* `text-measurement-host.ts` for why that read was the dashboard's largest
* main-thread cost.
*/
import { installTextMeasurementHost } from "./text-measurement-host";
installTextMeasurementHost();
export { HealthChart } from "./health-chart";
export { MoodChart } from "./mood-chart";
export { MedicationComplianceChart } from "./medication-compliance-chart";
@@ -0,0 +1,73 @@
/**
* Where Recharts measures text, moved out of `<body>`.
*
* Recharts sizes every axis tick and label by writing the string into one
* shared, off-screen `<span id="recharts_measurement_span">` and reading its
* `getBoundingClientRect()`. It looks the span up by id and creates it under
* `<body>` only when it is missing, so whichever element carries that id is
* the one it uses.
*
* Under `<body>` each of those reads cost a document-wide style recalc. The
* stylesheet carries `:has()` rules whose subject is any descendant of an
* anchor (`:is(:where(.group/avatar-group):has(…) *)`, the card and dialog
* slot rules), so Chromium cannot scope the invalidation a text change in a
* `<body>` descendant causes. Measured on a seeded dashboard: 149 reads per
* cold load, about 2.8 ms each unthrottled (about 1.8 s of the 2.5 s of
* blocking time under a 4x CPU slowdown). The same read on a span outside
* `<body>`, inside a `contain: strict` box, costs about 0.01 ms.
*
* So the span lives under `<html>`, next to `<body>`, inside a zero-size
* contained host. The host copies `<body>`'s class list, which is where the
* app font is set (`--font-sans` + `font-sans`), so every string measures in
* the same font at the same size as before and the charts lay out exactly as
* they did.
*/
export const RECHARTS_MEASUREMENT_SPAN_ID = "recharts_measurement_span";
const HOST_STYLE = [
"position:absolute",
"top:0",
"left:0",
"width:0",
"height:0",
"overflow:hidden",
"contain:strict",
"visibility:hidden",
"pointer-events:none",
].join(";");
/**
* Put the measurement span into its contained host under `<html>`. Moves a
* span Recharts already created under `<body>`; a no-op once installed.
* Returns the span, or `null` where there is no DOM.
*/
export function installTextMeasurementHost(
doc: Document | undefined = typeof document === "undefined"
? undefined
: document,
): HTMLElement | null {
if (!doc?.documentElement || !doc.body) return null;
const existing = doc.getElementById(RECHARTS_MEASUREMENT_SPAN_ID);
const parent = existing?.parentElement;
if (
existing &&
parent?.dataset.slot === "chart-text-measurement-host" &&
parent.parentElement === doc.documentElement
) {
return existing;
}
const host = doc.createElement("div");
host.dataset.slot = "chart-text-measurement-host";
host.setAttribute("aria-hidden", "true");
host.className = doc.body.className;
host.style.cssText = HOST_STYLE;
const span = existing ?? doc.createElement("span");
span.id = RECHARTS_MEASUREMENT_SPAN_ID;
span.setAttribute("aria-hidden", "true");
host.appendChild(span);
doc.documentElement.appendChild(host);
return span;
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { DeferUntilNear } from "../defer-until-near";
/**
* The first paint of a deferred slot is the sentinel alone: the children,
* and so every read they own, wait until the slot nears the visible area.
* The mounted state is exercised in a real browser by the e2e specs that
* reveal the Insights overview (`e2e/utils/deferred-sections.ts`).
*/
describe("<DeferUntilNear>", () => {
it("paints only a 1 px sentinel, never the children, before it is near", () => {
const html = renderToStaticMarkup(
<DeferUntilNear id="labs-changes">
<section data-slot="labs-changes-section">reads on mount</section>
</DeferUntilNear>,
);
expect(html).toBe(
'<div data-deferred-section="labs-changes" aria-hidden="true" class="h-px"></div>',
);
});
});
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
/**
* How far ahead of the visible area a deferred section mounts. Roughly half a
* phone screen: far enough that a section is usually painted before it
* scrolls in, close enough that a visit that never scrolls never asks for it.
*/
export const DEFER_UNTIL_NEAR_MARGIN = "400px 0px";
/** The nearest ancestor that scrolls vertically, or `null` for the viewport. */
function scrollRoot(el: HTMLElement): Element | null {
for (let node = el.parentElement; node; node = node.parentElement) {
const overflowY = getComputedStyle(node).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return node;
}
return null;
}
/**
* Mount `children` only once their slot comes near the visible area.
*
* Until then a 1 px sentinel holds the slot, so a section that owns its own
* reads does not send them on page entry. Observed against the nearest
* scroll container (the app shell scrolls inside `<main>`, not the window),
* since a root margin only widens the root and an observer on the viewport
* would not see a section until it was already on screen.
*
* Once mounted the children stay mounted, and the sentinel leaves no wrapper
* behind: the rendered tree is the same as an eager mount. Without
* `IntersectionObserver` the children mount on the next task.
*/
export function DeferUntilNear({
id,
children,
}: {
/** Names the slot for tests: `data-deferred-section`. */
id: string;
children: ReactNode;
}) {
const [near, setNear] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (near) return;
const el = sentinelRef.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
// No observer to wait on: mount on the next task instead of never.
const timer = setTimeout(() => setNear(true));
return () => clearTimeout(timer);
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) setNear(true);
},
{ root: scrollRoot(el), rootMargin: DEFER_UNTIL_NEAR_MARGIN },
);
observer.observe(el);
return () => observer.disconnect();
}, [near]);
if (near) return <>{children}</>;
return (
<div
ref={sentinelRef}
data-deferred-section={id}
aria-hidden="true"
className="h-px"
/>
);
}
@@ -945,6 +945,9 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
/>
<div
data-slot="vitals-dashboard-grid"
// The loading label needs a role to name (axe:
// aria-prohibited-attr); the loaded grid carries neither.
role={isLoading ? "status" : undefined}
aria-busy={isLoading}
aria-live="polite"
aria-label={
@@ -245,6 +245,10 @@ export function WellnessScores({
/>
<div
data-slot="wellness-scores-grid"
// `aria-label` needs a role to name; a bare <div> may not carry one
// (axe: aria-prohibited-attr). `status` is the loading region's
// role and already implies the polite live region.
role="status"
aria-busy="true"
aria-live="polite"
aria-label={t("insights.derived.scores.loadingLabel")}
@@ -57,6 +57,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
SIDEBAR_EXPANDED_CONTENT_WIDTH,
SidebarCollapseToggle,
SidebarNav,
readSidebarCollapsedPref,
@@ -498,3 +499,63 @@ describe("<SidebarNav> collapse preference persistence", () => {
expect(html.indexOf('href="/admin"')).toBeGreaterThan(toggle);
});
});
/**
* The rail animates `width` between `w-16` and `w-64`. Expanding used to lay
* the labels out against the growing width: multi-word labels wrapped, the
* rows overran the nav, and its `overflow-y-auto` painted a vertical and a
* horizontal scrollbar for the length of the transition. The content now
* lays out at its final width from the first frame and the rail clips it, so
* nothing inside can overflow a scroll container while the width moves.
*/
describe("<SidebarNav> width transition never overflows the nav", () => {
const KEY = "healthlog-sidebar-collapsed";
afterEach(() => {
vi.unstubAllGlobals();
mockMountedRef.value = false;
});
function asideClass(html: string): string {
return /<aside[^>]*class="([^"]*)"/.exec(html)?.[1] ?? "";
}
function columnClass(html: string): string {
return (
/data-slot="sidebar-column"[^>]*class="([^"]*)"|class="([^"]*)"[^>]*data-slot="sidebar-column"/
.exec(html)
?.slice(1)
.find(Boolean) ?? ""
);
}
it("clips the content to the animating rail", () => {
const aside = asideClass(render());
expect(aside).toContain("transition-[width]");
expect(aside.split(" ")).toContain("overflow-hidden");
});
it("lays the expanded column out at the expanded rail's content width", () => {
const column = columnClass(render());
expect(column.split(" ")).toContain(SIDEBAR_EXPANDED_CONTENT_WIDTH);
// `w-64` minus the rail's 1 px `border-r`: a wider floor would push the
// right padding under the clip, a narrower one would let the labels
// re-flow while the width grows.
expect(SIDEBAR_EXPANDED_CONTENT_WIDTH).toBe("min-w-[calc(16rem-1px)]");
expect(asideClass(render()).split(" ")).toContain("border-r");
});
it("drops the floor on the collapsed rail, so the icons follow the narrowing width", () => {
const store = new Map([[KEY, "true"]]);
vi.stubGlobal("window", globalThis);
vi.stubGlobal("localStorage", {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
});
mockMountedRef.value = true;
const html = render();
expect(asideClass(html).split(" ")).toContain("w-16");
expect(columnClass(html).split(" ")).not.toContain(
SIDEBAR_EXPANDED_CONTENT_WIDTH,
);
});
});
+165 -98
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Bell,
LogOut,
@@ -54,6 +54,14 @@ import {
const SIDEBAR_COLLAPSED_STORAGE_KEY = "healthlog-sidebar-collapsed";
/**
* The expanded sidebar's content width: `w-64` minus the rail's 1 px
* `border-r`, so the column fills the expanded rail exactly. Applied as a
* floor while expanded, it keeps the content at its final layout during the
* width transition (see the `sidebar-column` comment in the render).
*/
export const SIDEBAR_EXPANDED_CONTENT_WIDTH = "min-w-[calc(16rem-1px)]";
/**
* The stored collapse choice: `true` / `false`, or `null` when this browser
* has none (or storage is unavailable), which lets the viewport default decide.
@@ -302,6 +310,27 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
);
}
/**
* Whether the nav list is taller than the room the rail leaves it, i.e.
* whether it scrolls. Re-measured when the nav (viewport height) or the list
* (entries arriving as modules resolve) changes size.
*/
function useNavOverflows(navRef: React.RefObject<HTMLElement | null>): boolean {
const [overflows, setOverflows] = useState(false);
useEffect(() => {
const nav = navRef.current;
if (!nav || typeof ResizeObserver === "undefined") return;
// Strict: any overflow, the same test that gives the list a scrollbar.
const update = () => setOverflows(nav.scrollHeight > nav.clientHeight);
const observer = new ResizeObserver(update);
observer.observe(nav);
if (nav.firstElementChild) observer.observe(nav.firstElementChild);
update();
return () => observer.disconnect();
}, [navRef]);
return overflows;
}
/**
* The sidebar's collapse / expand control, drawn as a footer nav row.
*
@@ -418,6 +447,9 @@ export function SidebarNav() {
);
const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false;
const navRef = useRef<HTMLElement | null>(null);
const navOverflows = useNavOverflows(navRef);
function toggleCollapsed() {
const next = !collapsed;
setCollapsedPref(next);
@@ -554,119 +586,154 @@ export function SidebarNav() {
<aside
aria-label={t("nav.sidebar")}
className={cn(
"bg-sidebar border-sidebar-border relative hidden h-full flex-shrink-0 border-r transition-[width] duration-200 motion-reduce:transition-none md:flex md:flex-col",
"bg-sidebar border-sidebar-border relative hidden h-full flex-shrink-0 overflow-hidden border-r transition-[width] duration-200 motion-reduce:transition-none md:flex md:flex-col",
collapsed ? "w-16" : "w-64",
)}
>
{/* Logo band. Height + bottom border come from `SHELL_HEADER_BAND`,
{/* The rail animates its width; the content does not. Expanded, this
column lays out at the final width from the first frame
(`SIDEBAR_EXPANDED_CONTENT_WIDTH`) and the rail's
`overflow-hidden` reveals it as the width grows. Laying the
labels out against the growing width instead wrapped every
multi-word label, pushed the list past the nav's height and its
rows past its width, and the nav's `overflow-y-auto` painted both
scrollbars for the length of the transition. Collapsing needs no
floor: the icon rows already fit the narrowing rail. */}
<div
data-slot="sidebar-column"
className={cn(
"flex h-full w-full flex-col",
!collapsed && SIDEBAR_EXPANDED_CONTENT_WIDTH,
)}
>
{/* Logo band. Height + bottom border come from `SHELL_HEADER_BAND`,
which the top bar reads too, so the two borders draw one
continuous line. The band owns the 4rem; the link inside
stretches with `h-full` and never restates a height. */}
<div
data-slot="sidebar-header"
className={cn(
"border-sidebar-border",
SHELL_HEADER_BAND,
collapsed ? "px-3" : "px-6",
)}
>
<Link
href="/"
<div
data-slot="sidebar-header"
className={cn(
"flex h-full items-center",
collapsed ? "justify-center px-0" : "gap-2",
"border-sidebar-border",
SHELL_HEADER_BAND,
collapsed ? "px-3" : "px-6",
)}
>
<Logo className="text-primary shrink-0" size={24} />
{!collapsed && (
<span className="text-lg font-bold tracking-tight">
HealthLog
</span>
)}
</Link>
</div>
<nav
aria-label={t("nav.mainNavigation")}
className={cn("flex-1 overflow-y-auto", collapsed ? "p-1.5" : "p-3")}
>
<div className="space-y-1">
{visibleNavItems.map((item) => {
const isActive = isNavDestinationActive(
item.href,
pathname,
visibleNavItems,
);
const label = t(item.tKey);
if (collapsed) {
return (
<Tooltip key={item.href}>
<TooltipTrigger asChild>
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
{label}
</Link>
);
})}
<Link
href="/"
className={cn(
"flex h-full items-center",
collapsed ? "justify-center px-0" : "gap-2",
)}
>
<Logo className="text-primary shrink-0" size={24} />
{!collapsed && (
<span className="text-lg font-bold tracking-tight">
HealthLog
</span>
)}
</Link>
</div>
</nav>
{/* Bottom utility links — the shared utility tail (minus
<nav
ref={navRef}
aria-label={t("nav.mainNavigation")}
className={cn(
"flex-1 overflow-y-auto",
collapsed ? "p-1.5" : "p-3",
)}
>
<div className="space-y-1">
{visibleNavItems.map((item) => {
const isActive = isNavDestinationActive(
item.href,
pathname,
visibleNavItems,
);
const label = t(item.tKey);
if (collapsed) {
return (
<Tooltip key={item.href}>
<TooltipTrigger asChild>
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
"flex items-center justify-center rounded-lg p-2.5 transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
</Link>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}
return (
<Link
key={item.href}
href={item.href}
aria-current={isActive ? "page" : undefined}
data-tour-id={item.tourId}
{...(item.href === "/medications" ? medsIntent : {})}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-accent",
)}
>
<item.icon className="h-4 w-4" />
{label}
</Link>
);
})}
</div>
</nav>
{/* Bottom utility links — the shared utility tail (minus
Notifications, which lives in the avatar menu) with the
role-gated Admin entry inserted before Settings. The collapse
control heads the group, so it sits directly above Admin for an
administrator and directly above Settings for everyone else. */}
<div className={cn("space-y-1 pb-1", collapsed ? "px-1.5" : "px-3")}>
<SidebarCollapseToggle
collapsed={collapsed}
onToggle={toggleCollapsed}
/>
{footerUtilityItems
.filter((item) => !isSettingsUtilityDestination(item))
.map((item) => renderUtilityLink(item))}
{renderAdminLink()}
{footerUtilityItems
.filter((item) => isSettingsUtilityDestination(item))
.map((item) => renderUtilityLink(item))}
</div>
{/* On a short window the list scrolls under this group. Without an
edge the last visible row ran straight into the Collapse row,
cut off mid-entry, and read as the footer covering it (macOS
hides the scrollbar that would have said otherwise). While the
list overflows, the group draws the same hairline the user
section does, so the boundary reads as a scroll edge. */}
<div
data-slot="sidebar-footer"
data-nav-overflows={navOverflows ? "true" : undefined}
className={cn(
"space-y-1 pb-1",
collapsed ? "px-1.5" : "px-3",
navOverflows && "border-sidebar-border border-t pt-1",
)}
>
<SidebarCollapseToggle
collapsed={collapsed}
onToggle={toggleCollapsed}
/>
{footerUtilityItems
.filter((item) => !isSettingsUtilityDestination(item))
.map((item) => renderUtilityLink(item))}
{renderAdminLink()}
{footerUtilityItems
.filter((item) => isSettingsUtilityDestination(item))
.map((item) => renderUtilityLink(item))}
</div>
<SidebarUserSection collapsed={collapsed} />
<SidebarUserSection collapsed={collapsed} />
</div>
</aside>
</TooltipProvider>
);
+10 -2
View File
@@ -2,6 +2,8 @@
import { useEffect } from "react";
import { UMAMI_PROXY_PREFIX } from "@/lib/monitoring/umami-paths";
interface UmamiScriptProps {
enabled: boolean;
websiteId: string | null;
@@ -22,8 +24,14 @@ export function UmamiScript({ enabled, websiteId }: UmamiScriptProps) {
script.defer = true;
script.src = "/api/monitoring/umami-script";
script.setAttribute("data-website-id", websiteId);
// Force same-origin tracking endpoint so CSP stays strict.
script.setAttribute("data-host-url", window.location.origin);
// Send events to the same-origin proxy so CSP stays strict. The tracker
// appends its own collect path (`/api/send`, or whatever the operator's
// Umami build renamed it to), and every path under the prefix reaches
// the proxy route.
script.setAttribute(
"data-host-url",
`${window.location.origin}${UMAMI_PROXY_PREFIX}`,
);
if (existing) {
existing.replaceWith(script);
+8 -1
View File
@@ -34,7 +34,10 @@ import {
getRecordSessionTransition,
subscribeToRecordSessionTransition,
} from "@/lib/query-keys/record-session-transition";
import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications";
import {
forgetMedicationsPrefetch,
prefetchMedicationsList,
} from "@/lib/queries/prefetch-medications";
import {
getRecordScope,
subscribeToRecordScope,
@@ -245,6 +248,10 @@ function DashboardSnapshotPreloader() {
// intent, so this is the fallback for direct loads + reloads.
if (pathname === "/medications") {
prefetchMedicationsList(queryClient, controller.signal);
} else {
// Off the page: a later prefetch starts a new visit, and an earlier one
// must not excuse the next mount's re-verification.
forgetMedicationsPrefetch();
}
// A transition to an unresolved, refused, or different record scope must
// abort a preloader before its response can be adopted by the next route.