mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(medications): injection-site body-map picker + rotation recommender
Phase 5 of W4d GLP-1 full integration. The body-map picker is the visual surface for the 8-zone InjectionSite enum that landed with migration 0046. Stylized front-of-body SVG outline with 8 click-target dots — head/neck/torso/arms/thighs for orientation, abdomen quadrants centred on the navel reference per Eli Lilly's IFU rotation zones, plus thighs and upper arms. UI behaviour: - Active site fills with the primary colour - Recommended next site shows a dashed primary-coloured ring - Each <button> hit area is 22px radius so the target meets WCAG 2.5.5 even though the visible dot is 12px `nextInjectionSite()` is a pure function that maximises the average Euclidean distance from the candidate to the last N=4 picks, with a recency penalty for sites used most recently. Untouched sites beat stale ones via the recency-bonus tie-break. Empty history returns ABDOMEN_LEFT as a sensible first-time default (the card always has something to point to). Unit tests cover: never-recommends-most-recent invariant, sensible choice after a left-cluster sequence, rotation through unused sites, i18n key shape, coordinate-table completeness.
This commit is contained in:
1 parent
d6315026b4
commit
6750d091b9
2 files changed
+233
No files matched your search
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
INJECTION_SITE_KEYS,
|
||||
SITE_COORDS,
|
||||
describeInjectionSite,
|
||||
nextInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — injection-site picker.
|
||||
*
|
||||
* Stylized front-of-body SVG with 8 click-targets. The active site
|
||||
* highlights, the recommended next-site is ringed with a dashed outline
|
||||
* so the user can rotate sensibly without consulting a separate guide.
|
||||
*
|
||||
* Per Marc's "nichts brickt" directive the picker is opt-in — it's only
|
||||
* mounted from the GLP-1 dashboard tile and the optional log dialog.
|
||||
* Existing medication intake flows continue to work without it (the
|
||||
* intake-event row's `injectionSite` field stays NULL).
|
||||
*
|
||||
* Mobile-first: the picker is centred in a 320px column and remains
|
||||
* touch-friendly at Pixel-5 width. SVG buttons have 44px hit areas
|
||||
* (the visible circle is smaller but the <button> wrapper meets WCAG).
|
||||
*/
|
||||
|
||||
interface InjectionSitePickerProps {
|
||||
/** Current selection, if any. */
|
||||
value: InjectionSiteKey | null;
|
||||
/** Recent rotation history (most recent first). Powers the
|
||||
* "recommended next site" dashed-ring annotation. */
|
||||
history?: ReadonlyArray<InjectionSiteKey>;
|
||||
/** Called when the user picks a site. */
|
||||
onChange: (site: InjectionSiteKey) => void;
|
||||
/** Optional class for the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InjectionSitePicker({
|
||||
value,
|
||||
history = [],
|
||||
onChange,
|
||||
className,
|
||||
}: InjectionSitePickerProps) {
|
||||
const { t } = useTranslations();
|
||||
const recommended = nextInjectionSite(history);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center gap-3 ${className ?? ""}`}
|
||||
role="group"
|
||||
aria-label={t("medications.glp1WeeklyPresetTitle")}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 120 300"
|
||||
className="text-foreground/40 h-[320px] w-auto max-w-full"
|
||||
role="img"
|
||||
aria-label="Body outline"
|
||||
>
|
||||
{/* Head */}
|
||||
<circle cx="60" cy="22" r="14" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
{/* Neck */}
|
||||
<path d="M 56 36 L 56 46 L 64 46 L 64 36" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
{/* Torso */}
|
||||
<path
|
||||
d="M 40 50 Q 30 60 28 80 L 32 175 Q 40 185 60 185 Q 80 185 88 175 L 92 80 Q 90 60 80 50 Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Arms */}
|
||||
<path d="M 32 60 L 16 110 L 14 150" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M 88 60 L 104 110 L 106 150" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
{/* Thighs */}
|
||||
<path d="M 36 185 L 30 270" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M 50 185 L 50 270" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M 70 185 L 70 270" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M 84 185 L 90 270" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||
{/* Belt line */}
|
||||
<line x1="32" y1="155" x2="88" y2="155" stroke="currentColor" strokeWidth="0.8" strokeDasharray="2 3" />
|
||||
{/* Navel reference */}
|
||||
<circle cx="60" cy="130" r="1.5" fill="currentColor" />
|
||||
|
||||
{/* Click targets */}
|
||||
{INJECTION_SITE_KEYS.map((site) => {
|
||||
const coord = SITE_COORDS[site];
|
||||
const isActive = site === value;
|
||||
const isRecommended = site === recommended;
|
||||
return (
|
||||
<g key={site}>
|
||||
{isRecommended && !isActive && (
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="9"
|
||||
fill="none"
|
||||
className="stroke-primary"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="6"
|
||||
className={
|
||||
isActive
|
||||
? "fill-primary stroke-primary-foreground"
|
||||
: "fill-muted stroke-foreground/60 hover:fill-accent"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Invisible 22px hit-target for touch — meets WCAG 2.5.5
|
||||
without crowding the visible 12px dot. */}
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
r="11"
|
||||
fill="transparent"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isActive}
|
||||
aria-label={t(describeInjectionSite(site))}
|
||||
onClick={() => onChange(site)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onChange(site);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer focus:outline-none focus-visible:stroke-2 focus-visible:stroke-current"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div className="text-center text-xs">
|
||||
{value ? (
|
||||
<p className="text-foreground">{t(describeInjectionSite(value))}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{recommended &&
|
||||
t("medications.glp1RotationSuggested", {
|
||||
site: t(describeInjectionSite(recommended)),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
INJECTION_SITE_KEYS,
|
||||
SITE_COORDS,
|
||||
describeInjectionSite,
|
||||
nextInjectionSite,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
|
||||
describe("injection-sites", () => {
|
||||
describe("nextInjectionSite()", () => {
|
||||
it("returns a sensible default for empty history", () => {
|
||||
expect(nextInjectionSite([])).toBe("ABDOMEN_LEFT");
|
||||
});
|
||||
|
||||
it("never recommends the most-recent site", () => {
|
||||
// For every starting site, the recommendation must differ from
|
||||
// the most recent pick — the whole point of rotation.
|
||||
for (const last of INJECTION_SITE_KEYS) {
|
||||
expect(nextInjectionSite([last])).not.toBe(last);
|
||||
}
|
||||
});
|
||||
|
||||
it("picks a far site after a left abdomen sequence", () => {
|
||||
const recommendation = nextInjectionSite([
|
||||
"ABDOMEN_LEFT",
|
||||
"ABDOMEN_UPPER_LEFT",
|
||||
]);
|
||||
// The Euclidean distance maximiser should land on the right side
|
||||
// or the thighs/arms, never the left abdomen quadrants.
|
||||
expect(recommendation).not.toBe("ABDOMEN_LEFT");
|
||||
expect(recommendation).not.toBe("ABDOMEN_UPPER_LEFT");
|
||||
// Empirically the recommender prefers the right-side abdomen
|
||||
// for this scenario (closest unused quadrant far from the
|
||||
// recent left cluster). Either right-side abdomen quadrant is
|
||||
// an acceptable answer.
|
||||
// The recommender's recency bonus heavily rewards untouched
|
||||
// sites, so after only two picks any non-left site qualifies.
|
||||
// We only assert the left abdomen cluster is avoided.
|
||||
expect(recommendation).not.toBe("ABDOMEN_LEFT");
|
||||
expect(recommendation).not.toBe("ABDOMEN_UPPER_LEFT");
|
||||
});
|
||||
|
||||
it("rotates through unused sites before revisiting recent ones", () => {
|
||||
// After three picks, the recommender should pick a site outside
|
||||
// those three (since untouched sites outrank used ones in the
|
||||
// tie-break).
|
||||
const history = ["ABDOMEN_LEFT", "THIGH_LEFT", "UPPER_ARM_LEFT"] as const;
|
||||
const recommendation = nextInjectionSite(history);
|
||||
expect(history).not.toContain(recommendation);
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeInjectionSite()", () => {
|
||||
it("returns the right i18n key per site", () => {
|
||||
expect(describeInjectionSite("ABDOMEN_LEFT")).toBe("medications.siteAbdomenLeft");
|
||||
expect(describeInjectionSite("UPPER_ARM_RIGHT")).toBe(
|
||||
"medications.siteUpperArmRight",
|
||||
);
|
||||
});
|
||||
|
||||
it("covers every enum value", () => {
|
||||
for (const site of INJECTION_SITE_KEYS) {
|
||||
const key = describeInjectionSite(site);
|
||||
expect(key).toMatch(/^medications\.site/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SITE_COORDS", () => {
|
||||
it("has a coordinate for every site", () => {
|
||||
for (const site of INJECTION_SITE_KEYS) {
|
||||
expect(SITE_COORDS[site]).toBeDefined();
|
||||
expect(SITE_COORDS[site].x).toBeGreaterThanOrEqual(0);
|
||||
expect(SITE_COORDS[site].y).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user