@startsimpli/funnels 0.1.4 → 0.1.6

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 -3241
  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 -3194
  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 -20
  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 -18
  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 -389
  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 -386
  151. package/dist/store/index.js.map +0 -1
@@ -0,0 +1,52 @@
1
+ /**
2
+ * StatusBadge Component
3
+ *
4
+ * Displays funnel status as a color-coded pill badge.
5
+ *
6
+ * Design Rationale:
7
+ * - Pill shape provides soft, approachable aesthetic
8
+ * - Color coding enables instant status recognition
9
+ * - Uppercase text improves scannability
10
+ */
11
+
12
+ import { FunnelStatus } from '../../types';
13
+
14
+ interface StatusBadgeProps {
15
+ status: FunnelStatus;
16
+ className?: string;
17
+ }
18
+
19
+ const statusConfig: Record<FunnelStatus, { color: string; bgColor: string; label: string }> = {
20
+ active: {
21
+ color: 'text-green-800',
22
+ bgColor: 'bg-green-100',
23
+ label: 'ACTIVE',
24
+ },
25
+ draft: {
26
+ color: 'text-yellow-800',
27
+ bgColor: 'bg-yellow-100',
28
+ label: 'DRAFT',
29
+ },
30
+ paused: {
31
+ color: 'text-gray-800',
32
+ bgColor: 'bg-gray-100',
33
+ label: 'PAUSED',
34
+ },
35
+ archived: {
36
+ color: 'text-red-800',
37
+ bgColor: 'bg-red-100',
38
+ label: 'ARCHIVED',
39
+ },
40
+ };
41
+
42
+ export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
43
+ const config = statusConfig[status];
44
+
45
+ return (
46
+ <span
47
+ className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color} ${className}`}
48
+ >
49
+ {config.label}
50
+ </span>
51
+ );
52
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * FunnelCard Component Exports
3
+ *
4
+ * Main export: FunnelCard
5
+ * Sub-components exported for composition flexibility
6
+ */
7
+
8
+ export { FunnelCard } from './FunnelCard';
9
+ export type { FunnelCardProps } from './FunnelCard';
10
+
11
+ export { StatusBadge } from './StatusBadge';
12
+ export { StageIndicator } from './StageIndicator';
13
+ export { MatchBar } from './MatchBar';
14
+ export { FunnelStats } from './FunnelStats';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * EntityCard Component
3
+ *
4
+ * Displays a single entity in the preview list.
5
+ * Supports custom rendering via render prop for entity-specific display.
6
+ *
7
+ * Design Rationale:
8
+ * - Card design provides visual containment
9
+ * - Border and shadow create depth
10
+ * - Rounded corners match overall design system
11
+ * - Render prop pattern keeps component BRUTALLY GENERIC
12
+ *
13
+ * Accessibility:
14
+ * - Semantic article element for content
15
+ * - Focus states for keyboard navigation
16
+ */
17
+
18
+ import type { ReactNode } from 'react';
19
+
20
+ interface EntityCardProps {
21
+ entity: any;
22
+ renderEntity?: (entity: any) => ReactNode;
23
+ className?: string;
24
+ }
25
+
26
+ /**
27
+ * Default entity renderer
28
+ * Shows entity name if available, otherwise shows JSON preview
29
+ */
30
+ function defaultEntityRenderer(entity: any): ReactNode {
31
+ if (entity.name) {
32
+ return (
33
+ <div>
34
+ <div className="font-medium text-gray-900">{entity.name}</div>
35
+ <div className="text-sm text-gray-600 mt-1">
36
+ {Object.keys(entity)
37
+ .filter((key) => key !== 'name')
38
+ .slice(0, 3)
39
+ .map((key) => (
40
+ <span key={key} className="mr-2">
41
+ {key}: {String(entity[key]).slice(0, 20)}
42
+ </span>
43
+ ))}
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ // Fallback: show JSON preview
50
+ return (
51
+ <div className="text-sm text-gray-700 font-mono">
52
+ <pre className="whitespace-pre-wrap break-all">
53
+ {JSON.stringify(entity, null, 2).slice(0, 150)}
54
+ {JSON.stringify(entity, null, 2).length > 150 ? '...' : ''}
55
+ </pre>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ export function EntityCard({
61
+ entity,
62
+ renderEntity = defaultEntityRenderer,
63
+ className = '',
64
+ }: EntityCardProps) {
65
+ return (
66
+ <article
67
+ className={`p-3 bg-white border border-gray-200 rounded-lg shadow-sm hover:border-gray-300 transition-colors ${className}`}
68
+ >
69
+ {renderEntity(entity)}
70
+ </article>
71
+ );
72
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * FunnelPreview Storybook stories
3
+ */
4
+
5
+ import type { Meta, StoryObj } from '@storybook/react';
6
+ import { FunnelPreview } from './FunnelPreview';
7
+ import { sampleInvestors, investorFunnelExample, sampleRecipes, recipeFunnelExample, sampleLeads, leadFunnelExample } from '../../stories/demo-data';
8
+
9
+ const meta: Meta<typeof FunnelPreview> = {
10
+ title: 'Components/FunnelPreview',
11
+ component: FunnelPreview,
12
+ parameters: {
13
+ layout: 'padded',
14
+ docs: {
15
+ description: {
16
+ component: 'Real-time preview of funnel execution on sample entities. Shows match stats and stage-by-stage breakdown.',
17
+ },
18
+ },
19
+ },
20
+ tags: ['autodocs'],
21
+ };
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof FunnelPreview>;
25
+
26
+ // Investor funnel preview
27
+ export const InvestorPreview: Story = {
28
+ args: {
29
+ funnel: investorFunnelExample,
30
+ entities: sampleInvestors,
31
+ renderEntity: (entity) => (
32
+ <div style={{ padding: '0.5rem' }}>
33
+ <div style={{ fontWeight: 600 }}>{entity['firm.name']}</div>
34
+ <div style={{ fontSize: '0.875rem', color: '#666' }}>
35
+ {entity['investor.name']} • {entity['investor.title']}
36
+ </div>
37
+ <div style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
38
+ Stage: {entity['firm.stage']} | Score: {entity['investor.score']}
39
+ </div>
40
+ </div>
41
+ ),
42
+ },
43
+ };
44
+
45
+ // Recipe funnel preview
46
+ export const RecipePreview: Story = {
47
+ args: {
48
+ funnel: recipeFunnelExample,
49
+ entities: sampleRecipes,
50
+ renderEntity: (entity) => (
51
+ <div style={{ padding: '0.5rem' }}>
52
+ <div style={{ fontWeight: 600 }}>{entity['recipe.name']}</div>
53
+ <div style={{ fontSize: '0.875rem', color: '#666' }}>
54
+ {entity['recipe.cuisine']} • {entity['recipe.difficulty']}
55
+ </div>
56
+ <div style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
57
+ {entity['recipe.cookTimeMinutes']} min | {entity['recipe.calories']} cal | ⭐ {entity['recipe.rating']}
58
+ </div>
59
+ </div>
60
+ ),
61
+ },
62
+ };
63
+
64
+ // Lead scoring preview
65
+ export const LeadPreview: Story = {
66
+ args: {
67
+ funnel: leadFunnelExample,
68
+ entities: sampleLeads,
69
+ renderEntity: (entity) => (
70
+ <div style={{ padding: '0.5rem' }}>
71
+ <div style={{ fontWeight: 600 }}>{entity['lead.name']}</div>
72
+ <div style={{ fontSize: '0.875rem', color: '#666' }}>
73
+ {entity['lead.company']} • {entity['lead.industry']}
74
+ </div>
75
+ <div style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.25rem' }}>
76
+ Score: {entity['lead.score']} | Status: {entity['lead.status']}
77
+ </div>
78
+ </div>
79
+ ),
80
+ },
81
+ };
82
+
83
+ // No matches
84
+ export const NoMatches: Story = {
85
+ args: {
86
+ funnel: {
87
+ ...investorFunnelExample,
88
+ stages: [
89
+ {
90
+ id: 'stage-1',
91
+ name: 'Impossible Filter',
92
+ description: 'Filter that matches nothing',
93
+ order: 0,
94
+ rules: [
95
+ {
96
+ field: 'investor.score',
97
+ operator: 'gte',
98
+ value: 1000, // Impossible score
99
+ logicalOperator: 'AND',
100
+ },
101
+ ],
102
+ },
103
+ ],
104
+ },
105
+ entities: sampleInvestors,
106
+ renderEntity: (entity) => (
107
+ <div style={{ padding: '0.5rem' }}>
108
+ <div style={{ fontWeight: 600 }}>{entity['firm.name']}</div>
109
+ </div>
110
+ ),
111
+ },
112
+ };
113
+
114
+ // Large dataset
115
+ export const LargeDataset: Story = {
116
+ args: {
117
+ funnel: investorFunnelExample,
118
+ entities: Array.from({ length: 100 }, (_, i) => ({
119
+ id: `inv-${i}`,
120
+ 'firm.name': `Firm ${i}`,
121
+ 'firm.stage': i % 3 === 0 ? 'Seed' : i % 3 === 1 ? 'Series A' : 'Pre-Seed',
122
+ 'firm.checkSizeMin': 500000 + i * 10000,
123
+ 'firm.checkSizeMax': 2000000 + i * 50000,
124
+ 'firm.sectors': i % 2 === 0 ? ['SaaS'] : ['AI/ML'],
125
+ 'investor.name': `Investor ${i}`,
126
+ 'investor.title': i % 2 === 0 ? 'Partner' : 'Principal',
127
+ 'investor.score': 70 + (i % 30),
128
+ 'firm.lastContactDays': i % 20,
129
+ 'firm.portfolioSize': 20 + i,
130
+ })),
131
+ renderEntity: (entity) => (
132
+ <div style={{ padding: '0.5rem' }}>
133
+ <div style={{ fontWeight: 600 }}>{entity['firm.name']}</div>
134
+ <div style={{ fontSize: '0.875rem', color: '#666' }}>
135
+ Score: {entity['investor.score']}
136
+ </div>
137
+ </div>
138
+ ),
139
+ },
140
+ };
141
+
142
+ // Empty funnel (no stages)
143
+ export const EmptyFunnel: Story = {
144
+ args: {
145
+ funnel: {
146
+ ...investorFunnelExample,
147
+ stages: [],
148
+ },
149
+ entities: sampleInvestors,
150
+ renderEntity: (entity) => (
151
+ <div style={{ padding: '0.5rem' }}>
152
+ <div style={{ fontWeight: 600 }}>{entity['firm.name']}</div>
153
+ </div>
154
+ ),
155
+ },
156
+ };
157
+
158
+ // Custom entity renderer (minimal)
159
+ export const MinimalEntityRenderer: Story = {
160
+ args: {
161
+ funnel: investorFunnelExample,
162
+ entities: sampleInvestors,
163
+ renderEntity: (entity) => (
164
+ <div style={{ padding: '1rem', textAlign: 'center', fontWeight: 600 }}>
165
+ {entity['firm.name']}
166
+ </div>
167
+ ),
168
+ },
169
+ };
170
+
171
+ // Custom entity renderer (detailed)
172
+ export const DetailedEntityRenderer: Story = {
173
+ args: {
174
+ funnel: investorFunnelExample,
175
+ entities: sampleInvestors,
176
+ renderEntity: (entity) => (
177
+ <div style={{ padding: '1rem', border: '2px solid #e5e7eb', borderRadius: '8px' }}>
178
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
179
+ <div>
180
+ <div style={{ fontSize: '1.125rem', fontWeight: 700, marginBottom: '0.25rem' }}>
181
+ {entity['firm.name']}
182
+ </div>
183
+ <div style={{ fontSize: '0.875rem', color: '#6b7280', marginBottom: '0.5rem' }}>
184
+ {entity['investor.name']} • {entity['investor.title']}
185
+ </div>
186
+ </div>
187
+ <div style={{
188
+ padding: '0.25rem 0.75rem',
189
+ backgroundColor: entity['investor.score'] >= 85 ? '#dcfce7' : '#fef3c7',
190
+ color: entity['investor.score'] >= 85 ? '#166534' : '#92400e',
191
+ borderRadius: '9999px',
192
+ fontSize: '0.875rem',
193
+ fontWeight: 600,
194
+ }}>
195
+ Score: {entity['investor.score']}
196
+ </div>
197
+ </div>
198
+ <div style={{
199
+ display: 'grid',
200
+ gridTemplateColumns: 'repeat(2, 1fr)',
201
+ gap: '0.5rem',
202
+ marginTop: '0.75rem',
203
+ fontSize: '0.875rem',
204
+ }}>
205
+ <div>
206
+ <div style={{ color: '#9ca3af', marginBottom: '0.125rem' }}>Stage</div>
207
+ <div style={{ fontWeight: 600 }}>{entity['firm.stage']}</div>
208
+ </div>
209
+ <div>
210
+ <div style={{ color: '#9ca3af', marginBottom: '0.125rem' }}>Check Size</div>
211
+ <div style={{ fontWeight: 600 }}>
212
+ ${(entity['firm.checkSizeMin'] / 1000000).toFixed(1)}M - ${(entity['firm.checkSizeMax'] / 1000000).toFixed(1)}M
213
+ </div>
214
+ </div>
215
+ <div>
216
+ <div style={{ color: '#9ca3af', marginBottom: '0.125rem' }}>Last Contact</div>
217
+ <div style={{ fontWeight: 600 }}>{entity['firm.lastContactDays']} days ago</div>
218
+ </div>
219
+ <div>
220
+ <div style={{ color: '#9ca3af', marginBottom: '0.125rem' }}>Portfolio</div>
221
+ <div style={{ fontWeight: 600 }}>{entity['firm.portfolioSize']} companies</div>
222
+ </div>
223
+ </div>
224
+ </div>
225
+ ),
226
+ },
227
+ };
@@ -0,0 +1,316 @@
1
+ /**
2
+ * FunnelPreview Component Tests
3
+ */
4
+
5
+ import { describe, it, expect, vi } from 'vitest';
6
+ import type { Funnel } from '../../types';
7
+
8
+ // Mock entities for testing
9
+ const mockEntities = [
10
+ { name: 'John Doe', firm: { stage: 'Series A' }, score: 85 },
11
+ { name: 'Jane Smith', firm: { stage: 'Seed' }, score: 92 },
12
+ { name: 'Bob Wilson', firm: { stage: 'Series B' }, score: 78 },
13
+ { name: 'Alice Johnson', firm: { stage: 'Series A' }, score: 88 },
14
+ { name: 'Charlie Brown', firm: { stage: 'Seed' }, score: 95 },
15
+ ];
16
+
17
+ // Mock funnel with multiple stages
18
+ const mockFunnel: Funnel = {
19
+ id: 'test-funnel',
20
+ name: 'Test Funnel',
21
+ description: 'A test funnel',
22
+ status: 'active',
23
+ input_type: 'contacts',
24
+ stages: [
25
+ {
26
+ id: 'stage-1',
27
+ order: 0,
28
+ name: 'High Score',
29
+ filter_logic: 'AND',
30
+ rules: [
31
+ {
32
+ field_path: 'score',
33
+ operator: 'gte',
34
+ value: 80,
35
+ },
36
+ ],
37
+ match_action: 'continue',
38
+ no_match_action: 'exclude',
39
+ },
40
+ {
41
+ id: 'stage-2',
42
+ order: 1,
43
+ name: 'Series A or Seed',
44
+ filter_logic: 'OR',
45
+ rules: [
46
+ {
47
+ field_path: 'firm.stage',
48
+ operator: 'eq',
49
+ value: 'Series A',
50
+ },
51
+ {
52
+ field_path: 'firm.stage',
53
+ operator: 'eq',
54
+ value: 'Seed',
55
+ },
56
+ ],
57
+ match_action: 'output',
58
+ no_match_action: 'exclude',
59
+ },
60
+ ],
61
+ created_at: new Date().toISOString(),
62
+ updated_at: new Date().toISOString(),
63
+ };
64
+
65
+ describe('FunnelPreview', () => {
66
+ describe('Preview Computation', () => {
67
+ it('should compute preview with matched entities', async () => {
68
+ const { FunnelEngine } = await import('../../core/engine');
69
+ const engine = new FunnelEngine();
70
+ const result = engine.execute(mockFunnel, mockEntities);
71
+
72
+ expect(result.total_input).toBe(5);
73
+ expect(result.total_matched).toBeGreaterThan(0);
74
+ expect(result.total_excluded).toBeGreaterThan(0);
75
+ expect(result.matched.length).toBe(result.total_matched);
76
+ });
77
+
78
+ it('should handle empty entities', async () => {
79
+ const { FunnelEngine } = await import('../../core/engine');
80
+ const engine = new FunnelEngine();
81
+ const result = engine.execute(mockFunnel, []);
82
+
83
+ expect(result.total_input).toBe(0);
84
+ expect(result.total_matched).toBe(0);
85
+ expect(result.total_excluded).toBe(0);
86
+ expect(result.matched).toEqual([]);
87
+ });
88
+
89
+ it('should handle empty funnel (no stages)', async () => {
90
+ const emptyFunnel: Funnel = {
91
+ ...mockFunnel,
92
+ stages: [],
93
+ };
94
+
95
+ const { FunnelEngine } = await import('../../core/engine');
96
+ const engine = new FunnelEngine();
97
+ const result = engine.execute(emptyFunnel, mockEntities);
98
+
99
+ // With no stages, all entities should match
100
+ expect(result.total_matched).toBe(5);
101
+ expect(result.total_excluded).toBe(0);
102
+ });
103
+ });
104
+
105
+ describe('Stage Statistics', () => {
106
+ it('should track entities at each stage', async () => {
107
+ const { FunnelEngine } = await import('../../core/engine');
108
+ const engine = new FunnelEngine();
109
+ const result = engine.execute(mockFunnel, mockEntities);
110
+
111
+ // Should have stats for both stages
112
+ expect(result.stage_stats['stage-1']).toBeDefined();
113
+ expect(result.stage_stats['stage-2']).toBeDefined();
114
+
115
+ // Stage 1 should process all entities
116
+ expect(result.stage_stats['stage-1'].input_count).toBe(5);
117
+ });
118
+
119
+ it('should count exclusions per stage', async () => {
120
+ const { FunnelEngine } = await import('../../core/engine');
121
+ const engine = new FunnelEngine();
122
+ const result = engine.execute(mockFunnel, mockEntities);
123
+
124
+ // Sum of excluded across all stages should equal total excluded
125
+ const totalExcludedAcrossStages = Object.values(result.stage_stats).reduce(
126
+ (sum, stats) => sum + stats.excluded_count,
127
+ 0
128
+ );
129
+
130
+ expect(totalExcludedAcrossStages).toBeLessThanOrEqual(result.total_excluded);
131
+ });
132
+ });
133
+
134
+ describe('Preview Result Conversion', () => {
135
+ it('should calculate match percentage correctly', async () => {
136
+ const { FunnelEngine } = await import('../../core/engine');
137
+ const engine = new FunnelEngine();
138
+ const result = engine.execute(mockFunnel, mockEntities);
139
+
140
+ const total = result.total_matched + result.total_excluded;
141
+ const expectedPercentage =
142
+ total > 0 ? Math.round((result.total_matched / total) * 100) : 0;
143
+
144
+ expect(expectedPercentage).toBeGreaterThanOrEqual(0);
145
+ expect(expectedPercentage).toBeLessThanOrEqual(100);
146
+ });
147
+
148
+ it('should limit preview entities to maxPreviewEntities', async () => {
149
+ const { FunnelEngine } = await import('../../core/engine');
150
+ const engine = new FunnelEngine();
151
+ const result = engine.execute(mockFunnel, mockEntities);
152
+
153
+ const maxPreview = 2;
154
+ const previewEntities = result.matched.slice(0, maxPreview);
155
+
156
+ expect(previewEntities.length).toBeLessThanOrEqual(maxPreview);
157
+ });
158
+ });
159
+
160
+ describe('Multi-stage Funnel', () => {
161
+ it('should process entities through all stages', async () => {
162
+ const multiFunnel: Funnel = {
163
+ ...mockFunnel,
164
+ stages: [
165
+ {
166
+ id: 'stage-1',
167
+ order: 0,
168
+ name: 'Stage 1',
169
+ filter_logic: 'AND',
170
+ rules: [{ field_path: 'score', operator: 'gte', value: 80 }],
171
+ match_action: 'continue',
172
+ no_match_action: 'exclude',
173
+ },
174
+ {
175
+ id: 'stage-2',
176
+ order: 1,
177
+ name: 'Stage 2',
178
+ filter_logic: 'AND',
179
+ rules: [{ field_path: 'score', operator: 'lte', value: 90 }],
180
+ match_action: 'continue',
181
+ no_match_action: 'exclude',
182
+ },
183
+ {
184
+ id: 'stage-3',
185
+ order: 2,
186
+ name: 'Stage 3',
187
+ filter_logic: 'AND',
188
+ rules: [{ field_path: 'firm.stage', operator: 'eq', value: 'Series A' }],
189
+ match_action: 'output',
190
+ no_match_action: 'exclude',
191
+ },
192
+ ],
193
+ };
194
+
195
+ const { FunnelEngine } = await import('../../core/engine');
196
+ const engine = new FunnelEngine();
197
+ const result = engine.execute(multiFunnel, mockEntities);
198
+
199
+ // Should have stats for all 3 stages
200
+ expect(Object.keys(result.stage_stats).length).toBe(3);
201
+ expect(result.stage_stats['stage-1']).toBeDefined();
202
+ expect(result.stage_stats['stage-2']).toBeDefined();
203
+ expect(result.stage_stats['stage-3']).toBeDefined();
204
+ });
205
+
206
+ it('should track remaining entities correctly', async () => {
207
+ const { FunnelEngine } = await import('../../core/engine');
208
+ const engine = new FunnelEngine();
209
+ const result = engine.execute(mockFunnel, mockEntities);
210
+
211
+ // For each stage, remaining = input - excluded
212
+ Object.values(result.stage_stats).forEach((stats) => {
213
+ const remaining = stats.input_count - stats.excluded_count;
214
+ expect(remaining).toBeGreaterThanOrEqual(0);
215
+ expect(remaining).toBeLessThanOrEqual(stats.input_count);
216
+ });
217
+ });
218
+ });
219
+
220
+ describe('Edge Cases', () => {
221
+ it('should handle 0 entities', async () => {
222
+ const { FunnelEngine } = await import('../../core/engine');
223
+ const engine = new FunnelEngine();
224
+ const result = engine.execute(mockFunnel, []);
225
+
226
+ expect(result.total_input).toBe(0);
227
+ expect(result.total_matched).toBe(0);
228
+ expect(result.total_excluded).toBe(0);
229
+ });
230
+
231
+ it('should handle 100 entities', async () => {
232
+ const largeEntities = Array.from({ length: 100 }, (_, i) => ({
233
+ name: `Entity ${i}`,
234
+ score: 70 + (i % 30),
235
+ firm: { stage: i % 2 === 0 ? 'Series A' : 'Seed' },
236
+ }));
237
+
238
+ const { FunnelEngine } = await import('../../core/engine');
239
+ const engine = new FunnelEngine();
240
+ const result = engine.execute(mockFunnel, largeEntities);
241
+
242
+ expect(result.total_input).toBe(100);
243
+ expect(result.total_matched + result.total_excluded).toBe(100);
244
+ });
245
+
246
+ it('should handle funnel with no rules', async () => {
247
+ const noRulesFunnel: Funnel = {
248
+ ...mockFunnel,
249
+ stages: [
250
+ {
251
+ id: 'stage-1',
252
+ order: 0,
253
+ name: 'Pass All',
254
+ filter_logic: 'AND',
255
+ rules: [],
256
+ match_action: 'output',
257
+ no_match_action: 'exclude',
258
+ },
259
+ ],
260
+ };
261
+
262
+ const { FunnelEngine } = await import('../../core/engine');
263
+ const engine = new FunnelEngine();
264
+ const result = engine.execute(noRulesFunnel, mockEntities);
265
+
266
+ // With no rules, all entities should match
267
+ expect(result.total_matched).toBe(5);
268
+ expect(result.total_excluded).toBe(0);
269
+ });
270
+
271
+ it('should handle custom evaluator', async () => {
272
+ const customFunnel: Funnel = {
273
+ ...mockFunnel,
274
+ stages: [
275
+ {
276
+ id: 'stage-1',
277
+ order: 0,
278
+ name: 'Custom',
279
+ filter_logic: 'AND',
280
+ rules: [],
281
+ match_action: 'output',
282
+ no_match_action: 'exclude',
283
+ custom_evaluator: (entity: any) => entity.score > 85,
284
+ },
285
+ ],
286
+ };
287
+
288
+ const { FunnelEngine } = await import('../../core/engine');
289
+ const engine = new FunnelEngine();
290
+ const result = engine.execute(customFunnel, mockEntities);
291
+
292
+ // Should match entities with score > 85
293
+ expect(result.total_matched).toBe(3); // Jane(92), Alice(88), Charlie(95)
294
+ });
295
+ });
296
+
297
+ describe('Performance', () => {
298
+ it('should complete in reasonable time for 100 entities', async () => {
299
+ const largeEntities = Array.from({ length: 100 }, (_, i) => ({
300
+ name: `Entity ${i}`,
301
+ score: 70 + (i % 30),
302
+ firm: { stage: i % 2 === 0 ? 'Series A' : 'Seed' },
303
+ }));
304
+
305
+ const { FunnelEngine } = await import('../../core/engine');
306
+ const engine = new FunnelEngine();
307
+
308
+ const start = Date.now();
309
+ engine.execute(mockFunnel, largeEntities);
310
+ const duration = Date.now() - start;
311
+
312
+ // Should complete in less than 100ms for 100 entities
313
+ expect(duration).toBeLessThan(100);
314
+ });
315
+ });
316
+ });