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
This commit is contained in:
Daniel Volz
2026-02-09 08:10:13 +01:00
committed by GitHub
parent 6d6f906a9a
commit fb937e795b
2 changed files with 111 additions and 5 deletions
+25 -5
View File
@@ -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));
+86
View File
@@ -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
});
});
// ---------------------------------------------------------------------------