import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react'; import { buildFieldTimeline } from '../../utils/fieldHistory/buildFieldTimeline'; import { demographicTimeline } from '../../utils/fieldHistory/demographicTimeline'; import type { FieldTimeline } from '../../utils/fieldHistory/buildFieldTimeline'; import { correlateMyHistory, correlateProvenance } from '../../utils/fieldHistory/correlateProvenance'; import { useFieldHistoryFetch } from '../../utils/fieldHistory/useFieldHistoryFetch'; import { readRecordCreation } from '../../utils/fieldHistory/recordCreation'; import type { RecordCreation } from '../../utils/fieldHistory/recordCreation'; import type { ActorFallbackRead, ReadOwner, ResourceHistoryFetchResult } from '../../utils/fieldHistory/useFieldHistoryFetch'; import type { FieldTimelineEntry } from '../../utils/fieldHistory/buildFieldTimeline'; import { isPermanentDenial } from '../../utils/fetchFailureMessage'; import { authSessionId } from '../../services/authStorage'; import { onViewerChange, viewerEpoch, writeThroughViewer } from '../../utils/writeThroughHold'; /** How many `_history`+`Provenance` fetches (Task 4) run concurrently during `activate()`. */ const CONCURRENCY_LIMIT = 6; function resourceKey(resourceType: string, id: string): string { return `${resourceType}/${id}`; } /** * Runs `worker` over `items` with at most `limit` in flight at once. A fixed-size pool of * "runners" each pull the next unclaimed item off the shared cursor until the list is exhausted -- * standard bounded-concurrency pattern, no external dependency. */ async function runWithConcurrencyLimit( items: T[], limit: number, worker: (item: T) => Promise ): Promise { let cursor = 0; async function runNext(): Promise { const index = cursor++; if (index >= items.length) { return; } await worker(items[index]); return runNext(); } const poolSize = Math.min(limit, items.length); await Promise.all(Array.from({ length: poolSize }, () => runNext())); } /** * What one record's attribution read covered: `complete`, `incomplete` (partial, failed or unsaid, so no author is * chosen), or `none` before it is read. `retry` reads that record again, reads only. */ export interface FieldAttribution { state: 'complete' | 'incomplete' | 'none'; retrying: boolean; retry: () => void; } export interface FieldHistoryContextValue { isActive: boolean; loading: boolean; /** Fetches history for every currently-registered resource (bounded concurrency), then goes active. Fire-and-forget. */ activate: () => void; deactivate: () => void; /** Counted per record: returns its removal, and the last removal retires that record's reads. Never churns a render. */ register: (resourceType: string, id: string) => () => void; /** What the latest read of one record's attribution covered, with a retry for it. */ attributionFor: (resourceType: string, id: string) => FieldAttribution; /** After a save while history shows: re-reads the saved field until its edit lands. Returns a cancel. */ refresh: (resourceType: string, id: string, path?: string) => () => void; /** True only when every registered resource was read and has its initial version. */ nothingEverEdited: boolean; /** Who created each registered resource, set only when nothing was ever edited. */ creations: RecordCreation[]; /** True when every registered resource history was read during the latest activation. */ historyComplete: boolean; /** Bound fields on screen, and how many of them have changed, while history shows. */ shownFields: { count: number; changed: number }; /** A bound field reports itself while mounted; returns its removal. */ reportShownField: (key: string, changed: boolean) => () => void; /** True when no registered resource history could be read during the latest activation. */ historyUnavailable: boolean; /** True when every one of those failed reads was refused rather than broken. */ historyRefused: boolean; /** True when a registered resource showed only this practice's verified edits. */ verifiedOnly?: boolean; /** Null when not active, when the resource hasn't been fetched, or when the field never changed. */ getFieldHistory: ( resourceType: string, id: string, path: string, label: string ) => FieldTimeline | null; } /** * Safe no-op default so `useFieldHistory()` NEVER throws when called with no `FieldHistoryProvider` * above it. `renderField` (Task 6) calls this hook at 752 existing call sites that have no provider * yet -- a throwing `useContext` guard (the pattern most other service contexts in this codebase * use, e.g. `useCareViewAnchorServiceContext`) would crash every one of them. Passing this object * directly as `createContext`'s default (rather than `undefined`) makes "no provider" behave * exactly like "provider present but inactive": every field query returns null, activate/deactivate/ * register are inert. */ const DEFAULT_FIELD_HISTORY_CONTEXT: FieldHistoryContextValue = { isActive: false, loading: false, nothingEverEdited: false, creations: [], historyUnavailable: false, historyRefused: false, historyComplete: false, shownFields: { count: 0, changed: 0 }, reportShownField: () => () => {}, activate: () => {}, deactivate: () => {}, register: () => () => {}, attributionFor: () => ({ state: 'none', retrying: false, retry: () => {} }), refresh: () => () => {}, getFieldHistory: () => null, }; const FieldHistoryContext = createContext( DEFAULT_FIELD_HISTORY_CONTEXT ); export function useFieldHistory(): FieldHistoryContextValue { return useContext(FieldHistoryContext); } export interface FieldHistoryScopeValue { resourceType: string; id?: string; } /** Absent (no `FieldHistoryScope` ancestor) is a real, expected state -- callers must handle `null`. */ const FieldHistoryScopeContext = createContext(null); export function useFieldHistoryScope(): FieldHistoryScopeValue | null { return useContext(FieldHistoryScopeContext); } /** * Registers `resourceType`/`id` with the nearest `FieldHistoryProvider` while mounted. A missing/empty `id` (an * unsaved record) is a no-op: there is no `_history` to fetch. Two scopes of one record share it; the record leaves * only when its last scope does. */ export function useRegisterFieldHistoryResource(resourceType: string, id?: string): void { const { register } = useFieldHistory(); useEffect(() => { if (!id) { return undefined; } return register(resourceType, id); }, [register, resourceType, id]); } /** * Supplies `{ resourceType, id }` to descendants (so `renderField`/Task 6 knows which resource a * field path belongs to) and registers that resource with the enclosing `FieldHistoryProvider`. */ export const FieldHistoryScope: React.FC<{ resourceType: string; id?: string; children: ReactNode; }> = ({ resourceType, id, children }) => { useRegisterFieldHistoryResource(resourceType, id); const scopeValue = useMemo( () => ({ resourceType, id }), [resourceType, id] ); return ( {children} ); }; // When a saved field's history is re-read: soon, then wider, out to the couple of minutes it can take. const REFRESH_DELAYS_MS = [2_000, 6_000, 15_000, 35_000, 75_000, 150_000]; /** How much history a read returned; a save adds a version, a Provenance or a verified row. */ const historySize = (entry: ResourceHistoryFetchResult | undefined): number => entry ? entry.versions.length + entry.provenances.length + (entry.demographicRows?.length ?? 0) : 0; /** How many entries one field's timeline has in a read; the whole read when no field is named. */ const fieldSize = (entry: ResourceHistoryFetchResult | undefined, path?: string): number => { if (!entry) return 0; if (!path) return historySize(entry); return (entry.demographicRows ? demographicTimeline(entry.demographicRows, path, '') : buildFieldTimeline(entry.versions, path, '')).entries.length; }; /** One registered record's latest owned read: its history, or why it could not be read, and its filtered fallback. */ interface RecordRead { result?: ResourceHistoryFetchResult; failure?: 'refused' | 'broken'; fallback?: ActorFallbackRead; } // Who is reading, as which session, viewer and practice: work begun under one never publishes under another. const readingContext = (): string => `${authSessionId() ?? ''}|${writeThroughViewer() ?? ''}|${viewerEpoch()}`; /** Entries whose attribution was not all read: their values stand, and no author is chosen for them. */ const withoutAuthors = (entries: FieldTimelineEntry[]): FieldTimelineEntry[] => entries.map((entry) => ({ ...entry, who: undefined, whoConfidence: 'incomplete' })); /** Whether a read covered the whole attribution: all its Provenance, a whole filtered fallback, or verified rows. */ const attributionOf = (read: RecordRead | undefined): FieldAttribution['state'] => { const result = read?.result; if (!result) return 'none'; if (result.demographicRows) return 'complete'; if (result.provenanceRefused) return read.fallback?.complete ? 'complete' : 'incomplete'; return result.attribution === 'complete' ? 'complete' : 'incomplete'; }; /** * Tracks the records in view (`register`), reads their history and attribution on `activate()`, and answers per-field * timelines (`getFieldHistory`). Reads live in `readsRef`, written only by work the current reader still owns; built * timelines are memoized in `fieldCacheRef` and rebuilt whenever a newer read is published. */ export const FieldHistoryProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const { fetchResourceHistory, readActorFallback } = useFieldHistoryFetch(); const registeredRef = useRef>(new Map()); const readsRef = useRef>(new Map()); const fieldCacheRef = useRef>(new Map()); // A superseded activation (deactivated, reactivated, or under another reader) publishes nothing. const generationRef = useRef(0); // Each record's latest read: an older retry or refresh of the same record publishes nothing. const attemptRef = useRef>(new Map()); const [isActive, setIsActive] = useState(false); const [loading, setLoading] = useState(false); // Set from what was actually fetched, never assumed: a resource with a single version has never // been edited, and one that failed to fetch is not evidence of anything either way. const [nothingEverEdited, setNothingEverEdited] = useState(false); const [historyUnavailable, setHistoryUnavailable] = useState(false); const [historyRefused, setHistoryRefused] = useState(false); const [historyComplete, setHistoryComplete] = useState(false); const [verifiedOnly, setVerifiedOnly] = useState(false); const [retrying, setRetrying] = useState>(new Set()); const shownRef = useRef>(new Map()); const [shownFields, setShownFields] = useState({ count: 0, changed: 0 }); const reportShownField = useCallback((key: string, changed: boolean) => { const recount = () => { const values = Array.from(shownRef.current.values()); setShownFields({ count: values.length, changed: values.filter(Boolean).length }); }; shownRef.current.set(key, changed); recount(); return () => { shownRef.current.delete(key); recount(); }; }, []); const [creations, setCreations] = useState([]); // Bumped when a read is published, so fields re-ask getFieldHistory. const [revision, setRevision] = useState(0); const refreshTimersRef = useRef>>(new Map()); const isActiveRef = useRef(false); isActiveRef.current = isActive; const unmountedRef = useRef(false); // Reads in flight, each with whether it still belongs to the current reader: a retired one is cancelled at once. const liveReadsRef = useRef(new Set<{ still: () => boolean; stop: AbortController }>()); const sweep = () => liveReadsRef.current.forEach(({ still, stop }) => { if (!still()) stop.abort(); }); // A new session, viewer or practice retires every read in the render that sees it, before an old one can publish. const contextRef = useRef(readingContext()); const retireIfMoved = useCallback(() => { const now = readingContext(); if (contextRef.current === now) return; contextRef.current = now; generationRef.current += 1; readsRef.current.clear(); fieldCacheRef.current.clear(); attemptRef.current.clear(); refreshTimersRef.current.forEach(clearTimeout); refreshTimersRef.current.clear(); sweep(); }, []); retireIfMoved(); const context = contextRef.current; // Set again on every setup: StrictMode runs setup, cleanup, setup on one mounted provider. useEffect(() => { unmountedRef.current = false; const timers = refreshTimersRef.current; return () => { unmountedRef.current = true; generationRef.current += 1; timers.forEach(clearTimeout); timers.clear(); sweep(); }; }, []); /** * One record, read whole: its history, then the filtered fallback when its Provenance was refused. Every request is * admitted only while `still()` holds, and cancelled once it does not, so retired work starts no later stage. */ const readRecord = useCallback(async (resourceType: string, id: string, still: () => boolean): Promise => { const live = { still, stop: new AbortController() }; const owner: ReadOwner = { admit: () => { if (still()) return; live.stop.abort(); throw new DOMException('This field-history read was retired.', 'AbortError'); }, signal: live.stop.signal, }; liveReadsRef.current.add(live); try { let result: ResourceHistoryFetchResult; try { result = await fetchResourceHistory(resourceType, id, owner); } catch (err) { if (live.stop.signal.aborted) return { failure: 'broken' }; // The record itself is on screen, so a 404 on its history is the server refusing it without saying so. const status = (err as { status?: unknown } | null | undefined)?.status; console.error(`Failed to load field history for ${resourceKey(resourceType, id)}:`, err); return { failure: typeof status === 'number' && (isPermanentDenial(status) || status === 404) ? 'refused' : 'broken' }; } if (!result.provenanceRefused || live.stop.signal.aborted) return { result }; return { result, fallback: await readActorFallback(resourceType, id, owner) }; } finally { liveReadsRef.current.delete(live); } }, [fetchResourceHistory, readActorFallback]); /** True while this work still belongs to the current reader, activation and (when given) record attempt. */ const owns = (generation: number, readContextAt: string, key?: string, attempt?: number) => !unmountedRef.current && generationRef.current === generation && contextRef.current === readContextAt && readingContext() === readContextAt && (key === undefined || attemptRef.current.get(key) === attempt); // What the published reads establish together, recomputed from them alone. const summarize = () => { const targets = Array.from(registeredRef.current.entries()); const reads = targets.map(([key]) => readsRef.current.get(key)); const fetched = reads.flatMap((read) => (read?.result ? [read.result] : [])); const failed = fetched.length < targets.length; const broke = reads.some((read) => !read?.result && read?.failure !== 'refused'); const unedited = !failed && targets.length > 0 && fetched.every((entry) => entry.versions.length === 1 && entry.versions[0].meta?.versionId === '1'); setNothingEverEdited(unedited); // An unedited record has no change to attribute, so it names who created it instead. setCreations(unedited ? targets.flatMap(([key, { resourceType, id }]) => { const read = readsRef.current.get(key); return read?.result ? [readRecordCreation(read.result, read.fallback?.events ?? [], resourceType, id)] : []; }) : []); const unavailable = targets.length > 0 && fetched.length === 0; setHistoryUnavailable(unavailable); setHistoryRefused(unavailable && !broke); // Verified edits alone cannot say every change is accounted for. const anyVerifiedOnly = fetched.some((entry) => entry.demographicRows !== undefined); setVerifiedOnly(anyVerifiedOnly); setHistoryComplete(!anyVerifiedOnly && targets.length > 0 && !failed); }; // The record's computed timelines are rebuilt from its newest published read. const forgetFields = (key: string) => { for (const cached of Array.from(fieldCacheRef.current.keys())) { if (cached.startsWith(`${key}/`)) fieldCacheRef.current.delete(cached); } }; const register = useCallback((resourceType: string, id: string) => { // On a ref, not state: registering never re-renders by itself; it matters to the next read. const key = resourceKey(resourceType, id); const entry = registeredRef.current.get(key); if (entry) entry.count += 1; else registeredRef.current.set(key, { resourceType, id, count: 1 }); let removed = false; return () => { const current = registeredRef.current.get(key); if (removed || !current) return; removed = true; current.count -= 1; if (current.count > 0) return; // Its last scope left: its reads are retired with it, and a later answer for it publishes nothing. registeredRef.current.delete(key); attemptRef.current.set(key, (attemptRef.current.get(key) ?? 0) + 1); readsRef.current.delete(key); forgetFields(key); refreshTimersRef.current.forEach((timer, chain) => { if (!chain.startsWith(`${key}#`)) return; clearTimeout(timer); refreshTimersRef.current.delete(chain); }); sweep(); // What the overlay says of the records together, such as who created them, no longer counts this one. if (isActiveRef.current && !unmountedRef.current) { summarize(); setRevision((n) => n + 1); } }; }, []); /** * A newer read of one record replaces the last whatever its size. One that failed keeps the values already read * but no longer vouches for who made them. */ const publish = (key: string, read: RecordRead) => { const previous = readsRef.current.get(key)?.result; readsRef.current.set(key, read.result || !previous ? read : { result: { ...previous, provenances: [], provenanceRefused: false, provenanceUnreadable: true, attribution: 'unreadable' } }); forgetFields(key); }; const activate = useCallback(() => { retireIfMoved(); const generation = ++generationRef.current; const at = contextRef.current; setLoading(true); setNothingEverEdited(false); setHistoryUnavailable(false); setHistoryRefused(false); setHistoryComplete(false); setVerifiedOnly(false); setCreations([]); const targets = Array.from(registeredRef.current.entries()).map(([key, { resourceType, id }]) => ({ key, resourceType, id, attempt: attemptRef.current.get(key) ?? 0 })); const run = async () => { // Collected here and published only if this activation still owns the screen when all are in. const collected: { key: string; attempt: number; read: RecordRead }[] = []; await runWithConcurrencyLimit(targets, CONCURRENCY_LIMIT, async ({ key, resourceType, id, attempt }) => { const still = () => owns(generation, at) && registeredRef.current.has(key) && (attemptRef.current.get(key) ?? 0) === attempt; collected.push({ key, attempt, read: await readRecord(resourceType, id, still) }); }); if (!owns(generation, at)) return; for (const { key, attempt, read } of collected) { if (registeredRef.current.has(key) && (attemptRef.current.get(key) ?? 0) === attempt) readsRef.current.set(key, read); } fieldCacheRef.current.clear(); summarize(); setLoading(false); setIsActive(true); }; void run(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [readRecord, retireIfMoved]); /** Reads one record again, reads only, under the current reader; the answer replaces the last if still wanted. */ const retry = useCallback((resourceType: string, id: string) => { retireIfMoved(); const key = resourceKey(resourceType, id); if (!isActiveRef.current || unmountedRef.current || !registeredRef.current.has(key)) return; const generation = generationRef.current; const at = contextRef.current; const attempt = (attemptRef.current.get(key) ?? 0) + 1; attemptRef.current.set(key, attempt); setRetrying((now) => new Set(now).add(key)); void readRecord(resourceType, id, () => owns(generation, at, key, attempt)).then((read) => { if (owns(generation, at, key, attempt)) { publish(key, read); summarize(); setRevision((n) => n + 1); } if (!unmountedRef.current) { setRetrying((now) => { const next = new Set(now); next.delete(key); return next; }); } }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [readRecord, retireIfMoved]); // A save's history lands late (the Provenance is written from a queue, then indexed), so the saved // field is re-read on a widening schedule until its own edit is attributed. Per field, so two saves // close together each wait for their own row; the caller cancels on unmount or a record switch. const refresh = useCallback((resourceType: string, id: string, path?: string) => { retireIfMoved(); if (!isActiveRef.current || unmountedRef.current) return () => {}; const key = resourceKey(resourceType, id); const chain = `${key}#${path ?? '*'}`; const generation = generationRef.current; const at = contextRef.current; const before = readsRef.current.get(key)?.result; const fieldBefore = fieldSize(before, path); const landed = (result: ResourceHistoryFetchResult | undefined) => { if (!result || fieldSize(result, path) <= fieldBefore) return false; if (result.demographicRows || result.provenanceRefused) return true; // Each new version has its own Provenance before the edit can say who made it. return result.provenances.length - (before?.provenances.length ?? 0) >= result.versions.length - (before?.versions.length ?? 0); }; let cancelled = false; const attempt = (index: number) => { refreshTimersRef.current.set(chain, setTimeout(() => { refreshTimersRef.current.delete(chain); if (cancelled || !owns(generation, at) || !registeredRef.current.has(key)) return; const mine = (attemptRef.current.get(key) ?? 0) + 1; attemptRef.current.set(key, mine); void readRecord(resourceType, id, () => !cancelled && owns(generation, at, key, mine)).then((read) => { if (cancelled || !owns(generation, at, key, mine)) return; publish(key, read); summarize(); setRevision((n) => n + 1); if (!landed(read.result) && index + 1 < REFRESH_DELAYS_MS.length) attempt(index + 1); }); }, REFRESH_DELAYS_MS[index])); }; const pending = refreshTimersRef.current.get(chain); if (pending) clearTimeout(pending); attempt(0); return () => { cancelled = true; sweep(); const timer = refreshTimersRef.current.get(chain); if (timer) clearTimeout(timer); refreshTimersRef.current.delete(chain); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [readRecord, retireIfMoved]); const deactivate = useCallback(() => { generationRef.current++; refreshTimersRef.current.forEach(clearTimeout); refreshTimersRef.current.clear(); sweep(); setLoading(false); setIsActive(false); setNothingEverEdited(false); setHistoryUnavailable(false); setHistoryRefused(false); setHistoryComplete(false); setVerifiedOnly(false); setCreations([]); setRetrying(new Set()); }, []); // Another reader closes the overlay: nothing read for the last one is shown or kept. useEffect(() => onViewerChange(() => { retireIfMoved(); deactivate(); }), [retireIfMoved, deactivate]); const seenContext = useRef(context); useEffect(() => { if (seenContext.current === context) return; seenContext.current = context; deactivate(); }, [context, deactivate]); const getFieldHistory = useCallback( (resourceType: string, id: string, path: string, label: string): FieldTimeline | null => { if (!isActive) { return null; } const fieldCacheKey = `${resourceType}/${id}/${path}`; const cached = fieldCacheRef.current.get(fieldCacheKey); if (cached !== undefined) { return cached; } const read = readsRef.current.get(resourceKey(resourceType, id)); const resource = read?.result; if (!resource) { // Not yet fetched (e.g. registered after the last activate()) -- no data to show. fieldCacheRef.current.set(fieldCacheKey, null); return null; } if (resource.demographicRows) { const verified = demographicTimeline(resource.demographicRows, path, label); fieldCacheRef.current.set(fieldCacheKey, verified.changed ? verified : null); return verified.changed ? verified : null; } const timeline = buildFieldTimeline(resource.versions, path, label); if (!timeline.changed) { fieldCacheRef.current.set(fieldCacheKey, null); return null; } // An author is chosen only from a whole attribution; otherwise the values stand without one. let entries: FieldTimelineEntry[]; if (attributionOf(read) !== 'complete') entries = withoutAuthors(timeline.entries); else if (resource.provenanceRefused) entries = correlateMyHistory(timeline.entries, read.fallback?.events ?? [], resourceType, id); else entries = correlateProvenance(timeline.entries, resource.provenances, false); const correlated: FieldTimeline = { ...timeline, entries }; fieldCacheRef.current.set(fieldCacheKey, correlated); return correlated; }, // revision: a newly published read must be re-read by every field showing it. // eslint-disable-next-line react-hooks/exhaustive-deps [isActive, revision, context] ); const attributionFor = useCallback((resourceType: string, id: string): FieldAttribution => { const key = resourceKey(resourceType, id); return { state: isActive ? attributionOf(readsRef.current.get(key)) : 'none', retrying: retrying.has(key), retry: () => retry(resourceType, id), }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isActive, revision, context, retrying, retry]); const value = useMemo( () => ({ isActive, loading, nothingEverEdited, creations, historyUnavailable, historyRefused, historyComplete, verifiedOnly, shownFields, reportShownField, activate, deactivate, register, attributionFor, refresh, getFieldHistory, }), [isActive, loading, nothingEverEdited, creations, historyUnavailable, historyRefused, historyComplete, verifiedOnly, shownFields, reportShownField, activate, deactivate, register, attributionFor, refresh, getFieldHistory] ); return ( {children} ); };