mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
33 files changed
+1458
-393
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user