diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 84042bc..ce04762 100755 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -201,6 +201,19 @@ export default function SettingsPage() { + + + + diff --git a/src/app/stock/page.tsx b/src/app/stock/page.tsx index 1e57ea2..d3e0eae 100755 --- a/src/app/stock/page.tsx +++ b/src/app/stock/page.tsx @@ -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, @@ -230,7 +237,7 @@ export default function StockPage() { /> )} - + {!settings.waterAtBottom && } {!vials.length && !adding && ( )} + {/* + 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 && } + {t("stock_bud_explainer")} @@ -971,6 +986,14 @@ const DILUENT_KEY: Record = { }; /** 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 = { + 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({ @@ -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); @@ -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) { @@ -1476,16 +1503,27 @@ function DiluentShelf() { )}
- {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 ( - + {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