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:
2026-04-15 18:56:04 +05:30
parent 28689254ca
commit 196a18fe1a
2 changed files with 93 additions and 48 deletions

View File

@@ -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
} } } }`;

View File

@@ -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;
} }
// 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); 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);
} }
}, []); }, []);