import { useEffect, useState } from "react"; import type { Location, Organization } from "fhir/r4"; import { useAuthServiceContext } from "../services/AuthService.tsx"; import { useOrganizationServiceContext } from "../services/OrganizationService.tsx"; import { useLocationServiceContext } from "../services/LocationService.tsx"; import { findPrimaryLocation } from "../utils/practiceSetupSteps"; import { calendarDateInZone, locationTimeZoneOf, practiceTimeZoneOf } from "../utils/practiceTimezone"; /** * TODAY, ON THE PRACTICE'S CLOCK - the day a patient-facing "due today" or "N days left" is counted from. * * Spencer, 2026-08-24: the PRIMARY LOCATION carries the timezone, and onboarding forces one to exist. * So the zone is the practice's primary site's, falling back to the organization's own (the order * `resolveBookingTimeZone` uses). Never the viewer's: a patient reading at 11pm in another zone would * otherwise be told a task is past due that the practice still counts as today's. * * THREE STATES. `pending` until the practice and its sites have been read; `unknown` when they could not * be read or carry no clock at all. A caller shows a day-relative line only on `ready` - a line worked * out on a guessed clock is the claim this exists to prevent. * * One read per practice per session, shared by every caller: a list of plans or medicines asks the same * question once per row. */ export type PracticeToday = | { state: "pending" } | { state: "unknown" } | { state: "ready"; zone: string; today: string }; const zones = new Map>(); /** Tests only: forget every practice's clock. */ export const forgetPracticeZones = () => zones.clear(); type ZoneReaders = { active: Organization | null | undefined; fetchMyOrganizations: () => Promise; fetchLocations: (organization: Organization) => Promise<{ entry?: { resource?: Location }[] }>; }; const askZone = (organizationId: string, readers: ZoneReaders): Promise => { let asked = zones.get(organizationId); if (!asked) { asked = (async () => { const organization: Organization | undefined = readers.active?.id === organizationId ? readers.active : (await readers.fetchMyOrganizations()).find((candidate) => candidate.id === organizationId); if (!organization) return null; const sites = await readers.fetchLocations(organization); const locations = (sites.entry ?? []).map((entry) => entry.resource).filter((site): site is Location => Boolean(site)); return locationTimeZoneOf(findPrimaryLocation(organization, locations)) ?? practiceTimeZoneOf(organization); })(); // A failed read is forgotten so the next screen asks again rather than inheriting it. asked.catch(() => zones.delete(organizationId)); zones.set(organizationId, asked); } return asked; }; const useZoneReaders = (): ZoneReaders => { const { wpOrganization } = useAuthServiceContext(); const { fetchMyOrganizations } = useOrganizationServiceContext(); const { fetchLocations } = useLocationServiceContext(); return { active: wpOrganization?.current, fetchMyOrganizations, fetchLocations }; }; export const usePracticeToday = (organizationId: string | undefined, now: () => Date = () => new Date()): PracticeToday => { const readers = useZoneReaders(); const [answer, setAnswer] = useState<{ for?: string; zone?: string | null }>({}); useEffect(() => { if (!organizationId) return undefined; let current = true; askZone(organizationId, readers).then( (zone) => { if (current) setAnswer({ for: organizationId, zone }); }, () => { if (current) setAnswer({ for: organizationId, zone: null }); }, ); return () => { current = false; }; // The practice is the whole key; the service functions are stable. // eslint-disable-next-line react-hooks/exhaustive-deps }, [organizationId]); if (!organizationId) return { state: "unknown" }; if (answer.for !== organizationId) return { state: "pending" }; if (!answer.zone) return { state: "unknown" }; return { state: "ready", zone: answer.zone, today: calendarDateInZone(now(), answer.zone) }; }; /** * Each practice's clock, for a list that spans several. A practice absent from the map is still * being read; `null` means it has none or could not be read. */ export const usePracticeZones = (organizationIds: readonly string[]): ReadonlyMap => { const readers = useZoneReaders(); const [answers, setAnswers] = useState>(new Map()); const key = [...new Set(organizationIds)].sort().join(","); useEffect(() => { let current = true; const settle = (id: string, zone: string | null) => { if (current) setAnswers((known) => (known.has(id) && known.get(id) === zone ? known : new Map(known).set(id, zone))); }; for (const id of key ? key.split(",") : []) { askZone(id, readers).then((zone) => settle(id, zone), () => settle(id, null)); } return () => { current = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [key]); return answers; }; export default usePracticeToday;