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
23 changes: 23 additions & 0 deletions document/06-traps.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,3 +695,26 @@ sentence has both an `n` and an amount, the amount is `formatDose`.
Worth checking the neighbours when this shape appears. The log sheet's "n more
doses of {dose} in this vial after this one" was already using `formatDose` and
was correct.

## A rule stated in three places, extended in one

Tablets shipped and the form for logging a dose said **No pack of this in
stock** with the pack sitting on the shelf. The pack was there, the compound
was marked as tablets, the dropdown even listed it.

Which container a dose comes out of was written three times in
`LogDoseSheet.tsx`: once for the list of containers to choose from, once when a
protocol is applied, once when the route is changed by hand. Adding tablets
taught the first one and left the other two saying `route === "intranasal" ?
"spray" : "vial"`. So the list offered the pack and nothing ever selected it,
`vialId` stayed empty, and every sentence downstream of `vial` reported an
empty shelf. The same shape had been survivable for sprays only because a
nasal dose is picked by route, which both copies already knew about.

The rule is now `containerForDose(preparation, route)` in `calc/inventory.ts`,
with a test, and the three call sites ask it.

The general shape: **a rule with three call sites and no name has no place to
add a case to.** When a third value joins a two-value decision, the first thing
to look for is the other spellings of that decision. `grep` for the ternary,
not for the function, because there is no function yet, which is the problem.
46 changes: 35 additions & 11 deletions src/components/LogDoseSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import { isBlend } from "@/lib/calc/blend";
import { allPeptides, findPeptide, useProfileData, useStore, vialStatus } from "@/lib/store";
import { AddCompoundInline } from "./AddCompoundInline";
import {
containerForDose,
matchesContainer,
pickVialForDose,
stockFor,
vialRemainingMcg,
vialUsable,
type ContainerKind,
} from "@/lib/calc/inventory";
import { mcgForTablets, mcgPerTablet, tabletsForDose } from "@/lib/calc/tablet";
import { mcgForSprays, mcgPerSpray, mlForSprays, routeChoices, spraysForDose } from "@/lib/calc/spray";
Expand Down Expand Up @@ -93,6 +93,15 @@ export function LogDoseSheet({
* Shared by both directions of the pair of dropdowns, so picking a protocol
* and picking a peptide that happens to have one cannot drift apart.
*/
/**
* The rule above, asked with a peptide id rather than with a peptide.
*
* These handlers run before the render that computes `container`, so they
* have to ask for themselves. They ask the same function.
*/
const containerFor = (forPeptideId: string, forRoute: Route) =>
containerForDose(findPeptide(custom, forPeptideId)?.preparation, forRoute);

function applyProtocol(proto: Protocol | undefined, forPeptideId: string, atMs: number) {
const dose = proto ? scheduledDoseMcg(proto, atMs) : 0;
/*
Expand All @@ -109,10 +118,11 @@ export function LogDoseSheet({
const nextRoute = proto?.route ?? (onlySpray ? "intranasal" : "subcutaneous");
setDoseMcg(dose);
setRoute(nextRoute);
// A nasal protocol draws from a bottle and never from a vial, so the
// container is decided by the route rather than by whatever is nearest.
// A nasal protocol draws from a bottle and never from a vial, and a
// compound sold as tablets comes out of a pack whatever the route says, so
// the container is decided by the rule rather than by whatever is nearest.
setVialId(
pickVialForDose(vials, forPeptideId, dose, atMs, nextRoute === "intranasal" ? "spray" : "vial")
pickVialForDose(vials, forPeptideId, dose, atMs, containerFor(forPeptideId, nextRoute))
?.id ?? "");
setSiteOverride(false);
setSite(suggestSite(logs.filter((l) => l.peptideId === forPeptideId), atMs, 14, proto?.sites));
Expand All @@ -139,8 +149,8 @@ export function LogDoseSheet({
*/
function chooseRoute(next: Route) {
setRoute(next);
const want = next === "intranasal" ? "spray" : "vial";
setVialId(pickVialForDose(vials, peptideId, doseMcg, at, want)?.id ?? "");
setVialId(
pickVialForDose(vials, peptideId, doseMcg, at, containerFor(peptideId, next))?.id ?? "");
}

/**
Expand Down Expand Up @@ -215,7 +225,7 @@ export function LogDoseSheet({
const tablets = peptide?.preparation === "tablet";
/** No barrel, no marks, no injection site. */
const noBarrel = nasal || tablets;
const container: ContainerKind = nasal ? "spray" : tablets ? "pack" : "vial";
const container = containerForDose(peptide?.preparation, route);

/*
* What the library says, plus intranasal once a spray bottle of this compound
Expand Down Expand Up @@ -574,7 +584,7 @@ export function LogDoseSheet({
carry it out.
*/
!vial
? t("log_no_pack_in_stock")
? t("log_tablets_no_pack")
: mcgPerTablet(vial) > 0
? t("log_per_tablet_hint", { dose: formatDose(mcgPerTablet(vial)) })
: t("log_no_tablet_size")
Expand Down Expand Up @@ -750,9 +760,19 @@ export function LogDoseSheet({

{usableVials.length > 0 ? (
<Field
label={nasal ? t("log_sprayed_from") : t("log_drawn_from")}
/*
Three containers, three sets of words. Nothing is drawn out of
a box of tablets, and the version that said so was one of the
places where the pack had been added to the model without being
added to the sentences around it.
*/
label={nasal ? t("log_sprayed_from") : tablets ? t("log_taken_from") : t("log_drawn_from")}
hint={t("log_vial_hint", {
container: nasal ? t("log_container_bottle") : t("log_container_vial"),
container: nasal
? t("log_container_bottle")
: tablets
? t("log_container_pack")
: t("log_container_vial"),
})}
>
<Select value={vialId} onChange={(e) => setVialId(e.target.value)}>
Expand All @@ -774,7 +794,11 @@ export function LogDoseSheet({
</Field>
) : (
<Callout tone="warn">
{nasal ? t("log_no_spray_filled") : t("log_no_vial_in_stock")}
{nasal
? t("log_no_spray_filled")
: tablets
? t("log_no_pack_in_stock")
: t("log_no_vial_in_stock")}
</Callout>
)}

Expand Down
37 changes: 37 additions & 0 deletions src/lib/calc/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
drawFromVial,
groupSealedVials,
supplyOutlook,
containerForDose,
pickVialForDose,
reconcileVials,
returnToVial,
Expand Down Expand Up @@ -824,3 +825,39 @@ describe("stock held back by a date", () => {
expect(stockFor([finished], "kpv", 250, NOW).dosesExpired).toBe(0);
});
});

/*
* The rule that decides where a dose comes from. It lived in the form, written
* out three times, and one of those copies was the only one that had heard of
* tablets: a compound sold as tablets therefore had nothing selected and the
* screen said no pack was in stock while the pack sat on the shelf.
*/
describe("containerForDose", () => {
it("sends an injection to a vial", () => {
expect(containerForDose(undefined, "subcutaneous")).toBe("vial");
expect(containerForDose("powder", "intramuscular")).toBe("vial");
expect(containerForDose("solution", "subcutaneous")).toBe("vial");
});

it("sends a tablet to a pack", () => {
expect(containerForDose("tablet", "oral")).toBe("pack");
expect(containerForDose("tablet", "subcutaneous")).toBe("pack");
});

/*
* Oral does not mean tablet. A solution somebody swallows out of a syringe
* is oral and still comes out of a vial, which is why the preparation and
* not the route is what decides here.
*/
it("leaves an oral solution in its vial", () => {
expect(containerForDose("solution", "oral")).toBe("vial");
expect(containerForDose(undefined, "oral")).toBe("vial");
});

/* The route wins over the preparation, not the other way round. */
it("sends a nasal dose to a bottle whatever the library says", () => {
expect(containerForDose(undefined, "intranasal")).toBe("spray");
expect(containerForDose("powder", "intranasal")).toBe("spray");
expect(containerForDose("tablet", "intranasal")).toBe("spray");
});
});
23 changes: 22 additions & 1 deletion src/lib/calc/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* derived from it for display.
*/

import type { Protocol, Vial, VialState } from "../types";
import type { Peptide, Protocol, Route, Vial, VialState } from "../types";
import { DAY_MS, protocolDoseTimesBetween, scheduledDoseMcg } from "./schedule";
import { unitsFromDose, type SyringeScale } from "./reconstitution";

Expand Down Expand Up @@ -83,6 +83,27 @@ export function matchesContainer(v: Pick<Vial, "container">, want: ContainerKind
return (v.container ?? "vial") === want;
}

/**
* Which container a dose of this compound comes out of.
*
* Here rather than in the form because the form asked it in three places and
* only one of them was taught about tablets. The two that were not went on
* asking for a vial, so a compound sold as tablets had nothing selected and
* the screen said there was no pack in stock while the pack sat on the shelf.
* One rule, one place, one test.
*
* The route decides first and the preparation second, which is the order the
* two facts deserve: a nasal dose comes out of a bottle whatever the library
* says the compound is, while "oral" covers a solution somebody swallows out
* of a syringe as well as a tablet, so it cannot decide anything on its own.
*/
export function containerForDose(
preparation: Peptide["preparation"],
route: Route): ContainerKind {
if (route === "intranasal") return "spray";
return preparation === "tablet" ? "pack" : "vial";
}

/**
* Which vial a dose should come out of.
*
Expand Down
20 changes: 16 additions & 4 deletions src/lib/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export const TRANSLATIONS = {
log_delete_returns: "Deleting this dose puts {dose} back into its vial.",
log_no_spray_filled: "No nasal spray of this peptide is filled, so this dose will be logged without drawing anything down. Make up a vial under Stock and transfer it into a bottle to keep the count accurate.",
log_no_vial_in_stock: "No vial of this peptide is in stock, so this dose will be logged without drawing anything down. Add one under Stock to keep the count accurate.",
log_no_pack_in_stock: "No pack of this compound is in stock, so this dose will be logged without counting anything down. Add one under Stock to keep the count accurate.",
checkin_save_count: "Save {rated} of {total}",
checkin_save_blank: "Save blank",
log_press_count_one: "{n} press",
Expand All @@ -251,6 +252,7 @@ export const TRANSLATIONS = {
log_site_short: "Site",
log_container_vial: "vial",
log_container_bottle: "bottle",
log_container_pack: "pack",
log_vial_hint: "This dose comes off the chosen {container}. Set it to \u201cNot recorded\u201d to log without touching stock.",
log_remaining_in_vial: "{left} remaining in the vial",
log_across_your_stock: "across all your stock",
Expand Down Expand Up @@ -353,7 +355,7 @@ export const TRANSLATIONS = {
log_tablets_suffix: "tablets",
log_per_tablet_hint: "{dose} a tablet.",
log_no_tablet_size: "Set the tablet size on this pack in Stock.",
log_no_pack_in_stock: "No pack of this in stock, so there is nothing to count out of.",
log_tablets_no_pack: "No pack of this in stock, so there is nothing to count out of.",
log_per_press_hint: "{dose} a press, {ml} mL in total.",
log_na: "n/a",
count_tablets_one: "{n} tablet",
Expand Down Expand Up @@ -1227,6 +1229,7 @@ export const TRANSLATIONS = {
log_use_any_site: "Injected somewhere else? Use any site",
log_sprayed_from: "Sprayed from",
log_drawn_from: "Drawn from",
log_taken_from: "Taken from",
log_save_changes: "Save changes",
log_record_skip: "Record skip",
log_save_dose: "Save dose",
Expand Down Expand Up @@ -1649,6 +1652,7 @@ export const TRANSLATIONS = {
log_delete_returns: "Diese Dosis zu löschen gibt {dose} in ihre Ampulle zurück.",
log_no_spray_filled: "Kein Nasenspray dieses Peptids ist befüllt, diese Dosis wird also eingetragen, ohne etwas abzuziehen. Setze unter Bestand eine Ampulle an und fülle sie in eine Flasche um, damit die Zählung stimmt.",
log_no_vial_in_stock: "Keine Ampulle dieses Peptids ist im Bestand, diese Dosis wird also eingetragen, ohne etwas abzuziehen. Lege eine unter Bestand an, damit die Zählung stimmt.",
log_no_pack_in_stock: "Keine Packung dieses Wirkstoffs ist im Vorrat, diese Dosis wird also eingetragen, ohne etwas abzuzählen. Lege eine unter Vorrat an, damit die Zählung stimmt.",
checkin_save_count: "{rated} von {total} speichern",
checkin_save_blank: "Leer speichern",
log_press_count_one: "{n} Sprühstoß",
Expand All @@ -1665,6 +1669,7 @@ export const TRANSLATIONS = {
log_site_short: "Stelle",
log_container_vial: "Ampulle",
log_container_bottle: "Flasche",
log_container_pack: "Packung",
log_vial_hint: "Diese Dosis geht von der gewählten {container} ab. Auf \u201eNicht erfasst\u201c stellen, um ohne Bestandsänderung einzutragen.",
log_remaining_in_vial: "{left} in der Ampulle übrig",
log_across_your_stock: "über deinen gesamten Bestand",
Expand Down Expand Up @@ -1767,7 +1772,7 @@ export const TRANSLATIONS = {
log_tablets_suffix: "Tabletten",
log_per_tablet_hint: "{dose} pro Tablette.",
log_no_tablet_size: "Trag die Tablettengröße dieser Packung unter Vorrat ein.",
log_no_pack_in_stock: "Keine Packung davon im Vorrat, also gibt es nichts abzuzählen.",
log_tablets_no_pack: "Keine Packung davon im Vorrat, also gibt es nichts abzuzählen.",
log_per_press_hint: "{dose} pro Sprühstoß, {ml} mL insgesamt.",
log_na: "k.A.",
count_tablets_one: "{n} Tablette",
Expand Down Expand Up @@ -2636,6 +2641,7 @@ export const TRANSLATIONS = {
log_use_any_site: "Woanders gespritzt? Beliebige Stelle wählen",
log_sprayed_from: "Gesprüht aus",
log_drawn_from: "Gezogen aus",
log_taken_from: "Genommen aus",
log_save_changes: "Änderungen speichern",
log_record_skip: "Auslassen erfassen",
log_save_dose: "Dosis speichern",
Expand Down Expand Up @@ -3074,6 +3080,7 @@ export const TRANSLATIONS = {
log_delete_returns: "Izbris te doze vrne {dose} nazaj v njeno vialko.",
log_no_spray_filled: "Nobeno nosno pršilo tega peptida ni napolnjeno, zato bo ta doza zabeležena, ne da bi se kaj odštelo. Pripravi vialko pod Zaloga in jo prelij v steklenico, da bo štetje točno.",
log_no_vial_in_stock: "Nobene vialke tega peptida ni v zalogi, zato bo ta doza zabeležena, ne da bi se kaj odštelo. Dodaj jo pod Zaloga, da bo štetje točno.",
log_no_pack_in_stock: "Nobene škatlice te spojine ni v zalogi, zato bo ta doza zabeležena, ne da bi se kaj odštelo. Dodaj jo pod Zaloga, da bo štetje točno.",
checkin_save_count: "Shrani {rated} od {total}",
checkin_save_blank: "Shrani prazno",
log_press_count_one: "{n} potisk",
Expand All @@ -3092,6 +3099,7 @@ export const TRANSLATIONS = {
log_site_short: "Mesto",
log_container_vial: "vialke",
log_container_bottle: "steklenice",
log_container_pack: "škatlice",
log_vial_hint: "Ta doza gre z izbrane {container}. Nastavi na \u00bbNi zabeleženo\u00ab za vpis brez spreminjanja zaloge.",
log_remaining_in_vial: "{left} ostane v vialki",
log_across_your_stock: "čez vso tvojo zalogo",
Expand Down Expand Up @@ -3196,7 +3204,7 @@ export const TRANSLATIONS = {
log_tablets_suffix: "tablet",
log_per_tablet_hint: "{dose} na tableto.",
log_no_tablet_size: "Velikost tablete za to škatlico vpiši v razdelku Zaloga.",
log_no_pack_in_stock: "Te škatlice ni v zalogi, zato ni iz česa šteti.",
log_tablets_no_pack: "Te škatlice ni v zalogi, zato ni iz česa šteti.",
log_per_press_hint: "{dose} na pritisk, skupaj {ml} mL.",
log_na: "ni podatka",
count_tablets_one: "{n} tableta",
Expand Down Expand Up @@ -4119,6 +4127,7 @@ export const TRANSLATIONS = {
log_use_any_site: "Vbrizgal drugam? Uporabi katero koli mesto",
log_sprayed_from: "Popršeno iz",
log_drawn_from: "Potegnjeno iz",
log_taken_from: "Vzeto iz",
log_save_changes: "Shrani spremembe",
log_record_skip: "Zabeleži preskok",
log_save_dose: "Shrani dozo",
Expand Down Expand Up @@ -4573,6 +4582,7 @@ export const TRANSLATIONS = {
log_delete_returns: "Usunięcie tej dawki zwraca {dose} do jej fiolki.",
log_no_spray_filled: "Żaden spray do nosa z tym peptydem nie jest napełniony, więc ta dawka zostanie zapisana bez odejmowania czegokolwiek. Przygotuj fiolkę w Zapasie i przelej ją do butelki, żeby liczenie było dokładne.",
log_no_vial_in_stock: "Żadnej fiolki z tym peptydem nie ma w zapasie, więc ta dawka zostanie zapisana bez odejmowania czegokolwiek. Dodaj ją w Zapasie, żeby liczenie było dokładne.",
log_no_pack_in_stock: "Żadnego opakowania tego związku nie ma w zapasie, więc ta dawka zostanie zapisana bez odliczania czegokolwiek. Dodaj je w Zapasie, żeby liczenie było dokładne.",
checkin_save_count: "Zapisz {rated} z {total}",
checkin_save_blank: "Zapisz pusto",
log_press_count_one: "{n} psiknięcie",
Expand All @@ -4591,6 +4601,7 @@ export const TRANSLATIONS = {
log_site_short: "Miejsce",
log_container_vial: "fiolki",
log_container_bottle: "butelki",
log_container_pack: "opakowania",
log_vial_hint: "Ta dawka schodzi z wybranej {container}. Ustaw na \u201eNie zapisano\u201d, aby zapisać bez ruszania zapasu.",
log_remaining_in_vial: "{left} zostaje w fiolce",
log_across_your_stock: "w całym twoim zapasie",
Expand Down Expand Up @@ -4695,7 +4706,7 @@ export const TRANSLATIONS = {
log_tablets_suffix: "tabletek",
log_per_tablet_hint: "{dose} na tabletkę.",
log_no_tablet_size: "Wielkość tabletki dla tego opakowania podaj w sekcji Zapas.",
log_no_pack_in_stock: "Nie ma tego opakowania w zapasach, więc nie ma z czego liczyć.",
log_tablets_no_pack: "Nie ma tego opakowania w zapasach, więc nie ma z czego liczyć.",
log_per_press_hint: "{dose} na psiknięcie, łącznie {ml} mL.",
log_na: "brak",
count_tablets_one: "{n} tabletka",
Expand Down Expand Up @@ -5618,6 +5629,7 @@ export const TRANSLATIONS = {
log_use_any_site: "Wstrzyknięte gdzie indziej? Użyj dowolnego miejsca",
log_sprayed_from: "Rozpylone z",
log_drawn_from: "Pobrane z",
log_taken_from: "Wzięte z",
log_save_changes: "Zapisz zmiany",
log_record_skip: "Zapisz pominięcie",
log_save_dose: "Zapisz dawkę",
Expand Down
Loading