import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Appointment, Task } from 'fhir/r4'; import { usePatientServiceContext } from '../services/PatientService'; import { useTaskServiceContext } from '../services/TaskService'; import { useFhirServiceContext } from '../services/FhirService'; import { resolveTaskAppointment } from '../services/TaskService'; import { DISPLAY_APPOINTMENT_TASK_STATUSES } from '../utils/appointmentTasks'; import type { PatientTaskCount } from '../utils/patientTaskCount'; /** * SPENCER, 2026-08-15 - every task a patient still owes, plus the subset due by a given visit. * * *"Both should be reflecting ALL tasks the patient hasn't completed yet... both the receptionist AND the * practitioner needs to know what tasks were not completed that were needed for this appointment as well * as any other tasks the patient has still not completed."* * * **THE COST, STATED RATHER THAN DISCOVERED.** `fetchTasks` takes a Patient RESOURCE, and a clinician's * queue holds patient IDs - so this is **two reads per distinct patient**, once per queue change. The * front desk pays the same price already and gets the tally for free out of it; this hook exists because * the clinician board had no equivalent read at all. * **The receptionist board deliberately does NOT use this yet** - `useReceptionistAppointmentSupport` * already fetches exactly these tasks to fill its per-appointment buckets, so routing it through here * would DOUBLE its requests to obtain numbers it is already holding. Consolidating the two is worth * doing when that hook is next opened; doing it now would trade a real cost for a tidier import graph. */ export interface TaskTallySubject { patientId: string; /** The visit this row is about, if it has one. A walk-in encounter has none and reports no due-here. */ appointment?: Appointment; /** Stable key for the row - the tally is returned against this, not against the patient. */ key: string; } /** * Keyed by the SUBJECT'S key, not by patient id: one patient can hold two rows on a board (a morning and * an afternoon visit) and *"due by this visit"* is a different number on each. */ export const usePatientTaskTallies = ( subjects: readonly TaskTallySubject[], ): Record => { const { fetchPatient } = usePatientServiceContext(); const { fetchTasks } = useTaskServiceContext(); const { unpackBundle } = useFhirServiceContext(); const [tasksByPatientId, setTasksByPatientId] = useState>({}); const runToken = useRef(0); // Keyed on the IDS. The queue is rebuilt on every poll, so depending on the array identity would // re-fire two reads per patient every minute for an unchanged list. const patientKey = useMemo( () => Array.from(new Set(subjects.map((s) => s.patientId).filter(Boolean))).sort().join(','), [subjects], ); const load = useCallback(async (ids: string[], token: number) => { const entries = await Promise.all( ids.map(async (patientId): Promise<[string, Task[] | 'unavailable']> => { try { const patient = await fetchPatient(patientId, false, undefined, true); const tasks = unpackBundle(await fetchTasks(patient)) as Task[]; return [patientId, tasks.filter((task) => DISPLAY_APPOINTMENT_TASK_STATUSES.has(task.status))]; } catch { // Failure-isolated AND reported. One refused read must not blank the board, and it must not // silently become "this patient owes nothing" either - the vocabulary renders 'unavailable' as // "task status unavailable", which is the honest sentence. return [patientId, 'unavailable']; } }), ); if (token !== runToken.current) return; setTasksByPatientId(Object.fromEntries(entries)); }, [fetchPatient, fetchTasks, unpackBundle]); useEffect(() => { const ids = patientKey ? patientKey.split(',') : []; if (ids.length === 0) { runToken.current += 1; setTasksByPatientId({}); return; } void load(ids, ++runToken.current); }, [patientKey, load]); return useMemo(() => { const out: Record = {}; for (const subject of subjects) { const tasks = tasksByPatientId[subject.patientId]; // Absent, not zero. A patient we have not read yet says NOTHING; a zeroed tally would tell the // clinician they owe nothing before we ever asked. if (tasks === undefined) continue; if (tasks === 'unavailable') { out[subject.key] = 'unavailable'; continue; } const appointments = subject.appointment ? [subject.appointment] : []; out[subject.key] = { total: tasks.length, completed: tasks.filter((task) => task.status === 'completed').length, // Resolved through the SHARED resolver, which is what makes "due by your next appointment" // land on today's visit - Spencer: *"this IS the next appointment."* Never a date comparison // here; that reproduces freeze on the read side. dueHereOutstanding: subject.appointment ? tasks.filter( (task) => task.status !== 'completed' && resolveTaskAppointment(task, appointments)?.id === subject.appointment?.id, ).length : 0, }; } return out; }, [subjects, tasksByPatientId]); };