{t(DILUENT_KEY[b.kind])}
- {b.state}
+
+ {t(BOTTLE_STATE_KEY[b.state])}
+
+ {/*
+ 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.
+ */}
- {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) })}
- {bottleUsable(b, now) ? null : unusable}
+ {bottleUsable(b, now) ? null : {t("stock_bottle_unusable")}}
{b.state === "sealed" && (
diff --git a/src/lib/calc/diluent.test.ts b/src/lib/calc/diluent.test.ts
index fc30d8b..e9ce536 100644
--- a/src/lib/calc/diluent.test.ts
+++ b/src/lib/calc/diluent.test.ts
@@ -10,6 +10,7 @@ import {
pickBottle,
returnToBottle,
shelfOrder,
+ shelfRows,
} from "./diluent";
import type { DiluentBottle } from "../types";
@@ -281,3 +282,99 @@ describe("shelfOrder", () => {
expect(shelfOrder([], NOW)).toEqual([]);
});
});
+
+describe("shelfRows", () => {
+ const keys = (rows: ReturnType) => rows.map((r) => r.key);
+ const counts = (rows: ReturnType) => 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([]);
+ });
+});
diff --git a/src/lib/calc/diluent.ts b/src/lib/calc/diluent.ts
index b5be12a..f0eda9d 100644
--- a/src/lib/calc/diluent.ts
+++ b/src/lib/calc/diluent.ts
@@ -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();
+
+ 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.
*
diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts
index ec0d1de..82f2f5f 100644
--- a/src/lib/i18n/translations.ts
+++ b/src/lib/i18n/translations.ts
@@ -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",
@@ -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",
@@ -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",
@@ -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",
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 46eec4a..774fb31 100755
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -763,6 +763,17 @@ export interface Settings {
*/
groupIdenticalVials?: boolean;
+ /**
+ * Put the shelf of water below the vials rather than above them.
+ *
+ * Off by default, which keeps the page as it is. Raised as "water is the
+ * least important thing here and it is at the top", which is true for
+ * somebody with forty bottles and false for somebody reconstituting today,
+ * for whom the bottle is the next thing they touch. Neither of them is
+ * wrong about their own fridge, so it is a setting rather than an argument.
+ */
+ waterAtBottom?: boolean;
+
/**
* Automatic backups to the device's Documents folder. Android only, a web
* page cannot write to a folder unattended, so the manual export is the answer