feat(insights): edit target ranges inline instead of routing to the targets page

The per-metric reference panel on each insights category page now opens
the self-contained target editor in place rather than linking out to the
targets page. The panel mounts the same <TargetEditSheet> the targets
card uses, seeded with the metric's type, unit, and range (plus the
diastolic band for blood pressure); the sheet writes the threshold
override and invalidates the shared targets cache on save, so the panel
repaints without a navigation.

Switch the sheet's targets-cache invalidation to the query-key factory
call so it stays aligned with the centralised key.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 23:07:32 +02:00
1 parent 68517240e1
commit c72715bb62
3 files changed
+79 -35

No files matched your search

@@ -23,8 +23,13 @@ vi.mock("@/hooks/use-auth", () => ({
}));
const useQueryMock = vi.fn();
// The inline <TargetEditSheet> (mounted by the panel once the
// adjust-target button is clicked) also pulls `useMutation` +
// `useQueryClient`, so stub those alongside `useQuery`.
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
const { MetricTargetSummary } = await import("../metric-target-summary");
@@ -188,10 +193,41 @@ describe("<MetricTargetSummary>", () => {
expect(html).toContain("30-day average: 122/76 mmHg");
});
it("renders the adjust-target link to /targets", () => {
it("renders the inline adjust-target button instead of a /targets link", () => {
const html = renderWith("weight", WEIGHT_DATA);
expect(html).toContain('href="/targets"');
// v1.8.6 — the /targets page is deprecated; the editor moved inline,
// so the footer is a button that opens the sheet, not a route link.
expect(html).toContain('data-slot="metric-target-adjust"');
expect(html).toContain("Adjust target range");
expect(html).not.toContain('href="/targets"');
});
it("keeps the inline editor lazy-mounted while the panel is closed", () => {
// The panel mounts <TargetEditSheet open={false}> alongside itself;
// the sheet body (and its TanStack Query hooks) only instantiate once
// the button flips the open flag, so a closed panel paints no editor.
const html = renderWith("weight", WEIGHT_DATA);
expect(html).not.toContain('data-slot="target-edit-sheet"');
});
it("renders the adjust-target affordance as a button, not a navigation link", () => {
// The migration's load-bearing change: the affordance is a local
// button (opens the inline sheet) rather than an <a>/<Link> routing to
// the deprecated /targets page.
const html = renderWith("weight", WEIGHT_DATA);
const button = html.match(/<button[^>]*data-slot="metric-target-adjust"[^>]*/)?.[0] ?? "";
expect(button).toContain('type="button"');
expect(html).not.toContain('href="/targets"');
});
it("threads the diastolic range into the editor for blood pressure", () => {
// The panel passes `bpDiastolic.range` as the sheet's
// `initialDiastolicRange`. Render the panel for BP and confirm the
// adjust button is present so the BP editor is reachable inline (the
// sheet body itself portals, so it isn't part of the static markup).
const html = renderWith("blood-pressure", BP_DATA);
expect(html).toContain('data-slot="metric-target-adjust"');
expect(html).not.toContain('href="/targets"');
});
it("suppresses the share + strip when the route flags insufficient data", () => {
@@ -1,6 +1,6 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ExternalLink, Target } from "lucide-react";
@@ -11,6 +11,7 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { RangeBar } from "@/components/targets/range-bar";
import { ConsistencyStrip } from "@/components/targets/consistency-strip";
import { TargetStatusPill } from "@/components/targets/target-status-pill";
import { TargetEditSheet } from "@/components/targets/target-edit-sheet";
import { getTargetSourceLink } from "@/lib/targets/source-link";
/**
@@ -36,7 +37,9 @@ import { getTargetSourceLink } from "@/lib/targets/source-link";
* (the spatial answer). BP renders the second diastolic bar.
* • Row 3: in-target share % + the 30-day average.
* • Row 4: `<ConsistencyStrip>` — the last seven days at a glance.
* • Footer: the "Adjust target range" link to `/targets`.
* • Footer: an "Adjust target range" button that opens the
* `<TargetEditSheet>` inline (v1.8.6 — the `/targets` page is
* deprecated, so the editor mounts here rather than routing).
*
* Each insights slug maps to one target `type`, except blood glucose
* which maps to up to four per-context cards (fasting / postprandial /
@@ -169,9 +172,9 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
if (panels.length === 0) return null;
// Glucose fans out to up to four per-context panels. The
// "Adjust target range" link is identical on every one, so render
// it once for the whole group rather than repeating it per panel.
// Glucose fans out to up to four per-context panels. Each context
// maps to its own editable threshold, so every panel carries its own
// inline edit button rather than a single shared one.
return (
<div className="space-y-2" data-slot="metric-target-summary-group">
{panels.map((panel) => (
@@ -179,12 +182,8 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
key={panel.type}
target={panel}
heading={panel.label}
hideAdjustLink
/>
))}
<div className="flex justify-end px-1">
<AdjustTargetLink />
</div>
</div>
);
}
@@ -207,25 +206,6 @@ interface TargetReferencePanelProps {
bpDiastolic?: TargetsResponse["bpDiastolic"];
/** Optional sub-heading shown above the panel (used for glucose contexts). */
heading?: string;
/**
* Suppress the per-panel "Adjust target range" link. Glucose stacks
* up to four panels and lifts a single link to the group wrapper, so
* each panel omits its own to avoid repeating the same link.
*/
hideAdjustLink?: boolean;
}
/** The shared "Adjust target range" link to the `/targets` editing surface. */
function AdjustTargetLink() {
const { t } = useTranslations();
return (
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
);
}
/**
@@ -239,10 +219,17 @@ function TargetReferencePanel({
target,
bpDiastolic,
heading,
hideAdjustLink,
}: TargetReferencePanelProps) {
const { t } = useTranslations();
// v1.8.6 — the `/targets` page is deprecated; the per-metric editor
// moved here. The button below opens the same self-contained
// `<TargetEditSheet>` the targets card mounts, seeded with this
// metric's type / unit / range (and the diastolic range for BP). The
// sheet writes `PUT /api/user/thresholds` and invalidates the
// `insightsTargets()` cache on save, so the panel repaints in place.
const [editOpen, setEditOpen] = useState(false);
const { range, unit } = target;
if (!range) return null;
@@ -378,7 +365,7 @@ function TargetReferencePanel({
/>
) : null}
{/* Footer: source link + adjust-target link. */}
{/* Footer: source link + inline adjust-target button. */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 pt-0.5">
{sourceLink ? (
<a
@@ -395,8 +382,29 @@ function TargetReferencePanel({
{t("targets.sourceLabel", { source: target.source })}
</span>
)}
{hideAdjustLink ? null : <AdjustTargetLink />}
<button
type="button"
onClick={() => setEditOpen(true)}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
data-slot="metric-target-adjust"
>
{t("insights.subPage.target.adjustLink")}
</button>
</div>
{/* v1.8.6 — inline target editor, mounted alongside the panel and
portalled by the sheet primitive. The body only instantiates its
TanStack Query hooks once `editOpen` is true (lazy mount inside
the sheet), so closed panels stay cheap. */}
<TargetEditSheet
targetType={target.type}
targetLabel={heading ?? target.label}
unit={unit}
initialRange={range}
initialDiastolicRange={bpDiastolic?.range ?? null}
open={editOpen}
onOpenChange={setEditOpen}
/>
</div>
);
}
+2 -2
View File
@@ -216,7 +216,7 @@ function TargetEditSheetBody({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
queryClient.invalidateQueries({ queryKey: ["insights", "targets"] });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
toast.success(t("targets.edit.saveSuccess"));
@@ -248,7 +248,7 @@ function TargetEditSheetBody({
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
queryClient.invalidateQueries({ queryKey: ["insights", "targets"] });
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
queryClient.invalidateQueries({ queryKey: ["insights"] });
toast.success(t("targets.edit.resetSuccess"));