diff --git a/frontend/src/context/AppContext.tsx b/frontend/src/context/AppContext.tsx index e18ddb9..9a4efa0 100644 --- a/frontend/src/context/AppContext.tsx +++ b/frontend/src/context/AppContext.tsx @@ -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, ] ); diff --git a/frontend/src/hooks/useDoses.ts b/frontend/src/hooks/useDoses.ts index e7fed41..94d7914 100644 --- a/frontend/src/hooks/useDoses.ts +++ b/frontend/src/hooks/useDoses.ts @@ -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; setTakenDoses: React.Dispatch>>; + takenDoseTimestamps: Map; dismissedDoses: Set; showClearMissedConfirm: boolean; setShowClearMissedConfirm: (show: boolean) => void; @@ -19,25 +20,39 @@ export interface UseDosesReturn { export function useDoses(): UseDosesReturn { const [takenDoses, setTakenDoses] = useState>(new Set()); + const [takenDoseTimestamps, setTakenDoseTimestamps] = useState>(new Map()); const [dismissedDoses, setDismissedDoses] = useState>(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(); + const timestamps = new Map(); const dismissed = new Set(); 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, diff --git a/frontend/src/test/hooks/useDoses.test.ts b/frontend/src/test/hooks/useDoses.test.ts index f02a002..181b2d4 100644 --- a/frontend/src/test/hooks/useDoses.test.ts +++ b/frontend/src/test/hooks/useDoses.test.ts @@ -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) .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) .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) .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" })); }); diff --git a/frontend/src/test/utils/schedule.test.ts b/frontend/src/test/utils/schedule.test.ts index 16f309d..c238178 100644 --- a/frontend/src/test/utils/schedule.test.ts +++ b/frontend/src/test/utils/schedule.test.ts @@ -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", () => { diff --git a/frontend/src/utils/schedule.ts b/frontend/src/utils/schedule.ts index 2581811..2c92c5e 100644 --- a/frontend/src/utils/schedule.ts +++ b/frontend/src/utils/schedule.ts @@ -100,7 +100,8 @@ export function calculateCoverage( locale: string, reminderDaysBefore: number, stockCalculationMode: "automatic" | "manual", - takenDoses: Set + takenDoses: Set, + takenDoseTimestamps?: Map ): { 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; }