Files
HealthLog/src/lib/insights/target-adjust-context.tsx
T
Marc-André Bombeck 528ce93c71 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.
2026-06-04 17:47:17 +02:00

143 lines
5.2 KiB
TypeScript

"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);
}