mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(insights): move target-range editing to a header gear
The target reference card carried an inline "Adjust target range" link, which read as a control buried in a read surface. Lift the action to a gear button in the category-page header, to the left of the Coach launch icon, so the two sit as a consistent icon cluster beside the heading. A small page-scoped provider bridges the two: the reference card registers its editable target (one per page, except blood glucose which fans out to several contexts and registers each) and the header gear opens the per-metric editor sheet the provider owns. Single-target pages open that target directly; glucose opens the primary context, since target editing lives only on these pages — there is no separate targets management surface. The card itself is now a pure read surface. The gear carries the action's existing label as its accessible name, clears the 44 px touch-target floor on mobile, and exposes a stable `data-slot` for end-to-end coverage.
This commit is contained in:
1 parent
9396bfea46
commit
528ce93c71
6 files changed
+445
-154
No files matched your search
@@ -193,43 +193,23 @@ describe("<MetricTargetSummary>", () => {
|
||||
expect(html).toContain("30-day average: 122/76 mmHg");
|
||||
});
|
||||
|
||||
it("renders the inline adjust-target button instead of a /targets link", () => {
|
||||
it("keeps the adjust-target affordance out of the card body", () => {
|
||||
// The "Adjust target range" affordance moved to the page-header gear
|
||||
// (`<TargetAdjustButton>` via `TargetAdjustProvider`); the card is now
|
||||
// a read surface, so neither the old in-card button nor a /targets
|
||||
// route link appears in its markup.
|
||||
const html = renderWith("weight", WEIGHT_DATA);
|
||||
// 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('data-slot="metric-target-adjust"');
|
||||
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.
|
||||
it("does not mount the target editor inside the card", () => {
|
||||
// The editor sheet is owned by the provider, opened from the header
|
||||
// gear — never mounted by the card itself.
|
||||
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", () => {
|
||||
const html = renderWith("weight", {
|
||||
...WEIGHT_DATA,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ExternalLink, Target } from "lucide-react";
|
||||
|
||||
@@ -11,7 +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 { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
import { getTargetSourceLink } from "@/lib/targets/source-link";
|
||||
|
||||
/**
|
||||
@@ -35,9 +35,13 @@ 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: an "Adjust target range" button that opens the
|
||||
* `<TargetEditSheet>` inline — this panel is the home of
|
||||
* target editing.
|
||||
* • Footer: the guideline source link.
|
||||
*
|
||||
* The panel is a read surface: editing the target range moved to a gear
|
||||
* button in the page header (`<SubPageShell>`). Each panel registers its
|
||||
* editable target with the `TargetAdjustProvider` (see
|
||||
* `target-adjust-context.tsx`); the header gear opens the per-metric
|
||||
* `<TargetEditSheet>` the provider owns.
|
||||
*
|
||||
* Each insights slug maps to one target `type`, except blood glucose
|
||||
* which maps to up to four per-context cards (fasting / postprandial /
|
||||
@@ -153,7 +157,9 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
|
||||
const panels = GLUCOSE_TARGET_TYPES.map((type) =>
|
||||
data.targets.find((entry) => entry.type === type),
|
||||
)
|
||||
.filter((entry): entry is TargetItem => entry != null && entry.range != null)
|
||||
.filter(
|
||||
(entry): entry is TargetItem => entry != null && entry.range != null,
|
||||
)
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
label: t(entry.label),
|
||||
@@ -171,8 +177,9 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
|
||||
if (panels.length === 0) return null;
|
||||
|
||||
// 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.
|
||||
// maps to its own editable threshold and registers with the
|
||||
// `TargetAdjustProvider`; the header gear opens the primary
|
||||
// (first-registered) context's editor.
|
||||
return (
|
||||
<div className="space-y-2" data-slot="metric-target-summary-group">
|
||||
{panels.map((panel) => (
|
||||
@@ -209,9 +216,16 @@ interface TargetReferencePanelProps {
|
||||
/**
|
||||
* One compact reference panel: range + status pill + source, a range
|
||||
* bar (plus a diastolic bar for BP), the in-target share + 30-day
|
||||
* average, the 7-day consistency strip, and the adjust-target link.
|
||||
* average, the 7-day consistency strip, and the guideline source.
|
||||
* All fields come from the `/api/insights/targets` payload — nothing is
|
||||
* recomputed here.
|
||||
*
|
||||
* Editing the range is a header action: the panel registers its
|
||||
* editable target with the `TargetAdjustProvider` and the header gear
|
||||
* opens the per-metric `<TargetEditSheet>` (seeded with this metric's
|
||||
* type / unit / range, plus 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.
|
||||
*/
|
||||
function TargetReferencePanel({
|
||||
target,
|
||||
@@ -219,15 +233,43 @@ function TargetReferencePanel({
|
||||
heading,
|
||||
}: TargetReferencePanelProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// The per-metric editor lives here. The button below opens a
|
||||
// self-contained `<TargetEditSheet>`, 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 adjust = useTargetAdjust();
|
||||
|
||||
const { range, unit } = target;
|
||||
|
||||
// Register this metric as the header gear's edit target. The effect
|
||||
// re-runs when the seeded range / unit / label changes (e.g. a glucose
|
||||
// unit-preference swap), keeping the sheet seed current, and the
|
||||
// cleanup unregisters when the panel unmounts so a metric without a
|
||||
// band never leaves a dead gear behind.
|
||||
const registerAdjust = adjust?.register;
|
||||
const adjustType = target.type;
|
||||
const adjustLabel = heading ?? target.label;
|
||||
const rangeMin = range?.min ?? null;
|
||||
const rangeMax = range?.max ?? null;
|
||||
const diaMin = bpDiastolic?.range?.min ?? null;
|
||||
const diaMax = bpDiastolic?.range?.max ?? null;
|
||||
useEffect(() => {
|
||||
if (!registerAdjust || rangeMin == null || rangeMax == null) return;
|
||||
return registerAdjust({
|
||||
type: adjustType,
|
||||
label: adjustLabel,
|
||||
unit,
|
||||
range: { min: rangeMin, max: rangeMax },
|
||||
diastolicRange:
|
||||
diaMin != null && diaMax != null ? { min: diaMin, max: diaMax } : null,
|
||||
});
|
||||
}, [
|
||||
registerAdjust,
|
||||
adjustType,
|
||||
adjustLabel,
|
||||
unit,
|
||||
rangeMin,
|
||||
rangeMax,
|
||||
diaMin,
|
||||
diaMax,
|
||||
]);
|
||||
|
||||
if (!range) return null;
|
||||
|
||||
const isBp = target.type === "BLOOD_PRESSURE";
|
||||
@@ -362,8 +404,10 @@ function TargetReferencePanel({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 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">
|
||||
{/* Footer: guideline source link. The adjust-target affordance
|
||||
moved to the header gear (see `TargetAdjustProvider`); this
|
||||
panel registers itself as that gear's edit target above. */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-0.5">
|
||||
{sourceLink ? (
|
||||
<a
|
||||
href={sourceLink}
|
||||
@@ -379,29 +423,7 @@ function TargetReferencePanel({
|
||||
{t("targets.sourceLabel", { source: target.source })}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { ListOrdered } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
|
||||
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
|
||||
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -157,11 +159,17 @@ export function SubPageShell({
|
||||
}, [focusOnMount]);
|
||||
|
||||
return (
|
||||
<div data-slot="insights-subpage" className="space-y-4 md:space-y-5">
|
||||
{/* v1.8.7.1 — back-nav leads the page, above the heading. */}
|
||||
{backLink}
|
||||
<header className="space-y-1.5">
|
||||
{/* v1.8.6 — the heading row pins the Coach icon top-right while the
|
||||
// The target-adjust provider bridges the header gear (rendered just
|
||||
// below) to the per-metric `<TargetEditSheet>` opened from the
|
||||
// `<MetricTargetSummary>` card in the page body. It owns the sheet
|
||||
// state + renders the sheet, so the gear and the card don't have to
|
||||
// share a parent beyond this shell.
|
||||
<TargetAdjustProvider>
|
||||
<div data-slot="insights-subpage" className="space-y-4 md:space-y-5">
|
||||
{/* v1.8.7.1 — back-nav leads the page, above the heading. */}
|
||||
{backLink}
|
||||
<header className="space-y-1.5">
|
||||
{/* v1.8.6 — the heading row pins the Coach icon top-right while the
|
||||
title / nudge / badge wrap together on the left.
|
||||
|
||||
Design M2 fix: the Coach icon is a `shrink-0` sibling of the
|
||||
@@ -176,100 +184,107 @@ export function SubPageShell({
|
||||
with and without a nudge now align. `items-start` keeps the
|
||||
Coach icon at the top of the row when the title wraps to two
|
||||
lines. */}
|
||||
<div className="flex min-h-10 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1
|
||||
ref={headingRef}
|
||||
id="insights-subpage-title"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"text-xl font-semibold sm:text-2xl",
|
||||
// a11y: sighted-keyboard users (e.g. "Skip to content")
|
||||
// need a visible focus indicator on the programmatic
|
||||
// `headingRef.focus()` call below. Match the focus ring
|
||||
// vocabulary used on insights pills + Coach affordances.
|
||||
"rounded-sm focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{/* v1.8.6 — the diversity nudge rides the heading as a
|
||||
<div className="flex min-h-10 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h1
|
||||
ref={headingRef}
|
||||
id="insights-subpage-title"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"text-xl font-semibold sm:text-2xl",
|
||||
// a11y: sighted-keyboard users (e.g. "Skip to content")
|
||||
// need a visible focus indicator on the programmatic
|
||||
// `headingRef.focus()` call below. Match the focus ring
|
||||
// vocabulary used on insights pills + Coach affordances.
|
||||
"focus-visible:ring-ring/50 rounded-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{/* v1.8.6 — the diversity nudge rides the heading as a
|
||||
`Lightbulb` glyph (the node self-gates to nothing when the
|
||||
spread is healthy), replacing the old inline block. */}
|
||||
{diversityNudge}
|
||||
{badge ? (
|
||||
<Badge variant="outline" className="border text-xs">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
{diversityNudge}
|
||||
{badge ? (
|
||||
<Badge variant="outline" className="border text-xs">
|
||||
{badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Header action cluster, pinned top-right at heading height by
|
||||
the row's `justify-between` so it aligns with the title
|
||||
regardless of badge / glyph presence or a wrapped title.
|
||||
|
||||
The target-adjust gear sits to the LEFT of the Coach icon.
|
||||
It self-gates on a registered editable target (a metric with
|
||||
no numeric band registers nothing, so the gear stays hidden),
|
||||
and the Coach icon self-gates on the Coach flag + per-user
|
||||
opt-out — so the cluster collapses cleanly to one, both, or
|
||||
neither control. */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<TargetAdjustButton />
|
||||
{coachLaunch ? <CoachLaunchButton variant="icon" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* v1.8.6 — Coach launch sits top-right at heading height as an
|
||||
icon, pinned by the row's `justify-between` so it aligns with
|
||||
the title regardless of badge / glyph presence or a wrapped
|
||||
title. The button self-gates on the Coach flag + per-user
|
||||
opt-out. */}
|
||||
{coachLaunch ? (
|
||||
<CoachLaunchButton variant="icon" className="shrink-0" />
|
||||
{explainerMetric ? (
|
||||
// v1.8.4 — surface the metric's static definition inline,
|
||||
// directly beneath the heading.
|
||||
//
|
||||
// v1.8.5 W4a — render the explainer body as the *single* caption
|
||||
// under the heading. Pre-v1.8.5 both this paragraph and the
|
||||
// `description` below it stacked, so every metric page opened
|
||||
// with two near-duplicative muted captions before any data — the
|
||||
// root of the "airy / static under the heading" feel. The
|
||||
// explainer body is the definition, so it wins; the description
|
||||
// only renders when no explainer is set (the mother page and any
|
||||
// future explainer-less sub-page).
|
||||
//
|
||||
// v1.8.6 — this caption is now the only surface for the
|
||||
// definition; the round `?` popover that used to read the same
|
||||
// string was removed.
|
||||
<p
|
||||
data-slot="metric-explainer-inline"
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{explainerMetric ? (
|
||||
// v1.8.4 — surface the metric's static definition inline,
|
||||
// directly beneath the heading.
|
||||
//
|
||||
// v1.8.5 W4a — render the explainer body as the *single* caption
|
||||
// under the heading. Pre-v1.8.5 both this paragraph and the
|
||||
// `description` below it stacked, so every metric page opened
|
||||
// with two near-duplicative muted captions before any data — the
|
||||
// root of the "airy / static under the heading" feel. The
|
||||
// explainer body is the definition, so it wins; the description
|
||||
// only renders when no explainer is set (the mother page and any
|
||||
// future explainer-less sub-page).
|
||||
//
|
||||
// v1.8.6 — this caption is now the only surface for the
|
||||
// definition; the round `?` popover that used to read the same
|
||||
// string was removed.
|
||||
<p
|
||||
data-slot="metric-explainer-inline"
|
||||
className="text-muted-foreground text-sm leading-relaxed"
|
||||
>
|
||||
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
|
||||
</p>
|
||||
) : null}
|
||||
{description && !explainerMetric ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
{/* v1.8.5 W4b — numbers-first stat strip sits between the header
|
||||
{description && !explainerMetric ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
{/* v1.8.5 W4b — numbers-first stat strip sits between the header
|
||||
and the chart so the page leads with data, mirroring the Apple
|
||||
Health / Withings detail layout. Self-gating (renders nothing
|
||||
without data), so the spacing rhythm holds on the empty-state
|
||||
and brand-new-metric paths.
|
||||
v1.8.6 — the diversity nudge moved up to the heading row. */}
|
||||
{statStrip}
|
||||
{children}
|
||||
{/* v1.8.5 W4b — "show all readings" entry at the foot, linking to
|
||||
{statStrip}
|
||||
{children}
|
||||
{/* v1.8.5 W4b — "show all readings" entry at the foot, linking to
|
||||
the dedicated per-metric values subpage.
|
||||
v1.8.6 — normalised to the `h-10` secondary-button height so it
|
||||
reads as a consistent control now that the foot-of-page Coach
|
||||
button (which set the old visual baseline) has moved to the
|
||||
header. */}
|
||||
{showAllValuesType ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
data-slot="metric-show-all-values"
|
||||
className="h-10 w-full sm:w-auto"
|
||||
>
|
||||
<Link
|
||||
href={`/insights/values/${showAllValuesType}${
|
||||
pathname ? `?from=${encodeURIComponent(pathname)}` : ""
|
||||
}`}
|
||||
{showAllValuesType ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
data-slot="metric-show-all-values"
|
||||
className="h-10 w-full sm:w-auto"
|
||||
>
|
||||
<ListOrdered className="mr-1.5 size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.showAllValues")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
href={`/insights/values/${showAllValuesType}${
|
||||
pathname ? `?from=${encodeURIComponent(pathname)}` : ""
|
||||
}`}
|
||||
>
|
||||
<ListOrdered className="mr-1.5 size-4" aria-hidden="true" />
|
||||
{t("insights.subPage.showAllValues")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TargetAdjustProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { Settings2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTargetAdjust } from "@/lib/insights/target-adjust-context";
|
||||
|
||||
/**
|
||||
* Header gear that opens the per-metric target editor.
|
||||
*
|
||||
* The "Adjust target range" affordance used to sit inside the target
|
||||
* reference card; it now lives in the Insights category-page header
|
||||
* (`<SubPageShell>`), to the left of the Coach launch icon, so the card
|
||||
* stays a read surface. The button is a thin wrapper around
|
||||
* `useTargetAdjust()`:
|
||||
*
|
||||
* - renders nothing when no provider is mounted or no editable target
|
||||
* has registered for the page (e.g. a metric without a numeric
|
||||
* target band, where the card paints nothing), so the header never
|
||||
* shows a dead control;
|
||||
* - opens the primary registered target's `<TargetEditSheet>` on click.
|
||||
*
|
||||
* Sized to match `<CoachLaunchButton variant="icon">` so the gear + the
|
||||
* Coach sparkle read as a consistent icon cluster top-right of the
|
||||
* heading.
|
||||
*/
|
||||
export function TargetAdjustButton({ className }: { className?: string }) {
|
||||
const { t } = useTranslations();
|
||||
const adjust = useTargetAdjust();
|
||||
|
||||
// No provider, or no editable target registered for this page.
|
||||
if (!adjust || !adjust.canAdjust) return null;
|
||||
|
||||
// Reuse the action's existing copy ("Adjust target range" /
|
||||
// "Zielbereich anpassen") — the gear performs exactly what the old
|
||||
// in-card link did, so the same string is the right accessible label.
|
||||
const label = t("insights.subPage.target.adjustLink");
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="target-adjust-trigger"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={() => adjust.requestAdjust()}
|
||||
className={cn(
|
||||
// The `icon` size is 40 px; bump to the 44 px WCAG 2.5.5 floor on
|
||||
// mobile and settle back to the 40 px that matches the sibling
|
||||
// Coach icon from `sm` up, so the cluster reads as one on desktop.
|
||||
"text-muted-foreground hover:text-foreground size-11 sm:size-10",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Settings2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
TargetAdjustProvider,
|
||||
useTargetAdjust,
|
||||
} from "../target-adjust-context";
|
||||
|
||||
/**
|
||||
* Target-adjust context smoke contract.
|
||||
*
|
||||
* The project's test convention is `renderToStaticMarkup` (no
|
||||
* `@testing-library/react` dependency), so we assert the SSR-observable
|
||||
* surface — interaction (register / requestAdjust opening the sheet)
|
||||
* runs in effects + state and is covered by the component-level
|
||||
* behaviour. Here we pin:
|
||||
* 1. The hook returns `null` outside the provider, so the header gear
|
||||
* degrades to rendering nothing rather than crashing.
|
||||
* 2. The hook exposes the full shape beneath the provider, with
|
||||
* `canAdjust` starting false (no target registered yet).
|
||||
* 3. The provider renders its children and mounts no editor sheet at
|
||||
* rest (nothing is open).
|
||||
*/
|
||||
|
||||
function Probe({ output }: { output: string[] }) {
|
||||
const adjust = useTargetAdjust();
|
||||
if (!adjust) {
|
||||
output.push("null");
|
||||
return null;
|
||||
}
|
||||
output.push(
|
||||
JSON.stringify({
|
||||
hasRegister: typeof adjust.register,
|
||||
hasRequestAdjust: typeof adjust.requestAdjust,
|
||||
canAdjust: adjust.canAdjust,
|
||||
}),
|
||||
);
|
||||
return <span data-slot="probe-output">{output.join(",")}</span>;
|
||||
}
|
||||
|
||||
describe("TargetAdjustProvider", () => {
|
||||
it("returns null when consumed outside the provider", () => {
|
||||
const output: string[] = [];
|
||||
renderToStaticMarkup(<Probe output={output} />);
|
||||
expect(output).toEqual(["null"]);
|
||||
});
|
||||
|
||||
it("exposes the full context shape with canAdjust false at rest", () => {
|
||||
const output: string[] = [];
|
||||
renderToStaticMarkup(
|
||||
<TargetAdjustProvider>
|
||||
<Probe output={output} />
|
||||
</TargetAdjustProvider>,
|
||||
);
|
||||
expect(output).toHaveLength(1);
|
||||
const parsed = JSON.parse(output[0]);
|
||||
expect(parsed.hasRegister).toBe("function");
|
||||
expect(parsed.hasRequestAdjust).toBe("function");
|
||||
expect(parsed.canAdjust).toBe(false);
|
||||
});
|
||||
|
||||
it("renders children and mounts no editor sheet at rest", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TargetAdjustProvider>
|
||||
<div data-slot="child-mount">child</div>
|
||||
</TargetAdjustProvider>,
|
||||
);
|
||||
expect(html).toContain('data-slot="child-mount"');
|
||||
expect(html).not.toContain('data-slot="target-edit-sheet"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { TargetEditSheet } from "@/components/targets/target-edit-sheet";
|
||||
|
||||
/**
|
||||
* Insights target-adjust context.
|
||||
*
|
||||
* The per-metric target editor (`<TargetEditSheet>`) used to be opened
|
||||
* by an "Adjust target range" link sitting inside the target reference
|
||||
* card on each Insights category page. The trigger moved up to a gear
|
||||
* button in the page header (`<SubPageShell>`), beside the Coach launch
|
||||
* icon, so the card stays a read surface and the action reads as a
|
||||
* header control.
|
||||
*
|
||||
* Because the gear lives in the header while the editable target's
|
||||
* type / range still come from the card in the page body, this context
|
||||
* bridges the two:
|
||||
*
|
||||
* - `<TargetAdjustProvider>` is mounted by `<SubPageShell>`, owns the
|
||||
* sheet's open state + which registered target is being edited, and
|
||||
* renders the `<TargetEditSheet>` once a target is open.
|
||||
* - `<MetricTargetSummary>` registers each editable target it paints
|
||||
* (one per category page, except blood glucose which fans out to up
|
||||
* to four per-context targets) via `register()`.
|
||||
* - The header gear reads `canAdjust` to gate its own visibility and
|
||||
* calls `requestAdjust()` to open the editor.
|
||||
*
|
||||
* Single-target pages open that target's sheet directly. Blood glucose
|
||||
* registers several contexts; there is no standalone targets-management
|
||||
* surface (target editing lives only on these category pages), so the
|
||||
* gear opens the first registered context — every context's threshold
|
||||
* is reachable from its own card, and the header gear is the quick path
|
||||
* into the primary one.
|
||||
*/
|
||||
|
||||
export interface AdjustableTarget {
|
||||
/** Target type literal, e.g. `"WEIGHT"` or `"BLOOD_GLUCOSE_FASTING"`. */
|
||||
type: string;
|
||||
/** Display label for the sheet heading. */
|
||||
label: string;
|
||||
/** Display unit, already converted (e.g. mmol/L for a glucose preference). */
|
||||
unit: string;
|
||||
/** Current range seed for the editor, in the display unit. */
|
||||
range: { min: number; max: number };
|
||||
/** Diastolic range seed for blood pressure; null otherwise. */
|
||||
diastolicRange?: { min: number; max: number } | null;
|
||||
}
|
||||
|
||||
interface TargetAdjustValue {
|
||||
/** Whether at least one editable target is registered on this page. */
|
||||
canAdjust: boolean;
|
||||
/** Register (or update) an editable target. Returns an unregister fn. */
|
||||
register: (target: AdjustableTarget) => () => void;
|
||||
/** Open the editor for the primary (first-registered) target. */
|
||||
requestAdjust: () => void;
|
||||
}
|
||||
|
||||
const TargetAdjustContext = createContext<TargetAdjustValue | null>(null);
|
||||
|
||||
export function TargetAdjustProvider({ children }: { children: ReactNode }) {
|
||||
// Insertion-ordered registry: a Map keyed by target type so the same
|
||||
// card can update its seed on a repaint without duplicating, and the
|
||||
// first-registered entry stays the primary one the gear opens.
|
||||
const [targets, setTargets] = useState<ReadonlyArray<AdjustableTarget>>([]);
|
||||
// The type currently open in the sheet (null when closed).
|
||||
const [openType, setOpenType] = useState<string | null>(null);
|
||||
|
||||
// Keep a ref of the latest registry so `requestAdjust` reads the
|
||||
// current primary without being recreated on every registration.
|
||||
// Synced in an effect (never during render) so the callback identity
|
||||
// stays stable across registrations.
|
||||
const targetsRef = useRef(targets);
|
||||
useEffect(() => {
|
||||
targetsRef.current = targets;
|
||||
}, [targets]);
|
||||
|
||||
const register = useCallback((target: AdjustableTarget) => {
|
||||
setTargets((prev) => {
|
||||
const next = prev.filter((entry) => entry.type !== target.type);
|
||||
next.push(target);
|
||||
return next;
|
||||
});
|
||||
return () => {
|
||||
setTargets((prev) => prev.filter((entry) => entry.type !== target.type));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const requestAdjust = useCallback(() => {
|
||||
const primary = targetsRef.current[0];
|
||||
if (primary) setOpenType(primary.type);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<TargetAdjustValue>(
|
||||
() => ({ canAdjust: targets.length > 0, register, requestAdjust }),
|
||||
[targets.length, register, requestAdjust],
|
||||
);
|
||||
|
||||
const openTarget = targets.find((entry) => entry.type === openType) ?? null;
|
||||
|
||||
return (
|
||||
<TargetAdjustContext.Provider value={value}>
|
||||
{children}
|
||||
{/* The sheet body lazy-mounts on `open`, so a closed page pays
|
||||
nothing. Keyed by type so switching the open target remounts a
|
||||
clean editor seeded with the right range. */}
|
||||
{openTarget ? (
|
||||
<TargetEditSheet
|
||||
key={openTarget.type}
|
||||
targetType={openTarget.type}
|
||||
targetLabel={openTarget.label}
|
||||
unit={openTarget.unit}
|
||||
initialRange={openTarget.range}
|
||||
initialDiastolicRange={openTarget.diastolicRange ?? null}
|
||||
open
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenType(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</TargetAdjustContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the target-adjust context, or `null` when no provider is
|
||||
* mounted (so dropping a consumer outside an Insights sub-page is a
|
||||
* no-op rather than a crash, mirroring `useCoachLaunch()`).
|
||||
*/
|
||||
export function useTargetAdjust(): TargetAdjustValue | null {
|
||||
return useContext(TargetAdjustContext);
|
||||
}
|
||||
Reference in new issue
Block a user