From 86b95202d42e96547eeeb6d5fc23d4d46f8f0eb2 Mon Sep 17 00:00:00 2001 From: suskozaver Date: Thu, 17 Sep 2026 20:53:11 +0200 Subject: [PATCH] Keep the injection site off doses that were not injected Reported as a box of tablets showing an injection site in the Log. It was, and so was every nasal spray, and every oral or topical dose before them. The form suggests a site the moment a compound is chosen, so that rotation happens by default, and then wrote that suggestion into the record whatever route the dose turned out to take. The field was hidden for a pack and for a spray, which made the screen look right and left the data wrong. Those records also counted: a swallowed tablet consumed a site's rest and pushed the next real injection somewhere else. routeHasSite names the rule, in calc/sites.ts where the rotation lives. Subcutaneous and intramuscular have a site. Oral, intranasal, topical and intravenous do not, because lipohypertrophy is what this module is about and none of them cause it. Four call sites, which is three more than it looks. The form hides the field and the map and writes no site. The quick log on Today writes none and offers no sites to rotate to. siteUsage ignores a record whose route has no site, which corrects the rotation for history already written without editing anyone's records. The Log and the history list stop printing one. That third is why this is not just a validation fix. Records already carry a site they should never have had. Filtering at the point of reading leaves the record as written, which is the honest thing to keep, and stops it counting, which is the honest thing to do with it. Two smaller things fall out. A compound that comes as tablets now offers oral among its routes, on the same reasoning that a filled spray bottle offers intranasal: evidence rather than permission. And a dose with no protocol defaults to oral for a tablet rather than to subcutaneous, which is what was feeding the wrong site to begin with. --- document/05-decisions.md | 36 ++++++++++++ src/app/log/page.tsx | 12 +++- src/app/page.tsx | 10 +++- src/app/plan/page.tsx | 4 +- src/components/HistoryWithoutPlan.tsx | 3 +- src/components/LogDoseSheet.tsx | 46 +++++++++++----- src/components/SiteMap.tsx | 2 +- src/lib/calc/sites.test.ts | 79 +++++++++++++++++++++++---- src/lib/calc/sites.ts | 39 +++++++++++-- src/lib/calc/spray.ts | 28 +++++++++- 10 files changed, 217 insertions(+), 42 deletions(-) diff --git a/document/05-decisions.md b/document/05-decisions.md index dc73f70..40e7cdf 100755 --- a/document/05-decisions.md +++ b/document/05-decisions.md @@ -426,3 +426,39 @@ so the only date a pack carries is the manufacturer's. The rule: **when an existing value already means the general thing, widen the comment, not the union.** A new state earns its place when something filters on it differently, and nothing here does. + +## A site is a fact about an injection, not about a dose + +Reported as a box of tablets showing an injection site in the Log. It was, and +so was every nasal spray, and every oral or topical dose before them. + +The form suggested a site the moment a compound was chosen, to make rotation +happen by default, and the suggestion was then written into the record whatever +route the dose turned out to take. The site field was hidden for a pack and for +a spray, which made the screen look right and left the data wrong. Worse, those +records counted: a swallowed tablet consumed a site's rest and pushed the next +real injection somewhere else. + +`routeHasSite` now names the rule, in `calc/sites.ts` where the rotation lives. +Subcutaneous and intramuscular have a site. Oral, intranasal, topical and +intravenous do not, because lipohypertrophy is what this whole module is about +and none of them cause it. + +It is applied in four places, which is three more than it looks: + +- the form hides the field and the map, and writes no site +- the quick log on Today writes none either, and offers no sites to rotate to +- `siteUsage` ignores a record whose route has no site, so the rotation is + corrected for history already written, without editing anyone's records +- the Log and the history list do not print one + +That third bullet is the reason this is not simply a validation fix. Thousands +of records carry a site they should never have had. Filtering at the point of +reading leaves the record as it was written, which is the honest thing to keep, +and stops it being counted, which is the honest thing to do with it. + +Two smaller things fall out. A compound that comes as tablets now offers oral +among its routes, on the same reasoning that a filled spray bottle offers +intranasal: evidence rather than permission. And a dose with no protocol +defaults to oral for a tablet rather than to subcutaneous, since the default +route was the thing feeding the wrong site in the first place. diff --git a/src/app/log/page.tsx b/src/app/log/page.tsx index 9065acf..fce183b 100755 --- a/src/app/log/page.tsx +++ b/src/app/log/page.tsx @@ -21,7 +21,7 @@ import { findPeptide, useStore, useProfileData } from "@/lib/store"; import { assignColors, colorSubjects, doseColor } from "@/lib/calc/palette"; import { adherence, logsForProtocol } from "@/lib/calc/schedule"; import { diaryDays, ratableDay } from "@/lib/calc/checkins"; -import { overusedSites } from "@/lib/calc/sites"; +import { overusedSites, routeHasSite } from "@/lib/calc/sites"; import { formatDate, formatDose, formatDateTime, formatTime, percent, siteLabel, toDateInput, fromDateInput, trim } from "@/lib/format"; import { FEELING_TONE, lowestRatedTone, ratingTone } from "@/lib/calc/feeling"; import { @@ -149,7 +149,7 @@ export default function LogPage() { )} - {logs.some((l) => l.site) && ( + {logs.some((l) => l.site && routeHasSite(l.route)) && ( {t("log_site_rotation")} @@ -251,7 +251,13 @@ export default function LogPage() { {entries.map((l) => { const p = findPeptide(custom, l.peptideId); const color = doseColor(palette, l); - const site = l.site ? siteLabel(l.site) : undefined; + /* + Only for a dose that went into tissue. Records written + before the form was corrected carry a site whatever the + route was, and a box of tablets reading "left abdomen" is + the complaint this answers. + */ + const site = l.site && routeHasSite(l.route) ? siteLabel(l.site) : undefined; return ( l.peptideId === track.protocol.peptideId), diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 7c616b4..a15a7df 100755 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -510,8 +510,8 @@ function ProtocolForm({ * compound exists. Evidence rather than permission. */ const routes = useMemo( - () => routeChoices(peptide?.routes ?? [], vials, peptideId), - [peptide?.routes, vials, peptideId]); + () => routeChoices(peptide?.routes ?? [], vials, peptideId, peptide?.preparation), + [peptide?.routes, peptide?.preparation, vials, peptideId]); const [route, setRoute] = useState(initial?.route ?? "subcutaneous"); diff --git a/src/components/HistoryWithoutPlan.tsx b/src/components/HistoryWithoutPlan.tsx index 92de044..f900e06 100755 --- a/src/components/HistoryWithoutPlan.tsx +++ b/src/components/HistoryWithoutPlan.tsx @@ -6,6 +6,7 @@ import { Badge, Button, ButtonLink, Card, EmptyState, SectionLabel, TONE_BG } fr import { findPeptide, useProfileData, useStore } from "@/lib/store"; import { inferAllProtocols, type InferredProtocol } from "@/lib/calc/infer"; import { formatDate, formatDose, siteLabel } from "@/lib/format"; +import { routeHasSite } from "@/lib/calc/sites"; import { useLang } from "@/lib/i18n"; /** @@ -143,7 +144,7 @@ export function HistoryWithoutPlan({ nowMs }: { nowMs: number }) { {formatDose(l.doseMcg)} {peptide?.name ?? l.peptideId} {formatDate(l.at)} - {l.site && ( + {l.site && routeHasSite(l.route) && ( {siteLabel(l.site)} diff --git a/src/components/LogDoseSheet.tsx b/src/components/LogDoseSheet.tsx index a4bf822..a1df027 100755 --- a/src/components/LogDoseSheet.tsx +++ b/src/components/LogDoseSheet.tsx @@ -22,8 +22,15 @@ import { vialUsable, } from "@/lib/calc/inventory"; import { mcgForTablets, mcgPerTablet, tabletsForDose } from "@/lib/calc/tablet"; -import { mcgForSprays, mcgPerSpray, mlForSprays, routeChoices, spraysForDose } from "@/lib/calc/spray"; -import { suggestSite } from "@/lib/calc/sites"; +import { + defaultRoute, + mcgForSprays, + mcgPerSpray, + mlForSprays, + routeChoices, + spraysForDose, +} from "@/lib/calc/spray"; +import { routeHasSite, suggestSite } from "@/lib/calc/sites"; import { calculateDraw, concentration, @@ -115,7 +122,8 @@ export function LogDoseSheet({ !proto && !pickVialForDose(vials, forPeptideId, dose, atMs, "vial") && !!pickVialForDose(vials, forPeptideId, dose, atMs, "spray"); - const nextRoute = proto?.route ?? (onlySpray ? "intranasal" : "subcutaneous"); + const nextRoute = + proto?.route ?? defaultRoute(findPeptide(custom, forPeptideId)?.preparation, onlySpray); setDoseMcg(dose); setRoute(nextRoute); // A nasal protocol draws from a bottle and never from a vial, and a @@ -223,8 +231,15 @@ export function LogDoseSheet({ * therefore chosen by the preparation rather than by the route. */ const tablets = peptide?.preparation === "tablet"; - /** No barrel, no marks, no injection site. */ + /** No barrel, no marks. */ const noBarrel = nasal || tablets; + /** + * Whether this dose goes into tissue, which is what a site is about. + * + * Not the same test as `noBarrel`, close as the two look: an oral solution + * is drawn up with a syringe and swallowed, so it has a barrel and no site. + */ + const hasSite = routeHasSite(route); const container = containerForDose(peptide?.preparation, route); /* @@ -234,8 +249,8 @@ export function LogDoseSheet({ * never on offer. */ const routes = useMemo( - () => routeChoices(peptide?.routes ?? [], vials, peptideId), - [peptide?.routes, vials, peptideId]); + () => routeChoices(peptide?.routes ?? [], vials, peptideId, peptide?.preparation), + [peptide?.routes, peptide?.preparation, vials, peptideId]); // Sites pinned to this protocol, if any were chosen when it was set up. // Memoised so the identity is stable across renders, it feeds hook deps. @@ -411,7 +426,9 @@ export function LogDoseSheet({ at, doseMcg, route, - site: site || undefined, + // A dose that went nowhere near tissue carries no site, whatever the + // form had suggested before the route was settled. + site: (hasSite && site) || undefined, vialId: vialId || undefined, volumeMl: draw?.volumeRoundedMl, units: draw?.unitsRounded, @@ -436,7 +453,7 @@ export function LogDoseSheet({ at, doseMcg, route, - site: site || undefined, + site: (hasSite && site) || undefined, vialId: vialId || undefined, volumeMl: draw?.volumeRoundedMl, units: draw?.unitsRounded, @@ -702,7 +719,7 @@ export function LogDoseSheet({ - {!noBarrel && ( + {hasSite && ( {/* - Nothing about rotation applies to a nose. Repeatedly injecting one - spot builds tissue that absorbs erratically, which is the whole - reason this map exists; a nose has no such problem, and asking which - nostril would invite a record nobody can act on. + Nothing about rotation applies to a nose, a mouth or a skin cream. + Repeatedly injecting one spot builds tissue that absorbs + erratically, which is the whole reason this map exists; none of the + other routes does that, and asking which nostril, or which side a + tablet went down, would invite a record nobody can act on. */} - {!noBarrel && ( + {hasSite && (
[]; + logs: Pick[]; selected?: InjectionSite | "" | InjectionSite[]; onSelect?: (site: InjectionSite) => void; restDays?: number; diff --git a/src/lib/calc/sites.test.ts b/src/lib/calc/sites.test.ts index a03a107..28e9bf6 100755 --- a/src/lib/calc/sites.test.ts +++ b/src/lib/calc/sites.test.ts @@ -1,11 +1,27 @@ import { describe, expect, it } from "vitest"; -import { BODY, DAY, SITE_DOTS, overusedSites, siteChoices, siteUsage, suggestSite } from "./sites"; +import { + BODY, + DAY, + SITE_DOTS, + overusedSites, + routeHasSite, + siteChoices, + siteUsage, + suggestSite, +} from "./sites"; import { INJECTION_SITES, type DoseLog, type InjectionSite } from "../types"; const NOW = Date.UTC(2026, 6, 29, 12, 0, 0); -const log = (site: InjectionSite, daysAgo: number, skipped = false) => - ({ at: NOW - daysAgo * DAY, site, skipped }) as Pick; +type SiteLog = Pick; + +/* Subcutaneous unless a test says otherwise, since that is what a site is for. */ +const log = ( + site: InjectionSite, + daysAgo: number, + skipped = false, + route: DoseLog["route"] = "subcutaneous") => + ({ at: NOW - daysAgo * DAY, site, skipped, route }) as SiteLog; describe("siteUsage", () => { it("covers every known site even with no history", () => { @@ -43,7 +59,7 @@ describe("siteUsage", () => { }); it("ignores logs with no site recorded", () => { - const u = siteUsage([{ at: NOW, skipped: false }], NOW); + const u = siteUsage([{ at: NOW, skipped: false, route: "subcutaneous" }], NOW); expect(u.every((s) => s.lastUsedAt === null)).toBe(true); }); @@ -91,12 +107,12 @@ describe("suggestSite", () => { }); it("produces a full rotation before repeating", () => { - const logs: Pick[] = []; + const logs: SiteLog[] = []; const picked: InjectionSite[] = []; for (let i = 0; i < INJECTION_SITES.length; i++) { const site = suggestSite(logs, NOW + i * 1000); picked.push(site); - logs.push({ at: NOW + i * 1000, site, skipped: false }); + logs.push({ at: NOW + i * 1000, site, skipped: false, route: "subcutaneous" }); } expect(new Set(picked).size).toBe(INJECTION_SITES.length); }); @@ -112,11 +128,11 @@ describe("siteChoices", () => { * nobody notices until a rotation has gone wrong for a month. */ it("offers the suggested site first, whatever the logs look like", () => { - const logs: Pick[] = []; + const logs: SiteLog[] = []; for (let i = 0; i < 12; i++) { const at = NOW + i * DAY; expect(siteChoices(logs, at, 14, pinned)[0].site).toBe(suggestSite(logs, at, 14, pinned)); - logs.push({ at, site: suggestSite(logs, at, 14, pinned), skipped: false }); + logs.push({ at, site: suggestSite(logs, at, 14, pinned), skipped: false, route: "subcutaneous" }); } }); @@ -182,12 +198,12 @@ describe("suggestSite with a pinned set", () => { }); it("rotates through the pinned sites in turn", () => { - const logs: Pick[] = []; + const logs: SiteLog[] = []; const picked: InjectionSite[] = []; for (let i = 0; i < pinned.length; i++) { const s = suggestSite(logs, NOW + i * 1000, 14, pinned); picked.push(s); - logs.push({ at: NOW + i * 1000, site: s, skipped: false }); + logs.push({ at: NOW + i * 1000, site: s, skipped: false, route: "subcutaneous" }); } expect(new Set(picked).size).toBe(pinned.length); }); @@ -334,3 +350,46 @@ describe("the site map", () => { } }); }); + +/* + * A site is a fact about an injection. Until the form was corrected it wrote + * whichever site it had suggested onto every dose, so a swallowed tablet and a + * nasal spray both landed on a thigh, and both counted against a rotation they + * had never touched. + */ +describe("routeHasSite", () => { + it("is true for the two routes that put a needle in tissue", () => { + expect(routeHasSite("subcutaneous")).toBe(true); + expect(routeHasSite("intramuscular")).toBe(true); + }); + + it("is false for everything else", () => { + expect(routeHasSite("oral")).toBe(false); + expect(routeHasSite("intranasal")).toBe(false); + expect(routeHasSite("topical")).toBe(false); + expect(routeHasSite("intravenous")).toBe(false); + }); +}); + +describe("siteUsage and a route that has no site", () => { + it("ignores a record that carries a site it should never have had", () => { + const logs = [log("thigh-l", 1, false, "oral"), log("thigh-l", 2, false, "intranasal")]; + const thigh = siteUsage(logs, NOW).find((s) => s.site === "thigh-l")!; + expect(thigh.lastUsedAt).toBeNull(); + expect(thigh.recentCount).toBe(0); + }); + + it("still counts the injections beside them", () => { + const logs = [log("thigh-l", 1, false, "oral"), log("thigh-l", 2)]; + const thigh = siteUsage(logs, NOW).find((s) => s.site === "thigh-l")!; + expect(thigh.recentCount).toBe(1); + expect(thigh.daysSince).toBeCloseTo(2, 6); + }); + + /* So a swallowed tablet cannot push the rotation off a site that is rested. */ + it("does not let a swallowed dose steer the suggestion", () => { + const every = INJECTION_SITES.map(({ id }) => log(id, 1)); + const fresh = every.filter((l) => l.site !== "thigh-l"); + expect(suggestSite([...fresh, log("thigh-l", 0, false, "oral")], NOW)).toBe("thigh-l"); + }); +}); diff --git a/src/lib/calc/sites.ts b/src/lib/calc/sites.ts index 3edc9fd..a2ddc18 100755 --- a/src/lib/calc/sites.ts +++ b/src/lib/calc/sites.ts @@ -8,10 +8,29 @@ * memory. */ -import { INJECTION_SITES, type DoseLog, type InjectionSite } from "../types"; +import { INJECTION_SITES, type DoseLog, type InjectionSite, type Route } from "../types"; export const DAY = 86_400_000; +/** + * Routes that put a needle into tissue, and so have a site to rotate. + * + * The whole of this module exists because of lipohypertrophy, which is what + * repeated injections into one spot do to that spot. Nothing else in the list + * of routes does that: a tablet is swallowed, a spray goes up a nose, a cream + * is rubbed in, and an intravenous dose goes into a vein rather than into the + * areas this module knows about. + * + * So the site is a fact about an injection, not about a dose. It was written + * onto every dose regardless, which showed a box of tablets as having been + * given in the left abdomen, on a rotation it was also quietly consuming. + */ +const INJECTED_ROUTES: Route[] = ["subcutaneous", "intramuscular"]; + +export function routeHasSite(route: Route): boolean { + return INJECTED_ROUTES.includes(route); +} + /** * The body figure the map is drawn on, in its own viewBox units. * @@ -102,11 +121,19 @@ export interface SiteUsage { * counts as fully recovered. */ export function siteUsage( - logs: Pick[], + logs: Pick[], nowMs: number, restDays = 14): SiteUsage[] { const windowStart = nowMs - restDays * DAY; - const relevant = logs.filter((l) => !l.skipped && l.site); + /* + * A record can carry a site it had no business carrying: until the form was + * corrected it wrote whichever site it had suggested onto every dose, so a + * swallowed tablet and a nasal spray both landed on a thigh. Filtering here + * rather than only at the point of writing means the old records stop + * counting against a rotation they never touched, without editing anyone's + * history to make it so. + */ + const relevant = logs.filter((l) => !l.skipped && l.site && routeHasSite(l.route)); return INJECTION_SITES.map(({ id }) => { const uses = relevant.filter((l) => l.site === id).map((l) => l.at); @@ -131,7 +158,7 @@ export function siteUsage( * hit three times last week loses to one hit once, even at equal rest. */ export function suggestSite( - logs: Pick[], + logs: Pick[], nowMs: number, restDays = 14, allowed?: InjectionSite[] | null): InjectionSite { @@ -157,7 +184,7 @@ export function suggestSite( * screen offers a way through to all of them for the rest. */ export function siteChoices( - logs: Pick[], + logs: Pick[], nowMs: number, restDays = 14, allowed?: InjectionSite[] | null, @@ -173,7 +200,7 @@ export function siteChoices( /** Sites hit hard enough recently that they are worth resting. */ export function overusedSites( - logs: Pick[], + logs: Pick[], nowMs: number, restDays = 14, threshold = 3): SiteUsage[] { diff --git a/src/lib/calc/spray.ts b/src/lib/calc/spray.ts index deabd72..608cee9 100644 --- a/src/lib/calc/spray.ts +++ b/src/lib/calc/spray.ts @@ -22,7 +22,7 @@ * dose, and no arithmetic can know when a pump has stopped lifting liquid. */ -import type { DiluentKind, Route, Vial } from "../types"; +import type { DiluentKind, Peptide, Route, Vial } from "../types"; import { MCG_PER_MG, vialConcentration, vialRemainingMcg, vialRemainingMl } from "./inventory"; /** What a pump delivers when nobody has measured it. */ @@ -196,9 +196,33 @@ export const salineForTransfer = (addedMl: number) => Math.max(0, addedMl); export function routeChoices( declared: Route[], vials: Pick[], - peptideId: string): Route[] { + peptideId: string, + preparation?: Peptide["preparation"]): Route[] { const hasSpray = vials.some((v) => v.peptideId === peptideId && isSpray(v)); const out = declared.length ? [...declared] : (["subcutaneous"] as Route[]); if (hasSpray && !out.includes("intranasal")) out.push("intranasal"); + /* + * And a compound that comes as tablets adds oral, on the same reasoning one + * step earlier. The evidence here is the preparation rather than a bottle on + * the shelf, because a tablet is swallowed whatever the library remembered + * to list, and without this the only route on offer for a pack could be one + * that involves a needle. + */ + if (preparation === "tablet" && !out.includes("oral")) out.push("oral"); return out; } + +/** + * The route a dose starts on when no protocol says. + * + * A protocol answers this whenever there is one. Without one the app has to + * guess, and the guess used to be subcutaneous for everything that was not a + * spray. That put a pack of tablets in front of somebody as an injection, + * which then wrote an injection site onto a dose that was swallowed. + */ +export function defaultRoute( + preparation: Peptide["preparation"], + onlySpray: boolean): Route { + if (onlySpray) return "intranasal"; + return preparation === "tablet" ? "oral" : "subcutaneous"; +}