fix(medications): route injection-site taps to the nearest quadrant centre

The two stacked abdomen quadrants sit 14 viewBox units apart, yet each
per-dot hit-target was an r=14 transparent circle (Ø=28u), so the
targets overlapped by ~14 units. SVG hit-testing resolves to the
topmost-painted element rather than the nearest centre, so a tap in the
overlap band logged the wrong quadrant — defeating the rotation feature.

Replace the overlapping per-dot hit-circles with a single transparent
overlay that projects the pointer into viewBox space and resolves it to
the nearest site centre (new `nearestSiteAt` helper, constrained to the
effective allowed set). Every pixel now maps to its closest site, so
full-area routing holds without sub-target dead zones, and the abdomen
quadrants can no longer mis-route. Keyboard and assistive-tech users
keep a per-site focusable r=6 circle (non-overlapping at this spacing)
with the Space/Enter handler and aria-label. Add tests asserting a tap
in the former overlap band maps to the nearer quadrant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit a07988f139b0067e82ccc5ad7e5890e0517fe869)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 17:34:52 +02:00
1 parent 107a111545
commit bfdf0a12a0
3 files changed
+146 -14

No files matched your search

@@ -6,6 +6,7 @@ import {
describeInjectionSite,
effectiveAllowedSites,
isSiteAllowed,
nearestSiteAt,
nextInjectionSite,
} from "@/lib/medications/injection-sites";
@@ -142,6 +143,54 @@ describe("injection-sites", () => {
});
});
describe("nearestSiteAt() — pointer overlay nearest-centre routing", () => {
it("resolves a tap exactly on a site centre to that site", () => {
for (const site of INJECTION_SITE_KEYS) {
const { x, y } = SITE_COORDS[site];
expect(nearestSiteAt(x, y)).toBe(site);
}
});
it("maps a tap in the former abdomen overlap band to the NEARER quadrant", () => {
// ABDOMEN_UPPER_LEFT y=81, ABDOMEN_LEFT (lower) y=95 → midpoint 88.
// The old r=14 hit-circles overlapped ~14u here and mis-routed to
// the last-painted dot (the lower quadrant). Nearest-centre must
// route a tap above the midpoint to the upper quadrant and below
// it to the lower one.
expect(nearestSiteAt(43.5, 84)).toBe("ABDOMEN_UPPER_LEFT");
expect(nearestSiteAt(43.5, 92)).toBe("ABDOMEN_LEFT");
// Same on the right column.
expect(nearestSiteAt(56.5, 84)).toBe("ABDOMEN_UPPER_RIGHT");
expect(nearestSiteAt(56.5, 92)).toBe("ABDOMEN_RIGHT");
});
it("routes a tap left of centre to a *_LEFT site (screen-left == links)", () => {
// A point in the abdomen row but left of the body midline resolves
// to a left-column quadrant, never a right one.
const site = nearestSiteAt(40, 88);
expect(site).toMatch(/_LEFT$/);
});
it("constrains routing to the allowed set (skips disabled sites)", () => {
// A tap right on the upper-left abdomen centre, but that quadrant is
// not allowed → the nearest ALLOWED site wins instead.
const allowed = ["ABDOMEN_LEFT", "THIGH_LEFT"] as const;
const { x, y } = SITE_COORDS.ABDOMEN_UPPER_LEFT;
expect(nearestSiteAt(x, y, allowed)).toBe("ABDOMEN_LEFT");
});
it("returns null when the allowed set is empty", () => {
expect(nearestSiteAt(50, 88, [])).toBeNull();
});
it("breaks ties toward the earlier site in canonical order", () => {
// Equidistant between the two upper-abdomen quadrants (x midline,
// y=81) → ABDOMEN_UPPER_LEFT precedes ABDOMEN_UPPER_RIGHT in
// INJECTION_SITE_KEYS, so it wins the tie.
expect(nearestSiteAt(50, 81)).toBe("ABDOMEN_UPPER_LEFT");
});
});
describe("describeInjectionSite()", () => {
it("returns the right i18n key per site", () => {
expect(describeInjectionSite("ABDOMEN_LEFT")).toBe(
+38
View File
@@ -56,6 +56,44 @@ export const SITE_COORDS: Record<InjectionSiteKey, { x: number; y: number }> = {
UPPER_ARM_RIGHT: { x: 73.8, y: 72 },
};
/**
* v1.9.0 — resolve the injection site whose `SITE_COORDS` centre is
* nearest to a point `(x, y)` expressed in the picker's `100 x 200`
* viewBox space. This is the deterministic "nearest-centre wins" rule
* the picker overlay relies on: a single transparent overlay catches
* the pointer, projects the tap into viewBox space, and resolves it to
* the closest site — so two visually-stacked dots (the abdomen
* quadrants sit only Δy=14u apart) can never mis-route to the wrong
* quadrant the way overlapping per-dot hit-circles did.
*
* `allowed` constrains the candidate pool exactly like
* {@link nextInjectionSite}: `undefined` considers all eight sites; a
* non-empty array restricts to those sites; an explicitly-empty array
* yields `null` (nothing selectable). Ties (equidistant centres) break
* toward the earlier site in canonical `INJECTION_SITE_KEYS` order.
*/
export function nearestSiteAt(
x: number,
y: number,
allowed?: ReadonlyArray<InjectionSiteKey>,
): InjectionSiteKey | null {
const allowedSet = allowed ? new Set(allowed) : null;
let best: InjectionSiteKey | null = null;
let bestDistSq = Infinity;
for (const site of INJECTION_SITE_KEYS) {
if (allowedSet !== null && !allowedSet.has(site)) continue;
const coord = SITE_COORDS[site];
const dx = coord.x - x;
const dy = coord.y - y;
const distSq = dx * dx + dy * dy;
if (distSq < bestDistSq) {
bestDistSq = distSq;
best = site;
}
}
return best;
}
/**
* v1.8.5 — resolve the effective allowed set for a medication given the
* per-medication preference and the user-level global exclusion. The