import { useCallback, useEffect, useRef, useState } from "react"; import type { QueueRefresh, ReferralQueueRow } from "./useReferralQueue"; /** * Redeeming writes the Task at once, but the queue is a search of Task and its index catches up minutes * later. So after a claim the queue is asked again until every claimed referral is listed. Each claim has * its own deadline, kept by its own timer so a read that never answers cannot hold the wait open, and only * one read is in flight at a time. */ export const CLAIM_ARRIVAL_POLL_MS = 10_000; export const CLAIM_ARRIVAL_DEADLINE_MS = 5 * 60_000; export type ClaimArrival = { /** Claimed and still being looked for. */ waiting: string[]; /** Past the deadline and still not listed. */ late: string[]; /** The queue refused the read, so looking again will not help. */ refused: boolean; }; const listed = (rows: ReferralQueueRow[], referralId: string) => rows.some((row) => row.orderId === referralId); const NOTHING_CLAIMED: ClaimArrival = { waiting: [], late: [], refused: false }; /** `scope` is the practice: claims made in one are not looked for in another, and its late answers are dropped. */ export const useClaimedReferralArrival = ( rows: ReferralQueueRow[], refresh: () => Promise, scope: string | undefined, onArrived?: () => void, ) => { const [state, setState] = useState(NOTHING_CLAIMED); const generation = useRef(0); const currentScope = useRef(scope); const waiting = useRef([]); const deadlines = useRef(new Map>()); const nextAsk = useRef>(); const asking = useRef(false); const alive = useRef(true); const askRef = useRef<() => void>(() => undefined); const stopAsking = () => { clearTimeout(nextAsk.current); nextAsk.current = undefined; }; const settle = useCallback((arrived: string[], expired: string[], refused = false) => { const gone = new Set([...arrived, ...expired]); waiting.current = refused ? [] : waiting.current.filter((id) => !gone.has(id)); for (const id of refused ? [...deadlines.current.keys()] : [...gone]) { clearTimeout(deadlines.current.get(id)); deadlines.current.delete(id); } if (waiting.current.length === 0) stopAsking(); setState((prev) => ({ waiting: [...waiting.current], late: [...prev.late.filter((id) => !arrived.includes(id)), ...expired], refused: prev.refused || refused, })); }, []); const schedule = useCallback(() => { if (nextAsk.current || asking.current || waiting.current.length === 0) return; nextAsk.current = setTimeout(() => { nextAsk.current = undefined; askRef.current(); }, CLAIM_ARRIVAL_POLL_MS); }, []); const ask = useCallback(() => { if (!alive.current || asking.current || waiting.current.length === 0) return; asking.current = true; const asked = generation.current; void refresh().then((outcome) => { if (!alive.current || asked !== generation.current) return; asking.current = false; if (outcome === "refused") settle([], [], true); else schedule(); }); }, [refresh, schedule, settle]); askRef.current = ask; /** False, and nothing started, when the claim was made in another practice, the queue is closed or the page is gone. */ const awaitClaim = useCallback((referralId: string, claimedIn: string | undefined): boolean => { if (!alive.current || !claimedIn || claimedIn !== currentScope.current) return false; if (waiting.current.includes(referralId)) return true; waiting.current = [...waiting.current, referralId]; deadlines.current.set(referralId, setTimeout(() => settle([], [referralId]), CLAIM_ARRIVAL_DEADLINE_MS)); setState((prev) => ({ waiting: [...waiting.current], late: prev.late.filter((id) => id !== referralId), refused: false })); stopAsking(); ask(); return true; }, [ask, settle]); // A claim is done when the list shows it, whichever read brought it; a late one that turns up is no longer late. useEffect(() => { const arrived = [...waiting.current, ...state.late].filter((id) => listed(rows, id)); if (arrived.length === 0) return; settle(arrived, []); onArrived?.(); }, [rows, state.late, settle, onArrived]); // A new practice, or the page going away, ends every wait begun before it. useEffect(() => { alive.current = true; currentScope.current = scope; const pending = deadlines.current; return () => { generation.current += 1; waiting.current = []; asking.current = false; clearTimeout(nextAsk.current); nextAsk.current = undefined; for (const timer of pending.values()) clearTimeout(timer); pending.clear(); setState(NOTHING_CLAIMED); }; }, [scope]); useEffect(() => () => { alive.current = false; }, []); return { ...state, awaitClaim }; };