diff --git a/README.md b/README.md index ea69b80..19d71a8 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,13 @@ Share your medication schedule with others via a public link. - Manual entry remains available ### Smart Inventory -- Track exact stock with package profiles (blister, bottle, tube, liquid container) +- Track exact stock with package profiles (blister, bottle, tube, liquid container, inhaler, injection) - Display remaining days of supply - Automatic calculation based on intake schedule -- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid) +- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, discrete capacity/current stock for bottle, inhaler, and injection, amount-based stock for tube and liquid container) ### Medication Refill -- One-click refill with pack or loose pill options +- One-click refill with package-aware refill options for discrete containers and amount-based packages - Complete refill history per medication - Automatic stock updates after each refill diff --git a/backend/src/i18n/translations.ts b/backend/src/i18n/translations.ts index 8f15afb..7d2c7cc 100644 --- a/backend/src/i18n/translations.ts +++ b/backend/src/i18n/translations.ts @@ -181,6 +181,8 @@ type TranslationKeys = { common: { pill: string; pills: string; + puffs: string; + injections: string; units: string; ml: string; blister: string; @@ -211,7 +213,7 @@ const translations: Record = { descriptionLow: "The following medications are running low and should be reordered soon:", tableHeaders: { medication: "Medication", - pills: "Pills", + pills: "Available", days: "Days", runsOut: "Runs Out", }, @@ -305,6 +307,8 @@ const translations: Record = { common: { pill: "pill", pills: "pills", + puffs: "puffs", + injections: "injections", units: "units", ml: "ml", blister: "blister", @@ -333,7 +337,7 @@ const translations: Record = { descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:", tableHeaders: { medication: "Medikament", - pills: "Tabletten", + pills: "Verfuegbar", days: "Tage", runsOut: "Aufgebraucht", }, @@ -430,6 +434,8 @@ const translations: Record = { common: { pill: "Tablette", pills: "Tabletten", + puffs: "Hübe", + injections: "Injektionen", units: "Einheiten", ml: "ml", blister: "Blister", diff --git a/backend/src/routes/export.ts b/backend/src/routes/export.ts index d48a5a3..a716e79 100644 --- a/backend/src/routes/export.ts +++ b/backend/src/routes/export.ts @@ -62,7 +62,7 @@ const medicationExportSchema = z.object({ lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"), inventory: inventorySchema, pillWeightMg: z.number().int().nullable().optional(), - doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"), + doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg"), schedules: z.array(scheduleSchema).default([]), medicationStartDate: z.string().nullable().optional(), medicationEndDate: z.string().nullable().optional(), @@ -560,7 +560,11 @@ export async function exportRoutes(app: FastifyInstance) { const packageType = normalizePackageType(medication?.packageType); const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1)); const quantityAdded = - packageType === "bottle" || packageType === "tube" || packageType === "liquid_container" + packageType === "bottle" || + packageType === "inhaler" || + packageType === "injection" || + packageType === "tube" || + packageType === "liquid_container" ? (refill.loosePillsAdded ?? 0) : (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0); diff --git a/backend/src/routes/medication-enrichment.ts b/backend/src/routes/medication-enrichment.ts index b509114..3b550b9 100644 --- a/backend/src/routes/medication-enrichment.ts +++ b/backend/src/routes/medication-enrichment.ts @@ -70,7 +70,10 @@ const strengthOptionSchema = { label: { type: "string" }, pillWeightMg: { type: "number", nullable: true }, doseUnit: { - anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }], + anyOf: [ + { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] }, + { type: "null" }, + ], }, }, } as const; @@ -80,7 +83,7 @@ const packageOptionSchema = { properties: { label: { type: "string" }, description: { type: "string" }, - packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] }, + packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] }, packCount: { type: "integer", minimum: 1 }, blistersPerPack: { type: "integer", minimum: 1, nullable: true }, pillsPerBlister: { type: "integer", minimum: 1, nullable: true }, diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index d6f942e..c63b2c6 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -24,6 +24,7 @@ import { } from "../utils/openapi-route-standards.js"; import { isAmountBasedPackageType, + isDiscreteCountPackageType, isLiquidContainerPackageType, isTubePackageType, normalizePackageType, @@ -67,7 +68,7 @@ const packageTypeSchema = z.enum(PACKAGE_TYPES).default("blister"); const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet"); const pillFormSchema = z.enum(["capsule", "tablet"]); const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"); -const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"); +const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg"); const medicationStartDateSchema = z .union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()]) .optional(); @@ -264,7 +265,7 @@ const medicationBodyOpenApiSchema = { totalPills: { type: ["integer", "null"], minimum: 1 }, looseTablets: { type: "integer", minimum: 0 }, pillWeightMg: { type: ["number", "null"], minimum: 0 }, - doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, + doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] }, medicationStartDate: { anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }], }, @@ -1201,7 +1202,7 @@ export async function medicationRoutes(app: FastifyInstance) { const packageType = normalizePackageType(existing.packageType); const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType); - const allowsBottleCapacityUpdate = packageType === "bottle"; + const allowsDiscreteCapacityUpdate = isDiscreteCountPackageType(packageType); if (allowsAmountBaseUpdate) { const normalizedAmountBase = looseTablets ?? totalPills; if (normalizedAmountBase !== undefined) { @@ -1210,7 +1211,7 @@ export async function medicationRoutes(app: FastifyInstance) { } if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue; } - if (allowsBottleCapacityUpdate && totalPills !== undefined) { + if (allowsDiscreteCapacityUpdate && totalPills !== undefined) { updateFields.totalPills = totalPills; } if (packCount !== undefined) updateFields.packCount = packCount; diff --git a/backend/src/routes/planner.ts b/backend/src/routes/planner.ts index 1441d6d..65d5e1c 100644 --- a/backend/src/routes/planner.ts +++ b/backend/src/routes/planner.ts @@ -19,7 +19,7 @@ import { type StockReminderItem as SharedStockReminderItem, } from "../services/notifications/builders.js"; import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "../services/notifications/delivery.js"; -import { escapeHtml, getPlannerUnit, isContainerPackage } from "../services/planner-service.js"; +import { escapeHtml, formatPlannerQuantity, getPlannerUnit, isContainerPackage } from "../services/planner-service.js"; import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js"; import type { AuthUser } from "../types/fastify.js"; import { @@ -54,6 +54,7 @@ type SendEmailBody = { type LowStockItem = { name: string; medsLeft: number; + packageType?: string; daysLeft: number | null; depletionDate: string | null; isCritical?: boolean; @@ -567,11 +568,10 @@ ${getFooterPlain(language)}`; .map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const) .filter(([name]) => name.length > 0) ); - const filteredLowStock = lowStock.filter((item) => { + const filteredLowStock = lowStock.flatMap((item) => { const packageType = activeMedicationByName.get(item.name); - if (!packageType) return false; - if (isTubePackageType(packageType)) return false; - return true; + if (!packageType || isTubePackageType(packageType)) return []; + return [{ ...item, packageType }]; }); if (filteredLowStock.length === 0) { request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering"); @@ -644,7 +644,7 @@ ${getFooterPlain(language)}`; messageParts.push(`🚨 ${tr.push.criticalSection}:`); criticalMeds.forEach((r) => messageParts.push( - ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` + ` • ${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` ) ); } @@ -653,7 +653,7 @@ ${getFooterPlain(language)}`; messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); lowStockMeds.forEach((r) => messageParts.push( - ` • ${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` + ` • ${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}` ) ); } @@ -734,12 +734,13 @@ ${getFooterPlain(language)}`; const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg; const safeName = escapeHtml(row.name); const safeMedsLeft = Number(row.medsLeft) || 0; + const safeQuantity = escapeHtml(formatPlannerQuantity(row.packageType, safeMedsLeft, tr)); const safeDaysLeft = Number(row.daysLeft) || 0; const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-"; return ` ${statusIcon} ${safeName} - ${safeMedsLeft} + ${safeQuantity} ${safeDaysLeft} ${isEmpty ? `${tr.stockReminder.now}` : safeDepletionDate} `; diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts index 85cf679..5e3542e 100644 --- a/backend/src/routes/refills.ts +++ b/backend/src/routes/refills.ts @@ -12,7 +12,12 @@ import { idParamsSchema, validationErrorSchema, } from "../utils/openapi-route-standards.js"; -import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js"; +import { + isAmountBasedPackageType, + isDiscreteCountPackageType, + isPackageAmountPackageType, + normalizePackageType, +} from "../utils/package-profiles.js"; const refillSchema = z .object({ @@ -143,10 +148,10 @@ export async function refillRoutes(app: FastifyInstance) { const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data; const packageType = normalizePackageType(med.packageType); - const isBottle = packageType === "bottle"; + const isDiscreteCountPackage = isDiscreteCountPackageType(packageType); const isAmountBased = isAmountBasedPackageType(packageType); - const isCountBasedAmountPackage = isAmountBased && !isBottle; - const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; + const isPackageAmountPackage = isPackageAmountPackageType(packageType); + const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister; const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0); const fallbackAmountPerPackage = Math.max( @@ -161,18 +166,11 @@ export async function refillRoutes(app: FastifyInstance) { const requestedPackAdds = Math.max(0, packsAdded); const requestedLooseAdds = Math.max(0, loosePillsAdded); const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds); - const requestedAmountAdds = isCountBasedAmountPackage ? requestedQuantityAdds : requestedLooseAdds; - const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage)); - let effectivePacksAdded = requestedPackAdds; - if (isBottle) { + if (isDiscreteCountPackage) { effectivePacksAdded = 0; - } else if (isCountBasedAmountPackage) { - effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount); } - const effectiveLoosePillsAdded = isCountBasedAmountPackage - ? effectivePacksAdded * amountPerPackage - : requestedAmountAdds; + const effectiveLoosePillsAdded = isPackageAmountPackage ? requestedQuantityAdds : requestedLooseAdds; const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0; const totalPillsAdded = isAmountBased ? effectiveLoosePillsAdded @@ -189,7 +187,7 @@ export async function refillRoutes(app: FastifyInstance) { if (remainingPrescriptionRefills <= 0) { return reply.status(409).send({ error: "No remaining prescription refills" }); } - if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) { + if (!isDiscreteCountPackage && effectivePacksAdded > remainingPrescriptionRefills) { return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" }); } } @@ -207,10 +205,11 @@ export async function refillRoutes(app: FastifyInstance) { let newStockAdjustment = med.stockAdjustment ?? 0; let newTotalAmount = med.totalPills ?? med.looseTablets; - if (isBottle) { + if (isDiscreteCountPackage) { newLooseTablets = targetCurrentStock; + newTotalAmount = Math.max(newTotalAmount, targetCurrentStock); newStockAdjustment = 0; - } else if (isCountBasedAmountPackage) { + } else if (isPackageAmountPackage) { newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage)); newLooseTablets = targetCurrentStock; newTotalAmount = targetCurrentStock; @@ -222,7 +221,7 @@ export async function refillRoutes(app: FastifyInstance) { let consumedRefills = 0; if (usePrescription) { - consumedRefills = isBottle ? 1 : effectivePacksAdded; + consumedRefills = isDiscreteCountPackage ? 1 : effectivePacksAdded; } const newRemainingRefills = usePrescription ? Math.max(0, remainingPrescriptionRefills - consumedRefills) @@ -246,7 +245,7 @@ export async function refillRoutes(app: FastifyInstance) { updatedAt: refillBaselineAt, }; - if (isCountBasedAmountPackage) { + if (isPackageAmountPackage) { updatePayload.totalPills = newTotalAmount; updatePayload.packageAmountValue = amountPerPackage; } @@ -329,9 +328,9 @@ export async function refillRoutes(app: FastifyInstance) { .orderBy(desc(refillHistory.refillDate)); const packageType = normalizePackageType(med.packageType); - const isBottle = packageType === "bottle"; + const isDiscreteCountPackage = isDiscreteCountPackageType(packageType); const isAmountBased = isAmountBasedPackageType(packageType); - const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister; + const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister; return refills.map((r) => ({ id: r.id, diff --git a/backend/src/services/notifications/builders.ts b/backend/src/services/notifications/builders.ts index f6eebc6..5bc614a 100644 --- a/backend/src/services/notifications/builders.ts +++ b/backend/src/services/notifications/builders.ts @@ -1,8 +1,10 @@ import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js"; +import { formatPlannerQuantity } from "../planner-service.js"; export type StockReminderItem = { name: string; medsLeft: number; + packageType?: string; daysLeft: number | null; depletionDate: string | null; isCritical?: boolean; @@ -47,7 +49,7 @@ export function buildStockReminderPushNotification( messageParts.push(`🚨 ${tr.push.criticalSection}:`); criticalItems.forEach((item) => messageParts.push( - ` • ${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}` + ` • ${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}` ) ); } @@ -56,7 +58,7 @@ export function buildStockReminderPushNotification( messageParts.push(`⚠️ ${tr.push.lowStockSection}:`); lowItems.forEach((item) => messageParts.push( - ` • ${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}` + ` • ${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}` ) ); } diff --git a/backend/src/services/planner-service.ts b/backend/src/services/planner-service.ts index 43450a6..57b301b 100644 --- a/backend/src/services/planner-service.ts +++ b/backend/src/services/planner-service.ts @@ -48,10 +48,20 @@ export function isContainerPackage(packageType?: string): boolean { export function getPlannerUnit( packageType: string | undefined, - tr: { common: { units: string; ml: string; pills: string } } + tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } } ): string { const unitKind = getPlannerUnitKind(packageType); if (unitKind === "units") return tr.common.units; if (unitKind === "ml") return tr.common.ml; + if (unitKind === "puffs") return tr.common.puffs ?? tr.common.pills; + if (unitKind === "injections") return tr.common.injections ?? tr.common.pills; return tr.common.pills; } + +export function formatPlannerQuantity( + packageType: string | undefined, + count: number, + tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } } +): string { + return `${count} ${getPlannerUnit(packageType, tr)}`; +} diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index 3dec954..e81dbfe 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -38,6 +38,7 @@ import { } from "./notifications/builders.js"; import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js"; import { loadReminderState, saveReminderState, updateUserReminderSentTime } from "./notifications/state.js"; +import { formatPlannerQuantity } from "./planner-service.js"; export { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js"; @@ -108,6 +109,7 @@ function releaseReminderSendLock(lockFilePath: string | null): void { type LowStockItem = { name: string; medsLeft: number; + packageType?: string; daysLeft: number | null; depletionDate: string | null; isCritical: boolean; @@ -309,6 +311,7 @@ async function getMedicationsNeedingReminder( lowStock.push({ name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }), medsLeft: currentPills, + packageType, daysLeft, depletionDate, isCritical, @@ -432,10 +435,11 @@ async function sendReminderEmail( const statusIcon = isEmpty ? "🚨" : nonEmptyIcon; const nonEmptyBg = isCritical ? "#fff7ed" : "white"; const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg; + const quantityText = formatPlannerQuantity(row.packageType, row.medsLeft, tr); return ` ${statusIcon} ${row.name} - ${row.medsLeft} + ${quantityText} ${row.daysLeft ?? 0} ${isEmpty ? `${tr.stockReminder.now ?? "-"}` : (row.depletionDate ?? "-")} `; @@ -479,7 +483,7 @@ async function sendReminderEmail( ${tr.stockReminder.description} -${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")} +${lowStock.map((r) => `${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")} --- ${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`; diff --git a/backend/src/test/decomposition-services.test.ts b/backend/src/test/decomposition-services.test.ts index e9e20bc..5fb926a 100644 --- a/backend/src/test/decomposition-services.test.ts +++ b/backend/src/test/decomposition-services.test.ts @@ -62,13 +62,17 @@ describe("planner-service decomposition regression", () => { }); it("maps package type to expected planner units after service extraction", () => { - const tr = { common: { units: "units", ml: "ml", pills: "pills" } }; + const tr = { common: { units: "units", ml: "ml", pills: "pills", puffs: "puffs", injections: "injections" } }; expect(isContainerPackage("bottle")).toBe(true); + expect(isContainerPackage("inhaler")).toBe(true); + expect(isContainerPackage("injection")).toBe(true); expect(isContainerPackage("blister")).toBe(false); expect(getPlannerUnit("tube", tr)).toBe("units"); expect(getPlannerUnit("liquid_container", tr)).toBe("ml"); expect(getPlannerUnit("bottle", tr)).toBe("pills"); + expect(getPlannerUnit("inhaler", tr)).toBe("puffs"); + expect(getPlannerUnit("injection", tr)).toBe("injections"); expect(getPlannerUnit("blister", tr)).toBe("pills"); }); }); diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 72fba3f..389af5a 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -1962,7 +1962,7 @@ describe("E2E Tests with Real Routes", () => { const refillResponse = await app.inject({ method: "POST", url: `/medications/${medId}/refill`, - payload: { packsAdded: 1, loosePillsAdded: 0 }, + payload: { packsAdded: 1, loosePillsAdded: 5, quantityAdded: 5 }, }); expect(refillResponse.statusCode).toBe(200); @@ -2336,10 +2336,9 @@ describe("E2E Tests with Real Routes", () => { expect(med.stockAdjustment).toBe(0); }); - it("should persist bottle zero reset with packCount 0 and zero totals", async () => { - const createResponse = await app.inject({ - method: "POST", - url: "/medications", + it.each([ + { + label: "bottle", payload: { name: "Bottle Zero Reset Med", packageType: "bottle", @@ -2350,6 +2349,40 @@ describe("E2E Tests with Real Routes", () => { looseTablets: 20, blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, + }, + { + label: "inhaler", + payload: { + name: "Inhaler Zero Reset Med", + packageType: "inhaler", + doseUnit: "puffs", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 200, + looseTablets: 40, + blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + }, + { + label: "injection", + payload: { + name: "Injection Zero Reset Med", + packageType: "injection", + doseUnit: "injections", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 12, + looseTablets: 4, + blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }], + }, + }, + ])("should persist $label zero reset with packCount 0 and zero totals", async ({ payload }) => { + const createResponse = await app.inject({ + method: "POST", + url: "/medications", + payload, }); expect(createResponse.statusCode).toBe(200); const medId = createResponse.json().id; @@ -2495,7 +2528,7 @@ describe("E2E Tests with Real Routes", () => { const refillResponse = await app.inject({ method: "POST", url: `/medications/${medId}/refill`, - payload: { packsAdded: 1, loosePillsAdded: 0 }, + payload: { packsAdded: 1, loosePillsAdded: 150, quantityAdded: 150 }, }); expect(refillResponse.statusCode).toBe(200); const refillData = refillResponse.json(); @@ -3125,6 +3158,39 @@ describe("E2E Tests with Real Routes", () => { blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], }; + const discreteContainerMedications = [ + { + label: "inhaler", + payload: { + name: "Asthma Inhaler", + packageType: "inhaler", + doseUnit: "puffs", + packCount: 0, + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 200, + looseTablets: 200, + blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], + }, + expectedDoseUnit: "puffs", + }, + { + label: "injection", + payload: { + name: "B12 Injection", + packageType: "injection", + doseUnit: "injections", + packCount: 0, + blistersPerPack: 1, + pillsPerBlister: 1, + totalPills: 12, + looseTablets: 12, + blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }], + }, + expectedDoseUnit: "injections", + }, + ] as const; + async function expectRefillInvariants({ medId, refillData, @@ -3225,6 +3291,23 @@ describe("E2E Tests with Real Routes", () => { expect(data.looseTablets).toBe(180); }); + it.each(discreteContainerMedications)("should create and return $label type medication", async ({ + payload, + expectedDoseUnit, + }) => { + const response = await app.inject({ + method: "POST", + url: "/medications", + payload, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + expect(data.packageType).toBe(payload.packageType); + expect(data.doseUnit).toBe(expectedDoseUnit); + expect(data.looseTablets).toBe(payload.looseTablets); + }); + it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => { await app.inject({ method: "POST", @@ -3407,7 +3490,7 @@ describe("E2E Tests with Real Routes", () => { looseTablets: 10, blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, - refillPayload: { packsAdded: 1, loosePillsAdded: 0 }, + refillPayload: { packsAdded: 1, loosePillsAdded: 100, quantityAdded: 100 }, expectedVisibleStockBeforeRefill: 10, expectedQuantityAdded: 100, expectedResponsePacksAdded: 1, @@ -3522,7 +3605,7 @@ describe("E2E Tests with Real Routes", () => { const refillResponse = await app.inject({ method: "POST", url: `/medications/${medId}/refill`, - payload: { packsAdded: 1, loosePillsAdded: 0 }, + payload: { packsAdded: 1, loosePillsAdded: 80, quantityAdded: 80 }, }); expect(refillResponse.statusCode).toBe(200); @@ -3602,7 +3685,7 @@ describe("E2E Tests with Real Routes", () => { const refillResponse = await app.inject({ method: "POST", url: `/medications/${medId}/refill`, - payload: { packsAdded: 1, loosePillsAdded: 0 }, + payload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180 }, }); expect(refillResponse.statusCode).toBe(200); @@ -3649,7 +3732,7 @@ describe("E2E Tests with Real Routes", () => { const refillResponse = await app.inject({ method: "POST", url: `/medications/${medId}/refill`, - payload: { packsAdded: 5, loosePillsAdded: 0 }, + payload: { packsAdded: 5, loosePillsAdded: 750, quantityAdded: 750 }, }); expect(refillResponse.statusCode).toBe(200); @@ -3689,7 +3772,7 @@ describe("E2E Tests with Real Routes", () => { prescriptionRemainingRefills: 2, prescriptionLowRefillThreshold: 1, }, - refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true }, + refillPayload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180, usePrescription: true }, expectedVisibleStockBeforeRefill: 180, expectedPacksAdded: 1, expectedLooseAdded: 180, @@ -3706,7 +3789,7 @@ describe("E2E Tests with Real Routes", () => { prescriptionRemainingRefills: 3, prescriptionLowRefillThreshold: 1, }, - refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true }, + refillPayload: { packsAdded: 2, loosePillsAdded: 80, quantityAdded: 80, usePrescription: true }, expectedVisibleStockBeforeRefill: 80, expectedPacksAdded: 2, expectedLooseAdded: 80, @@ -3786,7 +3869,7 @@ describe("E2E Tests with Real Routes", () => { const refillResponse = await app.inject({ method: "POST", url: `/medications/${medId}/refill`, - payload: { packsAdded: 1, loosePillsAdded: 0 }, + payload: { packsAdded: 1, loosePillsAdded: 40, quantityAdded: 40 }, }); expect(refillResponse.statusCode).toBe(200); diff --git a/backend/src/utils/package-profiles.ts b/backend/src/utils/package-profiles.ts index 2b32700..9f9b05b 100644 --- a/backend/src/utils/package-profiles.ts +++ b/backend/src/utils/package-profiles.ts @@ -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]; @@ -19,14 +19,25 @@ export function isLiquidContainerPackageType(packageType?: string | null): boole return normalizePackageType(packageType) === "liquid_container"; } -export function isAmountBasedPackageType(packageType?: string | null): boolean { +export function isPackageAmountPackageType(packageType?: string | null): boolean { const normalized = normalizePackageType(packageType); - return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container"; + return normalized === "tube" || normalized === "liquid_container"; } -export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" { +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 isPackageAmountPackageType(packageType) || isDiscreteCountPackageType(packageType); +} + +export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" | "puffs" | "injections" { const normalized = normalizePackageType(packageType); if (normalized === "tube") return "units"; if (normalized === "liquid_container") return "ml"; + if (normalized === "inhaler") return "puffs"; + if (normalized === "injection") return "injections"; return "pills"; } diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index 97ecd84..fad03f9 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -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({ {t("editStock.currentTotal")}: {currentTotal} - {isAmountPackage - ? ` ${stockUnitLabel}` - : ` ${currentTotal === 1 ? t("common.pill") : t("common.pills")}`} + {isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(currentTotal)}`}
{t("editStock.newTotal")}: {newTotal} - {isAmountPackage - ? ` ${stockUnitLabel}` - : ` ${newTotal === 1 ? t("common.pill") : t("common.pills")}`} + {isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(newTotal)}`}
@@ -713,9 +714,7 @@ export function MedDetailModal({ {difference > 0 ? "+" : ""} {difference} - {isAmountPackage - ? ` ${stockUnitLabel}` - : ` ${Math.abs(difference) === 1 ? t("common.pill") : t("common.pills")}`} + {isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(Math.abs(difference))}`}
@@ -1106,7 +1105,7 @@ export function MedDetailModal({ {(() => { const total = entry.quantityAdded; - return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${total === 1 ? t("common.pill") : t("common.pills")}`}`; + return `+${total}${isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(total)}`}`; })()} {entry.usedPrescription && ( @@ -1312,9 +1311,7 @@ export function MedDetailModal({ return totalRefill > 0 ? ( +{totalRefill} - {isAmountPackage - ? ` ${stockUnitLabel}` - : ` ${totalRefill === 1 ? t("common.pill") : t("common.pills")}`} + {isAmountPackage ? ` ${stockUnitLabel}` : ` ${getDiscreteUnitLabel(totalRefill)}`} ) : null; })()} diff --git a/frontend/src/components/MedicationEnrichmentSection.tsx b/frontend/src/components/MedicationEnrichmentSection.tsx index b3b0063..65bebf3 100644 --- a/frontend/src/components/MedicationEnrichmentSection.tsx +++ b/frontend/src/components/MedicationEnrichmentSection.tsx @@ -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": diff --git a/frontend/src/components/MobileEditModal.tsx b/frontend/src/components/MobileEditModal.tsx index de11bb8..2ce0369 100644 --- a/frontend/src/components/MobileEditModal.tsx +++ b/frontend/src/components/MobileEditModal.tsx @@ -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({

{totalLabel}: {deriveTotalFromForm(form)} - {` ${deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}`} + {` ${getDiscreteUnitLabel(deriveTotalFromForm(form))}`}

diff --git a/frontend/src/components/ReportModal.tsx b/frontend/src/components/ReportModal.tsx index 4e9951c..b1489d5 100644 --- a/frontend/src/components/ReportModal.tsx +++ b/frontend/src/components/ReportModal.tsx @@ -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 += `

${escHtml(t("report.docRefillHistory"))}

`; s += `
    `; 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 += ` ${escHtml(t("report.docRefillPrescription"))}`; s += `
  • ${entry}
  • `; } diff --git a/frontend/src/components/UserFilterModal.tsx b/frontend/src/components/UserFilterModal.tsx index ce2cfd6..a837701 100644 --- a/frontend/src/components/UserFilterModal.tsx +++ b/frontend/src/components/UserFilterModal.tsx @@ -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); diff --git a/frontend/src/features/schedule/formatters.ts b/frontend/src/features/schedule/formatters.ts index 49a35f9..bf718de 100644 --- a/frontend/src/features/schedule/formatters.ts +++ b/frontend/src/features/schedule/formatters.ts @@ -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)}`; } diff --git a/frontend/src/hooks/useMedicationForm.ts b/frontend/src/hooks/useMedicationForm.ts index 4c6ced4..c84d95d 100644 --- a/frontend/src/hooks/useMedicationForm.ts +++ b/frontend/src/hooks/useMedicationForm.ts @@ -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") { diff --git a/frontend/src/hooks/useRefill.ts b/frontend/src/hooks/useRefill.ts index 0d4d880..899052c 100644 --- a/frontend/src/hooks/useRefill.ts +++ b/frontend/src/hooks/useRefill.ts @@ -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" }, ""); }, []); diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index ac0c92f..e8abffb 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -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", diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 07fe140..24904cc 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -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", diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index b482dd4..804e961 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -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 }); }; diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index 584faa0..2112863 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -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) diff --git a/frontend/src/pages/PlannerPage.tsx b/frontend/src/pages/PlannerPage.tsx index f82b05f..c43ace8 100644 --- a/frontend/src/pages/PlannerPage.tsx +++ b/frontend/src/pages/PlannerPage.tsx @@ -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() { diff --git a/frontend/src/test/components/ReportModal.test.tsx b/frontend/src/test/components/ReportModal.test.tsx index 5081f8c..d963aea 100644 --- a/frontend/src/test/components/ReportModal.test.tsx +++ b/frontend/src/test/components/ReportModal.test.tsx @@ -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).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( + + ); + + 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).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(); diff --git a/frontend/src/test/features/schedule/formatters.test.ts b/frontend/src/test/features/schedule/formatters.test.ts index 1e39fa5..436f6bc 100644 --- a/frontend/src/test/features/schedule/formatters.test.ts +++ b/frontend/src/test/features/schedule/formatters.test.ts @@ -15,8 +15,14 @@ const t = (key: string, options?: Record): 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"); }); }); diff --git a/frontend/src/test/hooks/useMedicationForm.test.ts b/frontend/src/test/hooks/useMedicationForm.test.ts index aea9775..86239e3 100644 --- a/frontend/src/test/hooks/useMedicationForm.test.ts +++ b/frontend/src/test/hooks/useMedicationForm.test.ts @@ -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()); diff --git a/frontend/src/test/hooks/useRefill.test.ts b/frontend/src/test/hooks/useRefill.test.ts index 7805b27..6ddd364 100644 --- a/frontend/src/test/hooks/useRefill.test.ts +++ b/frontend/src/test/hooks/useRefill.test.ts @@ -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).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).mock.calls[0]; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8bdfa8b..034c656 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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) { diff --git a/frontend/src/types/package-profiles.ts b/frontend/src/types/package-profiles.ts index 2a6027e..68ec390 100644 --- a/frontend/src/types/package-profiles.ts +++ b/frontend/src/types/package-profiles.ts @@ -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(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; }