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

+6
View File
@@ -6,6 +6,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { openMenu } from "./open-menu";
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
import { revealDeferredSections } from "./utils/deferred-sections";
type AxeViolation = Awaited<
ReturnType<AxeBuilder["analyze"]>
@@ -41,6 +42,8 @@ type RouteCase = {
name: string;
path: string;
painted: (page: Page) => Locator;
/** Runs after navigation, before the painted gate (e.g. mount deferred sections). */
prepare?: (page: Page) => Promise<void>;
};
const THEMES: readonly Theme[] = ["light", "dark"];
@@ -325,6 +328,7 @@ async function visitAndScan(
): Promise<AxeViolation[]> {
return test.step(`${theme} ${routeCase.name}`, async () => {
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
await routeCase.prepare?.(page);
return scanPaintedState(
page,
theme,
@@ -773,6 +777,8 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
{
name: "/insights overview",
path: "/insights",
// Scan the whole overview, the sections that mount near the viewport too.
prepare: revealDeferredSections,
// The vitals grid, settled. The scores strip is not a stable gate: it
// unmounts for an account with no score yet, and since v1.39 it no longer
// keeps an empty cell for a cycle ring that has no dial to draw.
+104
View File
@@ -0,0 +1,104 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Recharts measures every tick label in one shared off-screen span
* (`#recharts_measurement_span`). Under `<body>` each read cost a
* document-wide style recalc, because the stylesheet's descendant-subject
* `:has()` rules leave Chromium no way to scope the invalidation; on a
* seeded dashboard that was about 1.8 s of main-thread time under a 4x CPU
* slowdown. The chart runtime now parks the span in a contained host under
* `<html>` (`src/components/charts/text-measurement-host.ts`).
*
* Pinned here, in a real engine:
* - the span Recharts uses is the one in the host, not a second one it
* created under `<body>`, including after a client-side round trip;
* - every string the charts drew measures exactly as it does under
* `<body>`, so the move cannot change a single tick or label.
*/
test.describe("chart text measurement host", () => {
test.use({ storageState: STORAGE_STATE_PATH });
async function assertHostInPlace(page: import("@playwright/test").Page) {
const placement = await page.evaluate(() => {
const spans = document.querySelectorAll("#recharts_measurement_span");
const span = spans[0] as HTMLElement | undefined;
return {
count: spans.length,
hostSlot: span?.parentElement?.dataset.slot ?? null,
hostParent: span?.parentElement?.parentElement?.tagName ?? null,
};
});
expect(placement).toEqual({
count: 1,
hostSlot: "chart-text-measurement-host",
hostParent: "HTML",
});
}
test("the dashboard charts measure text outside <body>, with identical results", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator(".recharts-wrapper").first()).toBeVisible({
timeout: 20_000,
});
await assertHostInPlace(page);
const parity = await page.evaluate(() => {
const host = document.getElementById(
"recharts_measurement_span",
) as HTMLElement;
const reference = document.createElement("span");
document.body.appendChild(reference);
// Recharts' own measurement style (`getStringSize`).
const base = {
position: "absolute",
top: "-20000px",
left: "0",
padding: "0",
margin: "0",
border: "none",
whiteSpace: "pre",
};
const strings = [
...new Set(
[...document.querySelectorAll(".recharts-wrapper text")]
.map((el) => el.textContent ?? "")
.filter(Boolean),
),
];
const mismatches: string[] = [];
for (const fontSize of ["10px", "12px", "14px"]) {
for (const text of strings) {
Object.assign(host.style, base, { fontSize });
Object.assign(reference.style, base, { fontSize });
host.textContent = text;
reference.textContent = text;
const a = host.getBoundingClientRect();
const b = reference.getBoundingClientRect();
if (a.width !== b.width || a.height !== b.height) {
mismatches.push(`${text}@${fontSize}`);
}
}
}
reference.remove();
return { measured: strings.length, mismatches };
});
// A chart with no labels would make the parity check vacuous.
expect(parity.measured).toBeGreaterThan(0);
expect(parity.mismatches).toEqual([]);
// A client-side round trip re-renders the shell; the host must survive
// it, or Recharts quietly recreates its span under <body>.
await page.locator('a[href="/insights"]:visible').first().click();
await page.waitForURL((url) => url.pathname === "/insights");
await page.locator('a[href="/"]:visible').first().click();
await page.waitForURL((url) => url.pathname === "/");
await expect(page.locator(".recharts-wrapper").first()).toBeVisible({
timeout: 20_000,
});
await assertHostInPlace(page);
});
});
+7
View File
@@ -9,6 +9,7 @@ import {
PHONE_WIDTHS,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
import { revealDeferredSections } from "./utils/deferred-sections";
/**
* The content-heavy routes must not scroll sideways at any phone width.
@@ -73,6 +74,9 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt
for (const width of PHONE_WIDTHS) {
await page.setViewportSize({ width, height: 851 });
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
// Measure the whole page, the overview sections that wait for the
// viewport included, or a wide card far down would go unmeasured.
if (routeCase.path === "/insights") await revealDeferredSections(page);
await expect(page.locator(routeCase.ready).first()).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`);
@@ -93,6 +97,7 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await revealDeferredSections(page);
const signals = page.locator('[data-slot="coincident-deviation-section"]');
await expect(signals).toBeVisible();
@@ -129,6 +134,7 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await revealDeferredSections(page);
// Wait on the CAPTION, not the heading: the card first paints a skeleton
// whose heading carries no action slot, then swaps to the resolved shell.
@@ -179,6 +185,7 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await revealDeferredSections(page);
// Wait for the same readiness signal and settle step every route case
// above uses. This was the one test in the file that measured and injected
// straight after `domcontentloaded`, against a tree that was still
+3
View File
@@ -25,6 +25,7 @@ import type { Page } from "@playwright/test";
import { MODULES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { modulesSeedState } from "./setup/modules-fixture";
import { expect, test } from "./setup/test";
import { revealDeferredSections } from "./utils/deferred-sections";
test.use({ storageState: MODULES_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
@@ -130,6 +131,8 @@ test("mood and medications, switched off, leave every surface they own", async (
await expect(
page.locator('[data-slot="insights-tab-strip-pill"][href="/insights"]'),
).toBeVisible({ timeout: 20_000 });
// The trends row sits below the sections that mount with the page.
await revealDeferredSections(page);
await expect(page.locator('[data-slot="trends-row"]')).toBeVisible();
await expect(page.locator(moodPill)).toHaveCount(0);
await expect(page.locator(medsPill)).toHaveCount(0);
+139
View File
@@ -0,0 +1,139 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Expanding the sidebar must never flash a scrollbar on the nav list.
*
* The rail animates `width` from `w-16` to `w-64`. When the labels laid out
* against the growing width, every row overran the 48 px nav and the
* multi-word labels wrapped, so the nav's `overflow-y-auto` painted a
* vertical and a horizontal scrollbar until the transition ended. The fix
* lays the column out at its final width from the first frame and lets the
* rail clip it (see `sidebar-column` in `src/components/layout/sidebar-nav.tsx`).
*
* This samples the nav on every animation frame of a real expand and a real
* collapse, so it fails on the overflow itself rather than on a class name.
* Desktop only: the sidebar is hidden below `md`.
*/
test.describe("sidebar width transition", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name === "chromium-mobile",
"global sidebar is desktop-only (md+)",
);
});
test("the nav list never overflows while the rail expands or collapses", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
const toggle = page.locator('[data-slot="sidebar-collapse-toggle"]');
await expect(toggle).toBeVisible();
const nav = page.locator("aside nav").first();
await expect(nav).toBeVisible();
async function sampleToggle() {
const heightBefore = await nav.evaluate((el) => el.scrollHeight);
await page.evaluate(() => {
const el = document.querySelector("aside nav") as HTMLElement;
const samples: { sw: number; cw: number; sh: number }[] = [];
(window as unknown as { __navSamples: typeof samples }).__navSamples =
samples;
const t0 = performance.now();
const tick = () => {
samples.push({
sw: el.scrollWidth,
cw: el.clientWidth,
sh: el.scrollHeight,
});
if (performance.now() - t0 < 600) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
await toggle.click();
await page.waitForTimeout(700);
const samples = await page.evaluate(
() =>
(
window as unknown as {
__navSamples: { sw: number; cw: number; sh: number }[];
}
).__navSamples,
);
const heightAfter = await nav.evaluate((el) => el.scrollHeight);
// Expanded rows are taller than rail rows, so the list may legitimately
// be either rest height while the width moves, never more.
return { samples, restHeight: Math.max(heightBefore, heightAfter) };
}
// Two round trips, so the run covers both directions whichever state
// the stored preference started in.
for (let i = 0; i < 4; i++) {
const { samples, restHeight } = await sampleToggle();
// A 200 ms transition yields several frames; too few means the
// sampler never ran and the assertions below would prove nothing.
expect(samples.length).toBeGreaterThan(5);
for (const s of samples) {
// No horizontal overflow: no row is wider than the nav.
expect(s.sw).toBeLessThanOrEqual(s.cw + 1);
}
// No vertical growth: no label wraps mid-transition, so the list is
// never taller than at rest and cannot summon a scrollbar.
const tallest = Math.max(...samples.map((s) => s.sh));
expect(tallest).toBeLessThanOrEqual(restHeight + 1);
}
});
test("on a short window the list scrolls behind a visible edge, never under the footer", async ({
page,
}) => {
// Short enough that the list cannot fit between the logo band and the
// footer group. The rail used to cut the last visible entry off right at
// the Collapse row with nothing marking the edge, which read as the
// footer covering the entry.
await page.setViewportSize({ width: 1280, height: 600 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const nav = page.locator("aside nav").first();
const footer = page.locator('aside [data-slot="sidebar-footer"]');
await expect(nav).toBeVisible();
await expect(footer).toHaveAttribute("data-nav-overflows", "true");
const borderTop = await footer.evaluate(
(el) => getComputedStyle(el).borderTopWidth,
);
expect(borderTop).toBe("1px");
// Scrolled to its end, the last entry sits wholly inside the list,
// above the footer, and is the element under its own centre.
const end = await nav.evaluate((el) => {
el.scrollTop = el.scrollHeight;
const links = [...el.querySelectorAll("a")];
const last = links[links.length - 1] as HTMLElement;
const r = last.getBoundingClientRect();
const navBox = el.getBoundingClientRect();
const footerTop = (
document.querySelector('aside [data-slot="sidebar-footer"]') as Element
).getBoundingClientRect().top;
const hit = document.elementFromPoint(
r.left + r.width / 2,
r.top + r.height / 2,
);
return {
insideList: r.bottom <= navBox.bottom + 0.5,
aboveFooter: r.bottom <= footerTop + 0.5,
hitsItself: last.contains(hit),
};
});
expect(end).toEqual({
insideList: true,
aboveFooter: true,
hitsItself: true,
});
// With room to spare there is no edge: the default rail is unchanged.
await page.setViewportSize({ width: 1280, height: 1400 });
await expect(footer).not.toHaveAttribute("data-nav-overflows", "true");
});
});
+38
View File
@@ -0,0 +1,38 @@
import { expect, type Page } from "@playwright/test";
/**
* Mount every overview section that waits for the viewport.
*
* Sections below the first screens of `/insights` render as a 1 px
* `[data-deferred-section]` sentinel until they come near the visible area
* (`src/components/insights/defer-until-near.tsx`). A spec that asserts on
* one of them, or measures or scans the whole page, scrolls each sentinel
* into view first, the way a reader would, until none is left. Polled, since
* the observers only exist once the page has hydrated.
*/
export async function revealDeferredSections(page: Page): Promise<void> {
// The sentinels render with the overview itself; until the hero is there,
// "no sentinel" only means the page has not rendered yet.
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeAttached({
timeout: 20_000,
});
const sentinels = page.locator("[data-deferred-section]");
await expect
.poll(
async () => {
const remaining = await sentinels.count();
if (remaining > 0) {
await sentinels
.first()
.scrollIntoViewIfNeeded({ timeout: 1_000 })
.catch(() => {
// Mounted between the count and the scroll.
});
}
return remaining;
},
{ timeout: 20_000 },
)
.toBe(0);
await page.evaluate(() => document.querySelector("main")?.scrollTo(0, 0));
}
@@ -320,7 +320,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/monitoring/glitchtip": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/settings": { kind: "internalOps", methods: ["GET"] },
"/api/monitoring/umami-script": { kind: "internalOps", methods: ["GET"] },
"/api/send": { kind: "internalOps", methods: ["POST"] },
"/api/monitoring/umami/{path}": { kind: "internalOps", methods: ["POST"] },
"/api/mcp/oauth/authorize": {
kind: "mcpTransport",
methods: ["GET", "POST"],
@@ -4,6 +4,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import {
resolveUmamiCollectPath,
resolveUmamiSendUrls,
} from "@/lib/monitoring/umami";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
@@ -12,30 +16,6 @@ function redact(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[url]");
}
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const pathSegments = parsed.pathname.split("/").filter(Boolean);
const segments = [...pathSegments];
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}/api/send`,
`${origin}/api/send`,
`${origin}/umami/api/send`,
];
return Array.from(new Set(candidates));
} catch {
return [];
}
}
function resolveAppUrl(request: NextRequest): URL {
const configured =
envValue("APP_URL") ??
@@ -63,7 +43,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Umami script URL and website ID must be configured", 422);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
// The collect path is compiled into the operator's tracker build
// (`COLLECT_API_ENDPOINT`), so the test posts where the tracker would.
const collectPath = await resolveUmamiCollectPath(settings.umamiScriptUrl);
if (collectPath === null) {
return apiError("Umami script could not be fetched", 502);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl, collectPath);
if (targetUrls.length === 0) {
return apiError("Umami script URL is invalid", 422);
}
+8 -39
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { safeFetch } from "@/lib/safe-fetch";
import { fetchUmamiScript } from "@/lib/monitoring/umami";
export const dynamic = "force-dynamic";
@@ -26,35 +26,14 @@ export const GET = apiHandler(async () => {
});
}
// Fail-soft: an unreachable / slow / rebinding-refused upstream must
// degrade to the noop script, not 500 the proxy route — `safeFetch`
// THROWS on its 15 s timeout and on the connect-time public-host
// refusal, and an uncaught throw here surfaced as a 500 on every
// mount whenever the operator's Umami host was down or unresolvable.
let response: Response;
try {
response = await safeFetch(
settings.umamiScriptUrl,
{
next: { revalidate: 3600 },
},
// Operator-configured host — pin the connect-time IP so a low-TTL
// DNS record cannot rebind the fetch at an internal range between
// the input-time `isPublicUrl` accept and the socket connect.
{ requirePublicHost: true },
);
} catch (err) {
// Fail-soft stays a 200, but the failure must stay observable:
// a proper action name keeps the degraded state visible on the
// wide-event dashboards instead of hiding behind a bare meta flag.
// Fail-soft: an unreachable / slow / rebinding-refused upstream degrades
// to the noop script, never a 500 on every mount. The failure stays
// observable under its own action name on the wide-event dashboards.
const fetched = await fetchUmamiScript(settings.umamiScriptUrl);
if (!fetched.ok) {
annotate({
action: { name: "monitoring.umami_script.fetch_failed" },
meta: {
reason:
err instanceof Error
? err.message.slice(0, 200)
: String(err).slice(0, 200),
},
meta: { reason: fetched.reason },
});
return new NextResponse(NOOP_SCRIPT, {
status: 200,
@@ -64,18 +43,8 @@ export const GET = apiHandler(async () => {
},
});
}
if (!response.ok) {
return new NextResponse(NOOP_SCRIPT, {
status: 200,
headers: {
"Content-Type": "application/javascript; charset=utf-8",
"Cache-Control": "public, max-age=60",
},
});
}
const script = await response.text();
return new NextResponse(script, {
return new NextResponse(fetched.script, {
status: 200,
headers: {
"Content-Type": "application/javascript; charset=utf-8",
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// safeFetch's requirePublicHost path runs through undici's own `fetch`
// (version-locked with its dispatcher). Delegate it to the global `fetch`
// stub these tests install so the interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/monitoring-settings", () => ({
getPublicMonitoringSettings: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
import { POST } from "../route";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { resetUmamiCollectPathCache } from "@/lib/monitoring/umami";
import { checkRateLimit } from "@/lib/rate-limit";
const SCRIPT_URL = "https://analytics.example.com/script.js";
/** A built tracker's endpoint template, as Umami compiles it. */
function tracker(collectPath: string): string {
return `!function(){const R=\`\${(x||""||u.src.split("/").slice(0,-1).join("/")).replace(/\\/$/,"")}${collectPath}\`;}();`;
}
function post(path: string[], ip = "203.0.113.11") {
return POST(
new NextRequest(`http://localhost/api/monitoring/umami/${path.join("/")}`, {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: { "content-type": "application/json", "x-real-ip": ip },
}),
{ params: Promise.resolve({ path }) },
);
}
/** Upstream: the tracker script on GET, `status` on the event POST. */
function upstream(collectPath: string, status = 202) {
vi.mocked(fetch).mockImplementation(async (input, init) => {
const url = String(input instanceof Request ? input.url : input);
if (url === SCRIPT_URL && (!init?.method || init.method === "GET")) {
return new Response(tracker(collectPath), { status: 200 });
}
return new Response(null, { status });
});
}
function eventPosts(): string[] {
return vi
.mocked(fetch)
.mock.calls.filter(([, init]) => init?.method === "POST")
.map(([input]) => String(input));
}
beforeEach(() => {
vi.resetAllMocks();
resetUmamiCollectPathCache();
vi.stubGlobal("fetch", vi.fn());
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 119,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(getPublicMonitoringSettings).mockResolvedValue({
umamiEnabled: true,
umamiScriptUrl: SCRIPT_URL,
umamiWebsiteId: "site-1",
} as never);
});
describe("POST /api/monitoring/umami/[...path]", () => {
it("rate-limits the public Umami proxy by client IP before anything else", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await post(["api", "send"], "203.0.113.10");
expect(response.status).toBe(429);
expect(checkRateLimit).toHaveBeenCalledWith(
"umami-proxy:203.0.113.10",
120,
60 * 1000,
);
expect(getPublicMonitoringSettings).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("forwards a default build's /api/send to the Umami host", async () => {
upstream("/api/send");
const response = await post(["api", "send"]);
expect(response.status).toBe(202);
expect(eventPosts()).toEqual(["https://analytics.example.com/api/send"]);
});
it("forwards a renamed collect endpoint to the same path on the Umami host", async () => {
// A build with COLLECT_API_ENDPOINT=/api/insight posts to
// `<data-host-url>/api/insight`. Before the proxy prefix, that landed on
// the app's own origin and 404'd with a full page on every page view.
upstream("/api/insight");
const response = await post(["api", "insight"]);
expect(response.status).toBe(202);
expect(eventPosts()).toEqual(["https://analytics.example.com/api/insight"]);
});
it("refuses any path that is not the tracker's collect path, with an empty body", async () => {
upstream("/api/insight");
const response = await post(["api", "websites"]);
expect(response.status).toBe(404);
expect(await response.text()).toBe("");
expect(eventPosts()).toEqual([]);
});
it("learns the collect path once, not on every event", async () => {
upstream("/api/insight");
await post(["api", "insight"]);
await post(["api", "insight"]);
const scriptFetches = vi
.mocked(fetch)
.mock.calls.filter(([input]) => String(input) === SCRIPT_URL);
expect(scriptFetches).toHaveLength(1);
expect(eventPosts()).toHaveLength(2);
});
it("answers an empty 502 when the tracker cannot be read", async () => {
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 }));
const response = await post(["api", "send"]);
expect(response.status).toBe(502);
expect(await response.text()).toBe("");
expect(eventPosts()).toEqual([]);
});
it("answers 204 without touching the network when Umami is off", async () => {
vi.mocked(getPublicMonitoringSettings).mockResolvedValue({
umamiEnabled: false,
umamiScriptUrl: SCRIPT_URL,
umamiWebsiteId: "site-1",
} as never);
const response = await post(["api", "send"]);
expect(response.status).toBe(204);
expect(fetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import {
resolveUmamiCollectPath,
resolveUmamiSendUrls,
} from "@/lib/monitoring/umami";
import { checkRateLimit } from "@/lib/rate-limit";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
/** An empty answer: a refused event must not cost the tracker a 404 page. */
function empty(status: number): NextResponse {
return new NextResponse(null, { status });
}
/**
* Same-origin proxy for the browser's Umami tracker.
*
* The tracker is loaded with `data-host-url` set to `UMAMI_PROXY_PREFIX`, so
* it posts to `<prefix><collect path>`, where the collect path is whatever
* the operator's Umami build calls its endpoint (`/api/send` by default).
* The path after the prefix must be exactly the collect path compiled into
* the configured tracker; anything else is refused, so the proxy forwards
* events and nothing else to the operator's host.
*/
export const POST = apiHandler(
async (
request: NextRequest,
context: { params: Promise<{ path: string[] }> },
) => {
annotate({ action: { name: "umami.proxy" } });
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getPublicMonitoringSettings();
if (
!settings.umamiEnabled ||
!settings.umamiScriptUrl ||
!settings.umamiWebsiteId
) {
return empty(204);
}
const { path } = await context.params;
const requested = `/${path.join("/")}`;
const collectPath = await resolveUmamiCollectPath(settings.umamiScriptUrl);
if (collectPath === null) {
annotate({
action: { name: "umami.proxy.script_unavailable" },
});
return empty(502);
}
if (requested !== collectPath) {
annotate({
action: { name: "umami.proxy.path_refused" },
meta: { requested: requested.slice(0, 128), collectPath },
});
return empty(404);
}
const targetUrls = resolveUmamiSendUrls(
settings.umamiScriptUrl,
collectPath,
);
if (targetUrls.length === 0) return empty(204);
const body = await request.arrayBuffer();
// Cap the proxied body at 64 KB.
if (body.byteLength > 65536) return empty(413);
let lastStatus = 404;
for (const targetUrl of targetUrls) {
let upstream: Response;
try {
upstream = await safeFetch(
targetUrl,
{
method: "POST",
headers: {
"content-type":
request.headers.get("content-type") || "application/json",
"user-agent":
request.headers.get("user-agent") || "healthlog-proxy",
},
body,
cache: "no-store",
},
// Operator-configured Umami host: pin the connect-time IP.
{ requirePublicHost: true },
);
} catch {
annotate({ action: { name: "umami.proxy.upstream_failed" } });
return empty(502);
}
lastStatus = upstream.status;
// Only a path miss moves on to the next candidate.
if (upstream.status !== 404) return empty(upstream.status);
}
return empty(lastStatus);
},
);
-103
View File
@@ -1,103 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// safeFetch's requirePublicHost path runs through undici's own `fetch`
// (version-locked with its dispatcher). Delegate it to the global `fetch`
// stub these tests install so the existing interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/monitoring-settings", () => ({
getPublicMonitoringSettings: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
import { POST } from "../route";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal("fetch", vi.fn());
});
describe("POST /api/send", () => {
it("rate-limits the public Umami proxy by client IP", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(
new NextRequest("http://localhost/api/send", {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: {
"content-type": "application/json",
"x-forwarded-for": "203.0.113.10",
},
}),
);
expect(response.status).toBe(429);
expect(checkRateLimit).toHaveBeenCalledWith(
"umami-proxy:203.0.113.10",
120,
60 * 1000,
);
expect(getPublicMonitoringSettings).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("continues to proxy when the request is within quota", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 119,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(getPublicMonitoringSettings).mockResolvedValue({
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.com/script.js",
umamiWebsiteId: "site-1",
} as never);
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 202 }));
const response = await POST(
new NextRequest("http://localhost/api/send", {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: {
"content-type": "application/json",
"x-real-ip": "203.0.113.11",
},
}),
);
expect(response.status).toBe(202);
expect(fetch).toHaveBeenCalledWith(
"https://analytics.example.com/api/send",
expect.objectContaining({ method: "POST" }),
);
});
});
-104
View File
@@ -1,104 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
// Use the canonical `isPublicUrl` guard rather than a hand-rolled
// allowlist. The previous local implementation predated the central
// helper and was missing CGNAT (100.64/10), IPv6 ULA (fc00::/7), the
// octal/hex/decimal IPv4 alt-notations, and the IPv4-mapped IPv6
// shapes — every one of which the shared helper catches. Dev mode is
// no longer special-cased here: a localhost-or-RFC1918 Umami URL was
// never a valid configuration anyway (the proxy ships the request to
// the configured host with the visitor's user-agent + body, which
// only makes sense against an externally-reachable analytics target).
function isAllowedUmamiUrl(url: string): boolean {
return isPublicUrl(url);
}
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const pathSegments = parsed.pathname.split("/").filter(Boolean);
const segments = [...pathSegments];
// Remove script file segment (e.g. script.js, umami.js) if present.
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}/api/send`,
`${origin}/api/send`,
`${origin}/umami/api/send`,
];
return Array.from(new Set(candidates.filter(isAllowedUmamiUrl)));
} catch {
return [];
}
}
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "umami.proxy" } });
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getPublicMonitoringSettings();
if (!settings.umamiEnabled) {
return NextResponse.json({}, { status: 204 });
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
if (targetUrls.length === 0 || !settings.umamiWebsiteId) {
return NextResponse.json({}, { status: 204 });
}
const body = await request.arrayBuffer();
// Limit proxy body size to 64 KB to prevent abuse
if (body.byteLength > 65536) {
return NextResponse.json({}, { status: 413 });
}
let lastResponseStatus = 404;
for (const targetUrl of targetUrls) {
const upstream = await safeFetch(
targetUrl,
{
method: "POST",
headers: {
"content-type":
request.headers.get("content-type") || "application/json",
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
},
body,
cache: "no-store",
},
// Operator-configured Umami host — pin the connect-time IP.
{ requirePublicHost: true },
);
lastResponseStatus = upstream.status;
// Try next candidate only for path misses.
if (upstream.status === 404) {
continue;
}
return new NextResponse(null, { status: upstream.status });
}
return new NextResponse(null, { status: lastResponseStatus });
});
+27 -2
View File
@@ -30,6 +30,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { HeroStrip } from "@/components/insights/hero-strip";
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
import { DeferUntilNear } from "@/components/insights/defer-until-near";
import { OverviewSectionBoundary } from "@/components/insights/overview-section-boundary";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { useCycleRingDial } from "@/components/cycle/use-cycle";
@@ -236,6 +237,19 @@ const CycleRingTile = dynamic(
{ ssr: false },
);
/**
* How many overview sections, in the account's own order, mount with the
* page. The rest mount as they come near the visible area
* (`DeferUntilNear`). On entry the overview used to fire every section's
* reads at once (about twenty requests on a seeded account, trends,
* narrative, cycle, signals, rhythm events, health status, breathing, labs
* and ECG included), and the server queued them behind each other, so the
* hero and the first sections waited on cards far below the fold. The first
* three always sit within the first two screens; everything after them is
* further down on every viewport measured (phone and desktop).
*/
const EAGER_OVERVIEW_SECTIONS = 3;
/**
* v1.4.25 W4d — Insights mother page.
*
@@ -548,6 +562,11 @@ export default function InsightsPageClient() {
const orderedSectionIds = orderedVisibleSectionIds(layout);
const everySectionHidden = orderedSectionIds.length === 0;
// Sections whose gate is off resolve to `null` and take no slot, so the
// eager / deferred split below counts only what actually renders.
const renderedSectionIds = orderedSectionIds.filter(
(id) => SECTION_REGISTRY[id] !== null,
);
return (
// v1.12.7 (L3) — one consistent vertical rhythm down the overview. The
@@ -621,12 +640,18 @@ export default function InsightsPageClient() {
registry, so the `space-y-6` rhythm closes the gap with no hole.
Each registry node is wrapped in a keyed Fragment so React keeps a
stable identity across a reorder. */
orderedSectionIds.map((id) => (
renderedSectionIds.map((id, index) => (
<Fragment key={id}>
{SECTION_REGISTRY[id] === null ? null : (
{index < EAGER_OVERVIEW_SECTIONS ? (
<OverviewSectionBoundary sectionId={id}>
{SECTION_REGISTRY[id]}
</OverviewSectionBoundary>
) : (
<DeferUntilNear id={id}>
<OverviewSectionBoundary sectionId={id}>
{SECTION_REGISTRY[id]}
</OverviewSectionBoundary>
</DeferUntilNear>
)}
</Fragment>
))
+4 -1
View File
@@ -55,6 +55,7 @@ import {
Wrench,
} from "lucide-react";
import { apiGet } from "@/lib/api/api-fetch";
import { refetchMedicationsOnMount } from "@/lib/queries/prefetch-medications";
import { useMedicationComplianceSummaryAll } from "@/lib/queries/use-medication-compliance-summary";
import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout";
import { applyMedicationOrder } from "@/lib/medications/medication-order";
@@ -345,7 +346,9 @@ export default function MedicationsPageClient() {
// client cache can't know to invalidate a cross-device write. "always"
// makes a return-visit fetch fresh; the per-user server cache keeps it
// cheap. Mutation-driven invalidation still covers same-tab writes.
refetchOnMount: "always",
// A prefetch this same navigation fired (nav-link intent, route commit)
// already is that fresh fetch, so it is not sent twice.
refetchOnMount: refetchMedicationsOnMount("list"),
});
// v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/measurements.
+3 -1
View File
@@ -39,7 +39,9 @@ import MedicationsPageClient from "./page-client";
* event, so the list must re-verify on every mount. With the prefetch in
* place the hydrated cards paint immediately and that refetch runs in the
* background — the "empty then fills" flash is gone WITHOUT dropping the
* freshness guarantee.
* freshness guarantee. The one exception is data a prefetch fired by this
* same navigation just fetched (`refetchMedicationsOnMount`): that already
* is the re-verification, so it is not sent twice.
* - Record identity: the session comes from `getUnswitchedSession()`, which
* answers null while the browser is acting on somebody else's record.
* `/api/medications` IS delegable, so the client cell corrects itself on its
+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.
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
extractUmamiCollectPath,
resolveUmamiSendUrls,
} from "@/lib/monitoring/umami";
import { UMAMI_PROXY_PREFIX } from "@/lib/monitoring/umami-paths";
/** The endpoint template of a built Umami tracker. */
function tracker(collectPath: string): string {
return `const{currentScript:u}=c;const R=\`\${(x||""||u.src.split("/").slice(0,-1).join("/")).replace(/\\/$/,"")}${collectPath}\`,_=\`\${e}x\${a}\`;`;
}
describe("extractUmamiCollectPath", () => {
it("reads Umami's default endpoint", () => {
expect(extractUmamiCollectPath(tracker("/api/send"))).toBe("/api/send");
});
it("reads an endpoint renamed through COLLECT_API_ENDPOINT", () => {
expect(extractUmamiCollectPath(tracker("/api/insight"))).toBe(
"/api/insight",
);
expect(extractUmamiCollectPath(tracker("/collect"))).toBe("/collect");
});
it("falls back to the default for an unrecognised build", () => {
expect(extractUmamiCollectPath("/* umami disabled */")).toBe("/api/send");
});
it("never accepts a traversal as the collect path", () => {
expect(extractUmamiCollectPath(tracker("/../admin"))).toBe("/api/send");
});
});
describe("resolveUmamiSendUrls", () => {
it("tries the script directory, the host root and the /umami mount", () => {
expect(
resolveUmamiSendUrls(
"https://stats.example.com/umami/script.js",
"/api/insight",
),
).toEqual([
"https://stats.example.com/umami/api/insight",
"https://stats.example.com/api/insight",
]);
});
it("refuses a private host", () => {
expect(
resolveUmamiSendUrls("http://10.0.0.5/script.js", "/api/send"),
).toEqual([]);
});
});
describe("UMAMI_PROXY_PREFIX", () => {
it("sits under the public monitoring family the proxy gate admits", () => {
expect(UMAMI_PROXY_PREFIX.startsWith("/api/monitoring/")).toBe(true);
});
});
+19
View File
@@ -0,0 +1,19 @@
// Client-safe: imported by the tracker tag, so it carries no server code.
/**
* Where the browser's Umami tracker sends its events, relative to the app.
*
* The tracker posts to `${data-host-url}${collectPath}`, and the collect
* path is compiled into the operator's tracker build: `/api/send` by
* default, anything at all when the Umami instance sets
* `COLLECT_API_ENDPOINT` (a common ad-blocker workaround). Pointing
* `data-host-url` at the app origin therefore only worked for default
* builds; a renamed endpoint posted every page view to an app path that did
* not exist. Every path under this prefix is answered by the one proxy route
* (`src/app/api/monitoring/umami/[...path]/route.ts`), so whatever the build
* calls its endpoint, the request lands somewhere that forwards it.
*/
export const UMAMI_PROXY_PREFIX = "/api/monitoring/umami";
/** Umami's own default collect endpoint. */
export const UMAMI_DEFAULT_COLLECT_PATH = "/api/send";
+113
View File
@@ -0,0 +1,113 @@
import { safeFetch } from "@/lib/safe-fetch";
import { isPublicUrl } from "@/lib/validations/notifications";
import { UMAMI_DEFAULT_COLLECT_PATH } from "./umami-paths";
/**
* The endpoint template in a built tracker:
* `` `${(hostUrl || …).replace(/\/$/,"")}/api/send` ``. The capture is the
* path the build appends to the host.
*/
const COLLECT_PATH_PATTERN =
/\.replace\(\/\\\/\$\/,\s*(?:""|'')\)\}(\/[\w\-./~]{1,128})`/;
/** A collect path is a plain absolute path: no traversal, no query. */
function isPlainPath(path: string): boolean {
return /^\/[\w\-./~]{1,128}$/.test(path) && !path.split("/").includes("..");
}
/**
* The collect path compiled into a tracker build, or Umami's default when
* the build does not carry a recognisable endpoint template.
*/
export function extractUmamiCollectPath(script: string): string {
const match = COLLECT_PATH_PATTERN.exec(script)?.[1];
return match && isPlainPath(match) ? match : UMAMI_DEFAULT_COLLECT_PATH;
}
/**
* Upstream URLs to try for an event, in order: the collect path under the
* script's own directory (Umami served below a path prefix), at the host
* root, and under the conventional `/umami` mount. Only public URLs survive.
*/
export function resolveUmamiSendUrls(
scriptUrl: string | null,
collectPath: string = UMAMI_DEFAULT_COLLECT_PATH,
): string[] {
if (!scriptUrl || !isPlainPath(collectPath)) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const segments = parsed.pathname.split("/").filter(Boolean);
// Drop the script file itself (script.js, umami.js, …).
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}${collectPath}`,
`${origin}${collectPath}`,
`${origin}/umami${collectPath}`,
];
return Array.from(new Set(candidates.filter((url) => isPublicUrl(url))));
} catch {
return [];
}
}
const COLLECT_PATH_TTL_MS = 60 * 60 * 1000;
const collectPathCache = new Map<string, { path: string; expiresAt: number }>();
export type UmamiScriptFetch =
{ ok: true; script: string } | { ok: false; reason: string };
/**
* Fetch the operator's tracker. Fail-soft: an unreachable, slow, refused or
* non-2xx upstream comes back as `{ ok: false, reason }`, never a throw. A
* successful fetch also records the build's collect path, so the event proxy
* learns it without a fetch of its own.
*/
export async function fetchUmamiScript(
scriptUrl: string,
): Promise<UmamiScriptFetch> {
try {
const response = await safeFetch(
scriptUrl,
{ next: { revalidate: 3600 } },
// Operator-configured host: pin the connect-time IP so a low-TTL DNS
// record cannot rebind the fetch at an internal range.
{ requirePublicHost: true },
);
if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
const script = await response.text();
collectPathCache.set(scriptUrl, {
path: extractUmamiCollectPath(script),
expiresAt: Date.now() + COLLECT_PATH_TTL_MS,
});
return { ok: true, script };
} catch (err) {
return {
ok: false,
reason: (err instanceof Error ? err.message : String(err)).slice(0, 200),
};
}
}
/**
* The collect path of the configured tracker, from the cache when fresh,
* otherwise by fetching the script. `null` when the script cannot be read,
* so the caller can refuse rather than guess.
*/
export async function resolveUmamiCollectPath(
scriptUrl: string,
): Promise<string | null> {
const cached = collectPathCache.get(scriptUrl);
if (cached && cached.expiresAt > Date.now()) return cached.path;
const fetched = await fetchUmamiScript(scriptUrl);
return fetched.ok ? extractUmamiCollectPath(fetched.script) : null;
}
/** Test seam: forget every learnt collect path. */
export function resetUmamiCollectPathCache(): void {
collectPathCache.clear();
}
@@ -1,12 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { QueryClient, QueryObserver } from "@tanstack/react-query";
import { QUERY_CLIENT_DEFAULT_OPTIONS } from "@/lib/pwa/query-client-options";
import { queryKeys } from "@/lib/query-keys";
import {
__resetRecordScopeForTests,
setRecordScope,
} from "@/lib/query-keys/record-scope";
import { prefetchMedicationsList } from "../prefetch-medications";
import {
MEDICATIONS_PREFETCH_COVERS_MOUNT_MS,
forgetMedicationsPrefetch,
prefetchMedicationsList,
refetchMedicationsOnMount,
} from "../prefetch-medications";
function envelopeResponse(data: unknown): Response {
return new Response(JSON.stringify({ data, error: null }), {
@@ -17,7 +22,9 @@ function envelopeResponse(data: unknown): Response {
afterEach(() => {
__resetRecordScopeForTests();
forgetMedicationsPrefetch();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("prefetchMedicationsList", () => {
@@ -70,3 +77,121 @@ describe("prefetchMedicationsList", () => {
});
});
});
/**
* The page cells re-verify on every mount (#316), but a prefetch fired by the
* same navigation already is that fresh read. Before, the hover / route-commit
* prefetch landed and the mount sent the identical request again, on every
* visit, for both the list and the compliance summary.
*/
describe("refetchMedicationsOnMount", () => {
function setup() {
const fetchMock = vi.fn(async () => envelopeResponse([]));
vi.stubGlobal("fetch", fetchMock);
const client = new QueryClient({
defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS,
});
const calls = (path: string) =>
fetchMock.mock.calls.filter((call) => (call as unknown[])[0] === path)
.length;
return { client, calls };
}
/** Mount a page observer the way the medications page does. */
async function mountList(client: QueryClient) {
const mountFetch = vi.fn(async () => []);
const observer = new QueryObserver(client, {
queryKey: queryKeys.medications(),
queryFn: mountFetch,
refetchOnMount: refetchMedicationsOnMount("list"),
});
const unsubscribe = observer.subscribe(() => {});
await Promise.resolve();
return { refetched: mountFetch.mock.calls.length > 0, unsubscribe };
}
it("does not repeat a prefetch this navigation just answered", async () => {
const { client, calls } = setup();
prefetchMedicationsList(client);
await vi.waitFor(() => expect(calls("/api/medications")).toBe(1));
await vi.waitFor(() =>
expect(client.getQueryState(queryKeys.medications())?.status).toBe(
"success",
),
);
const { refetched, unsubscribe } = await mountList(client);
expect(refetched).toBe(false);
unsubscribe();
});
it("still refetches on mount when the cache holds an older read", async () => {
const { client } = setup();
const queryFn = vi.fn(async () => []);
await client.fetchQuery({ queryKey: queryKeys.medications(), queryFn });
const { refetched, unsubscribe } = await mountList(client);
// No prefetch answered this visit: the #316 re-verification runs.
expect(refetched).toBe(true);
unsubscribe();
});
it("refetches once the prefetch is older than the visit window", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
const { client, calls } = setup();
prefetchMedicationsList(client);
await vi.waitFor(() =>
expect(client.getQueryState(queryKeys.medications())?.status).toBe(
"success",
),
);
expect(calls("/api/medications")).toBe(1);
vi.setSystemTime(Date.now() + MEDICATIONS_PREFETCH_COVERS_MOUNT_MS + 1);
const { refetched, unsubscribe } = await mountList(client);
expect(refetched).toBe(true);
unsubscribe();
});
it("a prefetch skipped as fresh does not excuse the mount refetch", async () => {
// The return-visit case: the list was read moments ago by a mount, so
// the nav-link prefetch does not go to the network, and the remount
// must still re-verify.
const { client, calls } = setup();
await client.fetchQuery({
queryKey: queryKeys.medications(),
queryFn: async () => [],
});
prefetchMedicationsList(client);
await Promise.resolve();
expect(calls("/api/medications")).toBe(0);
const { refetched, unsubscribe } = await mountList(client);
expect(refetched).toBe(true);
unsubscribe();
});
it("a prefetch from an earlier visit does not excuse a later mount", async () => {
// Load the page (route-commit prefetch), leave, come straight back: the
// nav-link prefetch finds the entry fresh and stays off the network, so
// only the mount can re-verify, and it must.
const { client, calls } = setup();
prefetchMedicationsList(client);
await vi.waitFor(() =>
expect(client.getQueryState(queryKeys.medications())?.status).toBe(
"success",
),
);
const first = await mountList(client);
expect(first.refetched).toBe(false);
first.unsubscribe();
forgetMedicationsPrefetch(); // the route left /medications
prefetchMedicationsList(client); // hover on the way back: still fresh
expect(calls("/api/medications")).toBe(1);
const second = await mountList(client);
expect(second.refetched).toBe(true);
second.unsubscribe();
});
});
+82 -10
View File
@@ -29,20 +29,92 @@ import { apiGet } from "@/lib/api/api-fetch";
*/
export const MEDICATIONS_LIST_STALE_TIME_MS = 15_000;
/**
* How long after a prefetch STARTED its data still counts as this visit's
* fresh read. Wide enough to span hover → click → route commit → mount on a
* slow device; far below any gap in which another surface could have
* written something the user would expect to see.
*/
export const MEDICATIONS_PREFETCH_COVERS_MOUNT_MS = 10_000;
type MedicationsRead = "list" | "compliance";
/** When the last prefetch that actually went to the network started. */
const prefetchStartedAt = new Map<MedicationsRead, number>();
function prefetchRead(
queryClient: QueryClient,
read: MedicationsRead,
queryKey: readonly unknown[],
path: string,
signal?: AbortSignal,
): void {
const state = queryClient.getQueryState(queryKey);
const willFetch =
state?.fetchStatus !== "fetching" &&
(!state ||
state.dataUpdatedAt < Date.now() - MEDICATIONS_LIST_STALE_TIME_MS);
if (willFetch) prefetchStartedAt.set(read, Date.now());
void queryClient.prefetchQuery({
queryKey,
queryFn: () => apiGet(path, { signal }),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
}
export function prefetchMedicationsList(
queryClient: QueryClient,
signal?: AbortSignal,
): void {
void queryClient.prefetchQuery({
queryKey: queryKeys.medications(),
queryFn: () => apiGet("/api/medications", { signal }),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
void queryClient.prefetchQuery({
queryKey: queryKeys.medicationComplianceSummary(),
queryFn: () => apiGet("/api/medications/compliance", { signal }),
staleTime: MEDICATIONS_LIST_STALE_TIME_MS,
});
prefetchRead(
queryClient,
"list",
queryKeys.medications(),
"/api/medications",
signal,
);
prefetchRead(
queryClient,
"compliance",
queryKeys.medicationComplianceSummary(),
"/api/medications/compliance",
signal,
);
}
/**
* `refetchOnMount` for the medications reads that must re-verify on every
* visit (#316: a take or skip on another device produces no event here).
*
* Still "always", except when the cached data is the answer to a prefetch
* that this same navigation fired moments ago: the hover / touch / focus
* intent on the nav link, or the route-commit preload. Leaving the page ends
* that navigation (`forgetMedicationsPrefetch`). Re-asking then sent
* the identical request a second time, a few hundred milliseconds after the
* first answer landed, on every visit to the page. A return to the page with
* an older cache entry, or a direct load, still refetches on mount.
*/
export function refetchMedicationsOnMount(
read: MedicationsRead,
): (query: { state: { dataUpdatedAt: number } }) => boolean | "always" {
return (query) => {
const startedAt = prefetchStartedAt.get(read);
const answeredThisVisit =
startedAt !== undefined &&
query.state.dataUpdatedAt >= startedAt &&
Date.now() - startedAt < MEDICATIONS_PREFETCH_COVERS_MOUNT_MS;
return answeredThisVisit ? false : "always";
};
}
/**
* End the visit a recorded prefetch belongs to. Called whenever the route is
* anything but `/medications`, so a prefetch only ever excuses the mounts of
* the visit it was fired for: leave the page and come back within seconds,
* and the remount still re-verifies.
*/
export function forgetMedicationsPrefetch(): void {
prefetchStartedAt.clear();
}
/**
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { refetchMedicationsOnMount } from "@/lib/queries/prefetch-medications";
import type { ComplianceDisplay } from "@/lib/analytics/compliance";
/**
@@ -69,9 +70,10 @@ export function useMedicationComplianceSummary(medicationId: string): {
// another surface (the iOS app) leaves this stale for up to the
// window, so refetch on every mount: returning to the page reflects
// cross-device intakes without a manual reload. The batched read is
// one request per visit, server-cached.
// one request per visit, server-cached. A prefetch fired by this same
// navigation already is that request (see `refetchMedicationsOnMount`).
staleTime: 5 * 60 * 1000,
refetchOnMount: "always",
refetchOnMount: refetchMedicationsOnMount("compliance"),
select: (rows: MedicationComplianceSummaryEntry[]) =>
rows.find((row) => row.medicationId === medicationId) ?? null,
});
@@ -92,7 +94,7 @@ export function useMedicationComplianceSummaryAll(): {
queryFn: fetchComplianceSummary,
staleTime: 5 * 60 * 1000,
// v1.16.12 (#316) — fresh on return-navigation (see the per-row hook).
refetchOnMount: "always",
refetchOnMount: refetchMedicationsOnMount("compliance"),
});
return { data };
}
-1
View File
@@ -41,7 +41,6 @@ export const PUBLIC_PATHS = [
"/api/version",
"/api/notifications/vapid",
"/api/monitoring/",
"/api/send",
"/api/withings/webhook",
// v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` /
// `workout.updated`, + `*.deleted`). Authenticated by the path-segment