Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions document/05-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 9 additions & 3 deletions src/app/log/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -149,7 +149,7 @@ export default function LogPage() {
</Card>
)}

{logs.some((l) => l.site) && (
{logs.some((l) => l.site && routeHasSite(l.route)) && (
<Card className="p-4">
<SectionLabel>{t("log_site_rotation")}</SectionLabel>
<SiteMap logs={shown} nowMs={now} />
Expand Down Expand Up @@ -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 (
<Card
key={l.id}
Expand Down
10 changes: 7 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
unloggedDoseTimes,
} from "@/lib/calc/schedule";
import { daysOfSupplyForProtocol, vialConcentration } from "@/lib/calc/inventory";
import { siteChoices, suggestSite } from "@/lib/calc/sites";
import { routeHasSite, siteChoices, suggestSite } from "@/lib/calc/sites";
import {
currentStreak,
recentDays,
Expand Down Expand Up @@ -197,7 +197,9 @@ export default function NowPage() {
at: Date.now(),
doseMcg,
route: protocol.route,
site,
// One guard for both callers. A site is a fact about an injection, and
// the suggestion is worked out before the route is looked at.
site: routeHasSite(protocol.route) ? site : undefined,
});
setLastQuickLog({ id, name });
},
Expand Down Expand Up @@ -509,7 +511,9 @@ export default function NowPage() {
* what lets the row name the site it is about to write.
*/
const choices =
track.protocol.route === "intranasal"
// Every route that does not put a needle in tissue, not just the
// nose. A tablet was offering a thigh to rotate to.
!routeHasSite(track.protocol.route)
? []
: siteChoices(
logs.filter((l) => l.peptideId === track.protocol.peptideId),
Expand Down
4 changes: 2 additions & 2 deletions src/app/plan/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Route>(initial?.route ?? "subcutaneous");

Expand Down
3 changes: 2 additions & 1 deletion src/components/HistoryWithoutPlan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -143,7 +144,7 @@ export function HistoryWithoutPlan({ nowMs }: { nowMs: number }) {
<span className="font-bold text-[var(--ink)]">{formatDose(l.doseMcg)}</span>
<span className="text-[var(--ink)]">{peptide?.name ?? l.peptideId}</span>
<span className="text-[var(--muted)]">{formatDate(l.at)}</span>
{l.site && (
{l.site && routeHasSite(l.route) && (
<span className="text-[var(--faint)]">
{siteLabel(l.site)}
</span>
Expand Down
46 changes: 32 additions & 14 deletions src/components/LogDoseSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

/*
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -702,7 +719,7 @@ export function LogDoseSheet({
</Select>
</Field>

{!noBarrel && (
{hasSite && (
<Field
label={t("log_site_short")}
hint={
Expand All @@ -724,12 +741,13 @@ export function LogDoseSheet({
</div>

{/*
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 && (
<div>
<SiteMap
logs={peptideLogs}
Expand Down
2 changes: 1 addition & 1 deletion src/components/SiteMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function SiteMap({
multi,
legend: showLegend = true,
}: {
logs: Pick<DoseLog, "at" | "site" | "skipped">[];
logs: Pick<DoseLog, "at" | "site" | "skipped" | "route">[];
selected?: InjectionSite | "" | InjectionSite[];
onSelect?: (site: InjectionSite) => void;
restDays?: number;
Expand Down
79 changes: 69 additions & 10 deletions src/lib/calc/sites.test.ts
Original file line number Diff line number Diff line change
@@ -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<DoseLog, "at" | "site" | "skipped">;
type SiteLog = Pick<DoseLog, "at" | "site" | "skipped" | "route">;

/* 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", () => {
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -91,12 +107,12 @@ describe("suggestSite", () => {
});

it("produces a full rotation before repeating", () => {
const logs: Pick<DoseLog, "at" | "site" | "skipped">[] = [];
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);
});
Expand All @@ -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<DoseLog, "at" | "site" | "skipped">[] = [];
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" });
}
});

Expand Down Expand Up @@ -182,12 +198,12 @@ describe("suggestSite with a pinned set", () => {
});

it("rotates through the pinned sites in turn", () => {
const logs: Pick<DoseLog, "at" | "site" | "skipped">[] = [];
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);
});
Expand Down Expand Up @@ -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");
});
});
Loading
Loading