mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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.
143 lines
5.2 KiB
TypeScript
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);
|
|
}
|