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