Files
medassist-ng/frontend/src/hooks/useDoses.ts
T
2026-05-10 23:24:18 +02:00

417 lines
11 KiB
TypeScript

// =============================================================================
// useDoses Hook - Dose tracking state and operations
// =============================================================================
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export interface UseDosesReturn {
takenDoses: Set<string>;
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
takenDoseTimestamps: Map<string, number>;
takenDoseSources: Map<string, "manual" | "automatic">;
skippedDoses: Set<string>;
dismissedDoses: Set<string>;
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<void>;
markDoseSkipped: (doseId: string) => Promise<void>;
undoDoseTaken: (doseId: string) => Promise<void>;
undoDoseSkipped: (doseId: string) => Promise<void>;
loadTakenDoses: () => Promise<void>;
}
export function useDoses(): UseDosesReturn {
const { t } = useTranslation();
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(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<string>();
const timestamps = new Map<string, number>();
const sources = new Map<string, "manual" | "automatic">();
const dismissed = new Set<string>();
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<string | null> => {
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,
};
}