fix: correct stock calculation for both manual and automatic modes (#136)

Manual mode: Use takenAt timestamp instead of dose date-only comparison
to correctly distinguish doses taken before vs after stock correction
on the same day. Add polling race condition guard (mutationInFlightRef)
so Take/Undo immediately reflects in dashboard stock.

Automatic mode: Grid-align effectiveStart to the medication schedule
and use hybrid consumed calculation (time-based + early-taken doses)
for accurate stock counting.
This commit is contained in:
Daniel Volz
2026-02-08 17:27:47 +01:00
committed by GitHub
parent 61b8812808
commit ffeecad14f
5 changed files with 549 additions and 123 deletions
+3 -1
View File
@@ -278,7 +278,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
systemLocale,
settingsHook.settings.reminderDaysBefore,
settingsHook.settings.stockCalculationMode,
doses.takenDoses
doses.takenDoses,
doses.takenDoseTimestamps
),
[
medications.meds,
@@ -287,6 +288,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
settingsHook.settings.reminderDaysBefore,
settingsHook.settings.stockCalculationMode,
doses.takenDoses,
doses.takenDoseTimestamps,
]
);
+92 -45
View File
@@ -2,11 +2,12 @@
// useDoses Hook - Dose tracking state and operations
// =============================================================================
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
export interface UseDosesReturn {
takenDoses: Set<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
takenDoseTimestamps: Map<string, number>;
dismissedDoses: Set<string>;
showClearMissedConfirm: boolean;
setShowClearMissedConfirm: (show: boolean) => void;
@@ -19,25 +20,39 @@ export interface UseDosesReturn {
export function useDoses(): UseDosesReturn {
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
// Track in-flight mutations to prevent polling from overwriting optimistic updates
const mutationInFlightRef = useRef(0);
// Load taken doses from server
const loadTakenDoses = useCallback(async () => {
// Skip polling while mutations are in-flight to prevent race conditions
// where a poll response with stale data overwrites optimistic updates
if (mutationInFlightRef.current > 0) return;
try {
const res = await fetch("/api/doses/taken", { credentials: "include" });
if (res.ok) {
// Double-check no mutation started while we were fetching
if (mutationInFlightRef.current > 0) return;
const data = await res.json();
const taken = new Set<string>();
const timestamps = new Map<string, number>();
const dismissed = new Set<string>();
for (const d of data.doses) {
if (d.dismissed) {
dismissed.add(d.doseId);
} else {
taken.add(d.doseId);
timestamps.set(d.doseId, d.takenAt);
}
}
setTakenDoses(taken);
setTakenDoseTimestamps(timestamps);
setDismissedDoses(dismissed);
}
// Don't reset on error - keep current state
@@ -77,59 +92,91 @@ export function useDoses(): UseDosesReturn {
[takenDoses, getDoseId]
);
const markDoseTaken = useCallback(async (doseId: string) => {
// Optimistic update
setTakenDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
return next;
});
// Send to server
try {
await fetch("/api/doses/taken", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
} catch {
// Revert on error
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
}
}, []);
const undoDoseTaken = useCallback(async (doseId: string) => {
// Optimistic update
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
// Send to server
try {
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
// Revert on error
const markDoseTaken = useCallback(
async (doseId: string) => {
// Optimistic update
mutationInFlightRef.current++;
setTakenDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
return next;
});
}
}, []);
setTakenDoseTimestamps((prev) => {
const next = new Map(prev);
next.set(doseId, Date.now());
return next;
});
// Send to server
try {
await fetch("/api/doses/taken", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ doseId }),
});
} catch {
// Revert on error
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
setTakenDoseTimestamps((prev) => {
const next = new Map(prev);
next.delete(doseId);
return next;
});
} finally {
mutationInFlightRef.current--;
// Re-sync with server after mutation completes
loadTakenDoses();
}
},
[loadTakenDoses]
);
const undoDoseTaken = useCallback(
async (doseId: string) => {
// Optimistic update
mutationInFlightRef.current++;
setTakenDoses((prev) => {
const next = new Set(prev);
next.delete(doseId);
return next;
});
setTakenDoseTimestamps((prev) => {
const next = new Map(prev);
next.delete(doseId);
return next;
});
// Send to server
try {
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
method: "DELETE",
credentials: "include",
});
} catch {
// Revert on error
setTakenDoses((prev) => {
const next = new Set(prev);
next.add(doseId);
return next;
});
} finally {
mutationInFlightRef.current--;
// Re-sync with server after mutation completes
loadTakenDoses();
}
},
[loadTakenDoses]
);
return {
takenDoses,
setTakenDoses,
takenDoseTimestamps,
dismissedDoses,
showClearMissedConfirm,
setShowClearMissedConfirm,
+18 -8
View File
@@ -103,10 +103,14 @@ describe("useDoses", () => {
});
it("marks dose as taken optimistically", async () => {
// First call for initial load, subsequent calls for marking dose
// First call for initial load, second for marking dose, third for re-sync
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockResolvedValueOnce({ ok: true });
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ doses: [{ doseId: "new-dose", takenAt: Date.now(), dismissed: false }] }),
});
const { result } = renderHook(() => useDoses());
@@ -119,7 +123,9 @@ describe("useDoses", () => {
await result.current.markDoseTaken("new-dose");
});
expect(result.current.takenDoses.has("new-dose")).toBe(true);
await waitFor(() => {
expect(result.current.takenDoses.has("new-dose")).toBe(true);
});
expect(fetch).toHaveBeenCalledWith(
"/api/doses/taken",
expect.objectContaining({
@@ -130,10 +136,11 @@ describe("useDoses", () => {
});
it("reverts optimistic update on error", async () => {
// First call for initial load, second for marking dose fails
// First call for initial load, second for marking dose fails, third for re-sync
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) })
.mockRejectedValueOnce(new Error("Network error"));
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) });
const { result } = renderHook(() => useDoses());
@@ -153,12 +160,13 @@ describe("useDoses", () => {
it("undoes dose taken optimistically", async () => {
const mockDoses = {
doses: [{ doseId: "taken-dose", dismissed: false }],
doses: [{ doseId: "taken-dose", takenAt: Date.now(), dismissed: false }],
};
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) })
.mockResolvedValueOnce({ ok: true });
.mockResolvedValueOnce({ ok: true })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ doses: [] }) });
const { result } = renderHook(() => useDoses());
@@ -170,7 +178,9 @@ describe("useDoses", () => {
await result.current.undoDoseTaken("taken-dose");
});
expect(result.current.takenDoses.has("taken-dose")).toBe(false);
await waitFor(() => {
expect(result.current.takenDoses.has("taken-dose")).toBe(false);
});
expect(fetch).toHaveBeenCalledWith("/api/doses/taken/taken-dose", expect.objectContaining({ method: "DELETE" }));
});
+367 -29
View File
@@ -409,8 +409,9 @@ describe("calculateCoverage", () => {
});
it("stock correction with dose tracking data also reflects correctly", () => {
// When the user has dose tracking data, the actualConsumed path is used.
// Verify that no phantom dose is generated right after a stock correction.
// In automatic mode, dose tracking data is ignored — stock is always
// reduced based on the schedule. Verify that tracked doses don't affect
// the calculation and that stock correction still resets the baseline.
const correctionTime = new Date("2024-03-15T12:00:00Z");
const march14 = new Date("2024-03-14T00:00:00").getTime();
@@ -437,20 +438,23 @@ describe("calculateCoverage", () => {
];
// User has tracked a dose yesterday (before the correction)
// In automatic mode, this should be ignored — only the schedule matters.
const takenDoses = new Set([`1-0-${march14}`]);
const result = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
expect(result.all).toHaveLength(1);
// getMedTotal = 30 - 7 = 23.
// The taken dose from yesterday should NOT be counted (it's before the correction).
// No new doses should exist since the correction just happened.
// Automatic mode ignores tracking data. After correction, consumption
// restarts from correctionTime + period, which is in the future.
expect(result.all[0].medsLeft).toBe(23);
});
it("stock correction consumption resumes after one full period", () => {
// After 1 day (for daily medication), the next dose should be consumed.
// Set system time to 1 day + 1 hour after correction.
// After correction, the next scheduled dose on the blister's grid should
// be counted once its time arrives.
// Correction at March 14 12:00, blister start 08:00 daily →
// next dose after correction = March 15 08:00. Now is 13:00 on March 15 → 1 dose.
const correctionTime = new Date("2024-03-14T12:00:00Z");
vi.setSystemTime(new Date("2024-03-15T13:00:00Z")); // 25 hours after correction
@@ -484,14 +488,205 @@ describe("calculateCoverage", () => {
expect(result.all[0].medsLeft).toBe(22);
});
it("manual mode: stock correction excludes same-day taken doses", () => {
// BUG FIX: In manual mode, doses taken on the same day as a stock correction
// were counted as consumed (>= comparison with date-only timestamps).
// The user already accounted for today's consumption when setting the stock count.
//
// Scenario: User has 110 pills, took 1 dose today, corrects to 111.
// Bug: medsLeft = 111 - 1 = 110 (today's dose counted)
// Fix: medsLeft = 111 - 0 = 111 (today's dose excluded)
it("stock correction aligns to schedule grid, not correction timestamp", () => {
// BUG: When correction happened just before a scheduled dose (e.g. 15:40
// correction, 15:42 dose), the old code added 1 full period to the correction
// time (15:40 + 24h = tomorrow 15:40), missing today's 15:42 dose entirely.
// FIX: Align effectiveStart to the blister's schedule grid so that the first
// dose counted is the next one on the schedule after the correction.
const correctionTime = new Date("2024-03-14T15:40:00Z"); // 2 min before dose
vi.setSystemTime(new Date("2024-03-14T15:45:00Z")); // 5 min after correction, 3 min after dose
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
stockAdjustment: -5, // 30 - 5 = 25 pills
lastStockCorrectionAt: correctionTime.toISOString(),
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-01T15:42:00Z", // Daily at 15:42
},
],
updatedAt: correctionTime.toISOString(),
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
// Correction at 15:40, dose at 15:42, now at 15:45.
// The 15:42 dose is AFTER the correction → should be counted.
// medsLeft = 25 - 1 = 24
expect(result.all[0].medsLeft).toBe(24);
});
it("stock correction shortly after a dose does not count that dose again", () => {
// If correction happens shortly AFTER a dose, that dose is already reflected
// in the stock count and should NOT be counted again.
const correctionTime = new Date("2024-03-14T15:45:00Z"); // 3 min AFTER the 15:42 dose
vi.setSystemTime(new Date("2024-03-14T16:00:00Z")); // 15 min after correction
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
stockAdjustment: -5,
lastStockCorrectionAt: correctionTime.toISOString(),
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-01T15:42:00Z", // Daily at 15:42
},
],
updatedAt: correctionTime.toISOString(),
},
];
const result = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(result.all).toHaveLength(1);
// Correction at 15:45, after today's 15:42 dose → next dose is TOMORROW 15:42.
// Now is 16:00 today → next dose hasn't arrived yet → 0 consumed.
// medsLeft = 25
expect(result.all[0].medsLeft).toBe(25);
});
it("automatic mode ignores past dose tracking data", () => {
// Automatic mode uses time-based expected consumption for past doses.
// Even if a user marks only some past doses as taken, the stock should still
// decrease for ALL scheduled doses whose time has passed.
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-10T09:00:00",
},
],
updatedAt: null,
},
];
// System time is March 15 12:00, start is 09:00 → 6 occurrences (March 10-15)
const march10 = new Date("2024-03-10T00:00:00").getTime();
const march11 = new Date("2024-03-11T00:00:00").getTime();
// User only marked 2 out of 6 past doses as taken
const takenDoses = new Set([`1-0-${march10}`, `1-0-${march11}`]);
const resultWithTracking = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
const resultWithoutTracking = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
// Both should have the same medsLeft — past tracking data doesn't reduce extra
expect(resultWithTracking.all[0].medsLeft).toBe(resultWithoutTracking.all[0].medsLeft);
// 30 pills - 6 consumed = 24
expect(resultWithTracking.all[0].medsLeft).toBe(24);
});
it("automatic mode counts early-taken future doses", () => {
// If a user marks a dose as taken BEFORE the scheduled time,
// it should count as consumed immediately (early intake).
// System time is March 15 12:00, intake at 21:00 → today's dose not yet auto-consumed
vi.setSystemTime(new Date("2024-03-15T12:00:00Z"));
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-10T21:00:00", // 21:00 = after current time 12:00
},
],
updatedAt: null,
},
];
// 5 occurrences auto-consumed: March 10-14 (all at 21:00, which is past)
// March 15 at 21:00 hasn't passed yet (it's only 12:00)
const resultNoTracking = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
expect(resultNoTracking.all[0].medsLeft).toBe(25); // 30 - 5 = 25
// User marks today's (March 15) dose as taken early at 12:00
const march15 = new Date("2024-03-15T00:00:00").getTime();
const takenDoses = new Set([`1-0-${march15}`]);
const resultEarlyTaken = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
// 5 auto + 1 early = 6 consumed → 30 - 6 = 24
expect(resultEarlyTaken.all[0].medsLeft).toBe(24);
});
it("automatic mode does not double-count after intake time passes", () => {
// After the scheduled time, the dose is auto-consumed.
// If it was also marked as taken (earlier), it should NOT be counted twice.
vi.setSystemTime(new Date("2024-03-15T22:00:00Z")); // After 21:00
const meds: Medication[] = [
{
id: 1,
name: "TestMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-10T21:00:00",
},
],
updatedAt: null,
},
];
// 6 occurrences auto-consumed: March 10-15 (all at 21:00, now it's 22:00)
const march15 = new Date("2024-03-15T00:00:00").getTime();
const march14 = new Date("2024-03-14T00:00:00").getTime();
// User marked March 14 and 15 as taken (both already auto-consumed by now)
const takenDoses = new Set([`1-0-${march14}`, `1-0-${march15}`]);
const resultTracked = calculateCoverage(meds, [], "en", 7, "automatic", takenDoses);
const resultNoTracking = calculateCoverage(meds, [], "en", 7, "automatic", new Set());
// Both should be 24 (30 - 6). No double counting!
expect(resultTracked.all[0].medsLeft).toBe(24);
expect(resultNoTracking.all[0].medsLeft).toBe(24);
});
it("manual mode: dose taken BEFORE stock correction is excluded", () => {
// When a user corrects stock, any dose marked BEFORE the correction
// is already reflected in the corrected count and should NOT be counted.
const correctionTime = new Date("2024-03-15T12:00:00Z");
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
@@ -517,17 +712,59 @@ describe("calculateCoverage", () => {
},
];
// User took a dose today (before the correction)
const takenDoses = new Set([`1-0-${todayMidnight}`]);
// User took a dose today at 10am (BEFORE the correction at 12pm)
const doseId = `1-0-${todayMidnight}`;
const takenDoses = new Set([doseId]);
const takenDoseTimestamps = new Map([[doseId, new Date("2024-03-15T10:00:00Z").getTime()]]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
expect(result.all).toHaveLength(1);
// getMedTotal = 196 - 85 = 111
// Today's taken dose should NOT be counted (same day as correction)
// Dose was taken BEFORE correction → NOT counted
expect(result.all[0].medsLeft).toBe(111);
});
it("manual mode: dose taken AFTER stock correction is counted", () => {
// When a user corrects stock and then takes a dose, that dose SHOULD be counted.
const correctionTime = new Date("2024-03-15T12:00:00Z");
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
const meds: Medication[] = [
{
id: 1,
name: "DailyMed",
packCount: 1,
blistersPerPack: 14,
pillsPerBlister: 14,
looseTablets: 0,
stockAdjustment: -85, // 196 - 85 = 111 pills
lastStockCorrectionAt: correctionTime.toISOString(),
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-01-01T08:00:00",
},
],
updatedAt: correctionTime.toISOString(),
},
];
// User took a dose today at 2pm (AFTER the correction at 12pm)
const doseId = `1-0-${todayMidnight}`;
const takenDoses = new Set([doseId]);
const takenDoseTimestamps = new Map([[doseId, new Date("2024-03-15T14:00:00Z").getTime()]]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
expect(result.all).toHaveLength(1);
// getMedTotal = 196 - 85 = 111
// Dose was taken AFTER correction → counted → 111 - 1 = 110
expect(result.all[0].medsLeft).toBe(110);
});
it("manual mode: stock correction counts next-day taken doses", () => {
// After a stock correction, doses taken the next day SHOULD be counted.
const correctionTime = new Date("2024-03-14T12:00:00Z");
@@ -555,14 +792,16 @@ describe("calculateCoverage", () => {
},
];
// User takes dose on March 15 (day after correction on March 14)
const takenDoses = new Set([`1-0-${march15Midnight}`]);
// User takes dose on March 15 at 8am (day after correction on March 14)
const doseId = `1-0-${march15Midnight}`;
const takenDoses = new Set([doseId]);
const takenDoseTimestamps = new Map([[doseId, new Date("2024-03-15T08:00:00Z").getTime()]]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
expect(result.all).toHaveLength(1);
// getMedTotal = 30 - 7 = 23
// March 15 dose should be counted (day after correction)
// March 15 dose should be counted (taken after correction)
expect(result.all[0].medsLeft).toBe(22);
});
@@ -592,9 +831,16 @@ describe("calculateCoverage", () => {
];
// User took doses on March 14 and 15
const takenDoses = new Set([`1-0-${march14Midnight}`, `1-0-${march15Midnight}`]);
const doseId1 = `1-0-${march14Midnight}`;
const doseId2 = `1-0-${march15Midnight}`;
const takenDoses = new Set([doseId1, doseId2]);
// No stock correction → takenAt doesn't matter, but provide for completeness
const takenDoseTimestamps = new Map([
[doseId1, new Date("2024-03-14T08:00:00Z").getTime()],
[doseId2, new Date("2024-03-15T08:00:00Z").getTime()],
]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
expect(result.all).toHaveLength(1);
// Both doses should be counted: medsLeft = 30 - 2 = 28
@@ -603,7 +849,7 @@ describe("calculateCoverage", () => {
it("manual mode: stock correction with multiple medications", () => {
// Regression test: 3 medications (daily, daily, weekly).
// Stock correction on all 3. The daily ones have same-day taken doses.
// Stock correction on all 3. Daily meds have doses taken BEFORE correction.
const correctionTime = new Date("2024-03-15T12:00:00Z");
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
@@ -649,21 +895,113 @@ describe("calculateCoverage", () => {
},
];
// Daily meds have same-day taken doses, weekly med does not
const takenDoses = new Set([`1-0-${todayMidnight}`, `2-0-${todayMidnight}`]);
// Daily meds have same-day doses taken BEFORE correction (at 8am, correction at 12pm)
const doseId1 = `1-0-${todayMidnight}`;
const doseId2 = `2-0-${todayMidnight}`;
const takenDoses = new Set([doseId1, doseId2]);
const takenDoseTimestamps = new Map([
[doseId1, new Date("2024-03-15T08:00:00Z").getTime()], // Before correction
[doseId2, new Date("2024-03-15T09:00:00Z").getTime()], // Before correction
]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
expect(result.all).toHaveLength(3);
const daily1 = result.all.find((c) => c.name === "DailyMed1")!;
const daily2 = result.all.find((c) => c.name === "DailyMed2")!;
const weekly = result.all.find((c) => c.name === "WeeklyMed")!;
// All three should reflect full stock (same-day doses excluded)
// All three should reflect full stock (doses taken before correction → excluded)
expect(daily1.medsLeft).toBe(111);
expect(daily2.medsLeft).toBe(20);
expect(weekly.medsLeft).toBe(8);
});
it("manual mode: person-suffix dose IDs are counted correctly", () => {
// BUG HUNT: In prod (manual mode), dose IDs have a person suffix like
// "31-0-1770505200000-Daniel". Does the manual mode code correctly parse
// and count these?
const march14 = new Date("2024-03-14T00:00:00").getTime();
const march15 = new Date("2024-03-15T00:00:00").getTime();
const meds: Medication[] = [
{
id: 31,
name: "ProdMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: ["Daniel"],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-01T08:00:00",
},
],
updatedAt: null,
},
];
// Dose IDs with person suffix (as prod generates them)
const doseId1 = `31-0-${march14}-Daniel`;
const doseId2 = `31-0-${march15}-Daniel`;
const takenDoses = new Set([doseId1, doseId2]);
// No stock correction → all counted
const takenDoseTimestamps = new Map([
[doseId1, new Date("2024-03-14T08:00:00Z").getTime()],
[doseId2, new Date("2024-03-15T08:00:00Z").getTime()],
]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
expect(result.all).toHaveLength(1);
// Both doses should be counted: medsLeft = 30 - 2 = 28
expect(result.all[0].medsLeft).toBe(28);
});
it("manual mode: future dose taken today counts immediately", () => {
// User marks a future dose (later today) as taken.
// It should be counted in manual mode immediately.
vi.setSystemTime(new Date("2024-03-15T12:00:00Z"));
const march15 = new Date("2024-03-15T00:00:00").getTime();
const meds: Medication[] = [
{
id: 31,
name: "ProdMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: ["Daniel"],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-01T21:00:00", // 21:00, still in future at 12:00
},
],
updatedAt: null,
},
];
// No doses taken → 30 pills
const resultBefore = calculateCoverage(meds, [], "en", 7, "manual", new Set());
expect(resultBefore.all[0].medsLeft).toBe(30);
// Take today's dose (future time) → 29 pills
const doseId = `31-0-${march15}-Daniel`;
const takenDoses = new Set([doseId]);
const takenDoseTimestamps = new Map([[doseId, Date.now()]]);
const resultAfter = calculateCoverage(meds, [], "en", 7, "manual", takenDoses, takenDoseTimestamps);
expect(resultAfter.all[0].medsLeft).toBe(29);
// Undo → back to 30 pills
const resultUndo = calculateCoverage(meds, [], "en", 7, "manual", new Set());
expect(resultUndo.all[0].medsLeft).toBe(30);
});
});
describe("getStockStatus", () => {
+69 -40
View File
@@ -100,7 +100,8 @@ export function calculateCoverage(
locale: string,
reminderDaysBefore: number,
stockCalculationMode: "automatic" | "manual",
takenDoses: Set<string>
takenDoses: Set<string>,
takenDoseTimestamps?: Map<string, number>
): { low: Coverage[]; all: Coverage[] } {
const MS_PER_DAY = 86_400_000;
const now = Date.now();
@@ -122,60 +123,90 @@ export function calculateCoverage(
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
if (stockCalculationMode === "automatic") {
// In automatic mode, calculate expected consumption based on time
// but also account for manual corrections (doses marked as not taken)
// In automatic mode, stock is reduced automatically based on the schedule.
// Every scheduled dose counts as consumed once its time has passed.
// Additionally, if a user marks a future dose as taken BEFORE the scheduled
// time (early intake), that dose is also counted as consumed immediately.
// This prevents double-counting: once the scheduled time arrives, the dose
// was already counted via the early-taken path, not again via time.
blisters.forEach((s, blisterIdx) => {
const blisterStart = new Date(s.start).getTime();
const period = Math.max(1, s.every) * MS_PER_DAY;
// After a stock correction, start counting consumption from the NEXT
// scheduled dose, because the user's pill count already reflects all
// consumption up to the correction time.
// scheduled dose on this blister's grid, because the user's pill count
// already reflects all consumption up to the correction time.
// We align to the schedule grid so that e.g. correction at 15:40 with
// a daily 15:42 dose counts today's 15:42 dose (2 min later), not
// tomorrow's dose (24h later as the old code did).
let effectiveStart: number;
if (stockCorrectionCutoff > 0 && stockCorrectionCutoff >= blisterStart) {
effectiveStart = stockCorrectionCutoff + period;
const elapsedSinceStart = stockCorrectionCutoff - blisterStart;
const periodsElapsed = Math.floor(elapsedSinceStart / period);
effectiveStart = blisterStart + (periodsElapsed + 1) * period;
} else {
effectiveStart = blisterStart;
}
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
if (Number.isNaN(effectiveStart)) return;
const intake = intakes[blisterIdx];
const intakePerson = intake?.takenBy;
// For per-intake takenBy, only count for that person
// For legacy (no takenBy), count for all people in medication takenBy
const peopleForThisIntake = intakePerson ? [intakePerson] : m.takenBy?.length > 0 ? m.takenBy : [null];
const expectedConsumed = occurrences * s.usage * peopleForThisIntake.length;
// Count how many doses were actually marked as taken for this blister
let actualConsumed = 0;
// Time-based: count doses where the scheduled time has already passed
let timeBasedConsumed = 0;
let lastAutoConsumedDateMs = 0;
// Generate all expected dose IDs for this blister up to now
for (let i = 0; i < occurrences; i++) {
const doseDate = new Date(effectiveStart + i * period);
const dateOnlyMs = new Date(doseDate.getFullYear(), doseDate.getMonth(), doseDate.getDate()).getTime();
const baseDoseId = `${m.id}-${blisterIdx}-${dateOnlyMs}`;
if (effectiveStart <= now) {
const occurrences = Math.floor((now - effectiveStart) / period) + 1;
timeBasedConsumed = occurrences * s.usage * peopleForThisIntake.length;
// Check if each person has taken this dose
for (const person of peopleForThisIntake) {
const doseId = person ? `${baseDoseId}-${person}` : baseDoseId;
if (takenDoses.has(doseId)) {
actualConsumed += s.usage;
// Date-only timestamp of the last auto-consumed dose
const lastDoseTime = new Date(effectiveStart + (occurrences - 1) * period);
lastAutoConsumedDateMs = new Date(
lastDoseTime.getFullYear(),
lastDoseTime.getMonth(),
lastDoseTime.getDate()
).getTime();
}
// Early intakes: count future doses already marked as taken.
// The cutoff is the later of: last auto-consumed date or stock correction date.
// This prevents double-counting (time-based + early-taken) and respects corrections.
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
let earlyTakenConsumed = 0;
for (const doseId of takenDoses) {
const parts = doseId.split("-");
if (parts.length >= 3) {
const medId = parseInt(parts[0], 10);
const bIdx = parseInt(parts[1], 10);
const timestamp = parseInt(parts[2], 10);
if (medId === m.id && bIdx === blisterIdx && timestamp > earlyCutoff) {
earlyTakenConsumed += s.usage;
}
}
}
// If we have tracking data (any doses marked), use actual consumed
// Otherwise fall back to expected (for backwards compatibility)
const hasTrackingData = Array.from(takenDoses).some((id) => {
const parts = id.split("-");
return parts.length >= 3 && parseInt(parts[0], 10) === m.id && parseInt(parts[1], 10) === blisterIdx;
});
consumed += hasTrackingData ? actualConsumed : expectedConsumed;
consumed += timeBasedConsumed + earlyTakenConsumed;
});
} else {
// In manual mode, only count doses that are explicitly marked as taken
// In manual mode, only count doses that are explicitly marked as taken.
// For stock correction filtering, we use the actual time the dose was marked
// as taken (takenAt), not the scheduled date. This correctly handles same-day
// scenarios: if a user corrects stock at 3pm, then takes a dose at 4pm,
// the dose counts because takenAt (4pm) > correctionTime (3pm).
takenDoses.forEach((doseId) => {
const parts = doseId.split("-");
if (parts.length >= 3) {
@@ -190,19 +221,17 @@ export function calculateCoverage(
blisterStartDate.getMonth(),
blisterStartDate.getDate()
).getTime();
// Convert stock correction cutoff to date-only as well
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0
? new Date(
new Date(stockCorrectionCutoff).getFullYear(),
new Date(stockCorrectionCutoff).getMonth(),
new Date(stockCorrectionCutoff).getDate()
).getTime()
: 0;
// Use actual takenAt timestamp for stock correction comparison.
// A dose counts only if it was MARKED after the stock correction,
// regardless of what day it was scheduled for.
const takenAt = takenDoseTimestamps?.get(doseId) ?? 0;
const afterCorrectionOrNoCorrectionMs = stockCorrectionCutoff === 0 || takenAt > stockCorrectionCutoff;
if (
!Number.isNaN(blisterStartDateOnly) &&
doseTimestamp >= blisterStartDateOnly &&
doseTimestamp > stockCorrectionDateOnly
afterCorrectionOrNoCorrectionMs
) {
consumed += blisters[blisterIdx].usage;
}