astro-tractstack 2.0.0-rc.26 → 2.0.0-rc.28

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.0-rc.26",
3
+ "version": "2.0.0-rc.28",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,4 +1,4 @@
1
- import { useEffect, useCallback, useRef, useState } from 'react';
1
+ import { useEffect, useRef } from 'react';
2
2
  import { useStore } from '@nanostores/react';
3
3
  import { epinetCustomFilters } from '@/stores/analytics';
4
4
  import { TractStackAPI } from '@/utils/api';
@@ -20,88 +20,115 @@ interface FetchAnalyticsProps {
20
20
  onAnalyticsUpdate: (analytics: AnalyticsState) => void;
21
21
  }
22
22
 
23
- export default function FetchAnalytics({
24
- onAnalyticsUpdate,
25
- }: FetchAnalyticsProps) {
26
- const $epinetCustomFilters = useStore(epinetCustomFilters);
27
- const isInitialized = useRef<boolean>(false);
28
- const isInitializing = useRef<boolean>(false);
29
- const fetchCount = useRef<number>(0);
30
-
31
- // Add polling state
32
- const [pollingTimer, setPollingTimer] = useState<NodeJS.Timeout | null>(null);
33
- const [pollingAttempts, setPollingAttempts] = useState(0);
34
- const [pollingStartTime, setPollingStartTime] = useState<number | null>(null);
35
- const MAX_POLLING_ATTEMPTS = 3;
36
- const POLLING_DELAYS = [2000, 5000, 10000]; // 2s, 5s, 10s
37
- const MAX_POLLING_TIME = 30000; // 30 seconds total max
38
-
39
- if (VERBOSE)
40
- console.log('🔄 FetchAnalytics RENDER', {
41
- renderCount: ++fetchCount.current,
42
- filters: {
43
- startTimeUTC: $epinetCustomFilters.startTimeUTC,
44
- endTimeUTC: $epinetCustomFilters.endTimeUTC,
45
- visitorType: $epinetCustomFilters.visitorType,
46
- selectedUserId: $epinetCustomFilters.selectedUserId,
47
- },
48
- isInitialized: isInitialized.current,
49
- storeObjectRef: $epinetCustomFilters,
50
- });
23
+ // Global singleton state to prevent multi-component conflicts
24
+ class AnalyticsService {
25
+ private static instance: AnalyticsService;
26
+ private isInitialized = false;
27
+ private activeRequest: AbortController | null = null;
28
+ private requestCache = new Map<string, { data: any; timestamp: number }>();
29
+ private readonly CACHE_TTL = 5000; // 5 seconds
30
+ private readonly DEBOUNCE_MS = 300;
31
+ private debounceTimer: NodeJS.Timeout | null = null;
32
+ private floodProtection = {
33
+ requestCount: 0,
34
+ windowStart: 0,
35
+ isBlocked: false,
36
+ };
37
+ private readonly FLOOD_WINDOW_MS = 10000; // 10 seconds
38
+ private readonly FLOOD_THRESHOLD = 5;
39
+
40
+ static getInstance(): AnalyticsService {
41
+ if (!AnalyticsService.instance) {
42
+ AnalyticsService.instance = new AnalyticsService();
43
+ }
44
+ return AnalyticsService.instance;
45
+ }
46
+
47
+ private getCacheKey(params: URLSearchParams): string {
48
+ const sorted = new URLSearchParams([...params.entries()].sort());
49
+ return sorted.toString();
50
+ }
51
+
52
+ private isFloodBlocked(): boolean {
53
+ const now = Date.now();
54
+
55
+ // Reset window if needed
56
+ if (now - this.floodProtection.windowStart > this.FLOOD_WINDOW_MS) {
57
+ this.floodProtection.requestCount = 0;
58
+ this.floodProtection.windowStart = now;
59
+ this.floodProtection.isBlocked = false;
60
+ }
51
61
 
52
- // Clear polling timer on unmount
53
- useEffect(() => {
54
- return () => {
55
- if (pollingTimer) {
56
- clearTimeout(pollingTimer);
62
+ // Check if blocked
63
+ if (this.floodProtection.isBlocked) {
64
+ if (VERBOSE) console.log('🚫 Request blocked by flood protection');
65
+ return true;
66
+ }
67
+
68
+ // Increment counter and check threshold
69
+ this.floodProtection.requestCount++;
70
+ if (this.floodProtection.requestCount > this.FLOOD_THRESHOLD) {
71
+ this.floodProtection.isBlocked = true;
72
+ if (VERBOSE) console.log('🚨 Flood protection activated');
73
+
74
+ // Auto-unblock after delay
75
+ setTimeout(() => {
76
+ this.floodProtection.isBlocked = false;
77
+ if (VERBOSE) console.log('✅ Flood protection deactivated');
78
+ }, 30000); // 30 second cooldown
79
+
80
+ return true;
81
+ }
82
+
83
+ return false;
84
+ }
85
+
86
+ private getCachedResponse(cacheKey: string): any | null {
87
+ const cached = this.requestCache.get(cacheKey);
88
+ if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
89
+ if (VERBOSE) console.log('📦 Using cached response');
90
+ return cached.data;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ private setCachedResponse(cacheKey: string, data: any): void {
96
+ this.requestCache.set(cacheKey, { data, timestamp: Date.now() });
97
+
98
+ // Cleanup old cache entries
99
+ const cutoff = Date.now() - this.CACHE_TTL;
100
+ for (const [key, value] of this.requestCache.entries()) {
101
+ if (value.timestamp < cutoff) {
102
+ this.requestCache.delete(key);
57
103
  }
58
- };
59
- }, [pollingTimer]);
60
-
61
- // Fetch all analytics data
62
- const fetchAllAnalytics = useCallback(async () => {
63
- if (VERBOSE)
64
- console.log('🚀 fetchAllAnalytics CALLED', {
65
- timestamp: new Date().toISOString(),
66
- filters: {
67
- startTimeUTC: $epinetCustomFilters.startTimeUTC,
68
- endTimeUTC: $epinetCustomFilters.endTimeUTC,
69
- visitorType: $epinetCustomFilters.visitorType,
70
- selectedUserId: $epinetCustomFilters.selectedUserId,
71
- },
72
- });
104
+ }
105
+ }
106
+
107
+ async fetchAnalytics(
108
+ filters: any,
109
+ onUpdate: (data: AnalyticsState) => void
110
+ ): Promise<void> {
111
+ // Flood protection
112
+ if (this.isFloodBlocked()) {
113
+ return;
114
+ }
73
115
 
74
116
  try {
75
- // Clear existing timer
76
- if (pollingTimer) {
77
- clearTimeout(pollingTimer);
78
- setPollingTimer(null);
117
+ // Cancel any existing request
118
+ if (this.activeRequest) {
119
+ this.activeRequest.abort();
120
+ if (VERBOSE) console.log('🛑 Cancelled previous request');
79
121
  }
80
122
 
81
- // Set loading state
82
- if (VERBOSE) console.log('📤 Setting loading state');
83
- onAnalyticsUpdate({
84
- dashboard: null,
85
- leads: null,
86
- epinet: null,
87
- userCounts: [],
88
- hourlyNodeActivity: {},
89
- isLoading: true,
90
- status: 'loading',
91
- error: null,
92
- });
93
-
94
- const { startTimeUTC, endTimeUTC, visitorType, selectedUserId } =
95
- $epinetCustomFilters;
123
+ // Create new abort controller
124
+ this.activeRequest = new AbortController();
96
125
 
97
- // Build URL parameters for TractStackAPI
126
+ // Build URL parameters
98
127
  const params = new URLSearchParams();
99
-
100
- if (startTimeUTC && endTimeUTC) {
101
- // Convert UTC timestamps to hours-back integers (what backend expects)
128
+ if (filters.startTimeUTC && filters.endTimeUTC) {
102
129
  const now = new Date();
103
- const startTime = new Date(startTimeUTC);
104
- const endTime = new Date(endTimeUTC);
130
+ const startTime = new Date(filters.startTimeUTC);
131
+ const endTime = new Date(filters.endTimeUTC);
105
132
 
106
133
  const startHour = Math.ceil(
107
134
  (now.getTime() - startTime.getTime()) / (1000 * 60 * 60)
@@ -112,27 +139,42 @@ export default function FetchAnalytics({
112
139
 
113
140
  params.append('startHour', startHour.toString());
114
141
  params.append('endHour', endHour.toString());
142
+ }
115
143
 
116
- if (VERBOSE)
117
- console.log('⏰ Time calculations', {
118
- now: now.toISOString(),
119
- startTime: startTime.toISOString(),
120
- endTime: endTime.toISOString(),
121
- startHour,
122
- endHour,
123
- });
144
+ if (filters.visitorType)
145
+ params.append('visitorType', filters.visitorType);
146
+ if (filters.selectedUserId)
147
+ params.append('userId', filters.selectedUserId);
148
+
149
+ const cacheKey = this.getCacheKey(params);
150
+
151
+ // Check cache first
152
+ const cachedData = this.getCachedResponse(cacheKey);
153
+ if (cachedData) {
154
+ onUpdate(cachedData);
155
+ return;
124
156
  }
125
157
 
126
- if (visitorType) params.append('visitorType', visitorType);
127
- if (selectedUserId) params.append('userId', selectedUserId);
158
+ // Set loading state
159
+ onUpdate({
160
+ dashboard: null,
161
+ leads: null,
162
+ epinet: null,
163
+ userCounts: [],
164
+ hourlyNodeActivity: {},
165
+ isLoading: true,
166
+ status: 'loading',
167
+ error: null,
168
+ });
128
169
 
129
- // Use TractStackAPI
170
+ // Make request using existing TractStackAPI
130
171
  const api = new TractStackAPI(
131
172
  window.TRACTSTACK_CONFIG?.tenantId || 'default'
132
173
  );
133
174
  const endpoint = `/api/v1/analytics/all${params.toString() ? `?${params.toString()}` : ''}`;
134
175
 
135
- if (VERBOSE) console.log('📡 Making API request', { endpoint });
176
+ if (VERBOSE) console.log('🔥 Making API request', { endpoint });
177
+
136
178
  const response = await api.get(endpoint);
137
179
 
138
180
  if (!response.success) {
@@ -140,15 +182,8 @@ export default function FetchAnalytics({
140
182
  }
141
183
 
142
184
  const data = response.data;
143
- if (VERBOSE)
144
- console.log('✅ API response received', {
145
- hasData: !!data,
146
- dataKeys: data ? Object.keys(data) : [],
147
- userCountsLength: data?.userCounts?.length || 0,
148
- hasHourlyNodeActivity: !!data?.hourlyNodeActivity,
149
- });
150
-
151
- // Check if data is still loading - add polling logic here
185
+
186
+ // Check if data is still loading - implement polling logic
152
187
  const isStillLoading =
153
188
  data?.status === 'loading' ||
154
189
  data?.status === 'refreshing' ||
@@ -159,63 +194,42 @@ export default function FetchAnalytics({
159
194
  data?.epinet?.status === 'loading' ||
160
195
  data?.epinet?.status === 'refreshing';
161
196
 
162
- if (VERBOSE) {
163
- console.log('🔍 Loading status check', {
164
- overallStatus: data?.status,
165
- dashboardStatus: data?.dashboard?.status,
166
- leadsStatus: data?.leads?.status,
167
- epinetStatus: data?.epinet?.status,
168
- isStillLoading,
169
- pollingAttempts,
170
- });
171
- }
172
-
173
- if (isStillLoading && pollingAttempts < MAX_POLLING_ATTEMPTS) {
174
- // Check if we've been polling too long
175
- const now = Date.now();
176
- if (pollingStartTime && now - pollingStartTime > MAX_POLLING_TIME) {
177
- if (VERBOSE) console.log('⏰ Max polling time reached, stopping');
178
- setPollingStartTime(null);
179
- // Continue with data even if still loading
180
- } else {
181
- if (VERBOSE)
182
- console.log('⏳ Analytics data still loading, polling...', {
183
- attempt: pollingAttempts + 1,
184
- });
185
-
186
- // Set start time if this is the first poll
187
- if (!pollingStartTime) {
188
- setPollingStartTime(now);
189
- }
190
-
191
- const delayMs =
192
- POLLING_DELAYS[pollingAttempts] ||
193
- POLLING_DELAYS[POLLING_DELAYS.length - 1];
194
-
195
- const newTimer = setTimeout(() => {
196
- setPollingAttempts(pollingAttempts + 1);
197
- fetchAllAnalytics();
198
- }, delayMs);
199
-
200
- setPollingTimer(newTimer);
201
-
202
- // Update with partial data but keep loading state
203
- onAnalyticsUpdate({
204
- dashboard: data.dashboard,
205
- leads: data.leads,
206
- epinet: data.epinet,
207
- userCounts: data.userCounts || [],
208
- hourlyNodeActivity: data.hourlyNodeActivity || {},
209
- status: 'loading',
210
- error: null,
211
- isLoading: true,
212
- });
213
-
214
- return;
197
+ if (isStillLoading) {
198
+ if (VERBOSE) console.log(' Backend data still loading, will poll...');
199
+
200
+ // Update with partial data but keep loading state
201
+ const partialAnalytics = {
202
+ dashboard: data.dashboard,
203
+ leads: data.leads,
204
+ epinet: data.epinet,
205
+ userCounts: data.userCounts || [],
206
+ hourlyNodeActivity: data.hourlyNodeActivity || {},
207
+ status: 'loading',
208
+ error: null,
209
+ isLoading: true,
210
+ };
211
+
212
+ onUpdate(partialAnalytics);
213
+
214
+ // Schedule polling retry - use the cache key to prevent multiple polls
215
+ const pollKey = `poll_${cacheKey}`;
216
+ if (!this.requestCache.has(pollKey)) {
217
+ this.requestCache.set(pollKey, { data: null, timestamp: Date.now() });
218
+
219
+ setTimeout(() => {
220
+ this.requestCache.delete(pollKey);
221
+ // Clear the main cache entry to force fresh request
222
+ this.requestCache.delete(cacheKey);
223
+ // Retry the fetch
224
+ this.fetchAnalytics(filters, onUpdate);
225
+ }, 2000);
215
226
  }
227
+
228
+ return;
216
229
  }
217
230
 
218
- const newAnalytics = {
231
+ // Process successful response
232
+ const analyticsData = {
219
233
  dashboard: data.dashboard,
220
234
  leads: data.leads,
221
235
  epinet: data.epinet,
@@ -226,90 +240,62 @@ export default function FetchAnalytics({
226
240
  isLoading: false,
227
241
  };
228
242
 
229
- if (VERBOSE) console.log('📤 Calling onAnalyticsUpdate');
230
- onAnalyticsUpdate(newAnalytics);
231
-
232
- // Update epinetCustomFilters with additional data from response
233
- if (VERBOSE)
234
- console.log('🔄 BEFORE store update', {
235
- currentStoreRef: epinetCustomFilters.get(),
236
- aboutToSet: {
237
- userCounts: data.userCounts?.length || 0,
238
- hourlyNodeActivity: !!data.hourlyNodeActivity,
239
- },
240
- });
241
-
242
- epinetCustomFilters.set(window.TRACTSTACK_CONFIG?.tenantId || 'default', {
243
- ...$epinetCustomFilters,
244
- userCounts: data.userCounts || [],
245
- hourlyNodeActivity: data.hourlyNodeActivity || {},
246
- });
243
+ // Cache the response
244
+ this.setCachedResponse(cacheKey, analyticsData);
245
+
246
+ // Update caller
247
+ onUpdate(analyticsData);
247
248
 
248
- // Reset polling attempts and start time on success
249
- setPollingAttempts(0);
250
- setPollingStartTime(null);
249
+ if (VERBOSE) console.log('✅ Analytics request completed successfully');
251
250
 
252
- if (VERBOSE)
253
- console.log('🔄 AFTER store update', {
254
- newStoreRef: epinetCustomFilters.get(),
255
- });
251
+ this.activeRequest = null;
256
252
  } catch (error) {
257
- console.error('❌ Analytics fetch error:', error);
253
+ if (error instanceof Error && error.name === 'AbortError') {
254
+ if (VERBOSE) console.log('🔄 Request aborted');
255
+ return;
256
+ }
258
257
 
259
- const errorMessage =
260
- error instanceof Error ? error.message : 'Unknown error occurred';
258
+ console.error('❌ Analytics fetch error:', error);
261
259
 
262
- onAnalyticsUpdate({
260
+ onUpdate({
263
261
  dashboard: null,
264
262
  leads: null,
265
263
  epinet: null,
266
264
  userCounts: [],
267
265
  hourlyNodeActivity: {},
268
266
  status: 'error',
269
- error: errorMessage,
267
+ error:
268
+ error instanceof Error ? error.message : 'Unknown error occurred',
270
269
  isLoading: false,
271
270
  });
272
271
 
273
- // Schedule retry if we haven't reached max attempts
274
- if (pollingAttempts < MAX_POLLING_ATTEMPTS) {
275
- if (VERBOSE)
276
- console.log(
277
- '🔄 Scheduling retry due to error, attempt',
278
- pollingAttempts + 1
279
- );
280
-
281
- const delayMs =
282
- POLLING_DELAYS[pollingAttempts] ||
283
- POLLING_DELAYS[POLLING_DELAYS.length - 1];
284
-
285
- const newTimer = setTimeout(() => {
286
- setPollingAttempts(pollingAttempts + 1);
287
- fetchAllAnalytics();
288
- }, delayMs);
272
+ this.activeRequest = null;
273
+ }
274
+ }
289
275
 
290
- setPollingTimer(newTimer);
291
- }
276
+ cleanup(): void {
277
+ if (this.activeRequest) {
278
+ this.activeRequest.abort();
279
+ this.activeRequest = null;
292
280
  }
293
- }, [
294
- $epinetCustomFilters.startTimeUTC,
295
- $epinetCustomFilters.endTimeUTC,
296
- $epinetCustomFilters.visitorType,
297
- $epinetCustomFilters.selectedUserId,
298
- pollingAttempts,
299
- ]);
281
+ if (this.debounceTimer) {
282
+ clearTimeout(this.debounceTimer);
283
+ this.debounceTimer = null;
284
+ }
285
+ }
300
286
 
301
- // Initialize on first mount
302
- useEffect(() => {
303
- if (!isInitialized.current && !isInitializing.current) {
304
- if (VERBOSE) console.log('🏁 Initializing FetchAnalytics');
305
- isInitializing.current = true;
287
+ initializeFilters(tenantId: string): void {
288
+ if (this.isInitialized) return;
306
289
 
307
- const nowUTC = new Date();
308
- const oneWeekAgoUTC = new Date(
309
- nowUTC.getTime() - 7 * 24 * 60 * 60 * 1000
310
- );
290
+ if (VERBOSE) console.log('🏁 Initializing analytics filters');
311
291
 
312
- epinetCustomFilters.set(window.TRACTSTACK_CONFIG?.tenantId || 'default', {
292
+ const nowUTC = new Date();
293
+ const oneWeekAgoUTC = new Date(nowUTC.getTime() - 7 * 24 * 60 * 60 * 1000);
294
+
295
+ // Only set if not already initialized to prevent store churn
296
+ const current = epinetCustomFilters.get();
297
+ if (!current.enabled) {
298
+ epinetCustomFilters.set(tenantId, {
313
299
  enabled: true,
314
300
  visitorType: 'all',
315
301
  selectedUserId: null,
@@ -318,32 +304,94 @@ export default function FetchAnalytics({
318
304
  userCounts: [],
319
305
  hourlyNodeActivity: {},
320
306
  });
307
+ }
321
308
 
322
- isInitialized.current = true;
323
- isInitializing.current = false;
309
+ this.isInitialized = true;
310
+ }
311
+
312
+ debouncedFetch(filters: any, onUpdate: (data: AnalyticsState) => void): void {
313
+ // Clear existing debounce timer
314
+ if (this.debounceTimer) {
315
+ clearTimeout(this.debounceTimer);
324
316
  }
317
+
318
+ // Set new debounced fetch
319
+ this.debounceTimer = setTimeout(() => {
320
+ this.fetchAnalytics(filters, onUpdate);
321
+ }, this.DEBOUNCE_MS);
322
+ }
323
+ }
324
+
325
+ export default function FetchAnalytics({
326
+ onAnalyticsUpdate,
327
+ }: FetchAnalyticsProps) {
328
+ const $epinetCustomFilters = useStore(epinetCustomFilters);
329
+ const analyticsService = useRef(AnalyticsService.getInstance());
330
+ const lastFiltersRef = useRef<string>('');
331
+
332
+ if (VERBOSE) {
333
+ console.log('🔄 FetchAnalytics render', {
334
+ filters: {
335
+ startTimeUTC: $epinetCustomFilters.startTimeUTC,
336
+ endTimeUTC: $epinetCustomFilters.endTimeUTC,
337
+ visitorType: $epinetCustomFilters.visitorType,
338
+ selectedUserId: $epinetCustomFilters.selectedUserId,
339
+ },
340
+ });
341
+ }
342
+
343
+ // Cleanup on unmount
344
+ useEffect(() => {
345
+ return () => {
346
+ analyticsService.current.cleanup();
347
+ };
325
348
  }, []);
326
349
 
327
- // Fetch when filters change
350
+ // Initialize filters once
351
+ useEffect(() => {
352
+ const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
353
+ analyticsService.current.initializeFilters(tenantId);
354
+ }, []);
355
+
356
+ // Debounced fetch when filters change
328
357
  useEffect(() => {
329
358
  if (
330
- isInitialized.current &&
331
- $epinetCustomFilters.enabled &&
332
- $epinetCustomFilters.visitorType !== null &&
333
- $epinetCustomFilters.startTimeUTC !== null &&
334
- $epinetCustomFilters.endTimeUTC !== null
359
+ !$epinetCustomFilters.enabled ||
360
+ $epinetCustomFilters.visitorType === null ||
361
+ $epinetCustomFilters.startTimeUTC === null ||
362
+ $epinetCustomFilters.endTimeUTC === null
335
363
  ) {
336
- if (VERBOSE) console.log('🔄 Filters changed, fetching analytics');
337
- setPollingAttempts(0); // Reset polling attempts when filters change
338
- setPollingStartTime(null); // Reset polling start time
339
- fetchAllAnalytics();
364
+ return;
340
365
  }
366
+
367
+ // Create stable filter signature to prevent unnecessary fetches
368
+ const filtersSignature = JSON.stringify({
369
+ startTimeUTC: $epinetCustomFilters.startTimeUTC,
370
+ endTimeUTC: $epinetCustomFilters.endTimeUTC,
371
+ visitorType: $epinetCustomFilters.visitorType,
372
+ selectedUserId: $epinetCustomFilters.selectedUserId,
373
+ });
374
+
375
+ // Skip if filters haven't actually changed
376
+ if (filtersSignature === lastFiltersRef.current) {
377
+ return;
378
+ }
379
+
380
+ lastFiltersRef.current = filtersSignature;
381
+
382
+ if (VERBOSE) console.log('🔄 Filters changed, debouncing fetch');
383
+
384
+ analyticsService.current.debouncedFetch(
385
+ $epinetCustomFilters,
386
+ onAnalyticsUpdate
387
+ );
341
388
  }, [
342
389
  $epinetCustomFilters.enabled,
343
390
  $epinetCustomFilters.visitorType,
344
391
  $epinetCustomFilters.selectedUserId,
345
392
  $epinetCustomFilters.startTimeUTC,
346
393
  $epinetCustomFilters.endTimeUTC,
394
+ onAnalyticsUpdate,
347
395
  ]);
348
396
 
349
397
  return null;
@@ -161,11 +161,10 @@ const pollingState = new Map<
161
161
  }
162
162
  >();
163
163
 
164
- // Constants for polling configuration
165
- const MAX_POLLING_ATTEMPTS = 10;
166
- const MAX_POLLING_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
167
- const BASE_POLLING_INTERVAL = 2000; // 2 seconds base interval
168
- const MAX_POLLING_INTERVAL = 32000; // 32 seconds max interval
164
+ const MAX_POLLING_ATTEMPTS = 25;
165
+ const MAX_POLLING_DURATION = 10 * 60 * 1000; // 10 minutes
166
+ const BASE_POLLING_INTERVAL = 10000; // 10 seconds
167
+ const MAX_POLLING_INTERVAL = 30000; // 30 seconds
169
168
 
170
169
  const fetchingStates = new Map<string, boolean>();
171
170
 
@@ -203,7 +202,7 @@ export async function loadOrphanAnalysis(): Promise<void> {
203
202
 
204
203
  updateTenantState(tenantId, {
205
204
  data,
206
- isLoading: false,
205
+ isLoading: data.status === 'loading', // Only stop loading if complete
207
206
  error: null,
208
207
  lastFetched: Date.now(),
209
208
  });
@@ -239,7 +238,7 @@ function startPolling(tenantId: string): void {
239
238
  lastAttemptTime: startTime,
240
239
  });
241
240
 
242
- // Start the first poll immediately
241
+ // Start the first poll
243
242
  scheduleNextPoll(tenantId);
244
243
  }
245
244
 
@@ -260,21 +259,21 @@ function scheduleNextPoll(tenantId: string): void {
260
259
  const elapsed = Date.now() - state.startTime;
261
260
  if (elapsed >= MAX_POLLING_DURATION) {
262
261
  console.warn(
263
- `Orphan analysis polling stopped: Maximum duration (${MAX_POLLING_DURATION}ms) exceeded for tenant ${tenantId}`
262
+ `Orphan analysis polling stopped: Maximum duration (${
263
+ MAX_POLLING_DURATION / 1000
264
+ }s) exceeded for tenant ${tenantId}`
264
265
  );
265
266
  handlePollingFailure(tenantId, 'Polling timeout exceeded');
266
267
  return;
267
268
  }
268
269
 
269
- // Calculate delay using exponential backoff for consecutive errors
270
- let delay = BASE_POLLING_INTERVAL;
271
- if (state.consecutiveErrors > 0) {
272
- // Exponential backoff: 2s → 4s → 8s → 16s → 32s (capped)
273
- delay = Math.min(
274
- BASE_POLLING_INTERVAL * Math.pow(2, state.consecutiveErrors),
275
- MAX_POLLING_INTERVAL
276
- );
277
- }
270
+ // This is more suitable for long-running jobs, as it spaces out requests
271
+ // even when the server responds successfully with a 'loading' status.
272
+ // Polling sequence: 10s → 20s → 30s → 30s...
273
+ const delay = Math.min(
274
+ BASE_POLLING_INTERVAL * Math.pow(2, state.attempts),
275
+ MAX_POLLING_INTERVAL
276
+ );
278
277
 
279
278
  // Schedule the next poll
280
279
  const timeoutId = setTimeout(() => executePoll(tenantId), delay);
@@ -304,6 +303,7 @@ async function executePoll(tenantId: string): Promise<void> {
304
303
 
305
304
  // Check if analysis is complete
306
305
  if (data.status === 'complete') {
306
+ updateTenantState(tenantId, { isLoading: false });
307
307
  stopPolling(tenantId);
308
308
  return;
309
309
  }
@@ -329,7 +329,6 @@ async function executePoll(tenantId: string): Promise<void> {
329
329
  error instanceof Error ? error.message : 'Unknown polling error';
330
330
  updateTenantState(tenantId, {
331
331
  error: `Polling error (attempt ${state.attempts}/${MAX_POLLING_ATTEMPTS}): ${errorMessage}`,
332
- lastFetched: Date.now(),
333
332
  });
334
333
 
335
334
  // Check if we should stop polling due to consecutive errors
@@ -344,7 +343,7 @@ async function executePoll(tenantId: string): Promise<void> {
344
343
  return;
345
344
  }
346
345
 
347
- // Schedule next poll with exponential backoff
346
+ // Schedule next poll (will use exponential backoff due to increased consecutiveErrors)
348
347
  scheduleNextPoll(tenantId);
349
348
  }
350
349
  }
@@ -357,8 +356,7 @@ function handlePollingFailure(tenantId: string, reason: string): void {
357
356
  // Update tenant state with final error
358
357
  updateTenantState(tenantId, {
359
358
  isLoading: false,
360
- error: `Orphan analysis polling failed: ${reason}. Please try refreshing the page or contact support if the issue persists.`,
361
- lastFetched: Date.now(),
359
+ error: `Orphan analysis failed: ${reason}. Please try refreshing the page.`,
362
360
  });
363
361
 
364
362
  // Clean up polling state