@pih/esm-audit-app 2.1.1-pre.144 → 2.1.1-pre.151

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/README.md +96 -2
  3. package/dist/115.js +1 -1
  4. package/dist/117.js +1 -1
  5. package/dist/117.js.map +1 -1
  6. package/dist/508.js +1 -1
  7. package/dist/508.js.map +1 -1
  8. package/dist/514.js +1 -0
  9. package/dist/514.js.map +1 -0
  10. package/dist/912.js +1 -1
  11. package/dist/912.js.map +1 -1
  12. package/dist/main.js +1 -1
  13. package/dist/pih-esm-audit-app.js +1 -1
  14. package/dist/pih-esm-audit-app.js.buildmanifest.json +34 -34
  15. package/dist/routes.json +1 -1
  16. package/locales/en/translation.json +118 -0
  17. package/locales/fr/translation.json +118 -0
  18. package/package.json +1 -1
  19. package/src/audit/audit-search.component.tsx +58 -0
  20. package/src/audit/audit.component.tsx +63 -16
  21. package/src/audit/audit.resource.ts +445 -10
  22. package/src/audit/audit.scss +6 -0
  23. package/src/audit/date-range-picker.test-stub.tsx +40 -0
  24. package/src/audit/date-range.test.ts +128 -0
  25. package/src/audit/date-range.ts +120 -0
  26. package/src/audit/encounter-filters.component.tsx +40 -18
  27. package/src/audit/encounter-filters.test.ts +1 -2
  28. package/src/audit/encounter-filters.ts +2 -27
  29. package/src/audit/patient-record.component.test.tsx +3 -5
  30. package/src/audit/patient-record.component.tsx +13 -2
  31. package/src/audit/patient-search.component.tsx +1 -1
  32. package/src/audit/provider-encounters.component.test.tsx +254 -0
  33. package/src/audit/provider-encounters.component.tsx +160 -0
  34. package/src/audit/provider-search.component.test.tsx +72 -0
  35. package/src/audit/provider-search.component.tsx +116 -0
  36. package/src/audit/user-encounters.component.test.tsx +431 -0
  37. package/src/audit/user-encounters.component.tsx +239 -0
  38. package/src/audit/user-encounters.test.ts +176 -0
  39. package/src/audit/user-encounters.ts +166 -0
  40. package/src/audit/user-search.component.test.tsx +89 -0
  41. package/src/audit/user-search.component.tsx +111 -0
  42. package/src/root.component.test.tsx +48 -3
  43. package/src/types.ts +34 -0
  44. package/translations/en.json +29 -0
  45. package/translations/es.json +29 -0
  46. package/translations/fr.json +29 -0
  47. package/dist/446.js +0 -1
  48. package/dist/446.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
2
  import useSWR from 'swr';
3
3
  import {
4
4
  type FetchResponse,
@@ -7,7 +7,23 @@ import {
7
7
  useOpenmrsFetchAll,
8
8
  useOpenmrsPagination,
9
9
  } from '@openmrs/esm-framework';
10
- import { type AuditEncounter, type AuditObs, type AuditPatient, type ObsTreeNode, type PagedResponse } from '../types';
10
+ import {
11
+ type AuditEncounter,
12
+ type AuditObs,
13
+ type AuditPatient,
14
+ type AuditProvider,
15
+ type AuditUser,
16
+ type ObsTreeNode,
17
+ type OpenmrsResourceRef,
18
+ type PagedResponse,
19
+ } from '../types';
20
+ import {
21
+ buildAuditDateQuery,
22
+ ENCOUNTER_DATE_PARAMS,
23
+ OBS_CREATED_DATE_PARAMS,
24
+ OBS_VOIDED_DATE_PARAMS,
25
+ type DateRange,
26
+ } from './date-range';
11
27
  import {
12
28
  buildEncounterFilterQuery,
13
29
  distinctEncounterTypes,
@@ -16,6 +32,17 @@ import {
16
32
  } from './encounter-filters';
17
33
  import { buildObsTree } from './obs-audit';
18
34
  import { buildAuditEvents, type ObsByEncounter, summariseByUser } from './patient-activity';
35
+ import {
36
+ type AuditAction,
37
+ canDiscoverMore,
38
+ countUserObs,
39
+ discoverEncounters,
40
+ emptyStream,
41
+ filterByEncounterType,
42
+ type ObsStream,
43
+ streamToExtend,
44
+ type UserEncounterActivity,
45
+ } from './user-encounters';
19
46
 
20
47
  const patientRep =
21
48
  'custom:(uuid,display,identifiers:(uuid,identifier,preferred,identifierType:(uuid,display))' +
@@ -50,6 +77,44 @@ const obsRep =
50
77
  /** A stable identity, so the unfiltered reads do not rebuild their memos on every render. */
51
78
  const noFilters: EncounterFilters = {};
52
79
 
80
+ const userRep = 'custom:(uuid,display,username,systemId,retired,person:(uuid,display))';
81
+
82
+ /**
83
+ * The pihapps audit endpoint, which searches observations by the user who created or voided them —
84
+ * something the core REST API cannot do, since neither the obs resource nor core's own
85
+ * `ObsSearchCriteria` has a creator or voidedBy field.
86
+ */
87
+ const obsAuditUrl = `${restBaseUrl}/pihapps/obs`;
88
+
89
+ /**
90
+ * The ordering an obs audit stream asks for: the column belonging to the action it searched on,
91
+ * most recent first. Ordering by the observation's own datetime would bury an obs backdated to last
92
+ * year but entered this morning, which is the opposite of what an audit needs; `obsId` breaks ties
93
+ * so that paging cannot repeat or skip a row when several share a timestamp.
94
+ */
95
+ function obsSortQuery(actionDate: 'dateCreated' | 'dateVoided'): string {
96
+ return `&sortBy=${actionDate}-desc&sortBy=obsId-desc`;
97
+ }
98
+
99
+ /**
100
+ * The whole encounter rather than a reference, so the observations a user touched can be grouped
101
+ * into encounters and listed without a further request per encounter.
102
+ */
103
+ const obsAuditRep =
104
+ 'custom:(uuid,voided,obsDatetime,concept:(uuid,display),auditInfo' +
105
+ ',encounter:(uuid,display,encounterDatetime,voided,encounterType:(uuid,display)' +
106
+ ',form:(uuid,display),location:(uuid,display),patient:(uuid,display)))';
107
+
108
+ const providerRep = 'custom:(uuid,display,identifier,retired,person:(uuid,display,gender))';
109
+
110
+ /**
111
+ * A provider's encounters span patients, so the list names the patient and who entered it —
112
+ * `encounterListRep` carries only the patient's uuid, which is all a single patient's list needs.
113
+ */
114
+ const providerEncounterRep =
115
+ 'custom:(uuid,display,encounterDatetime,voided,patient:(uuid,display),encounterType:(uuid,display)' +
116
+ ',form:(uuid,display),location:(uuid,display),auditInfo)';
117
+
53
118
  /** Just enough of an obs to say who touched it and when. */
54
119
  const activityObsRep = 'custom:(uuid,voided,concept:(uuid,display),previousVersion:(uuid),auditInfo)';
55
120
 
@@ -68,27 +133,48 @@ const bulkPageSize = 100;
68
133
  const maxBulkPages = 25;
69
134
 
70
135
  /**
71
- * Reads every page of a paginated REST endpoint imperatively.
136
+ * A last-resort ceiling for a read that is meant to be exhaustive, so that a paging bug on either
137
+ * side cannot spin forever. It sits far above any real audit trail; reaching it means something is
138
+ * wrong, which is why it warns and is reported rather than silently truncating.
139
+ */
140
+ const runawayRowLimit = 100000;
141
+
142
+ interface FetchAllPagesOptions {
143
+ /** Stop after this many rows. Defaults to a bound suited to one encounter's observations. */
144
+ maxRows?: number;
145
+ /** Called with the running total after each page, for reads long enough to be worth reporting. */
146
+ onProgress?: (rowsRead: number) => void;
147
+ }
148
+
149
+ /**
150
+ * Reads the pages of a paginated REST endpoint imperatively.
72
151
  *
73
- * `useOpenmrsFetchAll` covers this wherever the url is known at render time. The activity scan is
74
- * the exception: it reads the observations of many encounters, and a hook cannot be called once
75
- * per encounter, so that one loop needs a plain function.
152
+ * `useOpenmrsFetchAll` covers this wherever one url is known at render time. Two callers cannot use
153
+ * it: the activity scan reads the observations of many encounters, and a hook cannot be called once
154
+ * per encounter; and the user audit scan needs to report its progress as it goes.
76
155
  */
77
- async function fetchAllPages<T>(url: string): Promise<Array<T>> {
156
+ async function fetchAllPages<T>(url: string, options: FetchAllPagesOptions = {}): Promise<Array<T>> {
78
157
  const results: Array<T> = [];
79
158
  const separator = url.includes('?') ? '&' : '?';
159
+ const rowLimit = options.maxRows ?? maxBulkPages * bulkPageSize;
80
160
 
81
- for (let page = 0; page < maxBulkPages; page++) {
161
+ while (results.length < rowLimit) {
162
+ const pageSize = Math.min(bulkPageSize, rowLimit - results.length);
82
163
  const response = await openmrsFetch<PagedResponse<T>>(
83
- `${url}${separator}startIndex=${page * bulkPageSize}&limit=${bulkPageSize}`,
164
+ `${url}${separator}startIndex=${results.length}&limit=${pageSize}`,
84
165
  );
85
166
  const batch = response?.data?.results ?? [];
86
167
  results.push(...batch);
87
- if (batch.length < bulkPageSize) {
168
+ options.onProgress?.(results.length);
169
+ if (batch.length < pageSize) {
88
170
  break;
89
171
  }
90
172
  }
91
173
 
174
+ if (results.length >= runawayRowLimit) {
175
+ console.warn(`Stopped reading ${url} after ${runawayRowLimit} rows; results are incomplete.`);
176
+ }
177
+
92
178
  return results;
93
179
  }
94
180
 
@@ -216,6 +302,41 @@ export function useAllPatientEncounters(
216
302
  };
217
303
  }
218
304
 
305
+ /** One entry of the pihapps encounter type count endpoint. */
306
+ interface EncounterTypeCount {
307
+ encounterType: OpenmrsResourceRef;
308
+ count: number;
309
+ }
310
+
311
+ /**
312
+ * The encounter types this implementation actually records against, for a filter that cannot be
313
+ * narrowed to what a particular search contains. The user and provider drill-downs need this
314
+ * because they discover encounters lazily or page them on the server, and so do not know which
315
+ * types a trail holds until all of it is read.
316
+ *
317
+ * Every type in the system would be mostly noise, since which ones are in use varies a great deal
318
+ * by country, so the types come from pihapps' count endpoint, which is asked to leave out those
319
+ * with no encounters. Deleted encounters count, as they do everywhere else in an audit: a type whose
320
+ * encounters have all been deleted still has a trail to look at. Retired types are kept for the
321
+ * same reason, since encounters recorded before a type was retired can still be audited.
322
+ */
323
+ export function useUsedEncounterTypes() {
324
+ const { data, error, isLoading } = useSWR<FetchResponse<{ results: Array<EncounterTypeCount> }>>(
325
+ `${restBaseUrl}/pihapps/encounterTypeCount?includeVoided=true&onlyUsed=true&v=custom:(uuid,display)`,
326
+ openmrsFetch,
327
+ );
328
+
329
+ const encounterTypes = useMemo(
330
+ () =>
331
+ (data?.data?.results ?? [])
332
+ .map((entry) => entry.encounterType)
333
+ .sort((a, b) => (a.display ?? '').localeCompare(b.display ?? '')),
334
+ [data],
335
+ );
336
+
337
+ return { encounterTypes, error, isLoading };
338
+ }
339
+
219
340
  /**
220
341
  * The encounter types to offer as filters: the ones this patient's own encounters use, so the
221
342
  * dropdown never offers a type that would return nothing. Read unfiltered, so choosing a type
@@ -407,3 +528,317 @@ export function usePatientActivity(
407
528
  isLoading: isLoadingEncounters || obsResult.isLoading,
408
529
  };
409
530
  }
531
+
532
+ /** Searches providers by name or identifier, one page at a time. */
533
+ export function useProviderSearch(query: string, page: number, pageSize: number) {
534
+ const startIndex = (page - 1) * pageSize;
535
+ const url = query
536
+ ? `${restBaseUrl}/provider?q=${encodeURIComponent(query)}&v=${providerRep}` +
537
+ `&startIndex=${startIndex}&limit=${pageSize}&totalCount=true`
538
+ : null;
539
+
540
+ const { data, error, isLoading } = useSWR<FetchResponse<PagedResponse<AuditProvider>>>(url, openmrsFetch);
541
+
542
+ return {
543
+ providers: data?.data?.results ?? [],
544
+ totalCount: data?.data?.totalCount ?? data?.data?.results?.length ?? 0,
545
+ error,
546
+ isLoading,
547
+ };
548
+ }
549
+
550
+ export function useAuditProvider(providerUuid: string | null) {
551
+ const url = providerUuid ? `${restBaseUrl}/provider/${providerUuid}?v=${providerRep}` : null;
552
+ const { data, error, isLoading } = useSWR<FetchResponse<AuditProvider>>(url, openmrsFetch);
553
+
554
+ return { provider: data?.data, error, isLoading };
555
+ }
556
+
557
+ /**
558
+ * The encounters a provider is recorded on, newest first, a page at a time.
559
+ *
560
+ * Core cannot search encounters by provider: `EncounterSearchCriteria` carries a providers field
561
+ * but no search handler exposes it, and the free-text encounter search only matches patient name
562
+ * and identifier. The pihapps audit endpoint can, and it returns whole encounters including
563
+ * `auditInfo`, so one paged request per page is the whole cost — no second read per row, and no
564
+ * FHIR detour.
565
+ */
566
+ const PROVIDER_ENCOUNTER_SORT = ['encounterDatetime-desc', 'encounterId-desc'];
567
+
568
+ export function useProviderEncounters(providerUuid: string | null, pageSize: number, filters: EncounterFilters) {
569
+ const filterQuery =
570
+ (filters.encounterType ? `&encounterType=${filters.encounterType.uuid}` : '') +
571
+ buildAuditDateQuery(filters, ENCOUNTER_DATE_PARAMS);
572
+ // `includeVoided=true` because this is an audit: the endpoint leaves voided encounters out unless
573
+ // asked, and an auditor wants to see what has since been deleted as much as what survives. The
574
+ // rows carry a `voided` flag so the table can mark them.
575
+ //
576
+ // The endpoint sorts nothing unless asked. A provider search names no audit action, so the most
577
+ // recent thing it is about is the encounter's own datetime; `encounterId` breaks ties so that
578
+ // paging cannot repeat or skip a row when several share a timestamp.
579
+ const url = providerUuid
580
+ ? `${restBaseUrl}/pihapps/encounter?provider=${providerUuid}&includeVoided=true` +
581
+ `&sortBy=${PROVIDER_ENCOUNTER_SORT.join('&sortBy=')}&v=${providerEncounterRep}${filterQuery}`
582
+ : null;
583
+ // `useOpenmrsPagination` appends limit, startIndex and totalCount itself, so the url omits them.
584
+ const result = useOpenmrsPagination<AuditEncounter>(url as string, pageSize, restFetchOptions);
585
+
586
+ /**
587
+ * Both filters narrow on the server, so a change makes the pages a different set. The hook keeps
588
+ * its own page number, so paging has to be sent back to the start or a filter applied on page
589
+ * three would ask for a page that may no longer exist.
590
+ */
591
+ useEffect(() => {
592
+ if (result.currentPage !== 1) {
593
+ result.goTo(1);
594
+ }
595
+ // eslint-disable-next-line react-hooks/exhaustive-deps
596
+ }, [filterQuery, providerUuid]);
597
+
598
+ return {
599
+ encounters: result.data ?? [],
600
+ // The hook reports NaN until the first page has been read.
601
+ totalCount: Number.isNaN(result.totalCount) ? 0 : result.totalCount,
602
+ currentPage: result.currentPage,
603
+ goTo: result.goTo,
604
+ error: result.error,
605
+ isLoading: result.isLoading,
606
+ };
607
+ }
608
+
609
+ /** Searches user accounts by username, system id, or the person's name, one page at a time. */
610
+ export function useUserSearch(query: string, page: number, pageSize: number) {
611
+ const startIndex = (page - 1) * pageSize;
612
+ const url = query
613
+ ? `${restBaseUrl}/user?q=${encodeURIComponent(query)}&v=${userRep}` +
614
+ `&startIndex=${startIndex}&limit=${pageSize}&totalCount=true`
615
+ : null;
616
+
617
+ const { data, error, isLoading } = useSWR<FetchResponse<PagedResponse<AuditUser>>>(url, openmrsFetch);
618
+
619
+ return {
620
+ users: data?.data?.results ?? [],
621
+ totalCount: data?.data?.totalCount ?? data?.data?.results?.length ?? 0,
622
+ error,
623
+ isLoading,
624
+ };
625
+ }
626
+
627
+ export function useAuditUser(userUuid: string | null) {
628
+ const url = userUuid ? `${restBaseUrl}/user/${userUuid}?v=${userRep}` : null;
629
+ const { data, error, isLoading } = useSWR<FetchResponse<AuditUser>>(url, openmrsFetch);
630
+
631
+ return { user: data?.data, error, isLoading };
632
+ }
633
+
634
+ /** Observations read per request while walking the audit searches. */
635
+ const auditScanPageSize = 100;
636
+
637
+ /**
638
+ * A safety ceiling on how many requests one page of encounters may cost, so that a pathological
639
+ * trail — thousands of observations all on a handful of encounters — cannot spin indefinitely.
640
+ */
641
+ const maxScanRequestsPerPage = 40;
642
+
643
+ /** Reads the next page of one of the two audit searches and folds it into that stream. */
644
+ async function extendStream(baseUrl: string, stream: ObsStream): Promise<ObsStream> {
645
+ const response = await openmrsFetch<PagedResponse<AuditObs>>(
646
+ `${baseUrl}&startIndex=${stream.obs.length}&limit=${auditScanPageSize}`,
647
+ );
648
+ const batch = response?.data?.results ?? [];
649
+ return { obs: [...stream.obs, ...batch], exhausted: batch.length < auditScanPageSize };
650
+ }
651
+
652
+ /**
653
+ * The encounters whose observations a given user recorded or deleted, most recently changed first,
654
+ * discovered a page at a time.
655
+ *
656
+ * `createdBy` and `voidedBy` narrow rather than widen on the audit endpoint, so "touched" means
657
+ * merging two searches. Neither is read to the end: both return observations by audit date
658
+ * descending, so the merge yields encounters in the order the list wants them, and reading stops as
659
+ * soon as enough encounters are certain to fill the page being viewed. Paging further reads further,
660
+ * and paging back costs nothing, since what has been read is kept.
661
+ *
662
+ * A date range is handed to both searches, which bound it against their own audit column, so a
663
+ * range narrows the trail on the server rather than after it has been read.
664
+ *
665
+ * See `discoverEncounters` for why partial reads still give a stable order, and `countUserObs` for
666
+ * how the per-encounter counts are made exact for the rows on screen.
667
+ */
668
+ export function useUserEncounters(
669
+ userUuid: string | null,
670
+ pageSize: number,
671
+ dateRange: DateRange,
672
+ encounterTypeUuid?: string,
673
+ ): {
674
+ activity: Array<UserEncounterActivity>;
675
+ currentPage: number;
676
+ goTo(page: number): void;
677
+ firstRowOnPage: number;
678
+ hasNextPage: boolean;
679
+ obsRead: number;
680
+ /** True when the scan gave up before the trail ran out, so more may match than is shown. */
681
+ stoppedEarly: boolean;
682
+ /**
683
+ * True while the trail is still being read behind a list that already has rows in it — which a
684
+ * filter makes common, since matching rows trickle in as more of the trail is read.
685
+ */
686
+ isDiscovering: boolean;
687
+ error: unknown;
688
+ isLoading: boolean;
689
+ } {
690
+ const [page, setPage] = useState(1);
691
+ const [created, setCreated] = useState<ObsStream>(emptyStream);
692
+ const [voided, setVoided] = useState<ObsStream>(emptyStream);
693
+ const [isScanning, setIsScanning] = useState(false);
694
+ const [stoppedEarly, setStoppedEarly] = useState(false);
695
+ const [error, setError] = useState<unknown>(undefined);
696
+
697
+ // The range identifies the search rather than forming part of a url: each stream now builds its
698
+ // own date query against the column it searched on.
699
+ const dateKey = `${dateRange.fromDate ?? ''}..${dateRange.toDate ?? ''}`;
700
+
701
+ /**
702
+ * Identifies the search the streams belong to — the account and the range both, since changing
703
+ * either changes which observations exist — so a scan that was in flight when it changed cannot
704
+ * append its rows to the results of a different search.
705
+ */
706
+ const scanToken = useRef<string | null>(null);
707
+ const searchKey = userUuid ? `${userUuid}${dateKey}` : null;
708
+
709
+ useEffect(() => {
710
+ scanToken.current = searchKey;
711
+ setPage(1);
712
+ setCreated(emptyStream);
713
+ setVoided(emptyStream);
714
+ setStoppedEarly(false);
715
+ setError(undefined);
716
+ }, [searchKey]);
717
+
718
+ /**
719
+ * The type filter is applied here rather than by the server, so changing it does not invalidate
720
+ * what has been read — only which page of it is being looked at, and possibly how much more of
721
+ * the trail has to be read to fill that page.
722
+ */
723
+ useEffect(() => {
724
+ setPage(1);
725
+ }, [encounterTypeUuid]);
726
+
727
+ const discovered = useMemo(
728
+ () => filterByEncounterType(discoverEncounters(created, voided), encounterTypeUuid),
729
+ [created, encounterTypeUuid, voided],
730
+ );
731
+ const needed = page * pageSize;
732
+ // One more than the page needs, so that whether a next page exists is known without reading a
733
+ // whole further page of encounters.
734
+ const wanted = needed + 1;
735
+
736
+ useEffect(() => {
737
+ const token = searchKey;
738
+ if (!userUuid || !token || discovered.length >= wanted || !canDiscoverMore(created, voided)) {
739
+ return;
740
+ }
741
+
742
+ let cancelled = false;
743
+ // `includeVoided=true` because this is an audit: the endpoint leaves voided observations out
744
+ // unless asked, and the voidedBy stream would come back empty without it. The rows carry a
745
+ // `voided` flag so what was deleted can be told from what survives.
746
+ //
747
+ // The ordering and the date range are per stream rather than on the base url: the endpoint
748
+ // sorts nothing unless asked and bounds no column unless told which, and each stream wants the
749
+ // column belonging to the action it searched on.
750
+ const baseUrl = `${obsAuditUrl}?includeVoided=true&v=${obsAuditRep}`;
751
+
752
+ (async () => {
753
+ setIsScanning(true);
754
+ try {
755
+ let nextCreated = created;
756
+ let nextVoided = voided;
757
+ for (let request = 0; request < maxScanRequestsPerPage; request++) {
758
+ const action = streamToExtend(nextCreated, nextVoided);
759
+ if (!action) {
760
+ break;
761
+ }
762
+ if (action === 'created') {
763
+ nextCreated = await extendStream(
764
+ `${baseUrl}&createdBy=${userUuid}${obsSortQuery('dateCreated')}` +
765
+ buildAuditDateQuery(dateRange, OBS_CREATED_DATE_PARAMS),
766
+ nextCreated,
767
+ );
768
+ } else {
769
+ nextVoided = await extendStream(
770
+ `${baseUrl}&voidedBy=${userUuid}${obsSortQuery('dateVoided')}` +
771
+ buildAuditDateQuery(dateRange, OBS_VOIDED_DATE_PARAMS),
772
+ nextVoided,
773
+ );
774
+ }
775
+ if (cancelled || scanToken.current !== token) {
776
+ return;
777
+ }
778
+ if (filterByEncounterType(discoverEncounters(nextCreated, nextVoided), encounterTypeUuid).length >= wanted) {
779
+ break;
780
+ }
781
+ }
782
+ setCreated(nextCreated);
783
+ setVoided(nextVoided);
784
+ // Whether it ran out of trail or out of patience is the difference between an empty result
785
+ // that means "nothing more" and one that means "not found yet".
786
+ setStoppedEarly(
787
+ canDiscoverMore(nextCreated, nextVoided) &&
788
+ filterByEncounterType(discoverEncounters(nextCreated, nextVoided), encounterTypeUuid).length < wanted,
789
+ );
790
+ } catch (e) {
791
+ setError(e);
792
+ } finally {
793
+ if (!cancelled) {
794
+ setIsScanning(false);
795
+ }
796
+ }
797
+ })();
798
+
799
+ return () => {
800
+ cancelled = true;
801
+ };
802
+ // `created` and `voided` are the accumulators this effect appends to; re-running on them is how
803
+ // it continues a scan that needed more than one round.
804
+ }, [created, voided, dateKey, dateRange, discovered.length, encounterTypeUuid, searchKey, userUuid, wanted]);
805
+
806
+ const firstRowOnPage = (page - 1) * pageSize;
807
+ const pageRows = useMemo(
808
+ () => discovered.slice(firstRowOnPage, firstRowOnPage + pageSize),
809
+ [discovered, firstRowOnPage, pageSize],
810
+ );
811
+
812
+ // Exact counts for the rows on screen only: one small read per encounter, not per audit trail.
813
+ const countsResult = useSWR<Record<string, { obsCreated: number; obsVoided: number }>>(
814
+ userUuid && pageRows.length ? ['audit-user-obs-counts', userUuid, pageRows.map((row) => row.encounter.uuid)] : null,
815
+ async () => {
816
+ const uuids = pageRows.map((row) => row.encounter.uuid);
817
+ const counted = await mapWithConcurrency(uuids, scanConcurrency, async (encounterUuid) => {
818
+ const obs = await fetchAllPages<AuditObs>(
819
+ `${restBaseUrl}/obs?encounter=${encounterUuid}&includeAll=true&v=custom:(uuid,voided,auditInfo)`,
820
+ );
821
+ return countUserObs(obs, userUuid);
822
+ });
823
+ return Object.fromEntries(uuids.map((encounterUuid, index) => [encounterUuid, counted[index]]));
824
+ },
825
+ );
826
+
827
+ const activity = useMemo(
828
+ () => pageRows.map((row) => ({ ...row, ...countsResult.data?.[row.encounter.uuid] })),
829
+ [countsResult.data, pageRows],
830
+ );
831
+
832
+ return {
833
+ activity,
834
+ currentPage: page,
835
+ goTo: setPage,
836
+ firstRowOnPage,
837
+ hasNextPage: discovered.length > needed,
838
+ obsRead: created.obs.length + voided.obs.length,
839
+ stoppedEarly,
840
+ isDiscovering: isScanning && pageRows.length > 0,
841
+ error: error ?? countsResult.error,
842
+ isLoading: (isScanning && pageRows.length === 0) || (pageRows.length > 0 && countsResult.isLoading),
843
+ };
844
+ }
@@ -169,3 +169,9 @@
169
169
  .selectedRow td {
170
170
  background-color: $color-blue-10;
171
171
  }
172
+
173
+ /* Sits above the pager while the trail is still being read, so the list does not look finished. */
174
+ .discovering {
175
+ min-height: auto;
176
+ margin-bottom: layout.$spacing-03;
177
+ }
@@ -0,0 +1,40 @@
1
+ import React, { useState } from 'react';
2
+ import { OpenmrsDateRangePicker, type OpenmrsDateRangePickerProps } from '@openmrs/esm-framework';
3
+
4
+ /** Reads `MM/DD/YYYY` into something shaped like the react-aria dates the real picker reports. */
5
+ function parsePickerDate(text: string | undefined) {
6
+ const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(text?.trim() ?? '');
7
+ if (!match) {
8
+ return null;
9
+ }
10
+ const [, month, day, year] = match.map(Number);
11
+ return { toDate: () => new Date(year, month - 1, day) };
12
+ }
13
+
14
+ /**
15
+ * Stands in for `OpenmrsDateRangePicker` the way the real one behaves, which the framework's own
16
+ * mock does not: react-aria reports the range through `onChangeRaw`, and reports `null` on every
17
+ * change until both ends are complete. Type the range as `MM/DD/YYYY–MM/DD/YYYY`.
18
+ */
19
+ export function stubDateRangePicker() {
20
+ jest
21
+ .mocked(OpenmrsDateRangePicker)
22
+ .mockImplementation(({ id, labelText, onChangeRaw }: OpenmrsDateRangePickerProps) => {
23
+ const [text, setText] = useState('');
24
+ return (
25
+ <div>
26
+ <label htmlFor={id}>{labelText}</label>
27
+ <input
28
+ id={id}
29
+ onChange={(event) => {
30
+ setText(event.target.value);
31
+ const [start, end] = event.target.value.split('–').map(parsePickerDate);
32
+ onChangeRaw?.(start && end ? ({ start, end } as never) : null);
33
+ }}
34
+ type="text"
35
+ value={text}
36
+ />
37
+ </div>
38
+ );
39
+ });
40
+ }
@@ -0,0 +1,128 @@
1
+ import {
2
+ buildAuditDateQuery,
3
+ ENCOUNTER_DATE_PARAMS,
4
+ fromDateKey,
5
+ fromPickerRange,
6
+ toPickerDefault,
7
+ hasDateRange,
8
+ OBS_CREATED_DATE_PARAMS,
9
+ OBS_VOIDED_DATE_PARAMS,
10
+ toDateKey,
11
+ } from './date-range';
12
+
13
+ describe('date keys', () => {
14
+ it('reads the calendar date a moment falls on', () => {
15
+ expect(toDateKey(new Date(2026, 8, 10, 14, 30))).toBe('2026-09-10');
16
+ expect(toDateKey(new Date(2026, 0, 5))).toBe('2026-01-05');
17
+ });
18
+
19
+ it('has nothing to say about a missing or unparseable date', () => {
20
+ expect(toDateKey(null)).toBeUndefined();
21
+ expect(toDateKey(undefined)).toBeUndefined();
22
+ expect(toDateKey(new Date('nonsense'))).toBeUndefined();
23
+ });
24
+
25
+ it('round-trips back to local midnight, not to UTC midnight', () => {
26
+ const date = fromDateKey('2026-09-10');
27
+
28
+ expect(date?.getHours()).toBe(0);
29
+ expect(toDateKey(date)).toBe('2026-09-10');
30
+ });
31
+
32
+ it('has nothing to round-trip when no date is set', () => {
33
+ expect(fromDateKey(undefined)).toBeNull();
34
+ expect(fromDateKey('')).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe('fromPickerRange', () => {
39
+ // `new Date` would read a year below 100 as 19xx, where react-aria's `toDate` keeps it as typed
40
+ const pickerDate = (year: number, month: number, day: number) => ({
41
+ toDate: () => {
42
+ const date = new Date(2000, month - 1, day);
43
+ date.setFullYear(year);
44
+ return date;
45
+ },
46
+ });
47
+
48
+ it('reads a complete range as calendar dates', () => {
49
+ expect(fromPickerRange({ start: pickerDate(2026, 9, 1), end: pickerDate(2026, 9, 30) })).toEqual({
50
+ fromDate: '2026-09-01',
51
+ toDate: '2026-09-30',
52
+ });
53
+ });
54
+
55
+ /** What the picker reports on every keystroke until both ends of the range are complete. */
56
+ it('reads an incomplete range as no range at all', () => {
57
+ expect(hasDateRange(fromPickerRange(null))).toBe(false);
58
+ expect(hasDateRange(fromPickerRange(undefined))).toBe(false);
59
+ });
60
+
61
+ /** What the picker reports on the way to typing a year, as 2 and then 20 on the way to 2026. */
62
+ it('reads a range the picker would reject as no range at all', () => {
63
+ expect(hasDateRange(fromPickerRange({ start: pickerDate(2026, 9, 1), end: pickerDate(20, 9, 30) }))).toBe(false);
64
+ expect(hasDateRange(fromPickerRange({ start: pickerDate(20, 9, 1), end: pickerDate(2026, 9, 30) }))).toBe(false);
65
+ });
66
+ });
67
+
68
+ describe('toPickerDefault', () => {
69
+ it('seeds the picker with a range that is set', () => {
70
+ expect(toPickerDefault({ fromDate: '2026-09-01', toDate: '2026-09-30' })).toEqual([
71
+ new Date(2026, 8, 1),
72
+ new Date(2026, 8, 30),
73
+ ]);
74
+ });
75
+
76
+ /** An empty value hides what react-aria holds of a half-typed range; see `toPickerDefault`. */
77
+ it('leaves the picker empty rather than holding an empty range', () => {
78
+ expect(toPickerDefault({})).toBeUndefined();
79
+ });
80
+ });
81
+
82
+ describe('hasDateRange', () => {
83
+ it('knows when either end is set', () => {
84
+ expect(hasDateRange({})).toBe(false);
85
+ expect(hasDateRange({ fromDate: '2026-09-01' })).toBe(true);
86
+ expect(hasDateRange({ toDate: '2026-09-30' })).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe('buildAuditDateQuery', () => {
91
+ it('sends nothing when no range is set', () => {
92
+ expect(buildAuditDateQuery({}, OBS_CREATED_DATE_PARAMS)).toBe('');
93
+ });
94
+
95
+ /**
96
+ * Bare dates on purpose: the pihapps endpoints read a date-only upper bound as the whole of that day,
97
+ * so spelling out a time would only risk disagreeing with it.
98
+ */
99
+ it('sends the range as plain calendar dates', () => {
100
+ expect(buildAuditDateQuery({ fromDate: '2026-09-01', toDate: '2026-09-30' }, OBS_CREATED_DATE_PARAMS)).toBe(
101
+ '&createdOnOrAfter=2026-09-01&createdOnOrBefore=2026-09-30',
102
+ );
103
+ });
104
+
105
+ it('sends whichever end is set on its own', () => {
106
+ expect(buildAuditDateQuery({ fromDate: '2026-09-01' }, OBS_CREATED_DATE_PARAMS)).toBe(
107
+ '&createdOnOrAfter=2026-09-01',
108
+ );
109
+ expect(buildAuditDateQuery({ toDate: '2026-09-30' }, OBS_CREATED_DATE_PARAMS)).toBe(
110
+ '&createdOnOrBefore=2026-09-30',
111
+ );
112
+ });
113
+
114
+ /** Each stream of an obs audit bounds the column it searched on, so they differ from each other. */
115
+ it('names the bounds the way the voided obs stream does when asked to', () => {
116
+ expect(buildAuditDateQuery({ fromDate: '2026-09-01', toDate: '2026-09-30' }, OBS_VOIDED_DATE_PARAMS)).toBe(
117
+ '&voidedOnOrAfter=2026-09-01&voidedOnOrBefore=2026-09-30',
118
+ );
119
+ });
120
+
121
+ /** No two endpoints agree on what the bounds are called, hence the parameter. */
122
+ it('names the bounds the way the encounter endpoint does when asked to', () => {
123
+ expect(buildAuditDateQuery({ fromDate: '2026-09-01', toDate: '2026-09-30' }, ENCOUNTER_DATE_PARAMS)).toBe(
124
+ '&encounterDatetimeOnOrAfter=2026-09-01&encounterDatetimeOnOrBefore=2026-09-30',
125
+ );
126
+ expect(buildAuditDateQuery({}, ENCOUNTER_DATE_PARAMS)).toBe('');
127
+ });
128
+ });