feat: add inhaler and injection package types

Closes #558

- add inhaler and injection as supported medication package types
- align refill, planner, dashboard, report, export, and notification wording for the new discrete package types
- include the validated CI repair for formatting and dashboard label parity
This commit is contained in:
Daniel Volz
2026-05-11 21:29:59 +02:00
committed by GitHub
parent 26e9b39f47
commit c5c75f65e4
32 changed files with 584 additions and 141 deletions
+11 -14
View File
@@ -206,6 +206,11 @@ export function MedDetailModal({
if (!selectedMed) return null;
const isAmountPackage =
isTubePackageType(selectedMed.packageType) || isLiquidContainerPackageType(selectedMed.packageType);
const getDiscreteUnitLabel = (value: number) => {
if (selectedMed.packageType === "inhaler") return value === 1 ? t("common.puff") : t("common.puffs");
if (selectedMed.packageType === "injection") return value === 1 ? t("common.injection") : t("common.injections");
return value === 1 ? t("common.pill") : t("common.pills");
};
const amountUnitLabel =
isLiquidContainerPackageType(selectedMed.packageType) || selectedMed.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
@@ -266,7 +271,7 @@ export function MedDetailModal({
if (isTubePackageType(selectedMed.packageType)) {
return `${usage} ${t("form.blisters.applications", { count: Math.abs(usage) })}`;
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
return `${usage} ${getDiscreteUnitLabel(usage)}`;
};
const scheduleIntakes = getMedicationIntakes(selectedMed);
const hasAnyIntakeReminder = scheduleIntakes.some((intake) => intake.intakeRemindersEnabled === true);
@@ -694,18 +699,14 @@ export function MedDetailModal({
<span>{t("editStock.currentTotal")}:</span>
<span>
{currentTotal}
{isAmountPackage
? ` ${stockUnitLabel}`
: ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`}
{isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(currentTotal)}`}
</span>
</div>
<div className="summary-row">
<span>{t("editStock.newTotal")}:</span>
<span>
{newTotal}
{isAmountPackage
? ` ${stockUnitLabel}`
: ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`}
{isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(newTotal)}`}
</span>
</div>
<div className={`summary-row difference ${differenceClass}`}>
@@ -713,9 +714,7 @@ export function MedDetailModal({
<span>
{difference > 0 ? "+" : ""}
{difference}
{isAmountPackage
? ` ${stockUnitLabel}`
: ` ${Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}`}
{isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(Math.abs(difference))}`}
</span>
</div>
</div>
@@ -1106,7 +1105,7 @@ export function MedDetailModal({
<span className="refill-amount">
{(() => {
const total = entry.quantityAdded;
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`;
return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(total)}`}`;
})()}
{entry.usedPrescription && (
<span className="refill-prescription-badge" title={t("refill.viaPrescription")}>
@@ -1312,9 +1311,7 @@ export function MedDetailModal({
return totalRefill > 0 ? (
<span className="refill-preview">
+{totalRefill}
{isAmountPackage
? ` ${stockUnitLabel}`
: ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`}
{isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(totalRefill)}`}
</span>
) : null;
})()}
@@ -79,6 +79,10 @@ function getPackageContainerTranslationKey(packageType: MedicationEnrichmentPack
return "form.enrichment.packageContainers.blister";
case "bottle":
return "form.enrichment.packageContainers.bottle";
case "inhaler":
return "form.enrichment.packageContainers.inhaler";
case "injection":
return "form.enrichment.packageContainers.injection";
case "liquid_container":
return "form.enrichment.packageContainers.liquidContainer";
case "tube":
+28 -4
View File
@@ -24,7 +24,9 @@ import {
allowsPillFormSelection,
DOSE_UNITS,
isAmountBasedPackageType,
isDiscreteCountPackageType,
isLiquidContainerPackageType,
isPackageAmountPackageType,
isTubePackageType,
PACKAGE_PROFILES,
} from "../types";
@@ -193,6 +195,15 @@ export function MobileEditModal({
return form.pillForm === "tablet";
}, [form.packageType, form.medicationForm, form.pillForm]);
const getDiscreteUnitLabel = useCallback(
(count: number) => {
if (form.packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
if (form.packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
return count === 1 ? t("common.pill") : t("common.pills");
},
[form.packageType, t]
);
const getUsageLabel = useCallback(
(intake: (typeof form.intakes)[number]) => {
if (isLiquidContainerPackageType(form.packageType)) {
@@ -203,16 +214,29 @@ export function MobileEditModal({
if (isTubePackageType(form.packageType)) {
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
}
if (form.packageType === "inhaler") return t("common.puffs");
if (form.packageType === "injection") return t("common.injections");
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
return t("form.blisters.usageTablets");
},
[form.packageType, form.medicationForm, form.pillForm, t]
);
const usesAmountLabels = isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType);
const usesAmountLabels = isPackageAmountPackageType(form.packageType);
const usesCountLabels = isDiscreteCountPackageType(form.packageType) && form.packageType !== "bottle";
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
let currentStockLabel = t("form.currentPills");
if (usesAmountLabels) {
currentStockLabel = t("form.currentAmount");
} else if (usesCountLabels) {
currentStockLabel = t("form.currentStockCount");
}
let totalLabel = t("form.total");
if (usesAmountLabels) {
totalLabel = t("form.totalAmountLabel");
} else if (usesCountLabels) {
totalLabel = t("form.totalCount");
}
const weekdayOptions = useMemo(
() =>
WEEKDAY_CODES.map((day) => ({
@@ -816,7 +840,7 @@ export function MobileEditModal({
<div className="stock-total-field">
<p className="sub">
<strong>{totalLabel}:</strong> {deriveTotalFromForm(form)}
{` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`}
{` ${getDiscreteUnitLabel(deriveTotalFromForm(form))}`}
</p>
</div>
</div>
+20 -4
View File
@@ -307,11 +307,17 @@ function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" {
return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications";
}
function getDiscreteUnitText(med: Medication, value: number, t: TFn): string {
if (med.packageType === "inhaler") return value === 1 ? t("common.puff") : t("common.puffs");
if (med.packageType === "injection") return value === 1 ? t("common.injection") : t("common.injections");
return value === 1 ? t("common.pill") : t("common.pills");
}
function getUsageText(med: Medication, usage: number, t: TFn): string {
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
return `${usage} ${t(getTubeUnitKey(med))}`;
}
return `${usage} ${usage === 1 ? t("common.pill") : t("common.pills")}`;
return `${usage} ${getDiscreteUnitText(med, usage, t)}`;
}
function getTotalCapacityLabel(med: Medication, t: TFn): string {
@@ -325,12 +331,14 @@ function getCurrentStockText(med: Medication, t: TFn): string {
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
return `${getMedTotal(med)} ${t(getTubeUnitKey(med))}`;
}
return `${getMedTotal(med)} ${t("common.pills")}`;
return `${getMedTotal(med)} ${getDiscreteUnitText(med, getMedTotal(med), t)}`;
}
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
if (isTubePackageType(med.packageType)) return t("report.docTube");
if (isLiquidContainerPackageType(med.packageType)) return t("form.packageTypeLiquidContainer");
if (med.packageType === "inhaler") return t("form.packageTypeInhaler");
if (med.packageType === "injection") return t("form.packageTypeInjection");
if (isAmountBasedPackageType(med.packageType)) return t("report.docBottle");
return t("report.docBlister");
}
@@ -442,7 +450,11 @@ function generateTextReport(
if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory")));
for (const r of data.refills) {
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${
isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)
? t(getTubeUnitKey(med))
: getDiscreteUnitText(med, r.quantityAdded, t)
}`;
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
}
@@ -648,7 +660,11 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`;
for (const r of data.refills) {
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(
isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)
? t(getTubeUnitKey(med))
: getDiscreteUnitText(med, r.quantityAdded, t)
)}`;
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
s += `<li>${entry}</li>`;
}
+8 -2
View File
@@ -42,6 +42,12 @@ export function UserFilterModal({
);
};
const getDiscreteUnitLabel = (med: Medication, count: number): string => {
if (med.packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
if (med.packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
return count === 1 ? t("common.pill") : t("common.pills");
};
const formatIntakeUsageLabel = (med: Medication, usage: number, intakeUnit?: IntakeUnit | null): string => {
if (isLiquidMedication(med)) {
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage, t)}`;
@@ -49,7 +55,7 @@ export function UserFilterModal({
if (isTubePackageType(med.packageType)) {
return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`;
}
return `${formatNumber(usage)} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
return `${formatNumber(usage)} ${getDiscreteUnitLabel(med, usage)}`;
};
const formatStockSummaryLabel = (med: Medication, currentStock: number, packageSize: number): string => {
@@ -59,7 +65,7 @@ export function UserFilterModal({
if (isTubePackageType(med.packageType)) {
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${t("form.packageAmountUnitG")}`;
}
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${packageSize === 1 ? t("common.pill") : t("common.pills")}`;
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${getDiscreteUnitLabel(med, packageSize)}`;
};
useEscapeKey(!!selectedUser, onClose);
+9 -7
View File
@@ -1,5 +1,5 @@
import type { IntakeUnit } from "../../types";
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../../types";
import { isLiquidContainerPackageType, isTubePackageType } from "../../types";
import { formatNumber } from "../../utils/formatters";
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../../utils/intake-units";
@@ -27,6 +27,12 @@ function getTubeUnitLabel(med: MedicationLike, value: number, t: Translate): str
return t("form.blisters.applications", { count: Math.abs(value) });
}
function getDiscreteUnitLabel(med: MedicationLike, value: number, t: Translate): string {
if (med?.packageType === "inhaler") return value === 1 ? t("common.puff") : t("common.puffs");
if (med?.packageType === "injection") return value === 1 ? t("common.injection") : t("common.injections");
return value === 1 ? t("common.pill") : t("common.pills");
}
export function formatScheduleDoseUsageLabel(
med: MedicationLike,
usage: number,
@@ -41,7 +47,7 @@ export function formatScheduleDoseUsageLabel(
return `${usage} ${getTubeUnitLabel(med, usage, t)}`;
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
return `${usage} ${getDiscreteUnitLabel(med, usage, t)}`;
}
export function formatScheduleTotalUsageLabel(
@@ -77,9 +83,5 @@ export function formatScheduleTotalUsageLabel(
return `${total} ${getTubeUnitLabel(med, total, t)}`;
}
if (allowsPillFormSelection(med?.packageType)) {
return t("common.pillsTotal", { count: total });
}
return t("common.pillsTotal", { count: total });
return `${total} ${getDiscreteUnitLabel(med, total, t)}`;
}
+28 -2
View File
@@ -4,7 +4,9 @@ import type { FieldErrors, FormBlister, FormIntake, FormState, Medication } from
import {
FIELD_LIMITS,
isAmountBasedPackageType,
isDiscreteCountPackageType,
isLiquidContainerPackageType,
isPackageAmountPackageType,
isTubePackageType,
normalizePackageType,
} from "../types";
@@ -244,7 +246,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
const remainingRefills = Math.min(Math.max(0, med.prescriptionRemainingRefills ?? 0), authorizedRefills);
const lowRefillThreshold = Math.min(Math.max(0, med.prescriptionLowRefillThreshold ?? 1), authorizedRefills);
const packageType = normalizePackageType(med.packageType);
const isTubeOrLiquidPackage = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
const isTubeOrLiquidPackage = isPackageAmountPackageType(packageType);
let normalizedPackCount = String(med.packCount);
let normalizedPackageAmountValue = String(med.packageAmountValue ?? 0);
@@ -288,6 +290,15 @@ export function useMedicationForm(): UseMedicationFormReturn {
} else if (isLiquidContainerPackageType(packageType)) {
normalizedPackageAmountUnit = "ml";
}
let resolvedDoseUnit = med.doseUnit ?? "mg";
if (!med.doseUnit) {
if (packageType === "inhaler") {
resolvedDoseUnit = "puffs";
} else if (packageType === "injection") {
resolvedDoseUnit = "injections";
}
}
let resolvedTotalPills = bottleTotalPills;
if (normalizedDerivedTotal != null) {
resolvedTotalPills = String(normalizedDerivedTotal);
@@ -310,7 +321,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
totalPills: resolvedTotalPills,
looseTablets: normalizedDerivedTotal != null ? String(normalizedDerivedTotal) : String(med.looseTablets),
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
doseUnit: med.doseUnit ?? "mg",
doseUnit: resolvedDoseUnit,
medicationStartDate: med.medicationStartDate ?? "",
medicationEndDate: med.medicationEndDate ?? "",
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
@@ -373,9 +384,22 @@ export function useMedicationForm(): UseMedicationFormReturn {
next.doseUnit = "ml";
next.packageAmountUnit = "ml";
next.intakes = next.intakes.map((intake) => ({ ...intake, intakeUnit: intake.intakeUnit || "ml" }));
} else if (nextPackageType === "inhaler") {
next.medicationForm = "tablet";
next.pillForm = "tablet";
next.lifecycleCategory = "refill_when_empty";
next.doseUnit = "puffs";
} else if (nextPackageType === "injection") {
next.medicationForm = "tablet";
next.pillForm = "tablet";
next.lifecycleCategory = "refill_when_empty";
next.doseUnit = "injections";
} else {
next.medicationForm = next.pillForm;
next.lifecycleCategory = "refill_when_empty";
if (next.doseUnit === "puffs" || next.doseUnit === "injections") {
next.doseUnit = "mg";
}
}
}
@@ -399,6 +423,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
next.packageAmountUnit = "g";
} else if (isLiquidContainerPackageType(next.packageType)) {
next.packageAmountUnit = "ml";
} else if (isDiscreteCountPackageType(next.packageType)) {
next.packageAmountUnit = "ml";
}
if (key === "pillForm" && value === "capsule") {
+10 -5
View File
@@ -4,7 +4,9 @@ import {
getMedTotal,
getPackageSize,
isAmountBasedPackageType,
isDiscreteCountPackageType,
isLiquidContainerPackageType,
isPackageAmountPackageType,
isTubePackageType,
} from "../types";
@@ -172,6 +174,8 @@ export function useRefill(): UseRefillReturn {
const isTubePackage = isTubePackageType(selectedMed.packageType);
const isLiquidPackage = isLiquidContainerPackageType(selectedMed.packageType);
const isAmountPackage = isAmountBasedPackageType(selectedMed.packageType);
const isPackageAmountPackage = isPackageAmountPackageType(selectedMed.packageType);
const isDiscreteCountPackage = isDiscreteCountPackageType(selectedMed.packageType);
const liquidAmountPerBottle = Math.max(
1,
Number.isFinite(Number(selectedMed.packageAmountValue)) && Number(selectedMed.packageAmountValue) > 0
@@ -228,9 +232,9 @@ export function useRefill(): UseRefillReturn {
let baseTotal: number;
if (isLiquidPackage) {
baseTotal = liquidStructuralMax;
} else if (selectedMed.packageType === "bottle") {
} else if (isDiscreteCountPackage) {
baseTotal = selectedMed.looseTablets;
} else if (isAmountPackage) {
} else if (isPackageAmountPackage) {
baseTotal = getPackageSize(selectedMed);
} else {
baseTotal = structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
@@ -253,10 +257,10 @@ export function useRefill(): UseRefillReturn {
patchBody.stockAdjustment = 0;
patchBody.packCount = 0;
patchBody.looseTablets = 0;
if (selectedMed.packageType === "bottle" || isAmountPackage) {
if (isDiscreteCountPackage || isAmountPackage) {
patchBody.totalPills = 0;
}
if (isTubePackage || isLiquidPackage) {
if (isPackageAmountPackage) {
patchBody.packageAmountValue = 0;
}
} else if (isTubePackage) {
@@ -316,6 +320,7 @@ export function useRefill(): UseRefillReturn {
if (!selectedMed) return;
setEditStockMedication(selectedMed);
const isAmountPackage = isAmountBasedPackageType(selectedMed.packageType);
const isDiscreteCountPackage = isDiscreteCountPackageType(selectedMed.packageType);
// Get current stock from coverage (after consumption)
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
const dbTotal = getMedTotal(selectedMed);
@@ -338,7 +343,7 @@ export function useRefill(): UseRefillReturn {
// Pre-fill with current values
setEditStockFullBlisters(fullBlisters);
setEditStockPartialBlisterPills(partialPills);
setEditStockLoosePills(isAmountPackage ? 0 : knownLoose);
setEditStockLoosePills(isAmountPackage || isDiscreteCountPackage ? 0 : knownLoose);
setShowEditStockModal(true);
window.history.pushState({ modal: "editStock" }, "");
}, []);
+12
View File
@@ -185,6 +185,8 @@
"packageTypeBottle": "Pillendose",
"packageTypeTube": "Tube",
"packageTypeLiquidContainer": "Flüssigbehältnis",
"packageTypeInhaler": "Inhalator",
"packageTypeInjection": "Injektion",
"packs": "Packungen",
"bottles": "Flaschen",
"tubes": "Tuben",
@@ -192,9 +194,11 @@
"pillsPerBlister": "Tabletten pro Blister",
"totalCapacity": "Gesamtkapazität",
"currentPills": "Aktuelle Tabletten",
"currentStockCount": "Aktueller Bestand",
"totalAmount": "Gesamtmenge",
"currentAmount": "Aktuelle Menge",
"totalAmountLabel": "Gesamt (Menge)",
"totalCount": "Gesamt (Anzahl)",
"packageAmount": "Packungsinhalt",
"packageAmountPerBottle": "Inhalt pro Flasche",
"packageAmountPerTube": "Inhalt pro Tube",
@@ -261,6 +265,10 @@
"blister_other": "{{count}} Blisterpackungen",
"bottle_one": "1 Flasche",
"bottle_other": "{{count}} Flaschen",
"inhaler_one": "1 Inhalator",
"inhaler_other": "{{count}} Inhalatoren",
"injection_one": "1 Injektionspackung",
"injection_other": "{{count}} Injektionspackungen",
"liquidContainer_one": "1 Flasche",
"liquidContainer_other": "{{count}} Flaschen",
"tube_one": "1 Tube",
@@ -636,6 +644,10 @@
"optional": "optional",
"pill": "Tablette",
"pills": "Tabletten",
"puff": "Hub",
"puffs": "Hübe",
"injection": "Injektion",
"injections": "Injektionen",
"of": "von",
"loose": "lose",
"none": "Kein",
+12
View File
@@ -185,6 +185,8 @@
"packageTypeBottle": "Pill Bottle",
"packageTypeTube": "Tube",
"packageTypeLiquidContainer": "Liquid Container",
"packageTypeInhaler": "Inhaler",
"packageTypeInjection": "Injection",
"packs": "Packs",
"bottles": "Bottles",
"tubes": "Tubes",
@@ -192,9 +194,11 @@
"pillsPerBlister": "Pills per blister",
"totalCapacity": "Total Capacity",
"currentPills": "Current Pills",
"currentStockCount": "Current Stock",
"totalAmount": "Total Amount",
"currentAmount": "Current Amount",
"totalAmountLabel": "Total (amount)",
"totalCount": "Total (count)",
"packageAmount": "Package amount",
"packageAmountPerBottle": "Amount per bottle",
"packageAmountPerTube": "Amount per tube",
@@ -261,6 +265,10 @@
"blister_other": "{{count}} blister packs",
"bottle_one": "1 bottle",
"bottle_other": "{{count}} bottles",
"inhaler_one": "1 inhaler",
"inhaler_other": "{{count}} inhalers",
"injection_one": "1 injection pack",
"injection_other": "{{count}} injection packs",
"liquidContainer_one": "1 bottle",
"liquidContainer_other": "{{count}} bottles",
"tube_one": "1 tube",
@@ -636,6 +644,10 @@
"optional": "optional",
"pill": "pill",
"pills": "pills",
"puff": "puff",
"puffs": "puffs",
"injection": "injection",
"injections": "injections",
"of": "of",
"loose": "loose",
"none": "None",
+16 -3
View File
@@ -435,6 +435,12 @@ export function DashboardPage() {
setObsoleteCandidate(null);
};
const getDiscreteUnitLabel = (packageType: string | undefined, count: number) => {
if (packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
if (packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
return count === 1 ? t("common.pill") : t("common.pills");
};
const getTubeUnitLabel = (med: (typeof meds)[number] | undefined, value: number) =>
isLiquidContainerPackageType(med?.packageType) || med?.medicationForm === "liquid"
? t("form.packageAmountUnitMl")
@@ -449,7 +455,11 @@ export function DashboardPage() {
if (isTubePackageType(med?.packageType)) {
return `${formatNumber(medsLeft)} ${getTubeStockUnitLabel()}`;
}
return t("table.pillsCount", { count: Math.round(medsLeft) });
const roundedCount = Math.round(medsLeft);
if (med?.packageType !== "inhaler" && med?.packageType !== "injection") {
return t("table.pillsCount", { count: roundedCount });
}
return `${roundedCount} ${getDiscreteUnitLabel(med?.packageType, roundedCount)}`;
};
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
@@ -477,7 +487,7 @@ export function DashboardPage() {
if (isTubePackageType(med?.packageType)) {
return `${usage} ${getTubeUnitLabel(med, usage)}`;
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
return `${usage} ${getDiscreteUnitLabel(med?.packageType, usage)}`;
};
const formatTotalUsageLabel = (
@@ -511,6 +521,9 @@ export function DashboardPage() {
if (isTubePackageType(med?.packageType)) {
return `${total} ${getTubeUnitLabel(med, total)}`;
}
if (med?.packageType === "inhaler" || med?.packageType === "injection") {
return `${total} ${getDiscreteUnitLabel(med.packageType, total)}`;
}
return t("common.pillsTotal", { count: total });
};
@@ -551,7 +564,7 @@ export function DashboardPage() {
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: tubeUnit });
}
const pillUnit = dailyTotal === 1 ? t("common.pill") : t("common.pills");
const pillUnit = getDiscreteUnitLabel(med.packageType, dailyTotal);
return t("table.perDayWithUnit", { value: formatNumber(dailyTotal), unit: pillUnit });
};
+45 -12
View File
@@ -39,7 +39,9 @@ import {
getPackageProfile,
getPackageSize,
isAmountBasedPackageType,
isDiscreteCountPackageType,
isLiquidContainerPackageType,
isPackageAmountPackageType,
isTubePackageType,
normalizePackageType,
PACKAGE_PROFILES,
@@ -65,7 +67,16 @@ const OPEN_FDA_PACKAGE_CODE_PATTERN = /\s*\(([0-9A-Z]{4,}(?:-[0-9A-Z]{1,})+)\)\s
function normalizeMedicationEnrichmentDoseUnit(unit: MedicationEnrichmentStrengthOption["doseUnit"]): DoseUnit | null {
if (unit === "IU") return "units";
if (unit === "mg" || unit === "g" || unit === "mcg" || unit === "ml" || unit === "units") return unit;
if (
unit === "mg" ||
unit === "g" ||
unit === "mcg" ||
unit === "ml" ||
unit === "units" ||
unit === "puffs" ||
unit === "injections"
)
return unit;
return null;
}
@@ -768,6 +779,15 @@ export function MedicationsPage() {
return form.pillForm === "tablet";
}, [form.packageType, form.medicationForm, form.pillForm]);
const getDiscreteUnitLabel = useCallback(
(packageType: PackageType, count: number) => {
if (packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
if (packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
return count === 1 ? t("common.pill") : t("common.pills");
},
[t]
);
const getUsageLabel = useCallback(
(intakeUnit: "ml" | "tsp" | "tbsp") => {
if (isLiquidContainerPackageType(form.packageType)) {
@@ -778,16 +798,29 @@ export function MedicationsPage() {
if (isTubePackageType(form.packageType)) {
return form.medicationForm === "liquid" ? t("form.blisters.usageMl") : t("form.blisters.usageApplication");
}
if (form.packageType === "inhaler") return t("common.puffs");
if (form.packageType === "injection") return t("common.injections");
if (form.pillForm === "capsule") return t("form.blisters.usageCapsules");
return t("form.blisters.usageTablets");
},
[form.packageType, form.medicationForm, form.pillForm, t]
);
const usesAmountLabels = isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType);
const usesAmountLabels = isPackageAmountPackageType(form.packageType);
const usesCountLabels = isDiscreteCountPackageType(form.packageType) && form.packageType !== "bottle";
const totalCapacityLabel = usesAmountLabels ? t("form.totalAmount") : t("form.totalCapacity");
const currentStockLabel = usesAmountLabels ? t("form.currentAmount") : t("form.currentPills");
const totalLabel = usesAmountLabels ? t("form.totalAmountLabel") : t("form.total");
let currentStockLabel = t("form.currentPills");
if (usesAmountLabels) {
currentStockLabel = t("form.currentAmount");
} else if (usesCountLabels) {
currentStockLabel = t("form.currentStockCount");
}
let totalLabel = t("form.total");
if (usesAmountLabels) {
totalLabel = t("form.totalAmountLabel");
} else if (usesCountLabels) {
totalLabel = t("form.totalCount");
}
const weekdayOptions = useMemo(
() =>
WEEKDAY_CODES.map((day) => ({
@@ -818,9 +851,9 @@ export function MedicationsPage() {
(med: Medication) => {
if (isTubePackageType(med.packageType)) return "";
if (isLiquidContainerPackageType(med.packageType)) return " ml";
return ` ${getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}`;
return ` ${getDiscreteUnitLabel(normalizePackageType(med.packageType), getPackageSize(med))}`;
},
[t]
[getDiscreteUnitLabel]
);
const getMedicationUsageUnitLabel = useCallback(
@@ -829,10 +862,9 @@ export function MedicationsPage() {
return med.medicationForm === "liquid" ? "ml" : t("form.blisters.usageApplication");
}
if (isLiquidContainerPackageType(med.packageType)) return "ml";
if (usage === 1) return t("common.pill");
return t("common.pills");
return getDiscreteUnitLabel(normalizePackageType(med.packageType), usage);
},
[t]
[getDiscreteUnitLabel, t]
);
const clearMedicationLinkParams = useCallback(() => {
@@ -889,7 +921,7 @@ export function MedicationsPage() {
setReadOnlyView(false);
pendingAction();
} else if (source === "mobile-edit" && showEditModal) {
clearEditMedIdParam();
clearMedicationLinkParams();
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
@@ -1059,6 +1091,8 @@ export function MedicationsPage() {
form.medicationForm === "liquid" || form.medicationForm === "topical" ? form.medicationForm : "topical";
} else if (isLiquidContainerPackageType(form.packageType)) {
derivedMedicationForm = "liquid";
} else if (isDiscreteCountPackageType(form.packageType)) {
derivedMedicationForm = "tablet";
} else {
derivedMedicationForm = form.pillForm;
}
@@ -1079,8 +1113,7 @@ export function MedicationsPage() {
genericName: form.genericName.trim() || null,
takenBy: form.takenBy.length > 0 ? form.takenBy : [],
medicationForm: derivedMedicationForm,
pillForm:
isTubePackageType(form.packageType) || isLiquidContainerPackageType(form.packageType) ? null : form.pillForm,
pillForm: allowsPillFormSelection(form.packageType) ? form.pillForm : null,
lifecycleCategory: form.lifecycleCategory,
packageType: normalizePackageType(form.packageType),
packCount: isTubePackageType(form.packageType)
+8 -2
View File
@@ -122,6 +122,12 @@ export function PlannerPage() {
const canSendNotification =
(settings.emailEnabled && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrUrl);
const getDiscreteUnitLabel = (packageType: string | undefined, count: number): string => {
if (packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
if (packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
return count === 1 ? t("common.pill") : t("common.pills");
};
const getUsageUnitLabel = (medicationId: number, count: number): string => {
const med = meds.find((m) => m.id === medicationId);
if (isLiquidContainerPackageType(med?.packageType)) {
@@ -130,7 +136,7 @@ export function PlannerPage() {
if (isTubePackageType(med?.packageType)) {
return med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
}
return count === 1 ? t("common.pill") : t("common.pills");
return getDiscreteUnitLabel(med?.packageType, count);
};
const getAvailableLabel = (medicationId: number, loosePills: number): string => {
@@ -143,7 +149,7 @@ export function PlannerPage() {
const unit = med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
return `${roundedLoose} ${unit}`;
}
return `${roundedLoose} ${roundedLoose === 1 ? t("common.pill") : t("common.pills")}`;
return `${roundedLoose} ${getDiscreteUnitLabel(med?.packageType, roundedLoose)}`;
};
async function sendPlannerNotification() {
@@ -163,6 +163,59 @@ describe("ReportModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("exports injection refill history with injection unit wording", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 0,
automaticDosesTaken: 0,
dosesSkipped: 0,
firstDoseAt: null,
lastDoseAt: null,
refills: [
{
packsAdded: 1,
loosePillsAdded: 0,
quantityAdded: 3,
usedPrescription: false,
refillDate: "2026-03-04",
},
],
},
}),
});
render(
<ReportModal
isOpen={true}
onClose={onClose}
medications={[
createMedication({
packageType: "injection",
totalPills: 6,
looseTablets: 6,
}),
]}
/>
);
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
const content = await (blob as Blob).text();
expect(content).toContain("report.docCurrentStock: 6 common.injections");
expect(content).toContain("+3 common.injections");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("generates printable report when PDF format is selected", async () => {
const onClose = vi.fn();
const mockWrite = vi.fn();
@@ -15,8 +15,14 @@ const t = (key: string, options?: Record<string, unknown>): string => {
return "pill";
case "common.pills":
return "pills";
case "common.pillsTotal":
return `${options?.count ?? 0} pills total`;
case "common.puff":
return "puff";
case "common.puffs":
return "puffs";
case "common.injection":
return "injection";
case "common.injections":
return "injections";
default:
return key;
}
@@ -33,6 +39,13 @@ describe("schedule formatters", () => {
expect(formatScheduleDoseUsageLabel({ packageType: "tube", medicationForm: "liquid" }, 3, t)).toBe("3 ml");
});
it("formats inhaler and injection doses with package-specific unit wording", () => {
expect(formatScheduleDoseUsageLabel({ packageType: "inhaler" }, 1, t)).toBe("1 puff");
expect(formatScheduleDoseUsageLabel({ packageType: "inhaler" }, 2, t)).toBe("2 puffs");
expect(formatScheduleDoseUsageLabel({ packageType: "injection" }, 1, t)).toBe("1 injection");
expect(formatScheduleDoseUsageLabel({ packageType: "injection" }, 3, t)).toBe("3 injections");
});
it("formats liquid totals from dose units and mixed-unit conversion", () => {
expect(
formatScheduleTotalUsageLabel(
@@ -71,6 +84,8 @@ describe("schedule formatters", () => {
"tbsp"
)
).toBe("4 tablespoons 60 ml");
expect(formatScheduleTotalUsageLabel({ packageType: "blister" }, 3, t)).toBe("3 pills total");
expect(formatScheduleTotalUsageLabel({ packageType: "blister" }, 3, t)).toBe("3 pills");
expect(formatScheduleTotalUsageLabel({ packageType: "inhaler" }, 4, t)).toBe("4 puffs");
expect(formatScheduleTotalUsageLabel({ packageType: "injection" }, 2, t)).toBe("2 injections");
});
});
@@ -199,6 +199,24 @@ describe("useMedicationForm", () => {
expect(result.current.form.packageAmountUnit).toBe("g");
});
it.each([
{ packageType: "inhaler" as const, expectedDoseUnit: "puffs" },
{ packageType: "injection" as const, expectedDoseUnit: "injections" },
])("enforces discrete container defaults when packageType is $packageType", ({ packageType, expectedDoseUnit }) => {
const { result } = renderHook(() => useMedicationForm());
act(() => {
result.current.handleValueChange("packageType", packageType);
});
expect(result.current.form.packageType).toBe(packageType);
expect(result.current.form.medicationForm).toBe("tablet");
expect(result.current.form.pillForm).toBe("tablet");
expect(result.current.form.lifecycleCategory).toBe("refill_when_empty");
expect(result.current.form.doseUnit).toBe(expectedDoseUnit);
expect(result.current.form.packageAmountUnit).toBe("ml");
});
it("normalizes legacy tube records to grams in startEdit", () => {
const { result } = renderHook(() => useMedicationForm());
const openEditModal = vi.fn();
@@ -227,6 +245,41 @@ describe("useMedicationForm", () => {
expect(result.current.form.packageAmountUnit).toBe("g");
});
it.each([
{ packageType: "inhaler" as const, totalPills: 200, looseTablets: 120, expectedDoseUnit: "puffs" },
{ packageType: "injection" as const, totalPills: 12, looseTablets: 6, expectedDoseUnit: "injections" },
])("assigns $expectedDoseUnit when editing $packageType records without a stored dose unit", ({
packageType,
totalPills,
looseTablets,
expectedDoseUnit,
}) => {
const { result } = renderHook(() => useMedicationForm());
const openEditModal = vi.fn();
Object.defineProperty(window, "innerWidth", { value: 1024, writable: true });
const med: Medication = {
id: 13,
name: `${packageType} med`,
takenBy: [],
packageType,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills,
looseTablets,
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
updatedAt: null,
};
act(() => {
result.current.startEdit(med, openEditModal);
});
expect(result.current.form.packageType).toBe(packageType);
expect(result.current.form.doseUnit).toBe(expectedDoseUnit);
});
it("adds, edits and removes blister rows", () => {
const { result } = renderHook(() => useMedicationForm());
+20 -10
View File
@@ -388,18 +388,28 @@ describe("useRefill", () => {
});
});
it("resets bottle stock correction payload to zero base fields", async () => {
it.each([
{ id: 9, packageType: "bottle" as const, name: "Zero Reset Bottle", totalPills: 100, looseTablets: 20 },
{ id: 10, packageType: "inhaler" as const, name: "Zero Reset Inhaler", totalPills: 200, looseTablets: 40 },
{ id: 11, packageType: "injection" as const, name: "Zero Reset Injection", totalPills: 12, looseTablets: 4 },
])("resets $packageType stock correction payload to zero base fields", async ({
id,
packageType,
name,
totalPills,
looseTablets,
}) => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const bottleMed: Medication = {
id: 9,
name: "Zero Reset Bottle",
packageType: "bottle",
const med: Medication = {
id,
name,
packageType,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
totalPills,
looseTablets,
stockAdjustment: 5,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
@@ -410,15 +420,15 @@ describe("useRefill", () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(bottleMed, {
all: [{ name: "Zero Reset Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
result.current.openEditStockModal(med, {
all: [{ name, medsLeft: 25, daysLeft: 25 }] as Coverage[],
});
result.current.setEditStockFullBlisters(0);
result.current.setEditStockPartialBlisterPills(0);
});
await act(async () => {
await result.current.submitStockCorrection(9, bottleMed, mockLoadMeds);
await result.current.submitStockCorrection(id, med, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
+11 -7
View File
@@ -7,7 +7,9 @@ export {
allowsPillFormSelection,
getPackageProfile,
isAmountBasedPackageType,
isDiscreteCountPackageType,
isLiquidContainerPackageType,
isPackageAmountPackageType,
isTubePackageType,
normalizePackageType,
PACKAGE_PROFILES,
@@ -15,10 +17,10 @@ export {
} from "./package-profiles";
import type { PackageType } from "./package-profiles";
import { isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType } from "./package-profiles";
import { isDiscreteCountPackageType, isPackageAmountPackageType } from "./package-profiles";
// Common medication dose units
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units" | "puffs" | "injections";
export type ScheduleMode = "interval" | "weekdays";
export type WeekdayCode = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
@@ -106,6 +108,8 @@ export const DOSE_UNITS: { value: DoseUnit; label: string }[] = [
{ value: "mcg", label: "mcg (µg)" },
{ value: "ml", label: "ml" },
{ value: "units", label: "units" },
{ value: "puffs", label: "puffs" },
{ value: "injections", label: "injections" },
];
export type Blister = {
@@ -406,14 +410,14 @@ type MedLike = Pick<
/** Calculate total pills including stockAdjustment */
export function getMedTotal(med: MedLike): number {
if (med.packageType === "bottle") {
if (isDiscreteCountPackageType(med.packageType)) {
return med.looseTablets + (med.stockAdjustment ?? 0);
}
// Amount-based package types use the same canonical base field as the backend:
// looseTablets stores the current amount baseline, while totalPills is kept in sync
// for compatibility and UI helpers.
if (isAmountBasedPackageType(med.packageType)) {
if (isPackageAmountPackageType(med.packageType)) {
const baseStock = med.looseTablets ?? med.totalPills ?? 0;
return baseStock + (med.stockAdjustment ?? 0);
}
@@ -423,12 +427,12 @@ export function getMedTotal(med: MedLike): number {
/** Get the base package size (without stockAdjustment) */
export function getPackageSize(med: MedLike): number {
if (med.packageType === "bottle") {
if (isDiscreteCountPackageType(med.packageType)) {
return med.totalPills ?? med.looseTablets;
}
// Amount-based package types reuse the backend canonical amount baseline.
if (isAmountBasedPackageType(med.packageType)) {
if (isPackageAmountPackageType(med.packageType)) {
return med.looseTablets ?? med.totalPills ?? 0;
}
// For blister type, calculate from packs + loose
@@ -437,7 +441,7 @@ export function getPackageSize(med: MedLike): number {
/** Get the configured structural capacity used for stock display/limits. */
export function getStockDisplayCapacity(med: MedLike): number {
if (isLiquidContainerPackageType(med.packageType) || isTubePackageType(med.packageType)) {
if (isPackageAmountPackageType(med.packageType)) {
const packageCount = Math.max(1, med.packCount || 1);
const packageAmountValue = Number(med.packageAmountValue ?? 0);
if (Number.isFinite(packageAmountValue) && packageAmountValue > 0) {
+26 -2
View File
@@ -1,4 +1,4 @@
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] as const;
export type PackageType = (typeof PACKAGE_TYPES)[number];
@@ -6,7 +6,7 @@ export type PackageProfile = {
value: PackageType;
labelKey: string;
amountBased: boolean;
plannerUnitKind: "pills" | "ml" | "units";
plannerUnitKind: "pills" | "ml" | "units" | "puffs" | "injections";
allowsPillFormSelection: boolean;
};
@@ -39,6 +39,20 @@ export const PACKAGE_PROFILES: PackageProfile[] = [
plannerUnitKind: "ml",
allowsPillFormSelection: false,
},
{
value: "inhaler",
labelKey: "form.packageTypeInhaler",
amountBased: true,
plannerUnitKind: "puffs",
allowsPillFormSelection: false,
},
{
value: "injection",
labelKey: "form.packageTypeInjection",
amountBased: true,
plannerUnitKind: "injections",
allowsPillFormSelection: false,
},
];
const PACKAGE_TYPE_SET = new Set<string>(PACKAGE_TYPES);
@@ -63,6 +77,16 @@ export function isLiquidContainerPackageType(packageType?: string | null): boole
return normalizePackageType(packageType) === "liquid_container";
}
export function isPackageAmountPackageType(packageType?: string | null): boolean {
const normalized = normalizePackageType(packageType);
return normalized === "tube" || normalized === "liquid_container";
}
export function isDiscreteCountPackageType(packageType?: string | null): boolean {
const normalized = normalizePackageType(packageType);
return normalized === "bottle" || normalized === "inhaler" || normalized === "injection";
}
export function isAmountBasedPackageType(packageType?: string | null): boolean {
return getPackageProfile(packageType).amountBased;
}