diff --git a/document/06-traps.md b/document/06-traps.md
index 8d8bf4a..81081a1 100755
--- a/document/06-traps.md
+++ b/document/06-traps.md
@@ -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.
diff --git a/src/components/LogDoseSheet.tsx b/src/components/LogDoseSheet.tsx
index 0576545..a4bf822 100755
--- a/src/components/LogDoseSheet.tsx
+++ b/src/components/LogDoseSheet.tsx
@@ -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";
@@ -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;
/*
@@ -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));
@@ -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 ?? "");
}
/**
@@ -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
@@ -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")
@@ -750,9 +760,19 @@ export function LogDoseSheet({
{usableVials.length > 0 ? (
) : (
- {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")}
)}
diff --git a/src/lib/calc/inventory.test.ts b/src/lib/calc/inventory.test.ts
index ad61582..49f77f5 100755
--- a/src/lib/calc/inventory.test.ts
+++ b/src/lib/calc/inventory.test.ts
@@ -8,6 +8,7 @@ import {
drawFromVial,
groupSealedVials,
supplyOutlook,
+ containerForDose,
pickVialForDose,
reconcileVials,
returnToVial,
@@ -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");
+ });
+});
diff --git a/src/lib/calc/inventory.ts b/src/lib/calc/inventory.ts
index 7de5aa9..7407126 100755
--- a/src/lib/calc/inventory.ts
+++ b/src/lib/calc/inventory.ts
@@ -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";
@@ -83,6 +83,27 @@ export function matchesContainer(v: Pick, 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.
*
diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts
index ade582b..fab1e8c 100644
--- a/src/lib/i18n/translations.ts
+++ b/src/lib/i18n/translations.ts
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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ß",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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ę",