From 196a18fe1ae7738b021c727b2b6da4e29a9806cf Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 15 Apr 2026 18:56:04 +0530 Subject: [PATCH] feat(data-provider): paginate entity queries + suppress polling-loading flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/queries.ts | 76 ++++++++++++++++++++------------- src/providers/data-provider.tsx | 65 ++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 48 deletions(-) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index e7cf847..69116ea 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,7 +1,21 @@ // 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 contactName { firstName lastName } contactPhone { primaryPhoneNumber primaryPhoneCallingCode } @@ -12,9 +26,9 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls firstContacted lastContacted contactAttempts convertedAt patientId campaignId 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 campaignName typeCustom status platform startDate endDate @@ -22,33 +36,33 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De amountSpent { amountMicros currencyCode } impressions clicks targetCount contacted converted leadsGenerated 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 adName externalAdId status format headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl } impressions clicks conversions spend { amountMicros currencyCode } 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 typeCustom status scheduledAt completedAt priority assignedAgent 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 activityType summary occurredAt performedBy previousValue newValue channel durationSec outcome 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 direction callStatus callerNumber { primaryPhoneNumber } agentName startedAt endedAt durationSec @@ -56,9 +70,27 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls patientId appointmentId leadId agentId agent { id name ozonetelAgentId } 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 } department specialty qualifications yearsOfExperience visitingHours @@ -67,19 +99,3 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node { active registrationNumber 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 -} } } }`; diff --git a/src/providers/data-provider.tsx b/src/providers/data-provider.tsx index 7409e55..1b0f4eb 100644 --- a/src/providers/data-provider.tsx +++ b/src/providers/data-provider.tsx @@ -1,15 +1,15 @@ 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 { - LEADS_QUERY, - CAMPAIGNS_QUERY, - ADS_QUERY, - FOLLOW_UPS_QUERY, - LEAD_ACTIVITIES_QUERY, - CALLS_QUERY, - APPOINTMENTS_QUERY, - PATIENTS_QUERY, + leadsQuery, + campaignsQuery, + adsQuery, + followUpsQuery, + leadActivitiesQuery, + callsQuery, + appointmentsQuery, + patientsQuery, } from '@/lib/queries'; import { transformLeads, @@ -70,6 +70,7 @@ export const DataProvider = ({ children }: DataProviderProps) => { const [patients, setPatients] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const hasLoadedRef = useRef(false); // These don't have platform entities yet — empty for now const [templates] = useState([]); @@ -82,21 +83,48 @@ export const DataProvider = ({ children }: DataProviderProps) => { 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); try { const gql = (query: string) => apiClient.graphql(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 + // `{ : { edges } }`. + const MAX_PAGES = 25; + const fetchAll = async (rootField: string, builder: (after?: string) => string): Promise => { + const allEdges: any[] = []; + let after: string | undefined = undefined; + for (let page = 0; page < MAX_PAGES; page++) { + const data: any = await gql(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([ - gql(LEADS_QUERY), - gql(CAMPAIGNS_QUERY), - gql(ADS_QUERY), - gql(FOLLOW_UPS_QUERY), - gql(LEAD_ACTIVITIES_QUERY), - gql(CALLS_QUERY), - gql(APPOINTMENTS_QUERY), - gql(PATIENTS_QUERY), + fetchAll('leads', leadsQuery), + fetchAll('campaigns', campaignsQuery), + fetchAll('ads', adsQuery), + fetchAll('followUps', followUpsQuery), + fetchAll('leadActivities', leadActivitiesQuery), + fetchAll('calls', callsQuery), + fetchAll('appointments', appointmentsQuery), + fetchAll('patients', patientsQuery), ]); if (leadsData) setLeads(transformLeads(leadsData)); @@ -110,6 +138,7 @@ export const DataProvider = ({ children }: DataProviderProps) => { } catch (err: any) { setError(err.message ?? 'Failed to load data'); } finally { + hasLoadedRef.current = true; setLoading(false); } }, []);