diff --git a/src/lib/calc/custom.ts b/src/lib/calc/custom.ts
index 68c1bb7..7ec036f 100755
--- a/src/lib/calc/custom.ts
+++ b/src/lib/calc/custom.ts
@@ -23,7 +23,7 @@ export interface CustomDraft {
/** Blank when unknown, which is the honest default for most research peptides. */
halfLifeHours?: number | null;
routes: Route[];
- preparation: "powder" | "solution";
+ preparation: "powder" | "solution" | "tablet";
/** Typical dose per administration, in micrograms. */
doseLowMcg?: number;
doseHighMcg?: number;
diff --git a/src/lib/calc/dosecsv.ts b/src/lib/calc/dosecsv.ts
index 2226881..9d5d940 100644
--- a/src/lib/calc/dosecsv.ts
+++ b/src/lib/calc/dosecsv.ts
@@ -34,6 +34,7 @@ export const DOSE_CSV_HEADER = [
* things, and the CSV is what somebody takes to a spreadsheet to add up.
*/
"presses",
+ "tablets",
] as const;
/** Quote only when the value would otherwise break the row. */
@@ -73,6 +74,7 @@ export function doseCsv(logs: DoseLog[], nameFor: (peptideId: string) => string)
(l.sideEffects ?? []).join("; "),
l.notes ?? "",
l.presses ?? "",
+ l.tablets ?? "",
]
.map(escapeCsv)
.join(",");
diff --git a/src/lib/calc/tablet.test.ts b/src/lib/calc/tablet.test.ts
new file mode 100644
index 0000000..5d9cb46
--- /dev/null
+++ b/src/lib/calc/tablet.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from "vitest";
+import {
+ isPack,
+ mcgForTablets,
+ mcgPerTablet,
+ mgPerTablet,
+ packStrengthMg,
+ tabletsForDose,
+ tabletsRemaining,
+} from "./tablet";
+import type { Vial } from "../types";
+
+/** Sixty tablets of 10 mg, which is the pack the request was about. */
+const pack = (over: Partial = {}): Vial => ({
+ id: "p1",
+ profileId: "me",
+ peptideId: "slu-pp-332",
+ strengthMg: 600,
+ state: "sealed",
+ container: "pack",
+ mgPerTablet: 10,
+ ...over,
+});
+
+describe("what a pack is", () => {
+ it("knows one from a vial and from a bottle", () => {
+ expect(isPack(pack())).toBe(true);
+ expect(isPack({ container: "spray" })).toBe(false);
+ expect(isPack({})).toBe(false);
+ });
+
+ it("is a mass, so the rest of the app needs no new unit", () => {
+ expect(packStrengthMg(10, 60)).toBe(600);
+ });
+
+ it("counts whole tablets into a pack and not fractions of one", () => {
+ expect(packStrengthMg(10, 60.7)).toBe(600);
+ });
+
+ it("refuses to build a pack out of nonsense", () => {
+ expect(packStrengthMg(-10, 60)).toBe(0);
+ expect(packStrengthMg(10, -1)).toBe(0);
+ });
+});
+
+/*
+ * The one real difference from the pump beside it. A bottle with no volume
+ * recorded still counts, because 0.1 mL is the conventional press. Tablets have
+ * no conventional size, so a pack with none recorded counts as nothing and the
+ * screen says the size is missing.
+ */
+describe("a pack with no tablet size", () => {
+ const nameless = pack({ mgPerTablet: undefined });
+
+ it("reports no size rather than a guessed one", () => {
+ expect(mgPerTablet(nameless)).toBe(0);
+ expect(mcgPerTablet(nameless)).toBe(0);
+ });
+
+ it("counts nothing rather than counting wrongly", () => {
+ expect(tabletsRemaining(nameless)).toBe(0);
+ expect(tabletsForDose(nameless, 10_000)).toBe(0);
+ });
+
+ it("treats a zero or a negative size the same as none at all", () => {
+ expect(mgPerTablet(pack({ mgPerTablet: 0 }))).toBe(0);
+ expect(mgPerTablet(pack({ mgPerTablet: -5 }))).toBe(0);
+ });
+});
+
+describe("tabletsForDose", () => {
+ it("answers one tablet for one tablet", () => {
+ expect(tabletsForDose(pack(), 10_000)).toBe(1);
+ });
+
+ /*
+ * Kept as a fraction, which is where a tablet parts company with a press.
+ * Rounding 1.5 up to 2 would be a fifth more drug than the plan asks for, and
+ * whether a half is takeable is a fact about the tablet rather than about the
+ * arithmetic.
+ */
+ it("keeps a half, because a scored tablet really is half a dose", () => {
+ expect(tabletsForDose(pack(), 15_000)).toBe(1.5);
+ expect(tabletsForDose(pack({ mgPerTablet: 25 }), 12_500)).toBe(0.5);
+ });
+
+ it("never answers with a negative number of tablets", () => {
+ expect(tabletsForDose(pack(), -10_000)).toBe(0);
+ });
+
+ it("goes back the way it came", () => {
+ expect(mcgForTablets(pack(), tabletsForDose(pack(), 15_000))).toBeCloseTo(15_000, 6);
+ });
+});
+
+describe("tabletsRemaining", () => {
+ it("counts a full pack", () => {
+ expect(tabletsRemaining(pack())).toBe(60);
+ });
+
+ it("takes the taken ones off", () => {
+ expect(tabletsRemaining(pack({ drawnMcg: 100_000 }))).toBe(50);
+ });
+
+ /*
+ * Floored rather than rounded. Four tablets and a half-used one answers four
+ * to "how many can I take", and a five would be a tablet that is not there.
+ */
+ it("floors, because half a tablet in the pack is not a tablet", () => {
+ expect(tabletsRemaining(pack({ drawnMcg: 555_000 }))).toBe(4);
+ });
+
+ it("bottoms out at nothing rather than going negative", () => {
+ expect(tabletsRemaining(pack({ drawnMcg: 700_000 }))).toBe(0);
+ });
+});
diff --git a/src/lib/calc/tablet.ts b/src/lib/calc/tablet.ts
new file mode 100644
index 0000000..d3467d5
--- /dev/null
+++ b/src/lib/calc/tablet.ts
@@ -0,0 +1,88 @@
+/**
+ * Tablets: a pack of them, and what one of them delivers.
+ *
+ * Asked for after somebody tried to record SLU-PP-332, which the library does
+ * not carry and which he had as tablets. The app could hold the compound
+ * straight away and had nowhere to put the tablets: stock was a mass in a
+ * vial, optionally dissolved in a volume, and a pack of tablets is a mass in
+ * a count.
+ *
+ * So a pack is a `Vial` with `container: "pack"`, which is the shape a nasal
+ * spray bottle already established and for the same reason: mass remaining,
+ * doses left, cost per dose, days of supply and the beyond-use rules are all
+ * arithmetic about a mass in a container, and none of them care what the
+ * container is. `strengthMg` is the whole pack, `mgPerTablet` is the unit it
+ * is counted in. Sixty tablets of 10 mg is one pack of 600 mg.
+ *
+ * Two deliberate differences from the spray beside it.
+ *
+ * There is no default tablet size. A pump has a conventional 0.1 mL, so a
+ * bottle with nothing recorded can still be counted; tablets come in whatever
+ * the manufacturer chose, so a pack with nothing recorded is counted as
+ * nothing rather than as a guess. The screen then says the size is missing,
+ * which is true, instead of a press-count that is false.
+ *
+ * And a tablet can be halved. A pump delivers a fixed volume or it fails, so
+ * `spraysForDose` rounds to whole presses; a scored tablet really is half a
+ * dose, so `tabletsForDose` keeps the fraction and lets the screen round it
+ * for reading. What is floored is the count left in the pack, because half a
+ * tablet you have not cut yet is not half a tablet you can take.
+ */
+
+import type { Vial } from "../types";
+import { MCG_PER_MG, vialRemainingMcg } from "./inventory";
+
+export const isPack = (v: Pick) => v.container === "pack";
+
+/** Milligrams in one tablet, or zero when nobody recorded it. */
+export function mgPerTablet(v: Pick): number {
+ const mg = v.mgPerTablet;
+ return Number.isFinite(mg) && (mg ?? 0) > 0 ? mg! : 0;
+}
+
+/** The mass one tablet delivers. */
+export function mcgPerTablet(v: Pick): number {
+ return mgPerTablet(v) * MCG_PER_MG;
+}
+
+/** The mass a number of tablets delivers. */
+export function mcgForTablets(v: Pick, tablets: number): number {
+ return mcgPerTablet(v) * Math.max(0, tablets);
+}
+
+/**
+ * How many tablets a dose comes to.
+ *
+ * The exact figure, fractions and all. A plan asking for 15 mg from a pack of
+ * 10 mg tablets is one and a half tablets, and saying "2" would be a fifth
+ * more drug than the plan says. Whether that fraction is takeable is a fact
+ * about the tablet in the hand, not about arithmetic, so the number is
+ * reported and the judgement is left where it belongs.
+ */
+export function tabletsForDose(v: Pick, doseMcg: number): number {
+ const per = mcgPerTablet(v);
+ return per > 0 ? Math.max(0, doseMcg / per) : 0;
+}
+
+/**
+ * Whole tablets left in the pack.
+ *
+ * Floored, unlike the dose above. Mass remaining divided by the tablet size
+ * can land at 4.5 when four tablets and a half-used one are in the pack, and
+ * the answer to "how many can I take" is four.
+ */
+export function tabletsRemaining(
+ v: Pick): number {
+ const per = mcgPerTablet(v);
+ return per > 0 ? Math.floor(vialRemainingMcg(v) / per) : 0;
+}
+
+/**
+ * The mass a pack of this many tablets holds.
+ *
+ * What the add form writes into `strengthMg`, so the pack is a mass like every
+ * other container and nothing downstream has to learn a second unit.
+ */
+export function packStrengthMg(mgEach: number, tablets: number): number {
+ return Math.max(0, mgEach) * Math.max(0, Math.floor(tablets));
+}
diff --git a/src/lib/i18n/translations.ts b/src/lib/i18n/translations.ts
index 82f2f5f..b8ca676 100644
--- a/src/lib/i18n/translations.ts
+++ b/src/lib/i18n/translations.ts
@@ -336,6 +336,27 @@ 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_mg_per_tablet: "Milligrams in one tablet",
+ dose_tablets_title: "{dose} in one tablet.",
+ stock_tablets_per_pack: "Tablets in the pack",
+ stock_how_many_packs: "How many packs",
+ stock_pack_holds: "One pack holds {mg} mg.",
+ stock_common_sizes: "Common: {sizes} mg",
+ stock_tablets_left: "{n} tablets left",
+ stock_per_tablet: "{mg} mg a tablet",
+ stock_no_tablet_size: "No tablet size recorded, so nothing can be counted.",
+ ccf_tablet: "Tablets",
+ ccf_tablet_hint: "Comes as tablets or capsules, counted rather than drawn. Nothing to make up, and no syringe when you log it.",
+ log_tablets: "Tablets",
+ log_tablets_suffix: "tablets",
+ log_per_tablet_hint: "{dose} a tablet.",
+ log_no_tablet_size: "Add the tablet size to the pack first.",
+ log_per_press_hint: "{dose} a press, {ml} mL in total.",
+ log_na: "n/a",
+ count_tablets_one: "{n} tablet",
+ count_tablets_other: "{n} tablets",
+ count_packs_one: "{n} pack",
+ count_packs_other: "{n} packs",
stock_bottle_sealed: "sealed",
stock_bottle_open: "open",
stock_bottle_finished: "finished",
@@ -1726,6 +1747,27 @@ 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_mg_per_tablet: "Milligramm pro Tablette",
+ dose_tablets_title: "{dose} pro Tablette.",
+ stock_tablets_per_pack: "Tabletten in der Packung",
+ stock_how_many_packs: "Wie viele Packungen",
+ stock_pack_holds: "Eine Packung enthält {mg} mg.",
+ stock_common_sizes: "Üblich: {sizes} mg",
+ stock_tablets_left: "{n} Tabletten übrig",
+ stock_per_tablet: "{mg} mg pro Tablette",
+ stock_no_tablet_size: "Keine Tablettengröße hinterlegt, also lässt sich nichts zählen.",
+ ccf_tablet: "Tabletten",
+ ccf_tablet_hint: "Kommt als Tabletten oder Kapseln, gezählt statt aufgezogen. Nichts anzusetzen, und beim Eintragen keine Spritze.",
+ log_tablets: "Tabletten",
+ log_tablets_suffix: "Tabletten",
+ log_per_tablet_hint: "{dose} pro Tablette.",
+ log_no_tablet_size: "Trag zuerst die Tablettengröße der Packung ein.",
+ log_per_press_hint: "{dose} pro Sprühstoß, {ml} mL insgesamt.",
+ log_na: "k.A.",
+ count_tablets_one: "{n} Tablette",
+ count_tablets_other: "{n} Tabletten",
+ count_packs_one: "{n} Packung",
+ count_packs_other: "{n} Packungen",
stock_bottle_sealed: "versiegelt",
stock_bottle_open: "offen",
stock_bottle_finished: "leer",
@@ -3131,6 +3173,31 @@ 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_mg_per_tablet: "Miligramov v eni tableti",
+ dose_tablets_title: "{dose} v eni tableti.",
+ stock_tablets_per_pack: "Tablet v škatlici",
+ stock_how_many_packs: "Koliko škatlic",
+ stock_pack_holds: "Ena škatlica ima {mg} mg.",
+ stock_common_sizes: "Običajno: {sizes} mg",
+ stock_tablets_left: "ostalo {n} tablet",
+ stock_per_tablet: "{mg} mg na tableto",
+ stock_no_tablet_size: "Velikost tablete ni vpisana, zato ni česa šteti.",
+ ccf_tablet: "Tablete",
+ ccf_tablet_hint: "Pride v tabletah ali kapsulah, ki se štejejo in ne vlečejo. Ničesar ni treba pripraviti, ob beleženju pa ni brizge.",
+ log_tablets: "Tablete",
+ log_tablets_suffix: "tablet",
+ log_per_tablet_hint: "{dose} na tableto.",
+ log_no_tablet_size: "Najprej vpiši velikost tablete pri škatlici.",
+ log_per_press_hint: "{dose} na pritisk, skupaj {ml} mL.",
+ log_na: "ni podatka",
+ count_tablets_one: "{n} tableta",
+ count_tablets_two: "{n} tableti",
+ count_tablets_few: "{n} tablete",
+ count_tablets_other: "{n} tablet",
+ count_packs_one: "{n} škatlica",
+ count_packs_two: "{n} škatlici",
+ count_packs_few: "{n} škatlice",
+ count_packs_other: "{n} škatlic",
stock_bottle_sealed: "zaprta",
stock_bottle_open: "odprta",
stock_bottle_finished: "prazna",
@@ -4602,6 +4669,31 @@ 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_mg_per_tablet: "Miligramy w jednej tabletce",
+ dose_tablets_title: "{dose} w jednej tabletce.",
+ stock_tablets_per_pack: "Tabletek w opakowaniu",
+ stock_how_many_packs: "Ile opakowań",
+ stock_pack_holds: "Jedno opakowanie ma {mg} mg.",
+ stock_common_sizes: "Typowo: {sizes} mg",
+ stock_tablets_left: "zostało {n} tabletek",
+ stock_per_tablet: "{mg} mg na tabletkę",
+ stock_no_tablet_size: "Nie podano wielkości tabletki, więc nie ma czego liczyć.",
+ ccf_tablet: "Tabletki",
+ ccf_tablet_hint: "Występuje w tabletkach albo kapsułkach, liczonych zamiast nabieranych. Nic nie trzeba rozpuszczać, a przy zapisie nie ma strzykawki.",
+ log_tablets: "Tabletki",
+ log_tablets_suffix: "tabletek",
+ log_per_tablet_hint: "{dose} na tabletkę.",
+ log_no_tablet_size: "Najpierw podaj wielkość tabletki przy opakowaniu.",
+ log_per_press_hint: "{dose} na psiknięcie, łącznie {ml} mL.",
+ log_na: "brak",
+ count_tablets_one: "{n} tabletka",
+ count_tablets_few: "{n} tabletki",
+ count_tablets_many: "{n} tabletek",
+ count_tablets_other: "{n} tabletki",
+ count_packs_one: "{n} opakowanie",
+ count_packs_few: "{n} opakowania",
+ count_packs_many: "{n} opakowań",
+ count_packs_other: "{n} opakowania",
stock_bottle_sealed: "zaplombowana",
stock_bottle_open: "otwarta",
stock_bottle_finished: "pusta",
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 774fb31..c963765 100755
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -168,7 +168,7 @@ export interface Peptide {
* concentration from the moment you open it and never needs the reconstitution
* step. Defaults to powder.
*/
- preparation?: "powder" | "solution";
+ preparation?: "powder" | "solution" | "tablet";
/**
* International units per milligram, for compounds conventionally dosed in IU
@@ -418,6 +418,16 @@ export interface DoseLog {
* from today's bottle would be inventing a number for a past event.
*/
presses?: number;
+ /**
+ * How many tablets this dose was, for a pack.
+ *
+ * Recorded for the same reason `presses` is: the pack a dose came out of can
+ * be replaced by one with a different tablet size, and a count worked out
+ * later from today's pack would answer a question about last Tuesday with
+ * today's arithmetic. Fractions are kept, because a scored tablet really is
+ * half a dose, which is where a tablet differs from a press.
+ */
+ tablets?: number;
/**
* The exact barrel, by id from `SYRINGES`.
*
@@ -693,7 +703,17 @@ export interface Vial {
*
* What it does not reuse is the syringe. See `calc/spray.ts`.
*/
- container?: "vial" | "spray";
+ container?: "vial" | "spray" | "pack";
+ /**
+ * Milligrams in one tablet, for a pack of them.
+ *
+ * The unit a pack is counted in, the way `mlPerSpray` is the unit a bottle is
+ * counted in. No default and no guess: a pump has a conventional 0.1 mL and a
+ * tablet has no conventional size at all, so zero here means nobody said, and
+ * the screen shows a count of nothing rather than a count of something
+ * invented.
+ */
+ mgPerTablet?: number;
/**
* Millilitres one press of the pump delivers, for a spray.
*