From bfa1618bfb2da6913882fc49892bad9fedf8ed02 Mon Sep 17 00:00:00 2001 From: suskozaver Date: Thu, 17 Sep 2026 20:20:36 +0200 Subject: [PATCH] Move an opened pack up to Open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A box of tablets being taken from every day sat at the bottom of the Stock page under Sealed. It is not sealed. Reconstitution is what moves a row out of that section and a pack has nothing to reconstitute, so nothing ever moved it. A pack now takes the same state every container in use takes. No fourth state: the name stopped being literal when spray bottles arrived, since a bottle is filled rather than made up and takes it too. What the state records is a position in a sequence, sealed then in use then finished, and all three containers pass through it. A new value would mean teaching eleven call sites and a migration to make one identifier honest. 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. One function, openPack, and both paths call it. It takes the dose's own time rather than the clock, so a dose logged for yesterday opens the pack yesterday. No beyond-use date. A BUD runs from first puncture because what starts then is a sterile solution at room temperature, and nothing about a foil strip changes on the day you press the first tablet out. Undoing the dose that emptied a pack reopens it rather than resealing it. The tablets do not go back in the foil. Two offers stand down for a pack among the open rows, Add diluent and To spray, for the same reason Reconstitute does among the sealed ones. Setting the tablet size is offered in both sections now, since a box already in use is exactly where a missing size gets noticed. Also splits stock_open, which was one key doing two jobs. In English one word serves both the button on a water bottle and the heading over a list of opened things. In German it cannot: the heading is Geöffnet and the button is Öffnen. Slovenian and Polish had taken the button reading, so the section heading read as an imperative. --- document/05-decisions.md | 30 +++++++++++++ src/app/stock/page.tsx | 51 ++++++++++++++++++++-- src/lib/calc/inventory.test.ts | 77 ++++++++++++++++++++++++++++++++++ src/lib/calc/inventory.ts | 44 +++++++++++++++++-- src/lib/i18n/translations.ts | 12 +++++- src/lib/store.ts | 4 +- 6 files changed, 208 insertions(+), 10 deletions(-) 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;