@startsimpli/funnels 0.1.3 → 0.1.5

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.
Files changed (151) hide show
  1. package/package.json +9 -31
  2. package/src/api/README.md +507 -0
  3. package/src/api/adapter.ts +106 -0
  4. package/src/api/client.test.ts +640 -0
  5. package/src/api/client.ts +385 -0
  6. package/src/api/default-adapter.ts +243 -0
  7. package/src/api/index.ts +24 -0
  8. package/src/components/FilterRuleEditor/ARCHITECTURE.md +354 -0
  9. package/src/components/FilterRuleEditor/FieldSelector.tsx +91 -0
  10. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +462 -0
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +520 -0
  12. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +225 -0
  13. package/src/components/FilterRuleEditor/LogicToggle.tsx +64 -0
  14. package/src/components/FilterRuleEditor/OperatorSelector.tsx +75 -0
  15. package/src/components/FilterRuleEditor/README.md +291 -0
  16. package/src/components/FilterRuleEditor/RuleRow.tsx +246 -0
  17. package/src/components/FilterRuleEditor/ValueInputs/BooleanValueInput.tsx +54 -0
  18. package/src/components/FilterRuleEditor/ValueInputs/ChoiceValueInput.tsx +83 -0
  19. package/src/components/FilterRuleEditor/ValueInputs/DateValueInput.tsx +70 -0
  20. package/src/components/FilterRuleEditor/ValueInputs/MultiChoiceValueInput.tsx +132 -0
  21. package/src/components/FilterRuleEditor/ValueInputs/NumberValueInput.tsx +73 -0
  22. package/src/components/FilterRuleEditor/ValueInputs/TextValueInput.tsx +50 -0
  23. package/src/components/FilterRuleEditor/ValueInputs/index.ts +12 -0
  24. package/src/components/FilterRuleEditor/constants.ts +64 -0
  25. package/src/components/FilterRuleEditor/index.ts +14 -0
  26. package/src/components/FunnelCard/DESIGN.md +447 -0
  27. package/src/components/FunnelCard/FunnelCard.stories.tsx +484 -0
  28. package/src/components/FunnelCard/FunnelCard.test.ts +257 -0
  29. package/src/components/FunnelCard/FunnelCard.test.tsx +336 -0
  30. package/src/components/FunnelCard/FunnelCard.tsx +204 -0
  31. package/src/components/FunnelCard/FunnelStats.tsx +68 -0
  32. package/src/components/FunnelCard/IMPLEMENTATION_SUMMARY.md +505 -0
  33. package/src/components/FunnelCard/INSTALLATION.md +304 -0
  34. package/src/components/FunnelCard/MatchBar.tsx +49 -0
  35. package/src/components/FunnelCard/README.md +294 -0
  36. package/src/components/FunnelCard/StageIndicator.tsx +62 -0
  37. package/src/components/FunnelCard/StatusBadge.tsx +52 -0
  38. package/src/components/FunnelCard/index.ts +14 -0
  39. package/src/components/FunnelPreview/EntityCard.tsx +72 -0
  40. package/src/components/FunnelPreview/FunnelPreview.stories.tsx +227 -0
  41. package/src/components/FunnelPreview/FunnelPreview.test.tsx +316 -0
  42. package/src/components/FunnelPreview/FunnelPreview.tsx +249 -0
  43. package/src/components/FunnelPreview/LoadingPreview.tsx +60 -0
  44. package/src/components/FunnelPreview/PreviewStats.tsx +78 -0
  45. package/src/components/FunnelPreview/README.md +337 -0
  46. package/src/components/FunnelPreview/StageBreakdown.tsx +94 -0
  47. package/src/components/FunnelPreview/example.tsx +286 -0
  48. package/src/components/FunnelPreview/index.ts +14 -0
  49. package/src/components/FunnelRunHistory/COMPONENT_SUMMARY.md +246 -0
  50. package/src/components/FunnelRunHistory/FunnelRunHistory.stories.tsx +272 -0
  51. package/src/components/FunnelRunHistory/FunnelRunHistory.test.tsx +323 -0
  52. package/src/components/FunnelRunHistory/FunnelRunHistory.tsx +329 -0
  53. package/src/components/FunnelRunHistory/README.md +325 -0
  54. package/src/components/FunnelRunHistory/RunActions.tsx +168 -0
  55. package/src/components/FunnelRunHistory/RunDetailsModal.tsx +221 -0
  56. package/src/components/FunnelRunHistory/RunFilters.tsx +128 -0
  57. package/src/components/FunnelRunHistory/RunRow.tsx +122 -0
  58. package/src/components/FunnelRunHistory/RunStatusBadge.tsx +75 -0
  59. package/src/components/FunnelRunHistory/StageBreakdownList.tsx +110 -0
  60. package/src/components/FunnelRunHistory/index.ts +51 -0
  61. package/src/components/FunnelRunHistory/types.ts +40 -0
  62. package/src/components/FunnelRunHistory/utils.test.ts +126 -0
  63. package/src/components/FunnelRunHistory/utils.ts +100 -0
  64. package/src/components/FunnelStageBuilder/AddStageButton.tsx +52 -0
  65. package/src/components/FunnelStageBuilder/FunnelStageBuilder.css +413 -0
  66. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +312 -0
  67. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +304 -0
  68. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +321 -0
  69. package/src/components/FunnelStageBuilder/README.md +341 -0
  70. package/src/components/FunnelStageBuilder/StageActions.test.tsx +205 -0
  71. package/src/components/FunnelStageBuilder/StageActions.tsx +126 -0
  72. package/src/components/FunnelStageBuilder/StageCard.tsx +202 -0
  73. package/src/components/FunnelStageBuilder/StageForm.tsx +262 -0
  74. package/src/components/FunnelStageBuilder/TagInput.test.tsx +178 -0
  75. package/src/components/FunnelStageBuilder/TagInput.tsx +129 -0
  76. package/src/components/FunnelStageBuilder/index.ts +21 -0
  77. package/src/components/FunnelVisualFlow/FlowLegend.tsx +77 -0
  78. package/{dist/components/index.css → src/components/FunnelVisualFlow/FunnelVisualFlow.css} +89 -13
  79. package/src/components/FunnelVisualFlow/FunnelVisualFlow.stories.tsx +254 -0
  80. package/src/components/FunnelVisualFlow/FunnelVisualFlow.test.tsx +208 -0
  81. package/src/components/FunnelVisualFlow/FunnelVisualFlow.tsx +229 -0
  82. package/src/components/FunnelVisualFlow/README.md +323 -0
  83. package/src/components/FunnelVisualFlow/StageNode.tsx +188 -0
  84. package/src/components/FunnelVisualFlow/example.tsx +227 -0
  85. package/src/components/FunnelVisualFlow/index.ts +10 -0
  86. package/src/components/index.ts +102 -0
  87. package/src/core/README.md +307 -0
  88. package/src/core/engine.test.ts +1087 -0
  89. package/src/core/engine.ts +329 -0
  90. package/src/core/evaluator.example.ts +353 -0
  91. package/src/core/evaluator.test.ts +639 -0
  92. package/src/core/evaluator.ts +261 -0
  93. package/src/core/field-resolver.example.ts +175 -0
  94. package/src/core/field-resolver.test.ts +541 -0
  95. package/src/core/field-resolver.ts +247 -0
  96. package/src/core/index.ts +34 -0
  97. package/src/core/operators.test.ts +539 -0
  98. package/src/core/operators.ts +241 -0
  99. package/src/hooks/index.ts +5 -0
  100. package/src/hooks/useDebouncedValue.ts +28 -0
  101. package/src/index.ts +155 -0
  102. package/src/store/README.md +342 -0
  103. package/src/store/create-funnel-store.test.ts +686 -0
  104. package/src/store/create-funnel-store.ts +538 -0
  105. package/src/store/index.ts +9 -0
  106. package/src/store/types.ts +294 -0
  107. package/src/stories/CrossDomain.stories.tsx +149 -0
  108. package/src/stories/Welcome.stories.tsx +81 -0
  109. package/src/stories/demo-data/index.ts +3 -0
  110. package/src/stories/demo-data/investors.ts +216 -0
  111. package/src/stories/demo-data/leads.ts +223 -0
  112. package/src/stories/demo-data/recipes.ts +217 -0
  113. package/src/test/setup.ts +5 -0
  114. package/src/types/index.ts +843 -0
  115. package/dist/client-3ESO2NHy.d.ts +0 -310
  116. package/dist/client-CZu03ACp.d.cts +0 -310
  117. package/dist/components/index.cjs +0 -3243
  118. package/dist/components/index.cjs.map +0 -1
  119. package/dist/components/index.css.map +0 -1
  120. package/dist/components/index.d.cts +0 -726
  121. package/dist/components/index.d.ts +0 -726
  122. package/dist/components/index.js +0 -3196
  123. package/dist/components/index.js.map +0 -1
  124. package/dist/core/index.cjs +0 -500
  125. package/dist/core/index.cjs.map +0 -1
  126. package/dist/core/index.d.cts +0 -359
  127. package/dist/core/index.d.ts +0 -359
  128. package/dist/core/index.js +0 -486
  129. package/dist/core/index.js.map +0 -1
  130. package/dist/hooks/index.cjs +0 -21
  131. package/dist/hooks/index.cjs.map +0 -1
  132. package/dist/hooks/index.d.cts +0 -11
  133. package/dist/hooks/index.d.ts +0 -11
  134. package/dist/hooks/index.js +0 -19
  135. package/dist/hooks/index.js.map +0 -1
  136. package/dist/index-BGDEXbuz.d.cts +0 -434
  137. package/dist/index-BGDEXbuz.d.ts +0 -434
  138. package/dist/index.cjs +0 -4499
  139. package/dist/index.cjs.map +0 -1
  140. package/dist/index.css +0 -198
  141. package/dist/index.css.map +0 -1
  142. package/dist/index.d.cts +0 -99
  143. package/dist/index.d.ts +0 -99
  144. package/dist/index.js +0 -4421
  145. package/dist/index.js.map +0 -1
  146. package/dist/store/index.cjs +0 -391
  147. package/dist/store/index.cjs.map +0 -1
  148. package/dist/store/index.d.cts +0 -225
  149. package/dist/store/index.d.ts +0 -225
  150. package/dist/store/index.js +0 -388
  151. package/dist/store/index.js.map +0 -1
@@ -0,0 +1,323 @@
1
+ /**
2
+ * FunnelRunHistory Tests
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { FunnelRun, FunnelRunStatus, StageStats } from '../../types';
7
+ import { FunnelApiClient } from '../../api/client';
8
+
9
+ // Mock API client
10
+ const createMockApiClient = (runs: FunnelRun[] = []) => {
11
+ return {
12
+ getFunnelRuns: vi.fn().mockResolvedValue({
13
+ count: runs.length,
14
+ next: null,
15
+ previous: null,
16
+ results: runs,
17
+ }),
18
+ runFunnel: vi.fn().mockResolvedValue({
19
+ id: 'new-run-id',
20
+ status: 'pending',
21
+ }),
22
+ cancelFunnelRun: vi.fn().mockResolvedValue({
23
+ id: 'run-id',
24
+ status: 'cancelled',
25
+ }),
26
+ } as unknown as FunnelApiClient;
27
+ };
28
+
29
+ // Helper to create mock run
30
+ const createMockRun = (
31
+ overrides?: Partial<FunnelRun>
32
+ ): FunnelRun => {
33
+ const stageStats: Record<string, StageStats> = {
34
+ 'stage-1': {
35
+ stage_id: 'stage-1',
36
+ stage_name: 'High ICP Score',
37
+ input_count: 1000,
38
+ matched_count: 500,
39
+ not_matched_count: 500,
40
+ excluded_count: 0,
41
+ tagged_count: 0,
42
+ continued_count: 500,
43
+ duration_ms: 1000,
44
+ },
45
+ 'stage-2': {
46
+ stage_id: 'stage-2',
47
+ stage_name: 'Frontend Stack',
48
+ input_count: 500,
49
+ matched_count: 350,
50
+ not_matched_count: 150,
51
+ excluded_count: 0,
52
+ tagged_count: 0,
53
+ continued_count: 350,
54
+ duration_ms: 800,
55
+ },
56
+ };
57
+
58
+ return {
59
+ id: 'run-123',
60
+ funnel_id: 'funnel-456',
61
+ status: 'completed',
62
+ trigger_type: 'manual',
63
+ triggered_by: 'user-789',
64
+ started_at: new Date().toISOString(),
65
+ completed_at: new Date().toISOString(),
66
+ duration_ms: 2300,
67
+ total_input: 1000,
68
+ total_matched: 350,
69
+ total_excluded: 150,
70
+ total_tagged: 0,
71
+ stage_stats: stageStats,
72
+ ...overrides,
73
+ };
74
+ };
75
+
76
+ describe('FunnelRunHistory Component Logic', () => {
77
+ let mockApiClient: FunnelApiClient;
78
+
79
+ beforeEach(() => {
80
+ mockApiClient = createMockApiClient([]);
81
+ });
82
+
83
+ describe('loadRuns', () => {
84
+ it('should fetch runs from API', async () => {
85
+ const runs = [createMockRun()];
86
+ mockApiClient = createMockApiClient(runs);
87
+
88
+ const getFunnelRuns = mockApiClient.getFunnelRuns as any;
89
+ await getFunnelRuns('funnel-456');
90
+
91
+ expect(getFunnelRuns).toHaveBeenCalledWith('funnel-456');
92
+ });
93
+
94
+ it('should apply status filter', async () => {
95
+ const getFunnelRuns = mockApiClient.getFunnelRuns as any;
96
+
97
+ await getFunnelRuns('funnel-456', {
98
+ status: 'completed',
99
+ page: 1,
100
+ page_size: 10,
101
+ ordering: '-started_at',
102
+ });
103
+
104
+ expect(getFunnelRuns).toHaveBeenCalledWith('funnel-456', {
105
+ status: 'completed',
106
+ page: 1,
107
+ page_size: 10,
108
+ ordering: '-started_at',
109
+ });
110
+ });
111
+
112
+ it('should apply trigger type filter', async () => {
113
+ const getFunnelRuns = mockApiClient.getFunnelRuns as any;
114
+
115
+ await getFunnelRuns('funnel-456', {
116
+ trigger_type: 'scheduled',
117
+ page: 1,
118
+ page_size: 10,
119
+ ordering: '-started_at',
120
+ });
121
+
122
+ expect(getFunnelRuns).toHaveBeenCalledWith('funnel-456', {
123
+ trigger_type: 'scheduled',
124
+ page: 1,
125
+ page_size: 10,
126
+ ordering: '-started_at',
127
+ });
128
+ });
129
+ });
130
+
131
+ describe('handleReRun', () => {
132
+ it('should trigger new run with metadata', async () => {
133
+ const run = createMockRun({ id: 'run-123' });
134
+ const runFunnel = mockApiClient.runFunnel as any;
135
+
136
+ await runFunnel('funnel-456', {
137
+ trigger_type: 'manual',
138
+ metadata: { re_run_of: 'run-123' },
139
+ });
140
+
141
+ expect(runFunnel).toHaveBeenCalledWith('funnel-456', {
142
+ trigger_type: 'manual',
143
+ metadata: { re_run_of: 'run-123' },
144
+ });
145
+ });
146
+ });
147
+
148
+ describe('handleCancel', () => {
149
+ it('should cancel running job', async () => {
150
+ const run = createMockRun({ id: 'run-123', status: 'running' });
151
+ const cancelFunnelRun = mockApiClient.cancelFunnelRun as any;
152
+
153
+ await cancelFunnelRun('run-123');
154
+
155
+ expect(cancelFunnelRun).toHaveBeenCalledWith('run-123');
156
+ });
157
+ });
158
+
159
+ describe('pagination', () => {
160
+ it('should calculate total pages correctly', () => {
161
+ const total = 47;
162
+ const pageSize = 10;
163
+ const totalPages = Math.ceil(total / pageSize);
164
+ expect(totalPages).toBe(5);
165
+ });
166
+
167
+ it('should calculate start and end indices', () => {
168
+ const page = 2;
169
+ const pageSize = 10;
170
+ const total = 47;
171
+
172
+ const startIndex = (page - 1) * pageSize + 1;
173
+ const endIndex = Math.min(page * pageSize, total);
174
+
175
+ expect(startIndex).toBe(11);
176
+ expect(endIndex).toBe(20);
177
+ });
178
+ });
179
+
180
+ describe('auto-refresh', () => {
181
+ it('should detect active runs', () => {
182
+ const runs = [
183
+ createMockRun({ status: 'completed' }),
184
+ createMockRun({ status: 'running' }),
185
+ ];
186
+
187
+ const hasActiveRuns = runs.some(
188
+ (r) => r.status === 'pending' || r.status === 'running'
189
+ );
190
+
191
+ expect(hasActiveRuns).toBe(true);
192
+ });
193
+
194
+ it('should not refresh when no active runs', () => {
195
+ const runs = [
196
+ createMockRun({ status: 'completed' }),
197
+ createMockRun({ status: 'failed' }),
198
+ ];
199
+
200
+ const hasActiveRuns = runs.some(
201
+ (r) => r.status === 'pending' || r.status === 'running'
202
+ );
203
+
204
+ expect(hasActiveRuns).toBe(false);
205
+ });
206
+ });
207
+ });
208
+
209
+ describe('Run Status Scenarios', () => {
210
+ it('should handle completed run', () => {
211
+ const run = createMockRun({
212
+ status: 'completed',
213
+ total_input: 1000,
214
+ total_matched: 235,
215
+ });
216
+
217
+ expect(run.status).toBe('completed');
218
+ expect(run.total_matched).toBe(235);
219
+ });
220
+
221
+ it('should handle failed run', () => {
222
+ const run = createMockRun({
223
+ status: 'failed',
224
+ error: 'Database connection timeout',
225
+ total_matched: 0,
226
+ });
227
+
228
+ expect(run.status).toBe('failed');
229
+ expect(run.error).toBe('Database connection timeout');
230
+ });
231
+
232
+ it('should handle running run', () => {
233
+ const run = createMockRun({
234
+ status: 'running',
235
+ completed_at: undefined,
236
+ duration_ms: undefined,
237
+ });
238
+
239
+ expect(run.status).toBe('running');
240
+ expect(run.completed_at).toBeUndefined();
241
+ });
242
+
243
+ it('should handle pending run', () => {
244
+ const run = createMockRun({
245
+ status: 'pending',
246
+ started_at: new Date().toISOString(),
247
+ completed_at: undefined,
248
+ });
249
+
250
+ expect(run.status).toBe('pending');
251
+ });
252
+
253
+ it('should handle cancelled run', () => {
254
+ const run = createMockRun({
255
+ status: 'cancelled',
256
+ });
257
+
258
+ expect(run.status).toBe('cancelled');
259
+ });
260
+ });
261
+
262
+ describe('Filter Scenarios', () => {
263
+ it('should filter by completed status', () => {
264
+ const runs = [
265
+ createMockRun({ id: '1', status: 'completed' }),
266
+ createMockRun({ id: '2', status: 'failed' }),
267
+ createMockRun({ id: '3', status: 'completed' }),
268
+ ];
269
+
270
+ const filtered = runs.filter((r) => r.status === 'completed');
271
+ expect(filtered).toHaveLength(2);
272
+ });
273
+
274
+ it('should filter by manual trigger', () => {
275
+ const runs = [
276
+ createMockRun({ id: '1', trigger_type: 'manual' }),
277
+ createMockRun({ id: '2', trigger_type: 'scheduled' }),
278
+ createMockRun({ id: '3', trigger_type: 'manual' }),
279
+ ];
280
+
281
+ const filtered = runs.filter((r) => r.trigger_type === 'manual');
282
+ expect(filtered).toHaveLength(2);
283
+ });
284
+ });
285
+
286
+ describe('Edge Cases', () => {
287
+ it('should handle empty runs', () => {
288
+ const runs: FunnelRun[] = [];
289
+ expect(runs).toHaveLength(0);
290
+ });
291
+
292
+ it('should handle run with no stage stats', () => {
293
+ const run = createMockRun({
294
+ stage_stats: {},
295
+ });
296
+
297
+ const stageStatsArray = Object.values(run.stage_stats);
298
+ expect(stageStatsArray).toHaveLength(0);
299
+ });
300
+
301
+ it('should handle run with zero match rate', () => {
302
+ const run = createMockRun({
303
+ total_input: 1000,
304
+ total_matched: 0,
305
+ });
306
+
307
+ const matchRate = run.total_input > 0
308
+ ? Math.round((run.total_matched / run.total_input) * 100)
309
+ : 0;
310
+
311
+ expect(matchRate).toBe(0);
312
+ });
313
+
314
+ it('should handle run with 100% match rate', () => {
315
+ const run = createMockRun({
316
+ total_input: 1000,
317
+ total_matched: 1000,
318
+ });
319
+
320
+ const matchRate = Math.round((run.total_matched / run.total_input) * 100);
321
+ expect(matchRate).toBe(100);
322
+ });
323
+ });
@@ -0,0 +1,329 @@
1
+ /**
2
+ * FunnelRunHistory Component
3
+ *
4
+ * Displays execution history for a funnel with filtering, sorting, and pagination.
5
+ *
6
+ * Design Goal:
7
+ * Users should be able to quickly scan run history, filter by status/trigger/date,
8
+ * view detailed breakdowns, and take actions (re-run, view results, cancel).
9
+ *
10
+ * Visual Hierarchy:
11
+ * - Header with title and refresh controls
12
+ * - Filter bar for narrowing results
13
+ * - Table showing run history (sorted by date by default)
14
+ * - Pagination controls
15
+ * - Details modal for deep dive
16
+ *
17
+ * Interaction Notes:
18
+ * - Auto-refresh when active runs exist (every 5 seconds)
19
+ * - Click row to view details
20
+ * - Action dropdown for quick actions
21
+ * - Filters apply immediately
22
+ * - Pagination loads new data
23
+ *
24
+ * Responsive Behavior:
25
+ * - Desktop: Full table with all columns
26
+ * - Tablet: Hide duration and trigger columns
27
+ * - Mobile: Card-based layout (responsive table variant)
28
+ *
29
+ * Accessibility:
30
+ * - Keyboard navigation throughout
31
+ * - ARIA labels for screen readers
32
+ * - Loading states announced
33
+ * - Error states clearly communicated
34
+ */
35
+
36
+ import { useState, useEffect, useCallback } from 'react';
37
+ import { FunnelRun } from '../../types';
38
+ import { FunnelApiClient } from '../../api/client';
39
+ import { RunFilters } from './RunFilters';
40
+ import { RunRow } from './RunRow';
41
+ import { RunDetailsModal } from './RunDetailsModal';
42
+ import { RunFilters as RunFiltersType, Pagination } from './types';
43
+
44
+ interface FunnelRunHistoryProps {
45
+ funnelId: string;
46
+ apiClient: FunnelApiClient;
47
+ onViewResults?: (run: FunnelRun) => void;
48
+ className?: string;
49
+ }
50
+
51
+ export function FunnelRunHistory({
52
+ funnelId,
53
+ apiClient,
54
+ onViewResults,
55
+ className = '',
56
+ }: FunnelRunHistoryProps) {
57
+ // State
58
+ const [runs, setRuns] = useState<FunnelRun[]>([]);
59
+ const [isLoading, setIsLoading] = useState(true);
60
+ const [error, setError] = useState<string | null>(null);
61
+ const [selectedRun, setSelectedRun] = useState<FunnelRun | null>(null);
62
+ const [filters, setFilters] = useState<RunFiltersType>({
63
+ status: 'all',
64
+ trigger_type: 'all',
65
+ date_range: 'month',
66
+ });
67
+ const [pagination, setPagination] = useState<Pagination>({
68
+ page: 1,
69
+ page_size: 10,
70
+ total: 0,
71
+ });
72
+ const [isRefreshing, setIsRefreshing] = useState(false);
73
+
74
+ // Load runs
75
+ const loadRuns = useCallback(async () => {
76
+ try {
77
+ setError(null);
78
+ const params: any = {
79
+ page: pagination.page,
80
+ page_size: pagination.page_size,
81
+ ordering: '-started_at', // Most recent first
82
+ };
83
+
84
+ // Add filters
85
+ if (filters.status && filters.status !== 'all') {
86
+ params.status = filters.status;
87
+ }
88
+ if (filters.trigger_type && filters.trigger_type !== 'all') {
89
+ params.trigger_type = filters.trigger_type;
90
+ }
91
+
92
+ const response = await apiClient.getFunnelRuns(funnelId, params);
93
+
94
+ setRuns(response.results);
95
+ setPagination((prev) => ({
96
+ ...prev,
97
+ total: response.count,
98
+ }));
99
+ } catch (err) {
100
+ setError(err instanceof Error ? err.message : 'Failed to load runs');
101
+ console.error('Failed to load funnel runs:', err);
102
+ } finally {
103
+ setIsLoading(false);
104
+ setIsRefreshing(false);
105
+ }
106
+ }, [funnelId, apiClient, pagination.page, pagination.page_size, filters]);
107
+
108
+ // Load runs on mount and when dependencies change
109
+ useEffect(() => {
110
+ loadRuns();
111
+ }, [loadRuns]);
112
+
113
+ // Auto-refresh for active runs
114
+ useEffect(() => {
115
+ const hasActiveRuns = runs.some(
116
+ (r) => r.status === 'pending' || r.status === 'running'
117
+ );
118
+
119
+ if (hasActiveRuns) {
120
+ const interval = setInterval(() => {
121
+ setIsRefreshing(true);
122
+ loadRuns();
123
+ }, 5000); // Poll every 5 seconds
124
+
125
+ return () => clearInterval(interval);
126
+ }
127
+ }, [runs, loadRuns]);
128
+
129
+ // Manual refresh
130
+ const handleRefresh = () => {
131
+ setIsRefreshing(true);
132
+ loadRuns();
133
+ };
134
+
135
+ // Actions
136
+ const handleReRun = async (run: FunnelRun) => {
137
+ try {
138
+ await apiClient.runFunnel(funnelId, {
139
+ trigger_type: 'manual',
140
+ metadata: { re_run_of: run.id },
141
+ });
142
+ // Refresh to show new run
143
+ loadRuns();
144
+ } catch (err) {
145
+ console.error('Failed to re-run funnel:', err);
146
+ alert('Failed to re-run funnel. Please try again.');
147
+ }
148
+ };
149
+
150
+ const handleCancel = async (run: FunnelRun) => {
151
+ try {
152
+ await apiClient.cancelFunnelRun(run.id);
153
+ // Refresh to show updated status
154
+ loadRuns();
155
+ } catch (err) {
156
+ console.error('Failed to cancel run:', err);
157
+ alert('Failed to cancel run. Please try again.');
158
+ }
159
+ };
160
+
161
+ const handleViewResults = (run: FunnelRun) => {
162
+ if (onViewResults) {
163
+ onViewResults(run);
164
+ } else {
165
+ // Default: open details modal
166
+ setSelectedRun(run);
167
+ }
168
+ };
169
+
170
+ // Pagination
171
+ const totalPages = Math.ceil(pagination.total / pagination.page_size);
172
+ const canGoBack = pagination.page > 1;
173
+ const canGoForward = pagination.page < totalPages;
174
+
175
+ const handlePreviousPage = () => {
176
+ if (canGoBack) {
177
+ setPagination((prev) => ({ ...prev, page: prev.page - 1 }));
178
+ }
179
+ };
180
+
181
+ const handleNextPage = () => {
182
+ if (canGoForward) {
183
+ setPagination((prev) => ({ ...prev, page: prev.page + 1 }));
184
+ }
185
+ };
186
+
187
+ const startIndex = (pagination.page - 1) * pagination.page_size + 1;
188
+ const endIndex = Math.min(
189
+ pagination.page * pagination.page_size,
190
+ pagination.total
191
+ );
192
+
193
+ return (
194
+ <div className={`bg-white rounded-lg border border-gray-200 ${className}`}>
195
+ {/* Header */}
196
+ <div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
197
+ <h2 className="text-lg font-semibold text-gray-900">
198
+ Funnel Run History
199
+ </h2>
200
+ <button
201
+ onClick={handleRefresh}
202
+ disabled={isRefreshing}
203
+ className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
204
+ aria-label="Refresh run history"
205
+ >
206
+ <span className={isRefreshing ? 'animate-spin inline-block' : ''}>
207
+
208
+ </span>{' '}
209
+ Refresh
210
+ </button>
211
+ </div>
212
+
213
+ {/* Filters */}
214
+ <RunFilters filters={filters} onFiltersChange={setFilters} />
215
+
216
+ {/* Table */}
217
+ <div className="overflow-x-auto">
218
+ <table className="w-full">
219
+ <thead className="bg-gray-50 border-b border-gray-200">
220
+ <tr>
221
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
222
+ Date
223
+ </th>
224
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
225
+ Status
226
+ </th>
227
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
228
+ Trigger
229
+ </th>
230
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-600 uppercase tracking-wider">
231
+ Duration
232
+ </th>
233
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-600 uppercase tracking-wider">
234
+ Input
235
+ </th>
236
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-600 uppercase tracking-wider">
237
+ Matched
238
+ </th>
239
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-600 uppercase tracking-wider">
240
+ %
241
+ </th>
242
+ <th className="px-4 py-3 text-right text-xs font-medium text-gray-600 uppercase tracking-wider">
243
+ <span className="sr-only">Actions</span>
244
+ </th>
245
+ </tr>
246
+ </thead>
247
+ <tbody>
248
+ {isLoading && runs.length === 0 ? (
249
+ <tr>
250
+ <td colSpan={8} className="px-4 py-12 text-center">
251
+ <div className="text-gray-500">Loading runs...</div>
252
+ </td>
253
+ </tr>
254
+ ) : error ? (
255
+ <tr>
256
+ <td colSpan={8} className="px-4 py-12 text-center">
257
+ <div className="text-red-600">Error: {error}</div>
258
+ <button
259
+ onClick={loadRuns}
260
+ className="mt-2 text-sm text-blue-600 hover:text-blue-700 underline"
261
+ >
262
+ Try again
263
+ </button>
264
+ </td>
265
+ </tr>
266
+ ) : runs.length === 0 ? (
267
+ <tr>
268
+ <td colSpan={8} className="px-4 py-12 text-center">
269
+ <div className="text-gray-500">
270
+ No runs found. Run this funnel to see history.
271
+ </div>
272
+ </td>
273
+ </tr>
274
+ ) : (
275
+ runs.map((run) => (
276
+ <RunRow
277
+ key={run.id}
278
+ run={run}
279
+ onViewDetails={setSelectedRun}
280
+ onViewResults={handleViewResults}
281
+ onReRun={handleReRun}
282
+ onCancel={handleCancel}
283
+ />
284
+ ))
285
+ )}
286
+ </tbody>
287
+ </table>
288
+ </div>
289
+
290
+ {/* Pagination */}
291
+ {runs.length > 0 && (
292
+ <div className="px-6 py-3 border-t border-gray-200 flex items-center justify-between">
293
+ <div className="text-sm text-gray-600">
294
+ Showing {startIndex}-{endIndex} of {pagination.total}
295
+ </div>
296
+ <div className="flex items-center gap-2">
297
+ <button
298
+ onClick={handlePreviousPage}
299
+ disabled={!canGoBack}
300
+ className="px-3 py-1 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
301
+ aria-label="Previous page"
302
+ >
303
+
304
+ </button>
305
+ <span className="text-sm text-gray-600">
306
+ Page {pagination.page} of {totalPages}
307
+ </span>
308
+ <button
309
+ onClick={handleNextPage}
310
+ disabled={!canGoForward}
311
+ className="px-3 py-1 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500"
312
+ aria-label="Next page"
313
+ >
314
+
315
+ </button>
316
+ </div>
317
+ </div>
318
+ )}
319
+
320
+ {/* Details Modal */}
321
+ <RunDetailsModal
322
+ run={selectedRun}
323
+ onClose={() => setSelectedRun(null)}
324
+ onViewResults={onViewResults}
325
+ onReRun={handleReRun}
326
+ />
327
+ </div>
328
+ );
329
+ }