// ============================================================================= // 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; 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; undoDoseTaken: (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.dismissed) { 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) => { // 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; }); 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); 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; }); } finally { mutationInFlightRef.current--; // Re-sync with server after mutation completes loadTakenDoses(); } }, [getErrorCode, loadTakenDoses, t] ); 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; }); 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; }); } finally { mutationInFlightRef.current--; // Re-sync with server after mutation completes loadTakenDoses(); } }, [loadTakenDoses] ); return { takenDoses, setTakenDoses, takenDoseTimestamps, takenDoseSources, dismissedDoses, clearDosesState, getDoseId, isDoseTakenAutomatically, countTakenDoses, markDoseTaken, undoDoseTaken, loadTakenDoses, }; }