mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat(data-provider): paginate entity queries + suppress polling-loading flash
Two related fixes: 1. KPIs were capped at 100. The data-provider's entity queries were hardcoded to first: 100; on Global the supervisor dashboard showed 'Total Calls: 100' this week while the AI assistant (which paginates) reported 182. Converted each query to a cursor-aware builder, added a generic fetchAll(rootField, builder) that loops until hasNextPage=false (capped at 25 pages × 200 as a runaway guard). Page size bumped 100→200 to cut round-trips on active tenants. 2. Every 30s background poll flipped loading=true, flashing a 'Loading...' overlay across supervisor surfaces. hasLoadedRef guards the flag so only the initial fetch triggers the loading state.
This commit is contained in:
@@ -1,7 +1,21 @@
|
|||||||
// GraphQL queries for platform entities
|
// GraphQL queries for platform entities
|
||||||
// Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection
|
// Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection.
|
||||||
|
//
|
||||||
|
// Each entity exports a query *builder* that accepts an optional `after`
|
||||||
|
// cursor. The data-provider paginates until `hasNextPage=false` so the
|
||||||
|
// dashboard KPIs reflect real totals instead of the first 100 rows. The
|
||||||
|
// previous hardcoded `first: 100` caps caused supervisor KPI cards to
|
||||||
|
// quietly plateau at 100 on busy tenants.
|
||||||
|
//
|
||||||
|
// `pageSize` is intentionally large (200) to keep round-trips low. The
|
||||||
|
// platform Relay pagination accepts up to 1000 but 200 is a good balance
|
||||||
|
// between latency per page and number of pages on active workspaces.
|
||||||
|
|
||||||
export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
const PAGE_SIZE = 200;
|
||||||
|
|
||||||
|
const cursorArg = (after?: string): string => (after ? `, after: "${after}"` : '');
|
||||||
|
|
||||||
|
export const leadsQuery = (after?: string) => `{ leads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||||
id name createdAt updatedAt
|
id name createdAt updatedAt
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||||
@@ -12,9 +26,9 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls
|
|||||||
firstContacted lastContacted contactAttempts convertedAt
|
firstContacted lastContacted contactAttempts convertedAt
|
||||||
patientId campaignId
|
patientId campaignId
|
||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
} } } }`;
|
} } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
|
||||||
export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
export const campaignsQuery = (after?: string) => `{ campaigns(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||||
id name createdAt updatedAt
|
id name createdAt updatedAt
|
||||||
campaignName typeCustom status platform
|
campaignName typeCustom status platform
|
||||||
startDate endDate
|
startDate endDate
|
||||||
@@ -22,33 +36,33 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
|
|||||||
amountSpent { amountMicros currencyCode }
|
amountSpent { amountMicros currencyCode }
|
||||||
impressions clicks targetCount contacted converted leadsGenerated
|
impressions clicks targetCount contacted converted leadsGenerated
|
||||||
externalCampaignId platformUrl { primaryLinkUrl }
|
externalCampaignId platformUrl { primaryLinkUrl }
|
||||||
} } } }`;
|
} } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
|
||||||
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
export const adsQuery = (after?: string) => `{ ads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||||
id name createdAt updatedAt
|
id name createdAt updatedAt
|
||||||
adName externalAdId status format
|
adName externalAdId status format
|
||||||
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
|
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
|
||||||
impressions clicks conversions
|
impressions clicks conversions
|
||||||
spend { amountMicros currencyCode }
|
spend { amountMicros currencyCode }
|
||||||
campaignId
|
campaignId
|
||||||
} } } }`;
|
} } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
|
||||||
export const FOLLOW_UPS_QUERY = `{ followUps(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
export const followUpsQuery = (after?: string) => `{ followUps(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id name createdAt
|
id name createdAt
|
||||||
typeCustom status scheduledAt completedAt
|
typeCustom status scheduledAt completedAt
|
||||||
priority assignedAgent
|
priority assignedAgent
|
||||||
patientId
|
patientId
|
||||||
} } } }`;
|
} } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
|
||||||
export const LEAD_ACTIVITIES_QUERY = `{ leadActivities(first: 200, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
export const leadActivitiesQuery = (after?: string) => `{ leadActivities(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||||
id name createdAt
|
id name createdAt
|
||||||
activityType summary occurredAt performedBy
|
activityType summary occurredAt performedBy
|
||||||
previousValue newValue
|
previousValue newValue
|
||||||
channel durationSec outcome
|
channel durationSec outcome
|
||||||
leadId
|
leadId
|
||||||
} } } }`;
|
} } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
|
||||||
export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
export const callsQuery = (after?: string) => `{ calls(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id name createdAt
|
id name createdAt
|
||||||
direction callStatus callerNumber { primaryPhoneNumber } agentName
|
direction callStatus callerNumber { primaryPhoneNumber } agentName
|
||||||
startedAt endedAt durationSec
|
startedAt endedAt durationSec
|
||||||
@@ -56,9 +70,27 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
|
|||||||
patientId appointmentId leadId
|
patientId appointmentId leadId
|
||||||
agentId agent { id name ozonetelAgentId }
|
agentId agent { id name ozonetelAgentId }
|
||||||
transferredTo transferType
|
transferredTo transferType
|
||||||
} } } }`;
|
} } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
|
||||||
export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
export const appointmentsQuery = (after?: string) => `{ appointments(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id name createdAt
|
||||||
|
scheduledAt durationMin appointmentType status
|
||||||
|
doctorName department reasonForVisit
|
||||||
|
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||||
|
doctor { id fullName { firstName lastName } }
|
||||||
|
clinicId clinic { id clinicName }
|
||||||
|
} } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
|
||||||
|
export const patientsQuery = (after?: string) => `{ patients(first: ${PAGE_SIZE}${cursorArg(after)}) { edges { node {
|
||||||
|
id name fullName { firstName lastName }
|
||||||
|
phones { primaryPhoneNumber }
|
||||||
|
emails { primaryEmail }
|
||||||
|
dateOfBirth gender patientType
|
||||||
|
} } pageInfo { hasNextPage endCursor } } }`;
|
||||||
|
|
||||||
|
// Doctors are a small reference set (< 50 per workspace) — no pagination
|
||||||
|
// needed. Left as a plain string for the single consumer that reads it.
|
||||||
|
export const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||||
id name fullName { firstName lastName }
|
id name fullName { firstName lastName }
|
||||||
department specialty qualifications yearsOfExperience
|
department specialty qualifications yearsOfExperience
|
||||||
visitingHours
|
visitingHours
|
||||||
@@ -67,19 +99,3 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
|||||||
active registrationNumber
|
active registrationNumber
|
||||||
clinic { id name clinicName }
|
clinic { id name clinicName }
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
|
||||||
id name createdAt
|
|
||||||
scheduledAt durationMin appointmentType status
|
|
||||||
doctorName department reasonForVisit
|
|
||||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
|
||||||
doctor { id fullName { firstName lastName } }
|
|
||||||
clinicId clinic { id clinicName }
|
|
||||||
} } } }`;
|
|
||||||
|
|
||||||
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
|
|
||||||
id name fullName { firstName lastName }
|
|
||||||
phones { primaryPhoneNumber }
|
|
||||||
emails { primaryEmail }
|
|
||||||
dateOfBirth gender patientType
|
|
||||||
} } } }`;
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import {
|
import {
|
||||||
LEADS_QUERY,
|
leadsQuery,
|
||||||
CAMPAIGNS_QUERY,
|
campaignsQuery,
|
||||||
ADS_QUERY,
|
adsQuery,
|
||||||
FOLLOW_UPS_QUERY,
|
followUpsQuery,
|
||||||
LEAD_ACTIVITIES_QUERY,
|
leadActivitiesQuery,
|
||||||
CALLS_QUERY,
|
callsQuery,
|
||||||
APPOINTMENTS_QUERY,
|
appointmentsQuery,
|
||||||
PATIENTS_QUERY,
|
patientsQuery,
|
||||||
} from '@/lib/queries';
|
} from '@/lib/queries';
|
||||||
import {
|
import {
|
||||||
transformLeads,
|
transformLeads,
|
||||||
@@ -70,6 +70,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
|
||||||
// These don't have platform entities yet — empty for now
|
// These don't have platform entities yet — empty for now
|
||||||
const [templates] = useState<WhatsAppTemplate[]>([]);
|
const [templates] = useState<WhatsAppTemplate[]>([]);
|
||||||
@@ -82,21 +83,48 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
// Only flip the global loading flag on the very first fetch. Background
|
||||||
|
// polls refresh data in place so the UI doesn't flash "Loading..." —
|
||||||
|
// QA reported this as the supervisor surfaces randomly refreshing.
|
||||||
|
if (!hasLoadedRef.current) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
||||||
|
|
||||||
|
// Generic Relay pagination. Keeps paging until hasNextPage=false
|
||||||
|
// or we hit MAX_PAGES (guard against runaway loops on bad data).
|
||||||
|
// Returned shape mirrors the original single-page response so
|
||||||
|
// transformX functions work unchanged — they already read
|
||||||
|
// `{ <rootField>: { edges } }`.
|
||||||
|
const MAX_PAGES = 25;
|
||||||
|
const fetchAll = async (rootField: string, builder: (after?: string) => string): Promise<any | null> => {
|
||||||
|
const allEdges: any[] = [];
|
||||||
|
let after: string | undefined = undefined;
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const data: any = await gql<any>(builder(after));
|
||||||
|
if (!data) return null;
|
||||||
|
const root: any = data[rootField];
|
||||||
|
if (!root) break;
|
||||||
|
if (Array.isArray(root.edges)) allEdges.push(...root.edges);
|
||||||
|
if (!root.pageInfo?.hasNextPage) break;
|
||||||
|
after = root.pageInfo.endCursor;
|
||||||
|
if (!after) break;
|
||||||
|
}
|
||||||
|
return { [rootField]: { edges: allEdges } };
|
||||||
|
};
|
||||||
|
|
||||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
|
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
|
||||||
gql<any>(LEADS_QUERY),
|
fetchAll('leads', leadsQuery),
|
||||||
gql<any>(CAMPAIGNS_QUERY),
|
fetchAll('campaigns', campaignsQuery),
|
||||||
gql<any>(ADS_QUERY),
|
fetchAll('ads', adsQuery),
|
||||||
gql<any>(FOLLOW_UPS_QUERY),
|
fetchAll('followUps', followUpsQuery),
|
||||||
gql<any>(LEAD_ACTIVITIES_QUERY),
|
fetchAll('leadActivities', leadActivitiesQuery),
|
||||||
gql<any>(CALLS_QUERY),
|
fetchAll('calls', callsQuery),
|
||||||
gql<any>(APPOINTMENTS_QUERY),
|
fetchAll('appointments', appointmentsQuery),
|
||||||
gql<any>(PATIENTS_QUERY),
|
fetchAll('patients', patientsQuery),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (leadsData) setLeads(transformLeads(leadsData));
|
if (leadsData) setLeads(transformLeads(leadsData));
|
||||||
@@ -110,6 +138,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? 'Failed to load data');
|
setError(err.message ?? 'Failed to load data');
|
||||||
} finally {
|
} finally {
|
||||||
|
hasLoadedRef.current = true;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Reference in New Issue
Block a user