diff --git a/document/05-decisions.md b/document/05-decisions.md index 7d8e4ac..dc73f70 100755 --- a/document/05-decisions.md +++ b/document/05-decisions.md @@ -396,3 +396,33 @@ from a rotation history that no longer describes the body. Nothing would fail loudly. The rule generalises: wherever a default is offered alongside the alternatives to it, the alternatives are the default's own ranking, held to it by a test. + +## "reconstituted" is the state a container in use has, whatever its name + +A pack of tablets that had been dosed from all week sat under Sealed on the +Stock page, because a pack has nothing to reconstitute and reconstitution is +what moves a row out of that section. + +The obvious fix is a fourth state, `"open"`. It was not taken. Every filter in +the app that means "in use" is written as `state === "reconstituted"`, and the +name has already stopped being literal once: a spray bottle is filled, not made +up, and takes that state. What the state records is a position in a sequence, +sealed then in use then finished, and all three containers pass through it. +Adding a value would mean teaching eleven call sites, a migration, and a period +where data written by one build reads wrong in another, all to make one +identifier honest. + +So a pack takes it too, and there are two ways in, because either alone fails a +real person. **Open the pack** is the button, for a box opened before its first +dose is due. The first logged dose opens it anyway, for the box opened three +days ago by somebody who never pressed anything. `openPack` is one function and +both paths call it. + +What a pack does not get is a beyond-use date. A BUD runs from first puncture +because what starts then is a sterile solution sitting at room temperature. +Nothing about a foil strip changes on the day you press the first tablet out, +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. diff --git a/src/app/stock/page.tsx b/src/app/stock/page.tsx index 24801ee..5863cff 100755 --- a/src/app/stock/page.tsx +++ b/src/app/stock/page.tsx @@ -26,6 +26,7 @@ import { diluentAfterTopUp, groupSealedVials, marksFromVial, + openPack, stockFor, supplyOutlook, vialConcentration, @@ -308,10 +309,32 @@ export default function StockPage() { currency={currency} peptideName={findPeptide(custom, v.peptideId)?.name ?? v.peptideId} onRemove={() => removeVial(v.id)} - onTopUp={() => setToppingUp(v.id)} - onTransfer={isSpray(v) ? undefined : () => setTransferring(v.id)} + /* + Neither offer means anything to an opened pack. There is no + solution in it to dilute and nothing to pour into a spray + bottle, so the two actions that assume a liquid stand down + here exactly as reconstitution does in the section above. + */ + onTopUp={isPack(v) ? undefined : () => setToppingUp(v.id)} + onTransfer={isSpray(v) || isPack(v) ? undefined : () => setTransferring(v.id)} onFinish={() => updateVial(v.id, { state: "finished" })} + tabletCompound={findPeptide(custom, v.peptideId)?.preparation === "tablet"} + onSetTabletSize={() => setSizing(v.id)} /> + {sizing === v.id && ( + setSizing(null)} + onSave={(mgEach, tabletsInPack) => { + updateVial(v.id, { + container: "pack", + mgPerTablet: mgEach, + strengthMg: packStrengthMg(mgEach, tabletsInPack), + }); + setSizing(null); + }} + /> + )} {transferring === v.id && ( setReconstituting(v.id)} tabletCompound={findPeptide(custom, v.peptideId)?.preparation === "tablet"} onSetTabletSize={() => setSizing(v.id)} + /* + What Reconstitute is to a vial. A pack needs no water and + records nothing when the foil is broken, so this exists to + say the box is started: the row moves up to Open, where a + box being taken from belongs. The first logged dose does + the same thing on its own, for anyone who never presses it. + */ + onOpenPack={() => updateVial(v.id, openPack(v, Date.now()))} /> {sizing === v.id && ( void; + /** Only for a pack still sealed. Moves it up to Open. */ + onOpenPack?: () => void; }) { const { t } = useLang(); const st = vialStatus(vial, now); @@ -744,6 +778,11 @@ function VialRow({ {t("stock_to_spray")} )} + {onOpenPack && pack && vial.state === "sealed" && ( + + )} {/* Offered for a pack, and also for an ordinary row of a compound the library calls tablets, which is how a row added before anyone said @@ -1738,7 +1777,13 @@ function DiluentShelf() { className="px-2.5 py-1 text-[12px]" onClick={() => openDiluent(b.id)} > - {t("stock_open")} + {/* + Its own key, not the section heading's. In English one + word does both jobs, the button and the label above a + list of opened things. In German it cannot: the heading + is Geöffnet and the button is Öffnen. + */} + {t("stock_open_bottle")} )} {b.state !== "discarded" && bottleRemainingMl(b) > 0 && ( diff --git a/src/lib/calc/inventory.test.ts b/src/lib/calc/inventory.test.ts index 49f77f5..2696ea4 100755 --- a/src/lib/calc/inventory.test.ts +++ b/src/lib/calc/inventory.test.ts @@ -9,6 +9,7 @@ import { groupSealedVials, supplyOutlook, containerForDose, + openPack, pickVialForDose, reconcileVials, returnToVial, @@ -861,3 +862,79 @@ describe("containerForDose", () => { expect(containerForDose("tablet", "intranasal")).toBe("spray"); }); }); + +/* + * A box of tablets that has been dosed from all week sat at the bottom of the + * Stock page under Sealed, which it plainly was not. + */ +describe("opening a pack", () => { + const pack = (over: Partial = {}) => + vial({ id: "p", container: "pack", mgPerTablet: 10, strengthMg: 600, ...over }); + + it("puts it in the state every container in use has", () => { + const out = openPack(pack(), NOW); + expect(out.state).toBe("reconstituted"); + expect(out.reconstitutedAt).toBe(NOW); + }); + + /* A BUD runs from first puncture. Nothing about foil changes on that day. */ + it("gives it no beyond-use date", () => { + expect(openPack(pack(), NOW).budAt).toBeUndefined(); + }); + + it("keeps the day it was first opened", () => { + const out = openPack(pack({ reconstitutedAt: NOW - DAY }), NOW); + expect(out.reconstitutedAt).toBe(NOW - DAY); + }); + + it("opens on the first dose, for anyone who never pressed the button", () => { + const [out] = drawFromVial([pack()], "p", 10_000, NOW); + expect(out.state).toBe("reconstituted"); + expect(out.reconstitutedAt).toBe(NOW); + expect(out.drawnMcg).toBe(10_000); + }); + + /* The dose's own time, so a dose logged for yesterday opens it yesterday. */ + it("opens it when the dose says, not when the clock does", () => { + const [out] = drawFromVial([pack()], "p", 10_000, NOW - 3 * DAY); + expect(out.reconstitutedAt).toBe(NOW - 3 * DAY); + }); + + /* + * The other half. A vial becomes open by being made up, which is a step with + * its own data, and a dose drawn from one that never had that step is an + * oddity worth leaving visible. + */ + it("leaves a sealed vial sealed", () => { + const [out] = drawFromVial([vial({ id: "v" })], "v", 10_000, NOW); + expect(out.state).toBe("sealed"); + expect(out.reconstitutedAt).toBeUndefined(); + }); + + it("still finishes a pack the last dose empties", () => { + const [out] = drawFromVial([pack({ strengthMg: 10 })], "p", 10_000, NOW); + expect(out.state).toBe("finished"); + }); + + /* Undoing that dose does not put the tablets back in the foil. */ + it("reopens a finished pack rather than resealing it", () => { + const emptied = drawFromVial([pack({ strengthMg: 10 })], "p", 10_000, NOW); + const [out] = returnToVial(emptied, "p", 10_000); + expect(out.state).toBe("reconstituted"); + }); + + it("still reseals a finished vial that was never made up", () => { + const emptied = drawFromVial([vial({ id: "v", strengthMg: 10 })], "v", 10_000, NOW); + const [out] = returnToVial(emptied, "v", 10_000); + expect(out.state).toBe("sealed"); + }); + + /* An opened pack is reached for before a sealed one, like every open container. */ + it("is preferred over a sealed pack once open", () => { + const vials = [ + pack({ id: "sealed" }), + { ...openPack(pack({ id: "started" }), NOW), drawnMcg: 100_000 }, + ]; + expect(pickVialForDose(vials, "klow", 10_000, NOW, "pack")?.id).toBe("started"); + }); +}); diff --git a/src/lib/calc/inventory.ts b/src/lib/calc/inventory.ts index 7407126..cec4c3e 100755 --- a/src/lib/calc/inventory.ts +++ b/src/lib/calc/inventory.ts @@ -136,23 +136,54 @@ export function pickVialForDose( return [...candidates].sort(byDeadline)[0]; } +/** + * A pack that has been opened. + * + * "reconstituted" is the state every container in use has, and it has meant + * that rather than its literal self since spray bottles: a bottle is filled, + * not made up, and it takes the same state. A pack is opened. The name is + * historical and the position in the sequence is what it is for, which is why + * this returns that state rather than growing a fourth one that every filter + * in the app would have to learn. + * + * No beyond-use date, unlike a vial. A BUD runs from first puncture, because + * what starts then is a sterile solution sitting at room temperature. Nothing + * about a foil strip changes on the day you press the first tablet out, so the + * only date a pack has is the manufacturer's. + */ +export function openPack(v: Vial, atMs: number): Vial { + return { ...v, state: "reconstituted", reconstitutedAt: v.reconstitutedAt ?? atMs }; +} + /** * Take mass out of a vial. * * Never goes below empty, and marks the vial finished once it is. Returns a * new array; the input is untouched. + * + * A sealed pack is opened on the way. Asked for after a box of tablets that + * had been dosed from all week sat at the bottom of the Stock page under + * Sealed, which it plainly was not. A vial is deliberately left alone: it + * becomes open by being made up, which is a step with its own data, and a + * dose drawn from a vial that never had that step is an oddity worth leaving + * visible rather than tidying away. */ -export function drawFromVial(vials: Vial[], vialId: string, mcg: number): Vial[] { +export function drawFromVial( + vials: Vial[], + vialId: string, + mcg: number, + atMs = Date.now()): Vial[] { if (!(mcg > 0)) return vials; return vials.map((v) => { if (v.id !== vialId) return v; const capacity = vialCapacityMcg(v); const drawnMcg = Math.min(capacity, (v.drawnMcg ?? 0) + mcg); const emptied = drawnMcg >= capacity - 1e-6; + const opened = matchesContainer(v, "pack") && v.state === "sealed" ? openPack(v, atMs) : v; return { - ...v, + ...opened, drawnMcg, - state: emptied && v.state !== "discarded" ? ("finished" as VialState) : v.state, + state: emptied && v.state !== "discarded" ? ("finished" as VialState) : opened.state, }; }); } @@ -166,9 +197,14 @@ export function returnToVial(vials: Vial[], vialId: string, mcg: number): Vial[] return vials.map((v) => { if (v.id !== vialId) return v; const drawnMcg = Math.max(0, (v.drawnMcg ?? 0) - mcg); + /* + * A pack goes back to open rather than to sealed. Undoing the dose that + * emptied it does not put the tablets back in the foil, and the row would + * otherwise reappear under Sealed with a box that is half gone. + */ const state: VialState = v.state === "finished" && drawnMcg < vialCapacityMcg(v) - 1e-6 - ? v.diluentMl + ? v.diluentMl || matchesContainer(v, "pack") ? "reconstituted" : "sealed" : v.state; diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts index fab1e8c..d54a926 100644 --- a/src/lib/i18n/translations.ts +++ b/src/lib/i18n/translations.ts @@ -391,6 +391,8 @@ export const TRANSLATIONS = { stock_no_vials: "Nothing in stock", stock_sealed: "Sealed", stock_open: "Open", + stock_open_bottle: "Open", + stock_open_pack: "Open the pack", stock_on_order: "On order", stock_finished: "Finished", stock_remaining: "Remaining", @@ -1807,6 +1809,8 @@ export const TRANSLATIONS = { stock_no_vials: "Kein Vorrat vorhanden", stock_sealed: "Versiegelt", stock_open: "Ge\u00f6ffnet", + stock_open_bottle: "\u00d6ffnen", + stock_open_pack: "Packung \u00f6ffnen", stock_on_order: "Bestellt", stock_finished: "Aufgebraucht", stock_remaining: "Verbleibend", @@ -3246,7 +3250,9 @@ export const TRANSLATIONS = { stock_add_vial: "Dodaj vialko", stock_no_vials: "Zaloga je prazna", stock_sealed: "Zaprta", - stock_open: "Odpri", + stock_open: "Odprto", + stock_open_bottle: "Odpri", + stock_open_pack: "Odpri \u0161katlico", stock_on_order: "Naročeno", stock_finished: "Porabljena", stock_remaining: "Preostanek", @@ -4748,7 +4754,9 @@ export const TRANSLATIONS = { stock_add_vial: "Dodaj fiolkę", stock_no_vials: "Zapas jest pusty", stock_sealed: "Zaplombowana", - stock_open: "Otwórz", + stock_open: "Otwarte", + stock_open_bottle: "Otwórz", + stock_open_pack: "Otwórz opakowanie", stock_on_order: "Zamówiona", stock_finished: "Zużyta", stock_remaining: "Pozostało", diff --git a/src/lib/store.ts b/src/lib/store.ts index 29e9249..52460a7 100755 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -480,7 +480,9 @@ export const useStore = create()( return { logs: logs.map((x) => (x.id === id ? { ...x, vialId, ...measured } : x)), - vials: drawFromVial(s.vials, vialId, l.doseMcg), + // The dose's own time, not the clock: a pack opened by a dose + // logged for yesterday was opened yesterday. + vials: drawFromVial(s.vials, vialId, l.doseMcg, l.at), }; }); return id;