Compare commits

...

2 Commits

Author SHA1 Message Date
Daniel Volz 6e85b29549 fix: align frontend refill history displays 2026-05-10 18:51:52 +02:00
Daniel Volz e55e695c88 fix: align refill report quantity semantics 2026-05-10 18:51:51 +02:00
6 changed files with 42 additions and 464 deletions
+7 -17
View File
@@ -2,10 +2,9 @@ import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod"; import { z } from "zod";
import { db } from "../db/client.js"; import { db } from "../db/client.js";
import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js"; import { medications, refillHistory } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js"; import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import type { AuthUser } from "../types/fastify.js"; import type { AuthUser } from "../types/fastify.js";
import { import {
applyOpenApiRouteStandards, applyOpenApiRouteStandards,
@@ -196,22 +195,13 @@ export async function refillRoutes(app: FastifyInstance) {
} }
const refillBaselineAt = new Date(); const refillBaselineAt = new Date();
const [settings] = await db const baselineStockBeforeRefill = isAmountBased
.select({ stockCalculationMode: userSettings.stockCalculationMode }) ? med.looseTablets + (med.stockAdjustment ?? 0)
.from(userSettings) : med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
.where(eq(userSettings.userId, userId)); const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
const stockCalculationMode = settings?.stockCalculationMode === "manual" ? "manual" : "automatic";
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const currentStockAtRefill = computeMedicationCurrentStock({
medication: med,
doses,
stockCalculationMode,
nowMs: refillBaselineAt.getTime(),
});
const targetCurrentStock = currentStockAtRefill + totalPillsAdded;
// Update medication stock. Refill establishes a new stock baseline at the current visible // Update medication stock. Refill establishes a new persisted stock baseline and resets
// stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets. // `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
let newPackCount = med.packCount + effectivePacksAdded; let newPackCount = med.packCount + effectivePacksAdded;
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
let newStockAdjustment = med.stockAdjustment ?? 0; let newStockAdjustment = med.stockAdjustment ?? 0;
+2 -22
View File
@@ -65,7 +65,6 @@ const reportDataResponseSchema = {
properties: { properties: {
packsAdded: { type: "integer" }, packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" }, loosePillsAdded: { type: "integer" },
quantityAdded: { type: "integer" },
usedPrescription: { type: "boolean" }, usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" }, refillDate: { type: "string", format: "date-time" },
}, },
@@ -116,16 +115,7 @@ export async function reportRoutes(app: FastifyInstance) {
: null; : null;
// Verify all medications belong to this user // Verify all medications belong to this user
const userMeds = await db const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
.select({
id: medications.id,
packageType: medications.packageType,
blistersPerPack: medications.blistersPerPack,
pillsPerBlister: medications.pillsPerBlister,
})
.from(medications)
.where(eq(medications.userId, userId));
const medMap = new Map(userMeds.map((med) => [med.id, med]));
const userMedIds = new Set(userMeds.map((m) => m.id)); const userMedIds = new Set(userMeds.map((m) => m.id));
for (const id of medicationIds) { for (const id of medicationIds) {
@@ -169,13 +159,7 @@ export async function reportRoutes(app: FastifyInstance) {
dosesSkipped: number; dosesSkipped: number;
firstDoseAt: string | null; firstDoseAt: string | null;
lastDoseAt: string | null; lastDoseAt: string | null;
refills: { refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
packsAdded: number;
loosePillsAdded: number;
quantityAdded: number;
usedPrescription: boolean;
refillDate: string;
}[];
} }
> = {}; > = {};
@@ -186,9 +170,6 @@ export async function reportRoutes(app: FastifyInstance) {
const skippedDoses = doses.filter((d) => d.dismissed); const skippedDoses = doses.filter((d) => d.dismissed);
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b); const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
const medication = medMap.get(medId);
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
// Get refills for this medication scoped to the authenticated user. // Get refills for this medication scoped to the authenticated user.
const refills = await db const refills = await db
@@ -205,7 +186,6 @@ export async function reportRoutes(app: FastifyInstance) {
refills: refills.map((r) => ({ refills: refills.map((r) => ({
packsAdded: r.packsAdded, packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded, loosePillsAdded: r.loosePillsAdded,
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false, usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate), refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
})), })),
+12 -402
View File
@@ -345,7 +345,6 @@ describe("E2E Tests with Real Routes", () => {
expect(data[medId].refills[0]).toMatchObject({ expect(data[medId].refills[0]).toMatchObject({
packsAdded: 2, packsAdded: 2,
loosePillsAdded: 5, loosePillsAdded: 5,
quantityAdded: 7,
usedPrescription: true, usedPrescription: true,
}); });
}); });
@@ -377,7 +376,6 @@ describe("E2E Tests with Real Routes", () => {
expect(data[medId].refills[0]).toMatchObject({ expect(data[medId].refills[0]).toMatchObject({
packsAdded: 1, packsAdded: 1,
loosePillsAdded: 0, loosePillsAdded: 0,
quantityAdded: 1,
usedPrescription: false, usedPrescription: false,
}); });
}); });
@@ -403,7 +401,8 @@ describe("E2E Tests with Real Routes", () => {
describe("Real /doses/taken routes", () => { describe("Real /doses/taken routes", () => {
it("should mark a dose using real route", async () => { it("should mark a dose using real route", async () => {
const doseId = "1-0-1735344000000"; const medicationId = await createMedication(testClient, userId, "Dose Route Med", []);
const doseId = `${medicationId}-0-1735344000000`;
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
@@ -1121,7 +1120,8 @@ describe("E2E Tests with Real Routes", () => {
describe("Real /doses/taken routes - edge cases", () => { describe("Real /doses/taken routes - edge cases", () => {
it("should return already marked message for duplicate dose", async () => { it("should return already marked message for duplicate dose", async () => {
const doseId = "1-0-1735344000000"; const medicationId = await createMedication(testClient, userId, "Duplicate Dose Med", []);
const doseId = `${medicationId}-0-1735344000000`;
// Mark first time // Mark first time
await app.inject({ await app.inject({
@@ -1142,7 +1142,8 @@ describe("E2E Tests with Real Routes", () => {
}); });
it("should handle doses with person name in doseId", async () => { it("should handle doses with person name in doseId", async () => {
const doseId = "1-0-1735344000000-Daniel"; const medicationId = await createMedication(testClient, userId, "Taken By Med", ["Daniel"]);
const doseId = `${medicationId}-0-1735344000000-Daniel`;
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
@@ -1354,7 +1355,8 @@ describe("E2E Tests with Real Routes", () => {
}); });
it("should handle dose marking and get taken doses", async () => { it("should handle dose marking and get taken doses", async () => {
const doseId = "99-0-1735344000099"; const medicationId = await createMedication(testClient, userId, "Coverage Dose Med", []);
const doseId = `${medicationId}-0-1735344000099`;
// Mark the dose // Mark the dose
const markResponse = await app.inject({ const markResponse = await app.inject({
@@ -2445,81 +2447,6 @@ describe("E2E Tests with Real Routes", () => {
expect(med.stockAdjustment).toBe(0); expect(med.stockAdjustment).toBe(0);
}); });
it("should align liquid amount-base fields for stale stock-adjustment clients before refill", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Liquid Stale Client Stock Correction",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 7,
packageAmountValue: 150,
packageAmountUnit: "ml",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 1050,
looseTablets: 1050,
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const correctionResponse = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: {
stockAdjustment: 0,
packCount: 1,
totalPills: 150,
},
});
expect(correctionResponse.statusCode).toBe(200);
const afterCorrectionResponse = await app.inject({ method: "GET", url: "/medications" });
expect(afterCorrectionResponse.statusCode).toBe(200);
const correctedMed = afterCorrectionResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(correctedMed).toBeTruthy();
expect(correctedMed.packCount).toBe(1);
expect(correctedMed.totalPills).toBe(150);
expect(correctedMed.looseTablets).toBe(150);
expect(correctedMed.stockAdjustment).toBe(0);
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.quantityAdded).toBe(150);
expect(refillData.newStock.packCount).toBe(2);
expect(refillData.newStock.looseTablets).toBe(300);
expect(refillData.newStock.totalPills).toBe(300);
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
expect(historyResponse.json()[0].quantityAdded).toBe(150);
const afterRefillResponse = await app.inject({ method: "GET", url: "/medications" });
expect(afterRefillResponse.statusCode).toBe(200);
const refilledMed = afterRefillResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(refilledMed).toBeTruthy();
expect(refilledMed.packCount).toBe(2);
expect(refilledMed.totalPills).toBe(300);
expect(refilledMed.looseTablets).toBe(300);
});
it("should persist stockAdjustment in GET /medications", async () => { it("should persist stockAdjustment in GET /medications", async () => {
const createResponse = await app.inject({ const createResponse = await app.inject({
method: "POST", method: "POST",
@@ -3125,47 +3052,6 @@ describe("E2E Tests with Real Routes", () => {
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }], blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
}; };
async function expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill,
expectedQuantityAdded,
expectedPacksAdded,
expectedAmountPerPackage,
}: {
medId: number;
refillData: {
refill: { packsAdded: number; quantityAdded: number; totalPillsAdded: number };
newStock: { packCount: number; totalPills: number; looseTablets: number };
};
visibleStockBeforeRefill: number;
expectedQuantityAdded: number;
expectedPacksAdded: number;
expectedAmountPerPackage?: number;
}) {
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
expect(refillData.refill.quantityAdded).toBe(expectedQuantityAdded);
expect(refillData.refill.totalPillsAdded).toBe(expectedQuantityAdded);
expect(refillData.newStock.totalPills - visibleStockBeforeRefill).toBe(expectedQuantityAdded);
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
expect(historyResponse.json()[0]).toMatchObject({
packsAdded: expectedPacksAdded,
quantityAdded: expectedQuantityAdded,
totalPillsAdded: expectedQuantityAdded,
});
if (expectedAmountPerPackage) {
expect(refillData.newStock.packCount).toBe(
Math.max(1, Math.ceil(refillData.newStock.totalPills / expectedAmountPerPackage))
);
}
}
it("should create and return bottle type medication", async () => { it("should create and return bottle type medication", async () => {
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
@@ -3359,196 +3245,6 @@ describe("E2E Tests with Real Routes", () => {
}); });
}); });
it.each([
{
name: "bottle",
payload: {
...bottleMedication,
totalPills: 100,
looseTablets: 10,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 100 },
expectedVisibleStockBeforeRefill: 4,
expectedQuantityAdded: 100,
expectedResponsePacksAdded: 0,
expectedPackCount: 0,
expectedLooseTablets: 104,
expectedTotalPills: 104,
expectedPersistedTotalPills: 100,
expectedStockAdjustment: 0,
},
{
name: "blister",
payload: {
...blisterMedication,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
},
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
expectedVisibleStockBeforeRefill: 4,
expectedQuantityAdded: 10,
expectedResponsePacksAdded: 1,
expectedPackCount: 2,
expectedLooseTablets: 0,
expectedTotalPills: 14,
expectedPersistedTotalPills: null,
expectedStockAdjustment: -6,
},
{
name: "liquid_container",
payload: {
...liquidContainerMedication,
packCount: 1,
packageAmountValue: 100,
packageAmountUnit: "ml",
totalPills: 10,
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
expectedVisibleStockBeforeRefill: 4,
expectedQuantityAdded: 100,
expectedResponsePacksAdded: 1,
expectedAmountPerPackage: 100,
expectedPackCount: 2,
expectedLooseTablets: 104,
expectedTotalPills: 104,
expectedPersistedTotalPills: 104,
expectedStockAdjustment: 0,
},
])("should refill from current visible stock after prior consumption for $name", async ({
payload,
refillPayload,
expectedVisibleStockBeforeRefill,
expectedQuantityAdded,
expectedResponsePacksAdded,
expectedAmountPerPackage,
expectedPackCount,
expectedLooseTablets,
expectedTotalPills,
expectedPersistedTotalPills,
expectedStockAdjustment,
}) => {
await testClient.execute({
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload,
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
for (let day = 1; day <= 6; day += 1) {
const doseDateOnlyMs = new Date(`2025-01-0${day}T00:00:00.000Z`).getTime();
const takenAtMs = new Date(`2025-01-0${day}T10:00:00.000Z`).getTime();
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
VALUES (?, ?, ?, 0)`,
args: [userId, `${medId}-0-${doseDateOnlyMs}`, takenAtMs],
});
}
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: refillPayload,
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
expectedQuantityAdded,
expectedPacksAdded: expectedResponsePacksAdded,
expectedAmountPerPackage,
});
expect(refillData.newStock.packCount).toBe(expectedPackCount);
expect(refillData.newStock.looseTablets).toBe(expectedLooseTablets);
expect(refillData.newStock.totalPills).toBe(expectedTotalPills);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(expectedPackCount);
expect(med.looseTablets).toBe(expectedLooseTablets);
expect(med.totalPills).toBe(expectedPersistedTotalPills);
expect(med.stockAdjustment).toBe(expectedStockAdjustment);
});
it("should refill tube stock from the corrected visible baseline", async () => {
await testClient.execute({
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
...tubeMedication,
packCount: 1,
packageAmountValue: 80,
packageAmountUnit: "g",
totalPills: 10,
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const correctionResponse = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: {
stockAdjustment: -6,
looseTablets: 10,
totalPills: 10,
packageAmountValue: 80,
packCount: 1,
},
});
expect(correctionResponse.statusCode).toBe(200);
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 4,
expectedQuantityAdded: 80,
expectedPacksAdded: 1,
expectedAmountPerPackage: 80,
});
expect(refillData.newStock.packCount).toBe(2);
expect(refillData.newStock.looseTablets).toBe(84);
expect(refillData.newStock.totalPills).toBe(84);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(2);
expect(med.looseTablets).toBe(84);
expect(med.totalPills).toBe(84);
expect(med.stockAdjustment).toBe(0);
});
it("should calculate correct refill totalPillsAdded for blister type", async () => { it("should calculate correct refill totalPillsAdded for blister type", async () => {
const createResponse = await app.inject({ const createResponse = await app.inject({
method: "POST", method: "POST",
@@ -3580,11 +3276,6 @@ describe("E2E Tests with Real Routes", () => {
}); });
it("should keep liquid_container refill additive and preserve amount baseline", async () => { it("should keep liquid_container refill additive and preserve amount baseline", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({ const createResponse = await app.inject({
method: "POST", method: "POST",
url: "/medications", url: "/medications",
@@ -3607,15 +3298,9 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200); expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json(); const refillData = refillResponse.json();
await expectRefillInvariants({ expect(refillData.refill.packsAdded).toBe(1);
medId,
refillData,
visibleStockBeforeRefill: 180,
expectedQuantityAdded: 180,
expectedPacksAdded: 1,
expectedAmountPerPackage: 180,
});
expect(refillData.refill.loosePillsAdded).toBe(180); expect(refillData.refill.loosePillsAdded).toBe(180);
expect(refillData.refill.totalPillsAdded).toBe(180);
expect(refillData.newStock.totalPills).toBe(360); expect(refillData.newStock.totalPills).toBe(360);
const medsResponse = await app.inject({ method: "GET", url: "/medications" }); const medsResponse = await app.inject({ method: "GET", url: "/medications" });
@@ -3626,54 +3311,6 @@ describe("E2E Tests with Real Routes", () => {
expect(med.looseTablets).toBe(360); expect(med.looseTablets).toBe(360);
}); });
it("should normalize liquid_container packCount to the full visible stock after refill", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
...liquidContainerMedication,
packCount: 0,
packageAmountValue: 150,
totalPills: 300,
looseTablets: 300,
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 5, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 300,
expectedQuantityAdded: 750,
expectedPacksAdded: 5,
expectedAmountPerPackage: 150,
});
expect(refillData.newStock.packCount).toBe(7);
expect(refillData.newStock.totalPills).toBe(1050);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(7);
expect(med.totalPills).toBe(1050);
expect(med.looseTablets).toBe(1050);
});
it.each([ it.each([
{ {
name: "liquid_container", name: "liquid_container",
@@ -3690,12 +3327,10 @@ describe("E2E Tests with Real Routes", () => {
prescriptionLowRefillThreshold: 1, prescriptionLowRefillThreshold: 1,
}, },
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true }, refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
expectedVisibleStockBeforeRefill: 180,
expectedPacksAdded: 1, expectedPacksAdded: 1,
expectedLooseAdded: 180, expectedLooseAdded: 180,
expectedRemainingRefills: 1, expectedRemainingRefills: 1,
expectedTotalPills: 360, expectedTotalPills: 360,
expectedAmountPerPackage: 180,
}, },
{ {
name: "tube", name: "tube",
@@ -3707,28 +3342,19 @@ describe("E2E Tests with Real Routes", () => {
prescriptionLowRefillThreshold: 1, prescriptionLowRefillThreshold: 1,
}, },
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true }, refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
expectedVisibleStockBeforeRefill: 80,
expectedPacksAdded: 2, expectedPacksAdded: 2,
expectedLooseAdded: 80, expectedLooseAdded: 80,
expectedRemainingRefills: 1, expectedRemainingRefills: 1,
expectedTotalPills: 160, expectedTotalPills: 160,
expectedAmountPerPackage: 40,
}, },
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({ ])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
payload, payload,
refillPayload, refillPayload,
expectedVisibleStockBeforeRefill,
expectedPacksAdded, expectedPacksAdded,
expectedLooseAdded, expectedLooseAdded,
expectedRemainingRefills, expectedRemainingRefills,
expectedTotalPills, expectedTotalPills,
expectedAmountPerPackage,
}) => { }) => {
await testClient.execute({
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({ const createResponse = await app.inject({
method: "POST", method: "POST",
url: "/medications", url: "/medications",
@@ -3745,17 +3371,8 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200); expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json(); const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
expectedQuantityAdded: expectedLooseAdded,
expectedPacksAdded,
expectedAmountPerPackage,
});
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded); expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded); expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded); expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
expect(refillData.prescription.used).toBe(true); expect(refillData.prescription.used).toBe(true);
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills); expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
@@ -3769,7 +3386,6 @@ describe("E2E Tests with Real Routes", () => {
expect(historyResponse.json()[0]).toMatchObject({ expect(historyResponse.json()[0]).toMatchObject({
packsAdded: expectedPacksAdded, packsAdded: expectedPacksAdded,
loosePillsAdded: expectedLooseAdded, loosePillsAdded: expectedLooseAdded,
quantityAdded: expectedLooseAdded,
usedPrescription: true, usedPrescription: true,
}); });
}); });
@@ -3791,15 +3407,9 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200); expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json(); const refillData = refillResponse.json();
await expectRefillInvariants({ expect(refillData.refill.packsAdded).toBe(1);
medId,
refillData,
visibleStockBeforeRefill: 80,
expectedQuantityAdded: 40,
expectedPacksAdded: 1,
expectedAmountPerPackage: 40,
});
expect(refillData.refill.loosePillsAdded).toBe(40); expect(refillData.refill.loosePillsAdded).toBe(40);
expect(refillData.refill.totalPillsAdded).toBe(40);
expect(refillData.newStock.totalPills).toBe(120); expect(refillData.newStock.totalPills).toBe(120);
const medsResponse = await app.inject({ method: "GET", url: "/medications" }); const medsResponse = await app.inject({ method: "GET", url: "/medications" });
+16 -10
View File
@@ -26,7 +26,7 @@ import {
isLiquidContainerPackageType, isLiquidContainerPackageType,
isTubePackageType, isTubePackageType,
} from "../types"; } from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils"; import { formatNumber, generateICS, getExpiryClass, getSystemLocale, withFormattingTimezone } from "../utils";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule"; import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
import { getLiquidCountUnitLabel } from "../utils/intake-units"; import { getLiquidCountUnitLabel } from "../utils/intake-units";
import { getStockStatus } from "../utils/schedule"; import { getStockStatus } from "../utils/schedule";
@@ -1092,16 +1092,22 @@ export function MedDetailModal({
{refillHistory.map((entry) => ( {refillHistory.map((entry) => (
<div key={entry.id} className="refill-history-item"> <div key={entry.id} className="refill-history-item">
<span className="refill-date"> <span className="refill-date">
{new Date(entry.refillDate).toLocaleDateString(getSystemLocale(i18n.language), { {new Date(entry.refillDate).toLocaleDateString(
day: "2-digit", getSystemLocale(i18n.language),
month: "short", withFormattingTimezone({
year: "numeric", day: "2-digit",
})} month: "short",
year: "numeric",
})
)}
,{" "} ,{" "}
{new Date(entry.refillDate).toLocaleTimeString(getSystemLocale(i18n.language), { {new Date(entry.refillDate).toLocaleTimeString(
hour: "2-digit", getSystemLocale(i18n.language),
minute: "2-digit", withFormattingTimezone({
})} hour: "2-digit",
minute: "2-digit",
})
)}
</span> </span>
<span className="refill-amount"> <span className="refill-amount">
{(() => { {(() => {
+5 -12
View File
@@ -6,7 +6,6 @@ import type { Medication } from "../types";
import { import {
getMedDisplayName, getMedDisplayName,
getMedTotal, getMedTotal,
getStockDisplayCapacity,
isAmountBasedPackageType, isAmountBasedPackageType,
isLiquidContainerPackageType, isLiquidContainerPackageType,
isTubePackageType, isTubePackageType,
@@ -31,13 +30,7 @@ type ReportData = Record<
dosesSkipped: number; dosesSkipped: number;
firstDoseAt: string | null; firstDoseAt: string | null;
lastDoseAt: string | null; lastDoseAt: string | null;
refills: { refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
packsAdded: number;
loosePillsAdded?: number;
quantityAdded: number;
usedPrescription: boolean;
refillDate: string;
}[];
} }
>; >;
@@ -384,7 +377,7 @@ function generateTextReport(
lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister))); lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister)));
if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets))); if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets)));
} else { } else {
lines.push(item(getTotalCapacityLabel(med, t), String(getStockDisplayCapacity(med)))); lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets)));
} }
lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t))); lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t)));
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
@@ -442,7 +435,7 @@ function generateTextReport(
if (data.refills.length > 0) { if (data.refills.length > 0) {
lines.push(h3(t("report.docRefillHistory"))); lines.push(h3(t("report.docRefillHistory")));
for (const r of data.refills) { 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.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`;
if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`; if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`;
lines.push(fmt === "md" ? `- ${entry}` : `${entry}`); lines.push(fmt === "md" ? `- ${entry}` : `${entry}`);
} }
@@ -582,7 +575,7 @@ function buildPrintHtml(
if (med.looseTablets > 0) if (med.looseTablets > 0)
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
} else { } else {
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${getStockDisplayCapacity(med)}</td></tr>`; s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${med.totalPills ?? med.looseTablets}</td></tr>`;
} }
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`; s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
@@ -648,7 +641,7 @@ function buildPrintHtml(
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`; s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`; s += `<ul>`;
for (const r of data.refills) { 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.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`; if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
s += `<li>${entry}</li>`; s += `<li>${entry}</li>`;
} }
@@ -190,7 +190,6 @@ describe("ReportModal", () => {
{ {
packsAdded: 1, packsAdded: 1,
loosePillsAdded: 0, loosePillsAdded: 0,
quantityAdded: 20,
usedPrescription: false, usedPrescription: false,
refillDate: "2026-03-04", refillDate: "2026-03-04",
}, },