From b0177c68045b9d5b381250f734e2ee294a123273 Mon Sep 17 00:00:00 2001 From: suskozaver Date: Wed, 16 Sep 2026 21:44:01 +0200 Subject: [PATCH] feat: the water shelf shortens, and moves if you want it to Feature Request 24, the half that is about water. The half about tapping "It arrived" forty times is a different list and goes on its own. Sealed bottles of the same kind and the same size collapse into one row with a count. Not "water as one row", which is what was asked for and would be wrong: bottles differ by what they are, by size, and by whether they have been opened. 30 mL bacteriostatic and 10 mL saline stay two rows because they are two things you reach for at different moments. An open bottle always keeps its own row. Its beyond-use date runs from the day it was punctured, so a group would have to hide that date or invent one for bottles that do not share it. A bottle past its date keeps its own row too, for the reason the stock count already has written down: a filter that can hide something has to say so where it hides it. Grouping is a view. Every bottle stays its own record with its own dates, cost and history, and the row acts on one bottle rather than on all of them: opening a group opens the one the shelf would reach for next, which is the same arrangement groupSealedVials uses for vials. It also obeys the same setting, because somebody who wants forty vials on one row wants forty bottles on one row. Where the shelf sits is now a setting, default unchanged. It was asked for at the bottom, on the grounds that water is the least important thing on the page. True with forty bottles and false while reconstituting, when the bottle is the next thing you touch, so the fridge's owner decides. Three more pieces of English the translation pass missed, all in these rows and all in a file that imports useLang: the state badge printed b.state raw, the volume read "12 of 30 mL", and an unusable bottle said so in English in every language. Eight tests on shelfRows. --- src/app/settings/page.tsx | 13 +++++ src/app/stock/page.tsx | 62 ++++++++++++++++++----- src/lib/calc/diluent.test.ts | 97 ++++++++++++++++++++++++++++++++++++ src/lib/calc/diluent.ts | 69 +++++++++++++++++++++++++ src/lib/i18n/translations.ts | 44 ++++++++++++++++ src/lib/types.ts | 11 ++++ 6 files changed, 284 insertions(+), 12 deletions(-) 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