mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user