mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(mcp): make the metric and lab deep links land on their pages
MCP `search` and `fetch` link a metric as `/insights?metric=<id>` and a lab value as `/labs?analyte=<name>`. Neither page read its query, so every link opened the overview or the full list. `/insights` now resolves the id server-side, in whichever spelling the result carries (a Coach source key such as `hrv` or `bp`, a signal or metric-status key such as `GRIP_STRENGTH`, a measurement type, or a sub-page slug), and redirects to the metric's page, or to its value list when no page focuses it. An id it cannot place renders the overview as before. `/labs` asks for the newest reading stored under the exact analyte name and opens its marker's page; a name with no reading keeps the list and drops the query.
This commit is contained in:
1 parent
b8c7eda89c
commit
196c331068
8 files changed
+298
-3
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* MCP deep links (`/insights?metric=<id>`) used to land on the overview,
|
||||
* whatever the id. The RSC now redirects a placeable id to the metric's page
|
||||
* and renders the overview for anything else.
|
||||
*/
|
||||
const redirect = vi.hoisted(() =>
|
||||
vi.fn((href: string) => {
|
||||
throw new Error(`REDIRECT ${href}`);
|
||||
}),
|
||||
);
|
||||
vi.mock("next/navigation", () => ({ redirect }));
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock("@/lib/dashboard/snapshot-read", () => ({
|
||||
readDashboardSnapshotCached: vi.fn(),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({ default: () => null }));
|
||||
|
||||
import InsightsPage from "../page";
|
||||
|
||||
beforeEach(() => {
|
||||
redirect.mockClear();
|
||||
});
|
||||
|
||||
describe("/insights?metric=", () => {
|
||||
it("redirects a metric id to its page", async () => {
|
||||
await expect(
|
||||
InsightsPage({ searchParams: Promise.resolve({ metric: "hrv" }) }),
|
||||
).rejects.toThrow("REDIRECT /insights/hrv");
|
||||
});
|
||||
|
||||
it("renders the overview for an id it cannot place, or none", async () => {
|
||||
await expect(
|
||||
InsightsPage({ searchParams: Promise.resolve({ metric: "nonsense" }) }),
|
||||
).resolves.toBeTruthy();
|
||||
await expect(
|
||||
InsightsPage({ searchParams: Promise.resolve({}) }),
|
||||
).resolves.toBeTruthy();
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
|
||||
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -51,7 +54,19 @@ import InsightsPageClient from "./page-client";
|
||||
* - Fail-soft: no session or a builder hiccup renders the page exactly as
|
||||
* before this wrapper existed — the client cells own the fetch.
|
||||
*/
|
||||
export default async function InsightsPage() {
|
||||
export default async function InsightsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<Record<string, string | string[] | undefined>>;
|
||||
} = {}) {
|
||||
// MCP deep links (`/insights?metric=<id>`) go to the metric's own page. An
|
||||
// id that names no metric leaves the overview as it was.
|
||||
const { metric } = (await searchParams) ?? {};
|
||||
const target = insightsHrefForMetric(
|
||||
typeof metric === "string" ? metric : null,
|
||||
);
|
||||
if (target) redirect(target);
|
||||
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
|
||||
// only see CLIENT fetches — keep governing what every prefetched page paints.
|
||||
|
||||
+28
-2
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowRight,
|
||||
FolderOpen,
|
||||
@@ -38,6 +38,12 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type { LabResultListResponse } from "@/components/labs/types";
|
||||
import {
|
||||
analyteFromQuery,
|
||||
labHrefForAnalyteReading,
|
||||
} from "@/lib/labs/analyte-deep-link";
|
||||
|
||||
export default function LabsPage() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
@@ -89,6 +95,26 @@ export default function LabsPage() {
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
// MCP deep links (`/labs?analyte=<name>`) open the marker's own page. The
|
||||
// newest reading under that exact name says which marker it is; a name
|
||||
// with no reading keeps the list and drops the query.
|
||||
const searchParams = useSearchParams();
|
||||
const linkedAnalyte = analyteFromQuery(searchParams.get("analyte"));
|
||||
const analyteLink = useQuery({
|
||||
queryKey: queryKeys.labAnalyteLink(linkedAnalyte ?? ""),
|
||||
queryFn: () =>
|
||||
apiGet<LabResultListResponse>(
|
||||
`/api/labs?analyte=${encodeURIComponent(linkedAnalyte ?? "")}&limit=1&sortDir=desc`,
|
||||
),
|
||||
enabled: !!linkedAnalyte && isAuthenticated && enabled,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!linkedAnalyte || !analyteLink.isFetched) return;
|
||||
router.replace(
|
||||
labHrefForAnalyteReading(analyteLink.data?.results?.[0]) ?? "/labs",
|
||||
);
|
||||
}, [linkedAnalyte, analyteLink.isFetched, analyteLink.data, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
|
||||
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
|
||||
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* The MCP `search` / `fetch` results link a metric as `/insights?metric=<id>`
|
||||
* in several spellings. Every one must land on the metric's own page; an id
|
||||
* that names nothing leaves the overview alone.
|
||||
*/
|
||||
describe("insightsHrefForMetric", () => {
|
||||
it("routes the Coach source keys the data inventory links carry", () => {
|
||||
expect(insightsHrefForMetric("hrv")).toBe("/insights/hrv");
|
||||
expect(insightsHrefForMetric("resting_hr")).toBe("/insights/resting-pulse");
|
||||
expect(insightsHrefForMetric("bp")).toBe("/insights/blood-pressure");
|
||||
expect(insightsHrefForMetric("weight")).toBe("/insights/weight");
|
||||
expect(insightsHrefForMetric("glucose")).toBe("/insights/blood-glucose");
|
||||
expect(insightsHrefForMetric("mood")).toBe("/insights/mood");
|
||||
expect(insightsHrefForMetric("workouts")).toBe("/insights/workouts");
|
||||
});
|
||||
|
||||
it("routes signal keys and measurement types", () => {
|
||||
expect(insightsHrefForMetric("GRIP_STRENGTH")).toBe(
|
||||
"/insights/grip-strength",
|
||||
);
|
||||
expect(insightsHrefForMetric("CARDIO_RECOVERY")).toBe(
|
||||
"/insights/cardio-recovery",
|
||||
);
|
||||
expect(insightsHrefForMetric("HRV_RMSSD")).toBe("/insights/hrv");
|
||||
});
|
||||
|
||||
it("falls back to the value list for a type no page focuses", () => {
|
||||
expect(insightsHrefForMetric("DAY_STRAIN")).toBe(
|
||||
"/insights/values/DAY_STRAIN",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes a sub-page slug in either separator", () => {
|
||||
expect(insightsHrefForMetric("walking-speed")).toBe(
|
||||
"/insights/walking-speed",
|
||||
);
|
||||
expect(insightsHrefForMetric("walking_speed")).toBe(
|
||||
"/insights/walking-speed",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the overview for an id it cannot place", () => {
|
||||
for (const id of [null, "", " ", "nonsense", "../admin", "a".repeat(80)]) {
|
||||
expect(insightsHrefForMetric(id)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("only ever points at a page that exists", () => {
|
||||
const ids = [
|
||||
...SUB_PAGE_SLUGS,
|
||||
...Object.keys(COACH_SOURCE_MEASUREMENT_TYPES),
|
||||
];
|
||||
for (const id of ids) {
|
||||
const href = insightsHrefForMetric(id);
|
||||
if (!href || href.startsWith("/insights/values/")) continue;
|
||||
const dir = join(process.cwd(), "src/app", href);
|
||||
expect(existsSync(join(dir, "page.tsx")), `${id} → ${href}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Where `/insights?metric=<id>` goes (MCP deep links).
|
||||
*
|
||||
* The MCP `search` and `fetch` results link a metric as
|
||||
* `/insights?metric=<id>`, with the id in whichever spelling that result
|
||||
* carries: a Coach source key (`hrv`, `resting_hr`, `bp`), a signal or
|
||||
* metric-status key (`GRIP_STRENGTH`, `CARDIO_RECOVERY`), or a sub-page slug.
|
||||
* The overview read none of them, so every such link landed on the overview.
|
||||
* This resolves the id to the metric's own page, or to its value list when no
|
||||
* page focuses it, and to nothing for an id it cannot place (the overview
|
||||
* then renders as before). Pure: no I/O.
|
||||
*/
|
||||
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
|
||||
import type { CoachScopeSource } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
SUB_PAGE_SLUGS,
|
||||
subPageSlugForType,
|
||||
type SubPageSlug,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
import { SIGNALS } from "@/lib/signals/registry";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
|
||||
/** Coach sources that read a model other than `Measurement`. */
|
||||
const NON_MEASUREMENT_SOURCES: Readonly<Record<string, string>> = {
|
||||
mood: "/insights/mood",
|
||||
workouts: "/insights/workouts",
|
||||
compliance: "/insights/medications",
|
||||
};
|
||||
|
||||
const SLUGS: ReadonlySet<string> = new Set(SUB_PAGE_SLUGS);
|
||||
|
||||
function hrefForType(type: string): string {
|
||||
const slug = subPageSlugForType(type);
|
||||
return slug
|
||||
? `/insights/${slug}`
|
||||
: `/insights/values/${encodeURIComponent(type)}`;
|
||||
}
|
||||
|
||||
export function insightsHrefForMetric(
|
||||
raw: string | null | undefined,
|
||||
): string | null {
|
||||
const id = raw?.trim() ?? "";
|
||||
if (id === "" || id.length > 64 || !/^[A-Za-z0-9_-]+$/.test(id)) {
|
||||
return null;
|
||||
}
|
||||
const lower = id.toLowerCase();
|
||||
|
||||
// A sub-page slug, in either separator.
|
||||
const slug = lower.replace(/_/g, "-");
|
||||
if (SLUGS.has(slug)) return `/insights/${slug as SubPageSlug}`;
|
||||
|
||||
// A Coach source key, the spelling the data inventory links carry.
|
||||
if (lower in NON_MEASUREMENT_SOURCES) return NON_MEASUREMENT_SOURCES[lower]!;
|
||||
const sourceTypes =
|
||||
COACH_SOURCE_MEASUREMENT_TYPES[lower as CoachScopeSource] ?? [];
|
||||
if (sourceTypes.length > 0) return hrefForType(sourceTypes[0]!);
|
||||
|
||||
// A signal key, then a measurement type itself.
|
||||
const upper = id.toUpperCase();
|
||||
const signal = SIGNALS[upper];
|
||||
if (signal && (signal.kind === "measurement" || signal.kind === "score")) {
|
||||
return hrefForType(signal.source.measurementType);
|
||||
}
|
||||
if (measurementTypeEnum.safeParse(upper).success) return hrefForType(upper);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
analyteFromQuery,
|
||||
labHrefForAnalyteReading,
|
||||
} from "@/lib/labs/analyte-deep-link";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* `/labs?analyte=<name>` (MCP deep links) opens the marker page of the newest
|
||||
* reading stored under that name, and keeps the list otherwise.
|
||||
*/
|
||||
describe("labs analyte deep link", () => {
|
||||
it("follows a named analyte and ignores an empty or oversized one", () => {
|
||||
expect(analyteFromQuery(" Ferritin ")).toBe("Ferritin");
|
||||
expect(analyteFromQuery(null)).toBeNull();
|
||||
expect(analyteFromQuery(" ")).toBeNull();
|
||||
expect(analyteFromQuery("x".repeat(121))).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the marker page of the reading, and nothing without one", () => {
|
||||
expect(labHrefForAnalyteReading({ biomarkerId: "bm_1" })).toBe(
|
||||
"/labs/bm_1",
|
||||
);
|
||||
expect(labHrefForAnalyteReading({ biomarkerId: null })).toBeNull();
|
||||
expect(labHrefForAnalyteReading(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps its read under the lab-results prefix, apart from the list", () => {
|
||||
const key = queryKeys.labAnalyteLink("Ferritin");
|
||||
expect(key[0]).toBe("lab-results");
|
||||
expect(key).not.toEqual(
|
||||
queryKeys.labResultsList({
|
||||
analyte: "Ferritin",
|
||||
panel: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 0,
|
||||
sortDir: "desc",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Where `/labs?analyte=<name>` goes (MCP deep links).
|
||||
*
|
||||
* MCP `search` and `fetch` link a lab value as `/labs?analyte=<name>`, the
|
||||
* analyte name exactly as stored on the reading. The labs page read nothing
|
||||
* from the query, so every such link showed the whole list. The page now asks
|
||||
* for the newest reading under that name and opens its marker's page; a name
|
||||
* with no reading, or one whose reading carries no marker, keeps the list.
|
||||
*/
|
||||
|
||||
/** The analyte named in the query, or null when there is none to follow. */
|
||||
export function analyteFromQuery(
|
||||
raw: string | null | undefined,
|
||||
): string | null {
|
||||
const analyte = raw?.trim() ?? "";
|
||||
return analyte.length > 0 && analyte.length <= 120 ? analyte : null;
|
||||
}
|
||||
|
||||
/** The marker page for the newest reading under that name, or null. */
|
||||
export function labHrefForAnalyteReading(
|
||||
reading: { biomarkerId: string | null } | undefined,
|
||||
): string | null {
|
||||
return reading?.biomarkerId
|
||||
? `/labs/${encodeURIComponent(reading.biomarkerId)}`
|
||||
: null;
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export const labKeys = {
|
||||
params.sortDir,
|
||||
] as const,
|
||||
|
||||
// `/labs?analyte=<name>` (MCP deep links): the newest reading stored under
|
||||
// that exact analyte name, one row, to find its marker's page. Its own key
|
||||
// because the page size differs from every list read; the `lab-results`
|
||||
// prefix lets a result mutation evict it too.
|
||||
labAnalyteLink: (analyte: string) =>
|
||||
["lab-results", "analyte-link", analyte] as const,
|
||||
|
||||
// The visit form's lab picker: newest results, its own page size. Its own
|
||||
// key because the Labs list reads a different page size, and a shared key
|
||||
// would serve one read's page to the other.
|
||||
|
||||
Reference in new issue
Block a user