// ============================================================================= // useDoses Hook - Dose tracking state and operations // ============================================================================= import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; export interface UseDosesReturn { takenDoses: Set; setTakenDoses: React.Dispatch>>; takenDoseTimestamps: Map; takenDoseSources: Map; skippedDoses: Set; dismissedDoses: Set; clearDosesState: () => void; getDoseId: (baseDoseId: string, person: string | null) => string; isDoseTakenAutomatically: (doseId: string) => boolean; countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number }; markDoseTaken: (doseId: string) => Promise; markDoseSkipped: (doseId: string) => Promise; undoDoseTaken: (doseId: string) => Promise; undoDoseSkipped: (doseId: string) => Promise; loadTakenDoses: () => Promise; } export function useDoses(): UseDosesReturn { const { t } = useTranslation(); const [takenDoses, setTakenDoses] = useState>(new Set()); const [takenDoseTimestamps, setTakenDoseTimestamps] = useState>(new Map()); const [takenDoseSources, setTakenDoseSources] = useState>(new Map()); const [dismissedDoses, setDismissedDoses] = useState>(new Set()); // Track in-flight mutations to prevent polling from overwriting optimistic updates const mutationInFlightRef = useRef(0); const clearDosesState = useCallback(() => { setTakenDoses(new Set()); setTakenDoseTimestamps(new Map()); setTakenDoseSources(new Map()); setDismissedDoses(new Set()); mutationInFlightRef.current = 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 sources = new Map(); const dismissed = new Set(); for (const d of data.doses) { if (d.skipped === true || d.dismissed === true) { dismissed.add(d.doseId); } else { taken.add(d.doseId); timestamps.set(d.doseId, d.takenAt); sources.set(d.doseId, d.takenSource === "automatic" ? "automatic" : "manual"); } } setTakenDoses(taken); setTakenDoseTimestamps(timestamps); setTakenDoseSources(sources); setDismissedDoses(dismissed); } else if (res.status === 401 || res.status === 403) { // Prevent showing previous user's dose state after auth/session changes. clearDosesState(); } // Don't reset on error - keep current state } catch { // Don't reset on error - keep current state } }, [clearDosesState]); // Poll for taken doses from server (works with or without auth) useEffect(() => { loadTakenDoses(); // Poll for updates every 5 seconds (real-time sync with share links) const interval = setInterval(loadTakenDoses, 5000); return () => clearInterval(interval); }, [loadTakenDoses]); // Get dose ID with optional person suffix const getDoseId = useCallback((baseDoseId: string, person: string | null): string => { return person ? `${baseDoseId}-${person}` : baseDoseId; }, []); const isDoseTakenAutomatically = useCallback( (doseId: string): boolean => { return takenDoseSources.get(doseId) === "automatic"; }, [takenDoseSources] ); // Count taken doses for a day/item const countTakenDoses = useCallback( (doses: Array<{ id: string; takenBy: string[] }>): { total: number; taken: number } => { let total = 0; let taken = 0; for (const d of doses) { const people = (d.takenBy || []).length > 0 ? d.takenBy : [null]; for (const person of people) { total++; if (takenDoses.has(getDoseId(d.id, person))) taken++; } } return { total, taken }; }, [takenDoses, getDoseId] ); const getErrorCode = useCallback(async (response: Response): Promise => { try { const data = (await response.json()) as { code?: string }; return typeof data.code === "string" ? data.code : null; } catch { return null; } }, []); const markDoseTaken = useCallback( async (doseId: string) => { if (dismissedDoses.has(doseId)) { return; } const wasTaken = takenDoses.has(doseId); const wasSkipped = dismissedDoses.has(doseId); const previousTimestamp = takenDoseTimestamps.get(doseId); const previousSource = takenDoseSources.get(doseId); // Optimistic update mutationInFlightRef.current++; setTakenDoses((prev) => { const next = new Set(prev); next.add(doseId); return next; }); setDismissedDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); setTakenDoseTimestamps((prev) => { const next = new Map(prev); next.set(doseId, Date.now()); return next; }); setTakenDoseSources((prev) => { const next = new Map(prev); next.set(doseId, "manual"); return next; }); // Send to server try { const response = await fetch("/api/doses/taken", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ doseId }), }); if (!response.ok) { if ((await getErrorCode(response)) === "OUT_OF_STOCK") { alert(t("common.outOfStockTakeBlocked")); } throw new Error("Failed to mark dose as taken"); } } catch { // Revert on error setTakenDoses((prev) => { const next = new Set(prev); if (wasTaken) { next.add(doseId); } else { next.delete(doseId); } return next; }); setDismissedDoses((prev) => { const next = new Set(prev); if (wasSkipped) { next.add(doseId); } else { next.delete(doseId); } return next; }); setTakenDoseTimestamps((prev) => { const next = new Map(prev); if (wasTaken && typeof previousTimestamp === "number") { next.set(doseId, previousTimestamp); } else { next.delete(doseId); } return next; }); setTakenDoseSources((prev) => { const next = new Map(prev); if (wasTaken && previousSource) { next.set(doseId, previousSource); } else { next.delete(doseId); } return next; }); } finally { mutationInFlightRef.current--; // Re-sync with server after mutation completes loadTakenDoses(); } }, [dismissedDoses, getErrorCode, loadTakenDoses, t, takenDoseSources, takenDoseTimestamps, takenDoses] ); const markDoseSkipped = useCallback( async (doseId: string) => { if (takenDoses.has(doseId)) { return; } const wasTaken = takenDoses.has(doseId); const wasSkipped = dismissedDoses.has(doseId); const previousTimestamp = takenDoseTimestamps.get(doseId); const previousSource = takenDoseSources.get(doseId); mutationInFlightRef.current++; setDismissedDoses((prev) => { const next = new Set(prev); next.add(doseId); return next; }); setTakenDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); setTakenDoseTimestamps((prev) => { const next = new Map(prev); next.delete(doseId); return next; }); setTakenDoseSources((prev) => { const next = new Map(prev); next.delete(doseId); return next; }); try { const response = await fetch("/api/doses/skip", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ doseId }), }); if (!response.ok) { throw new Error("Failed to mark dose as skipped"); } } catch { setDismissedDoses((prev) => { const next = new Set(prev); if (wasSkipped) { next.add(doseId); } else { next.delete(doseId); } return next; }); setTakenDoses((prev) => { const next = new Set(prev); if (wasTaken) { next.add(doseId); } return next; }); setTakenDoseTimestamps((prev) => { const next = new Map(prev); if (wasTaken && typeof previousTimestamp === "number") { next.set(doseId, previousTimestamp); } return next; }); setTakenDoseSources((prev) => { const next = new Map(prev); if (wasTaken && previousSource) { next.set(doseId, previousSource); } return next; }); } finally { mutationInFlightRef.current--; loadTakenDoses(); } }, [dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses] ); const undoDoseTaken = useCallback( async (doseId: string) => { const previousTimestamp = takenDoseTimestamps.get(doseId); const previousSource = takenDoseSources.get(doseId); // 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; }); setTakenDoseSources((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; }); setTakenDoseTimestamps((prev) => { const next = new Map(prev); if (typeof previousTimestamp === "number") { next.set(doseId, previousTimestamp); } return next; }); setTakenDoseSources((prev) => { const next = new Map(prev); if (previousSource) { next.set(doseId, previousSource); } return next; }); } finally { mutationInFlightRef.current--; // Re-sync with server after mutation completes loadTakenDoses(); } }, [loadTakenDoses, takenDoseSources, takenDoseTimestamps] ); const undoDoseSkipped = useCallback( async (doseId: string) => { const wasSkipped = dismissedDoses.has(doseId); mutationInFlightRef.current++; setDismissedDoses((prev) => { const next = new Set(prev); next.delete(doseId); return next; }); try { await fetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, { method: "DELETE", credentials: "include", }); } catch { setDismissedDoses((prev) => { const next = new Set(prev); if (wasSkipped) { next.add(doseId); } return next; }); } finally { mutationInFlightRef.current--; loadTakenDoses(); } }, [dismissedDoses, loadTakenDoses] ); return { takenDoses, setTakenDoses, takenDoseTimestamps, takenDoseSources, skippedDoses: dismissedDoses, dismissedDoses, clearDosesState, getDoseId, isDoseTakenAutomatically, countTakenDoses, markDoseTaken, markDoseSkipped, undoDoseTaken, undoDoseSkipped, loadTakenDoses, }; }