From fb937e795bd8339587a5d8f606e1b30b79c1db57 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Mon, 9 Feb 2026 08:10:13 +0100 Subject: [PATCH] fix: planner usage calculation uses user-selected start date (#144) The Demand Calculator used max(now, start) as the effective planner start, which caused asymmetric counting when the current time fell between morning and evening doses. For example, at 15:00 a medication with 07:00+20:00 intakes over 3 days showed 5 pills (2+3) instead of 6 (3+3) because the morning dose on the start day was skipped while the evening was counted. Changes: - Use the user-selected start date directly instead of max(now, start) - Optimize calculateUsageInRange to skip ahead to the relevant range instead of iterating from the original blister start date - Add regression tests for asymmetric counting and blister-before-range --- backend/src/routes/medications.ts | 30 ++++++++-- backend/src/test/integration.test.ts | 86 ++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index db2160f..5c1b400 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -731,9 +731,13 @@ export async function medicationRoutes(app: FastifyInstance) { const currentStock = Math.max(0, originalTotalPills - consumedUntilNow); // Calculate usage for the planning period + // Always use the user-selected start date for the usage calculation. + // Using max(now, start) would cause asymmetric counting when now falls + // between morning and evening doses on the start day (e.g., morning dose + // skipped but evening counted), leading to confusing off-by-one results. + // The stock already reflects consumed doses, so no double-counting occurs. // When includeUntilStart is true, calculate from now to end (useful for trip planning) - // When false, calculate from max(now, start) to end (default behavior) - const effectivePlannerStart = includeUntilStart ? now : new Date(Math.max(now.getTime(), start.getTime())); + const effectivePlannerStart = includeUntilStart ? now : start; const usageTotal = calculateUsageInRange(blisters, effectivePlannerStart, end); const blistersNeeded = pillsPerBlister > 0 ? Math.ceil(usageTotal / pillsPerBlister) : 0; @@ -840,12 +844,28 @@ function calculateUsageInRange( end: Date ) { let total = 0; + const msPerDay = 86400000; blisters.forEach((blister) => { const blisterStart = parseLocalDateTime(blister.start); if (Number.isNaN(blisterStart.getTime())) return; - // iterate occurrences from blisterStart up to end - for (let dt = new Date(blisterStart); dt < end; dt.setDate(dt.getDate() + blister.every)) { - if (dt >= start && dt < end) total += blister.usage; + + const every = Math.max(1, blister.every); + + // Skip ahead to the first occurrence at or after start to avoid + // iterating through months/years of past doses + const dt = new Date(blisterStart); + if (dt < start) { + const daysToSkip = Math.floor((start.getTime() - dt.getTime()) / (every * msPerDay)); + dt.setDate(dt.getDate() + daysToSkip * every); + // Fine-tune: advance until we reach or pass start + while (dt < start) { + dt.setDate(dt.getDate() + every); + } + } + + // Count occurrences in [start, end) + for (; dt < end; dt.setDate(dt.getDate() + every)) { + total += blister.usage; } }); return Number(total.toFixed(2)); diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index 7789fad..cf9a81d 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -1181,6 +1181,92 @@ describe("Integration Tests", () => { expect(data[0].plannerUsage).toBe(10); expect(data[0].enough).toBe(true); // 45 > 10 }); + + it("should use user-selected start date, not current time (fix asymmetric counting)", async () => { + // Regression test: When a planner range starts today, the old code used + // max(now, start) as the effective start. If now was between the morning + // dose (07:00) and evening dose (20:00), morning was skipped but evening + // counted, giving an asymmetric result (e.g., 5 instead of 6). + // + // Example: medication with daily morning (07:00) + evening (20:00) intakes, + // planner range [today 01:00, today+3 01:00). + // Old code at 15:00: morning 07:00 < 15:00 → skipped, evening 20:00 ≥ 15:00 → counted + // Result: 2 morning + 3 evening = 5 instead of 3+3 = 6. + + // Use a past start date so the intakes predate the planner range + const intakeStart = "2025-01-01T07:00:00.000Z"; + const intakeEvening = "2025-01-01T20:00:00.000Z"; + + // Plan range: Feb 9 00:00 to Feb 12 00:00 UTC (3 full days) + const planStart = "2026-02-09T00:00:00.000Z"; + const planEnd = "2026-02-12T00:00:00.000Z"; + + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Twice Daily Med Asymmetric", + packCount: 5, + blistersPerPack: 5, + pillsPerBlister: 10, + blisters: [ + { usage: 1, every: 1, start: intakeStart }, + { usage: 1, every: 1, start: intakeEvening }, + ], + }, + }); + + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: planStart, + endDate: planEnd, + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + // Both morning and evening should have exactly 3 occurrences each + // (Feb 9, 10, 11) for a total of 6, regardless of current time + expect(data[0].plannerUsage).toBe(6); + }); + + it("should handle planner range starting before blister start", async () => { + // Blister starts on Feb 10, planner range starts Feb 9 + // Should only count doses from Feb 10 onwards + const intakeMorning = "2026-02-10T07:00:00.000Z"; + const intakeEvening = "2026-02-10T20:00:00.000Z"; + + await app.inject({ + method: "POST", + url: "/medications", + payload: { + name: "Recent Start Med", + packCount: 1, + blistersPerPack: 1, + pillsPerBlister: 30, + blisters: [ + { usage: 1, every: 1, start: intakeMorning }, + { usage: 1, every: 1, start: intakeEvening }, + ], + }, + }); + + const response = await app.inject({ + method: "POST", + url: "/medications/usage", + payload: { + startDate: "2026-02-09T00:00:00.000Z", + endDate: "2026-02-12T00:00:00.000Z", + }, + }); + + expect(response.statusCode).toBe(200); + const data = response.json(); + // Only Feb 10 and Feb 11 have doses (blister starts Feb 10) + expect(data[0].plannerUsage).toBe(4); // 2 days × 2 intakes + }); }); // ---------------------------------------------------------------------------