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
13 changes: 13 additions & 0 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,19 @@ export default function SettingsPage() {
<option value="on">{t("settings_one_row_compound")}</option>
</Select>
</Field>

<Field
label={t("settings_water_position")}
hint={t("settings_water_position_desc")}
>
<Select
value={settings.waterAtBottom ? "bottom" : "top"}
onChange={(e) => updateSettings({ waterAtBottom: e.target.value === "bottom" })}
>
<option value="top">{t("settings_water_top")}</option>
<option value="bottom">{t("settings_water_bottom")}</option>
</Select>
</Field>
</Card>

<Card className="space-y-4 p-4">
Expand Down
62 changes: 50 additions & 12 deletions src/app/stock/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ import {
type VialGroup,
} from "@/lib/calc/inventory";
import { dosesPerDoseDay, phaseSpanAt, scheduledDoseMcg } from "@/lib/calc/schedule";
import { bottleRemainingMl, bottleUsable, diluentStock, pickBottle, shelfOrder } from "@/lib/calc/diluent";
import {
bottleRemainingMl,
bottleUsable,
diluentStock,
pickBottle,
shelfOrder,
shelfRows,
} from "@/lib/calc/diluent";
import {
DEFAULT_ML_PER_SPRAY,
MEASURE_A_PRESS,
Expand Down Expand Up @@ -230,7 +237,7 @@ export default function StockPage() {
/>
)}

<DiluentShelf />
{!settings.waterAtBottom && <DiluentShelf />}

{!vials.length && !adding && (
<EmptyState
Expand Down Expand Up @@ -385,6 +392,14 @@ export default function StockPage() {
</section>
)}

{/*
Water sits at whichever end the owner of the fridge put it. Rendered
twice in the tree and once on the page: the condition is exclusive, and
two call sites are easier to read than one shelf lifted into a variable
and dropped into a slot.
*/}
{settings.waterAtBottom && <DiluentShelf />}

<Callout tone="info" title={t("stock_28_day_note")}>
{t("stock_bud_explainer")}
</Callout>
Expand Down Expand Up @@ -971,6 +986,14 @@ const DILUENT_KEY: Record<DiluentKind, TranslationKey> = {
};

/** The kinds a vial or a bottle can actually be made up with. Oil is not one. */
/** What a bottle's state is called, which the badge used to print raw. */
const BOTTLE_STATE_KEY: Record<DiluentBottle["state"], TranslationKey> = {
sealed: "stock_bottle_sealed",
open: "stock_bottle_open",
finished: "stock_bottle_finished",
discarded: "stock_bottle_discarded",
};

const DILUENT_CHOICES: DiluentKind[] = ["bacteriostatic", "sterile", "saline"];

function ReconstituteForm({
Expand Down Expand Up @@ -1363,6 +1386,7 @@ function TopUpForm({
function DiluentShelf() {
const { t } = useLang();
const { diluents } = useProfileData();
const settings = useStore((s) => s.settings);
const addDiluent = useStore((s) => s.addDiluent);
const updateDiluent = useStore((s) => s.updateDiluent);
const removeDiluent = useStore((s) => s.removeDiluent);
Expand All @@ -1378,11 +1402,14 @@ function DiluentShelf() {
const [usedMl, setUsedMl] = useState(1);

const now = Date.now();
// Ordered the way the app itself would reach for them, so the bottle at the
// top of the shelf is the one reconstituting will suggest.
const live = shelfOrder(
diluents.filter((b) => b.state !== "finished" && b.state !== "discarded"),
now);
const live = diluents.filter((b) => b.state !== "finished" && b.state !== "discarded");
/*
Ordered the way the app itself would reach for them, so the bottle at the
top of the shelf is the one reconstituting will suggest, and collapsed on
the same setting the vials use. One switch for one idea: somebody who wants
forty vials on one row wants forty bottles on one row too.
*/
const rows = shelfRows(live, now, settings.groupIdenticalVials === true);
const stock = diluentStock(diluents, "bacteriostatic", now);

if (!live.length && !adding) {
Expand Down Expand Up @@ -1476,16 +1503,27 @@ function DiluentShelf() {
)}

<div className="space-y-1.5">
{live.map((b) => {
{/* `n` rather than `count`, which is the add form's own state above. */}
{rows.map(({ key, bottle: b, count: n }) => {
const left = bottleRemainingMl(b);
return (
<Card key={b.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 p-3">
<Card key={key} className="flex flex-wrap items-center gap-x-3 gap-y-1 p-3">
<span className="text-[13.5px] text-[var(--ink)]">{t(DILUENT_KEY[b.kind])}</span>
<Badge tone={b.state === "sealed" ? "neutral" : "tangerine"}>{b.state}</Badge>
<Badge tone={b.state === "sealed" ? "neutral" : "tangerine"}>
{t(BOTTLE_STATE_KEY[b.state])}
</Badge>
{/*
A group is whole sealed bottles, so it counts rather than
measures: four times thirty is the fridge, and "120 mL" would be
a number you cannot pour. A single bottle says what is left in
it, which is the figure that matters once it is open.
*/}
<span className="tnum font-mono text-[13px] text-[var(--muted)]">
{trim(left, 1)} of {trim(b.volumeMl, 1)} mL
{n > 1
? t("stock_bottles_each", { n, ml: trim(b.volumeMl, 1) })
: t("stock_bottle_left", { left: trim(left, 1), total: trim(b.volumeMl, 1) })}
</span>
{bottleUsable(b, now) ? null : <Badge tone="rose">unusable</Badge>}
{bottleUsable(b, now) ? null : <Badge tone="rose">{t("stock_bottle_unusable")}</Badge>}

<div className="ml-auto flex items-center gap-1">
{b.state === "sealed" && (
Expand Down
97 changes: 97 additions & 0 deletions src/lib/calc/diluent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
pickBottle,
returnToBottle,
shelfOrder,
shelfRows,
} from "./diluent";
import type { DiluentBottle } from "../types";

Expand Down Expand Up @@ -281,3 +282,99 @@ describe("shelfOrder", () => {
expect(shelfOrder([], NOW)).toEqual([]);
});
});

describe("shelfRows", () => {
const keys = (rows: ReturnType<typeof shelfRows>) => rows.map((r) => r.key);
const counts = (rows: ReturnType<typeof shelfRows>) => rows.map((r) => r.count);

it("gives every bottle its own row when grouping is off", () => {
const shelf = [
bottle({ id: "a" }),
bottle({ id: "b" }),
bottle({ id: "c" }),
];
expect(counts(shelfRows(shelf, NOW, false))).toEqual([1, 1, 1]);
expect(keys(shelfRows(shelf, NOW, false))).toEqual(["a", "b", "c"]);
});

it("collapses sealed bottles of the same kind and size", () => {
const shelf = [
bottle({ id: "a" }),
bottle({ id: "b" }),
bottle({ id: "c" }),
bottle({ id: "d" }),
];
const rows = shelfRows(shelf, NOW, true);
expect(rows).toHaveLength(1);
expect(rows[0].count).toBe(4);
expect(rows[0].bottles.map((b) => b.id)).toEqual(["a", "b", "c", "d"]);
});

/* The whole point of the request: two kinds of water are two things. */
it("keeps kinds and sizes apart", () => {
const shelf = [
bottle({ id: "bac-30" }),
bottle({ id: "bac-30-b" }),
bottle({ id: "sal-10", kind: "saline", volumeMl: 10 }),
bottle({ id: "bac-10", volumeMl: 10 }),
];
const rows = shelfRows(shelf, NOW, true);
expect(rows).toHaveLength(3);
// Keyed on kind and size, so the row holding two is the 30 mL
// bacteriostatic pair and the other two stand alone.
expect(rows.find((r) => r.key === "bacteriostatic:30")!.count).toBe(2);
expect(rows.find((r) => r.key === "bacteriostatic:10")!.count).toBe(1);
expect(rows.find((r) => r.key === "saline:10")!.count).toBe(1);
});

/*
* An open bottle has a beyond-use date of its own, running from the day it
* was punctured. A group would have to hide that or invent one.
*/
it("never collapses an open bottle", () => {
const shelf = [
bottle({ id: "open-1", state: "open", budAt: NOW + 10 * DAY }),
bottle({ id: "open-2", state: "open", budAt: NOW + 20 * DAY }),
];
expect(counts(shelfRows(shelf, NOW, true))).toEqual([1, 1]);
});

/* Same rule as the stock count: a filter that hides has to say where. */
it("leaves an expired bottle on its own row", () => {
const shelf = [
bottle({ id: "good-1" }),
bottle({ id: "good-2" }),
bottle({ id: "expired", expiresAt: NOW - DAY }),
];
const rows = shelfRows(shelf, NOW, true);
expect(rows).toHaveLength(2);
expect(rows[0].count).toBe(2);
expect(rows[1].bottle.id).toBe("expired");
});

/* Turning the setting on shortens the list without rearranging it. */
it("leaves a group where its first bottle stood", () => {
const shelf = [
bottle({ id: "open", state: "open", budAt: NOW + 5 * DAY }),
bottle({ id: "sealed-1" }),
bottle({ id: "sealed-2" }),
];
const rows = shelfRows(shelf, NOW, true);
expect(rows[0].bottle.id).toBe("open");
expect(rows[1].count).toBe(2);
});

it("acts on the bottle the shelf would reach for next", () => {
const shelf = [
bottle({ id: "later", expiresAt: NOW + 40 * DAY }),
bottle({ id: "sooner", expiresAt: NOW + 4 * DAY }),
];
const rows = shelfRows(shelf, NOW, true);
expect(rows[0].count).toBe(2);
expect(rows[0].bottle.id).toBe(pickBottle(shelf, "bacteriostatic", 1, NOW)!.id);
});

it("handles an empty shelf", () => {
expect(shelfRows([], NOW, true)).toEqual([]);
});
});
69 changes: 69 additions & 0 deletions src/lib/calc/diluent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,75 @@ export function shelfOrder(bottles: DiluentBottle[], nowMs: number): DiluentBott
a.id.localeCompare(b.id));
}

/**
* One row of the shelf, which is one bottle or several of the same thing.
*
* `bottle` is what the buttons act on and `bottles` is what the figures are
* about, which is the arrangement `groupSealedVials` already uses on the other
* shelf. Reaching for one bottle out of four identical ones is a real action;
* opening all four is not, so a button on a grouped row still opens one.
*/
export interface ShelfRow {
/** Stable across renders and unique within the list. */
key: string;
/** The one the buttons act on: whichever this shelf would reach for next. */
bottle: DiluentBottle;
/** Every bottle this row stands for, in the order the shelf would reach. */
bottles: DiluentBottle[];
count: number;
}

/**
* The shelf as rows, collapsing interchangeable bottles when asked to.
*
* Only a sealed bottle that is still usable can be collapsed, and both halves
* of that matter. An open bottle has its own beyond-use date running from the
* day it was punctured, so a group would have to either hide that date or
* invent one for bottles that do not share it. A bottle past its date has to
* keep its own row for the same reason a vial past its date is counted
* separately: a filter that can hide something has to say so where it hides it.
*
* The key is kind and size, because that is what makes two bottles the same
* thing to reach for. 30 mL bacteriostatic and 10 mL saline stay two rows.
*
* Order is `shelfOrder`'s, untouched: a group sits where its first bottle sat,
* so turning grouping on shortens the list without rearranging it.
*/
export function shelfRows(
bottles: DiluentBottle[],
nowMs: number,
grouped: boolean): ShelfRow[] {
const ordered = shelfOrder(bottles, nowMs);
if (!grouped) {
return ordered.map((b) => ({ key: b.id, bottle: b, bottles: [b], count: 1 }));
}

const rows: ShelfRow[] = [];
const byKey = new Map<string, ShelfRow>();

for (const b of ordered) {
const groupable = b.state === "sealed" && bottleUsable(b, nowMs);
if (!groupable) {
rows.push({ key: b.id, bottle: b, bottles: [b], count: 1 });
continue;
}

const key = `${b.kind}:${b.volumeMl}`;
const seen = byKey.get(key);
if (seen) {
seen.bottles.push(b);
seen.count++;
continue;
}

const row: ShelfRow = { key, bottle: b, bottles: [b], count: 1 };
byKey.set(key, row);
rows.push(row);
}

return rows;
}

/**
* Open a bottle without taking anything out of it yet.
*
Expand Down
44 changes: 44 additions & 0 deletions src/lib/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,17 @@ export const TRANSLATIONS = {
stock_water_section: "Water and diluents",
stock_water_section_desc: "Optional. Track bottles here and reconstituting will draw from one.",
stock_add_bottle: "Add a bottle",
stock_bottle_sealed: "sealed",
stock_bottle_open: "open",
stock_bottle_finished: "finished",
stock_bottle_discarded: "discarded",
stock_bottle_unusable: "unusable",
stock_bottle_left: "{left} of {total} mL",
stock_bottles_each: "{n} × {ml} mL",
settings_water_position: "Where the water goes",
settings_water_position_desc: "Bottles of water on the Stock page, above the vials or below them.",
settings_water_top: "Above the vials",
settings_water_bottom: "Below the vials",
// The save button on the add form, which knows how many are being added.
stock_add_bottles_one: "Add a bottle",
stock_add_bottles_other: "Add {n} bottles",
Expand Down Expand Up @@ -1715,6 +1726,17 @@ export const TRANSLATIONS = {
stock_water_section: "Wasser und Verdünnungsmittel",
stock_water_section_desc: "Optional. Verfolge hier Flaschen und die Rekonstitution zieht aus einer davon.",
stock_add_bottle: "Flasche hinzufügen",
stock_bottle_sealed: "versiegelt",
stock_bottle_open: "offen",
stock_bottle_finished: "leer",
stock_bottle_discarded: "verworfen",
stock_bottle_unusable: "unbrauchbar",
stock_bottle_left: "{left} von {total} mL",
stock_bottles_each: "{n} × {ml} mL",
settings_water_position: "Wo das Wasser steht",
settings_water_position_desc: "Wasserflaschen auf der Vorratsseite, über den Ampullen oder darunter.",
settings_water_top: "Über den Ampullen",
settings_water_bottom: "Unter den Ampullen",
stock_add_bottles_one: "Flasche hinzufügen",
stock_add_bottles_other: "{n} Flaschen hinzufügen",
stock_what: "Was",
Expand Down Expand Up @@ -3109,6 +3131,17 @@ export const TRANSLATIONS = {
stock_water_section: "Voda in topila",
stock_water_section_desc: "Neobvezno. Tu spremljaj steklenice in rekonstitucija bo črpala iz ene.",
stock_add_bottle: "Dodaj stekleničko",
stock_bottle_sealed: "zaprta",
stock_bottle_open: "odprta",
stock_bottle_finished: "prazna",
stock_bottle_discarded: "zavržena",
stock_bottle_unusable: "neuporabna",
stock_bottle_left: "{left} od {total} mL",
stock_bottles_each: "{n} × {ml} mL",
settings_water_position: "Kje stoji voda",
settings_water_position_desc: "Stekleničke vode na strani Zaloga, nad vialkami ali pod njimi.",
settings_water_top: "Nad vialkami",
settings_water_bottom: "Pod vialkami",
stock_add_bottles_one: "Dodaj stekleničko",
stock_add_bottles_two: "Dodaj {n} steklenički",
stock_add_bottles_few: "Dodaj {n} stekleničke",
Expand Down Expand Up @@ -4569,6 +4602,17 @@ export const TRANSLATIONS = {
stock_water_section: "Woda i rozpuszczalniki",
stock_water_section_desc: "Opcjonalnie. Śledź tu butelki, a rozpuszczanie będzie z nich pobierać.",
stock_add_bottle: "Dodaj butelkę",
stock_bottle_sealed: "zaplombowana",
stock_bottle_open: "otwarta",
stock_bottle_finished: "pusta",
stock_bottle_discarded: "odrzucona",
stock_bottle_unusable: "nie do użycia",
stock_bottle_left: "{left} z {total} mL",
stock_bottles_each: "{n} × {ml} mL",
settings_water_position: "Gdzie stoi woda",
settings_water_position_desc: "Butelki wody na stronie Zapas, nad fiolkami albo pod nimi.",
settings_water_top: "Nad fiolkami",
settings_water_bottom: "Pod fiolkami",
stock_add_bottles_one: "Dodaj butelkę",
stock_add_bottles_few: "Dodaj {n} butelki",
stock_add_bottles_many: "Dodaj {n} butelek",
Expand Down
Loading
Loading