astro-tractstack 2.0.1 → 2.0.2

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.
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback, useMemo, Component } from 'react';
1
+ import { useState, useCallback, useMemo, Component } from 'react';
2
2
  import type { ReactNode } from 'react';
3
3
  import { useStore } from '@nanostores/react';
4
4
  import { epinetCustomFilters } from '@/stores/analytics';
@@ -104,6 +104,30 @@ export default function StoryKeepDashboard_Analytics({
104
104
  error: null,
105
105
  });
106
106
 
107
+ const handleBeliefFilterChange = (beliefSlug: string, value: string) => {
108
+ const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
109
+ const currentFilters = epinetCustomFilters.get();
110
+ let newFilters = [...(currentFilters.appliedFilters || [])];
111
+
112
+ if (value === 'All') {
113
+ newFilters = newFilters.filter((f) => f.beliefSlug !== beliefSlug);
114
+ } else {
115
+ const existingIndex = newFilters.findIndex(
116
+ (f) => f.beliefSlug === beliefSlug
117
+ );
118
+ if (existingIndex > -1) {
119
+ newFilters[existingIndex] = { beliefSlug, value };
120
+ } else {
121
+ newFilters.push({ beliefSlug, value });
122
+ }
123
+ }
124
+
125
+ epinetCustomFilters.set(tenantId, {
126
+ ...currentFilters,
127
+ appliedFilters: newFilters,
128
+ });
129
+ };
130
+
107
131
  // Duration helper for UI
108
132
  const currentDurationHelper = useMemo(():
109
133
  | 'daily'
@@ -440,6 +464,10 @@ export default function StoryKeepDashboard_Analytics({
440
464
  analytics.isLoading || analytics.status === 'loading'
441
465
  }
442
466
  hourlyNodeActivity={analytics.hourlyNodeActivity}
467
+ // MODIFICATION: Read availableFilters from the store, not local state
468
+ availableFilters={$epinetCustomFilters.availableFilters}
469
+ appliedFilters={$epinetCustomFilters.appliedFilters}
470
+ onBeliefFilterChange={handleBeliefFilterChange}
443
471
  />
444
472
  </div>
445
473
  </ErrorBoundary>
@@ -455,6 +483,10 @@ export default function StoryKeepDashboard_Analytics({
455
483
  analytics.isLoading || analytics.status === 'loading'
456
484
  }
457
485
  hourlyNodeActivity={analytics.hourlyNodeActivity}
486
+ // MODIFICATION: Read availableFilters from the store, not local state
487
+ availableFilters={$epinetCustomFilters.availableFilters}
488
+ appliedFilters={$epinetCustomFilters.appliedFilters}
489
+ onBeliefFilterChange={handleBeliefFilterChange}
458
490
  />
459
491
  </>
460
492
  )
@@ -468,6 +500,10 @@ export default function StoryKeepDashboard_Analytics({
468
500
  fullContentMap={fullContentMap}
469
501
  isLoading={analytics.isLoading || analytics.status === 'loading'}
470
502
  hourlyNodeActivity={analytics.hourlyNodeActivity}
503
+ // MODIFICATION: Read availableFilters from the store, not local state
504
+ availableFilters={$epinetCustomFilters.availableFilters}
505
+ appliedFilters={$epinetCustomFilters.appliedFilters}
506
+ onBeliefFilterChange={handleBeliefFilterChange}
471
507
  />
472
508
  </>
473
509
  )}
@@ -20,13 +20,12 @@ interface FetchAnalyticsProps {
20
20
  onAnalyticsUpdate: (analytics: AnalyticsState) => void;
21
21
  }
22
22
 
23
- // Global singleton state to prevent multi-component conflicts
24
23
  class AnalyticsService {
25
24
  private static instance: AnalyticsService;
26
25
  private isInitialized = false;
27
26
  private activeRequest: AbortController | null = null;
28
27
  private requestCache = new Map<string, { data: any; timestamp: number }>();
29
- private readonly CACHE_TTL = 5000; // 5 seconds
28
+ private readonly CACHE_TTL = 5000;
30
29
  private readonly DEBOUNCE_MS = 300;
31
30
  private debounceTimer: NodeJS.Timeout | null = null;
32
31
  private floodProtection = {
@@ -34,7 +33,7 @@ class AnalyticsService {
34
33
  windowStart: 0,
35
34
  isBlocked: false,
36
35
  };
37
- private readonly FLOOD_WINDOW_MS = 10000; // 10 seconds
36
+ private readonly FLOOD_WINDOW_MS = 10000;
38
37
  private readonly FLOOD_THRESHOLD = 5;
39
38
 
40
39
  static getInstance(): AnalyticsService {
@@ -51,35 +50,25 @@ class AnalyticsService {
51
50
 
52
51
  private isFloodBlocked(): boolean {
53
52
  const now = Date.now();
54
-
55
- // Reset window if needed
56
53
  if (now - this.floodProtection.windowStart > this.FLOOD_WINDOW_MS) {
57
54
  this.floodProtection.requestCount = 0;
58
55
  this.floodProtection.windowStart = now;
59
56
  this.floodProtection.isBlocked = false;
60
57
  }
61
-
62
- // Check if blocked
63
58
  if (this.floodProtection.isBlocked) {
64
59
  if (VERBOSE) console.log('🚫 Request blocked by flood protection');
65
60
  return true;
66
61
  }
67
-
68
- // Increment counter and check threshold
69
62
  this.floodProtection.requestCount++;
70
63
  if (this.floodProtection.requestCount > this.FLOOD_THRESHOLD) {
71
64
  this.floodProtection.isBlocked = true;
72
65
  if (VERBOSE) console.log('🚨 Flood protection activated');
73
-
74
- // Auto-unblock after delay
75
66
  setTimeout(() => {
76
67
  this.floodProtection.isBlocked = false;
77
68
  if (VERBOSE) console.log('✅ Flood protection deactivated');
78
- }, 30000); // 30 second cooldown
79
-
69
+ }, 30000);
80
70
  return true;
81
71
  }
82
-
83
72
  return false;
84
73
  }
85
74
 
@@ -94,8 +83,6 @@ class AnalyticsService {
94
83
 
95
84
  private setCachedResponse(cacheKey: string, data: any): void {
96
85
  this.requestCache.set(cacheKey, { data, timestamp: Date.now() });
97
-
98
- // Cleanup old cache entries
99
86
  const cutoff = Date.now() - this.CACHE_TTL;
100
87
  for (const [key, value] of this.requestCache.entries()) {
101
88
  if (value.timestamp < cutoff) {
@@ -108,35 +95,29 @@ class AnalyticsService {
108
95
  filters: any,
109
96
  onUpdate: (data: AnalyticsState) => void
110
97
  ): Promise<void> {
111
- // Flood protection
112
98
  if (this.isFloodBlocked()) {
113
99
  return;
114
100
  }
115
101
 
116
102
  try {
117
- // Cancel any existing request
118
103
  if (this.activeRequest) {
119
104
  this.activeRequest.abort();
120
105
  if (VERBOSE) console.log('🛑 Cancelled previous request');
121
106
  }
122
107
 
123
- // Create new abort controller
124
108
  this.activeRequest = new AbortController();
125
109
 
126
- // Build URL parameters
127
110
  const params = new URLSearchParams();
128
111
  if (filters.startTimeUTC && filters.endTimeUTC) {
129
112
  const now = new Date();
130
113
  const startTime = new Date(filters.startTimeUTC);
131
114
  const endTime = new Date(filters.endTimeUTC);
132
-
133
115
  const startHour = Math.ceil(
134
116
  (now.getTime() - startTime.getTime()) / (1000 * 60 * 60)
135
117
  );
136
118
  const endHour = Math.floor(
137
119
  (now.getTime() - endTime.getTime()) / (1000 * 60 * 60)
138
120
  );
139
-
140
121
  params.append('startHour', startHour.toString());
141
122
  params.append('endHour', endHour.toString());
142
123
  }
@@ -146,16 +127,18 @@ class AnalyticsService {
146
127
  if (filters.selectedUserId)
147
128
  params.append('userId', filters.selectedUserId);
148
129
 
149
- const cacheKey = this.getCacheKey(params);
130
+ // MODIFICATION: Properly format appliedFilters for the backend
131
+ if (filters.appliedFilters && filters.appliedFilters.length > 0) {
132
+ params.append('appliedFilters', JSON.stringify(filters.appliedFilters));
133
+ }
150
134
 
151
- // Check cache first
135
+ const cacheKey = this.getCacheKey(params);
152
136
  const cachedData = this.getCachedResponse(cacheKey);
153
137
  if (cachedData) {
154
138
  onUpdate(cachedData);
155
139
  return;
156
140
  }
157
141
 
158
- // Set loading state
159
142
  onUpdate({
160
143
  dashboard: null,
161
144
  leads: null,
@@ -167,23 +150,17 @@ class AnalyticsService {
167
150
  error: null,
168
151
  });
169
152
 
170
- // Make request using existing TractStackAPI
171
153
  const api = new TractStackAPI(
172
154
  window.TRACTSTACK_CONFIG?.tenantId || 'default'
173
155
  );
174
156
  const endpoint = `/api/v1/analytics/all${params.toString() ? `?${params.toString()}` : ''}`;
175
-
176
157
  if (VERBOSE) console.log('🔥 Making API request', { endpoint });
177
158
 
178
159
  const response = await api.get(endpoint);
179
-
180
- if (!response.success) {
160
+ if (!response.success)
181
161
  throw new Error(response.error || 'Failed to fetch analytics data');
182
- }
183
-
184
162
  const data = response.data;
185
163
 
186
- // Check if data is still loading - implement polling logic
187
164
  const isStillLoading =
188
165
  data?.status === 'loading' ||
189
166
  data?.status === 'refreshing' ||
@@ -196,8 +173,6 @@ class AnalyticsService {
196
173
 
197
174
  if (isStillLoading) {
198
175
  if (VERBOSE) console.log('⏳ Backend data still loading, will poll...');
199
-
200
- // Update with partial data but keep loading state
201
176
  const partialAnalytics = {
202
177
  dashboard: data.dashboard,
203
178
  leads: data.leads,
@@ -208,27 +183,20 @@ class AnalyticsService {
208
183
  error: null,
209
184
  isLoading: true,
210
185
  };
211
-
212
186
  onUpdate(partialAnalytics);
213
187
 
214
- // Schedule polling retry - use the cache key to prevent multiple polls
215
188
  const pollKey = `poll_${cacheKey}`;
216
189
  if (!this.requestCache.has(pollKey)) {
217
190
  this.requestCache.set(pollKey, { data: null, timestamp: Date.now() });
218
-
219
191
  setTimeout(() => {
220
192
  this.requestCache.delete(pollKey);
221
- // Clear the main cache entry to force fresh request
222
193
  this.requestCache.delete(cacheKey);
223
- // Retry the fetch
224
194
  this.fetchAnalytics(filters, onUpdate);
225
195
  }, 2000);
226
196
  }
227
-
228
197
  return;
229
198
  }
230
199
 
231
- // Process successful response
232
200
  const analyticsData = {
233
201
  dashboard: data.dashboard,
234
202
  leads: data.leads,
@@ -240,23 +208,23 @@ class AnalyticsService {
240
208
  isLoading: false,
241
209
  };
242
210
 
243
- // Cache the response
244
211
  this.setCachedResponse(cacheKey, analyticsData);
245
-
246
- // Update caller
247
212
  onUpdate(analyticsData);
248
213
 
249
- if (VERBOSE) console.log('✅ Analytics request completed successfully');
214
+ // MODIFICATION: Correctly extract top-level availableFilters and update the store
215
+ epinetCustomFilters.set(window.TRACTSTACK_CONFIG?.tenantId || 'default', {
216
+ ...filters,
217
+ availableFilters: data.availableFilters || [],
218
+ });
250
219
 
220
+ if (VERBOSE) console.log('✅ Analytics request completed successfully');
251
221
  this.activeRequest = null;
252
222
  } catch (error) {
253
223
  if (error instanceof Error && error.name === 'AbortError') {
254
224
  if (VERBOSE) console.log('🔄 Request aborted');
255
225
  return;
256
226
  }
257
-
258
227
  console.error('❌ Analytics fetch error:', error);
259
-
260
228
  onUpdate({
261
229
  dashboard: null,
262
230
  leads: null,
@@ -268,7 +236,6 @@ class AnalyticsService {
268
236
  error instanceof Error ? error.message : 'Unknown error occurred',
269
237
  isLoading: false,
270
238
  });
271
-
272
239
  this.activeRequest = null;
273
240
  }
274
241
  }
@@ -286,13 +253,9 @@ class AnalyticsService {
286
253
 
287
254
  initializeFilters(tenantId: string): void {
288
255
  if (this.isInitialized) return;
289
-
290
256
  if (VERBOSE) console.log('🏁 Initializing analytics filters');
291
-
292
257
  const nowUTC = new Date();
293
258
  const oneWeekAgoUTC = new Date(nowUTC.getTime() - 7 * 24 * 60 * 60 * 1000);
294
-
295
- // Only set if not already initialized to prevent store churn
296
259
  const current = epinetCustomFilters.get();
297
260
  if (!current.enabled) {
298
261
  epinetCustomFilters.set(tenantId, {
@@ -301,19 +264,15 @@ class AnalyticsService {
301
264
  selectedUserId: null,
302
265
  startTimeUTC: oneWeekAgoUTC.toISOString(),
303
266
  endTimeUTC: nowUTC.toISOString(),
267
+ availableFilters: [],
268
+ appliedFilters: [],
304
269
  });
305
270
  }
306
-
307
271
  this.isInitialized = true;
308
272
  }
309
273
 
310
274
  debouncedFetch(filters: any, onUpdate: (data: AnalyticsState) => void): void {
311
- // Clear existing debounce timer
312
- if (this.debounceTimer) {
313
- clearTimeout(this.debounceTimer);
314
- }
315
-
316
- // Set new debounced fetch
275
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
317
276
  this.debounceTimer = setTimeout(() => {
318
277
  this.fetchAnalytics(filters, onUpdate);
319
278
  }, this.DEBOUNCE_MS);
@@ -329,29 +288,21 @@ export default function FetchAnalytics({
329
288
 
330
289
  if (VERBOSE) {
331
290
  console.log('🔄 FetchAnalytics render', {
332
- filters: {
333
- startTimeUTC: $epinetCustomFilters.startTimeUTC,
334
- endTimeUTC: $epinetCustomFilters.endTimeUTC,
335
- visitorType: $epinetCustomFilters.visitorType,
336
- selectedUserId: $epinetCustomFilters.selectedUserId,
337
- },
291
+ filters: { ...$epinetCustomFilters },
338
292
  });
339
293
  }
340
294
 
341
- // Cleanup on unmount
342
295
  useEffect(() => {
343
296
  return () => {
344
297
  analyticsService.current.cleanup();
345
298
  };
346
299
  }, []);
347
300
 
348
- // Initialize filters once
349
301
  useEffect(() => {
350
302
  const tenantId = window.TRACTSTACK_CONFIG?.tenantId || 'default';
351
303
  analyticsService.current.initializeFilters(tenantId);
352
304
  }, []);
353
305
 
354
- // Debounced fetch when filters change
355
306
  useEffect(() => {
356
307
  if (
357
308
  !$epinetCustomFilters.enabled ||
@@ -362,15 +313,14 @@ export default function FetchAnalytics({
362
313
  return;
363
314
  }
364
315
 
365
- // Create stable filter signature to prevent unnecessary fetches
366
316
  const filtersSignature = JSON.stringify({
367
317
  startTimeUTC: $epinetCustomFilters.startTimeUTC,
368
318
  endTimeUTC: $epinetCustomFilters.endTimeUTC,
369
319
  visitorType: $epinetCustomFilters.visitorType,
370
320
  selectedUserId: $epinetCustomFilters.selectedUserId,
321
+ appliedFilters: $epinetCustomFilters.appliedFilters,
371
322
  });
372
323
 
373
- // Skip if filters haven't actually changed
374
324
  if (filtersSignature === lastFiltersRef.current) {
375
325
  return;
376
326
  }
@@ -389,6 +339,7 @@ export default function FetchAnalytics({
389
339
  $epinetCustomFilters.selectedUserId,
390
340
  $epinetCustomFilters.startTimeUTC,
391
341
  $epinetCustomFilters.endTimeUTC,
342
+ $epinetCustomFilters.appliedFilters,
392
343
  onAnalyticsUpdate,
393
344
  ]);
394
345
 
@@ -1,6 +1,16 @@
1
1
  import { atom } from 'nanostores';
2
2
  import { TractStackAPI } from '@/utils/api';
3
3
 
4
+ interface AvailableFilter {
5
+ beliefSlug: string;
6
+ values: string[];
7
+ }
8
+
9
+ export interface AppliedFilter {
10
+ beliefSlug: string;
11
+ value: string;
12
+ }
13
+
4
14
  // Internal tenant-keyed storage
5
15
  const tenantEpinetCustomFilters = atom<
6
16
  Record<
@@ -22,6 +32,8 @@ const tenantEpinetCustomFilters = atom<
22
32
  }
23
33
  >
24
34
  >;
35
+ availableFilters: AvailableFilter[];
36
+ appliedFilters: AppliedFilter[];
25
37
  }
26
38
  >
27
39
  >({});
@@ -53,6 +65,8 @@ const defaultEpinetFilters = {
53
65
  endTimeUTC: null,
54
66
  userCounts: [],
55
67
  hourlyNodeActivity: {},
68
+ availableFilters: [],
69
+ appliedFilters: [],
56
70
  };
57
71
 
58
72
  // Create tenant-aware atoms that work with useStore