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 eb2e445398
5 changed files with 549 additions and 123 deletions
+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,