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
30 changes: 30 additions & 0 deletions document/05-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
51 changes: 48 additions & 3 deletions src/app/stock/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
diluentAfterTopUp,
groupSealedVials,
marksFromVial,
openPack,
stockFor,
supplyOutlook,
vialConcentration,
Expand Down Expand Up @@ -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 && (
<TabletSizeForm
vial={v}
onCancel={() => setSizing(null)}
onSave={(mgEach, tabletsInPack) => {
updateVial(v.id, {
container: "pack",
mgPerTablet: mgEach,
strengthMg: packStrengthMg(mgEach, tabletsInPack),
});
setSizing(null);
}}
/>
)}
{transferring === v.id && (
<TransferToSprayForm
vial={v}
Expand Down Expand Up @@ -369,6 +392,14 @@ export default function StockPage() {
onReconstitute={isPack(v) ? undefined : () => 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 && (
<TabletSizeForm
Expand Down Expand Up @@ -468,6 +499,7 @@ function VialRow({
onFinish,
tabletCompound,
onSetTabletSize,
onOpenPack,
}: {
vial: Vial;
/**
Expand Down Expand Up @@ -502,6 +534,8 @@ function VialRow({
/** Whether the library says this compound comes as tablets. */
tabletCompound?: boolean;
onSetTabletSize?: () => void;
/** Only for a pack still sealed. Moves it up to Open. */
onOpenPack?: () => void;
}) {
const { t } = useLang();
const st = vialStatus(vial, now);
Expand Down Expand Up @@ -744,6 +778,11 @@ function VialRow({
<SprayCan size={13} /> {t("stock_to_spray")}
</Button>
)}
{onOpenPack && pack && vial.state === "sealed" && (
<Button variant="primary" onClick={onOpenPack} className="px-3 py-1.5 text-[13px]">
{t("stock_open_pack")}
</Button>
)}
{/*
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
Expand Down Expand Up @@ -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")}
</Button>
)}
{b.state !== "discarded" && bottleRemainingMl(b) > 0 && (
Expand Down
77 changes: 77 additions & 0 deletions src/lib/calc/inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
groupSealedVials,
supplyOutlook,
containerForDose,
openPack,
pickVialForDose,
reconcileVials,
returnToVial,
Expand Down Expand Up @@ -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> = {}) =>
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");
});
});
44 changes: 40 additions & 4 deletions src/lib/calc/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
});
}
Expand All @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions src/lib/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,9 @@ export const useStore = create<StoreState>()(

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;
Expand Down
Loading