@startsimpli/funnels 0.4.13 → 0.4.15

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 (37) hide show
  1. package/README.md +326 -281
  2. package/package.json +24 -16
  3. package/src/api/client.paths.test.ts +175 -0
  4. package/src/api/client.ts +78 -19
  5. package/src/api/http-adapter.test.ts +68 -0
  6. package/src/api/http-adapter.ts +48 -0
  7. package/src/api/index.ts +16 -0
  8. package/src/api/paths.test.ts +138 -0
  9. package/src/api/paths.ts +192 -0
  10. package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
  11. package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
  12. package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
  13. package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
  14. package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
  15. package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
  16. package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
  17. package/src/components/FilterRuleEditor/constants.ts +12 -0
  18. package/src/components/FunnelPreview/example.tsx +15 -15
  19. package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
  20. package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
  21. package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
  22. package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
  23. package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
  24. package/src/core/evaluator.example.ts +23 -23
  25. package/src/core/evaluator.test.ts +4 -1
  26. package/src/core/evaluator.ts +11 -5
  27. package/src/core/operators.ts +35 -0
  28. package/src/page/FunnelsPage.test.tsx +383 -0
  29. package/src/page/FunnelsPage.tsx +739 -0
  30. package/src/page/index.ts +24 -0
  31. package/src/page/types.ts +102 -0
  32. package/src/store/create-funnel-store.ts +1 -0
  33. package/src/stories/demo-data/investors.ts +2 -2
  34. package/src/stories/demo-data/leads.ts +2 -2
  35. package/src/stories/demo-data/recipes.ts +2 -2
  36. package/src/types/contract.test.ts +284 -0
  37. package/src/types/index.ts +339 -49
package/README.md CHANGED
@@ -1,103 +1,104 @@
1
- # @simpli/funnels
1
+ # @startsimpli/funnels
2
2
 
3
3
  > Brutally generic filtering pipeline package for any Simpli product
4
4
 
5
- [![Tests](https://img.shields.io/badge/tests-487%20passing-green)]()
6
- [![TypeScript](https://img.shields.io/badge/typescript-5.9%2B-blue)]()
5
+ [![Tests](https://img.shields.io/badge/tests-494%20passing-green)]()
6
+ [![Version](https://img.shields.io/badge/version-0.4.13-blue)]()
7
+ [![TypeScript](https://img.shields.io/badge/typescript-6.x-blue)]()
7
8
  [![React](https://img.shields.io/badge/react-18%2B%20%7C%2019%2B-blue)]()
8
9
  [![Zustand](https://img.shields.io/badge/zustand-4%2B%20%7C%205%2B-blue)]()
9
10
 
10
11
  ## What is this?
11
12
 
12
- A reusable package for building multi-stage filtering funnels. Works with **ANY entity type** - investors, recipes, leads, tasks, GitHub issues, or whatever you dream up.
13
+ The filterable-entity-dashboard primitive for the StartSimpli monorepo. Field-path builders, filter chains, saved views, tag joins, custom-attribute lookups — wrapped in a React component kit and a Zustand store. Works with **ANY entity type**: investors, leads, recipes, GitHub issues, tasks, products. The package itself has zero domain coupling — your app supplies a `FieldDefinition[]` registry and the funnel system handles the rest.
14
+
15
+ > **Monorepo rule 9 (shared-first):** This package exists because filterable dashboards plausibly belong in more than one app. If you find yourself reaching for an app-local `useFunnel*`, `FunnelXProvider`, or `api/funnel-*-client` in `raise-simpli/` or `market-simpli/`, that logic almost certainly belongs here instead. See `/CLAUDE.md` rule 9 and `.claude/docs/conventions.md#shared-packages-policy`.
13
16
 
14
17
  ### The Philosophy
15
18
 
16
- **BRUTALLY GENERIC** - No domain-specific types. No investor-specific fields. No recipe-specific logic.
19
+ **BRUTALLY GENERIC** — no domain-specific types, no investor-specific fields, no recipe-specific logic. Pipelines stay reusable because the rules namespace through type/subtype on the backend's `core.Attribute / core.Profile / core.Tag / core.Metric` primitives instead of minting new Django models. See `start-simpli-api/.claude/docs/funnels.md` for the field-path syntax the backend evaluator understands.
20
+
21
+ The TypeScript flow:
17
22
 
18
- It's just:
19
23
  1. Start with entities (any type)
20
24
  2. Apply sequential filter stages
21
- 3. Each stage: keep/exclude/tag based on rules
22
- 4. End with filtered subset + accumulated tags/context
25
+ 3. Each stage: keep / exclude / tag based on rules
26
+ 4. End with filtered subset + accumulated tags + per-stage context
23
27
 
24
- ### Why Use This?
28
+ ### Why use this?
25
29
 
26
- - **Zero domain coupling** - Works for investors, recipes, leads, products, tasks, anything
27
- - **Type-safe** - Full TypeScript support with generics
28
- - **Modular** - Import only what you need (tree-shakeable)
29
- - **Server-compatible** - Core engine has NO React dependencies
30
- - **Battle-tested** - 487 tests passing
31
- - **Production-ready** - Used across multiple Simpli products
30
+ - **Zero domain coupling** — Investors, recipes, leads, products, tasks: same engine.
31
+ - **Type-safe** — Full TypeScript generics on `Funnel<TEntity>`, `FunnelStage<TEntity>`, `FunnelResult<TEntity>`.
32
+ - **Modular** — Tree-shakeable subpath exports (`/core`, `/components`, `/hooks`, `/store`).
33
+ - **Server-compatible** — The `/core` subpath has zero React dependencies (workers, Node, CLIs).
34
+ - **Battle-tested** — 494 tests across 16 files.
35
+ - **Production** — Used by `raise-simpli/web-app/` and `market-simpli/`.
32
36
 
33
37
  ## Installation
34
38
 
39
+ Inside the monorepo it's already wired via the workspace alias `@startsimpli/funnels`. Add it to a new app:
40
+
35
41
  ```bash
36
- npm install @simpli/funnels
42
+ pnpm add @startsimpli/funnels
37
43
  ```
38
44
 
45
+ Peer deps: `react ^18 || ^19`, `react-dom ^18 || ^19`, `zustand ^4 || ^5`.
46
+
39
47
  ## Quick Start
40
48
 
41
49
  ### Example 1: Investor Funnel
42
50
 
43
- Filter investors for a Series A fundraise:
51
+ Filter investors for a Series A fundraise. Note the **camelCase** property names (`field`, `filterLogic`, `matchAction`) and the registry keys (`metric.financial.check_size_min`, `tag.stage_focus`) that line up with `core.Attribute / core.Tag / core.Metric` on the API side — the same keys the server resolves.
44
52
 
45
53
  ```typescript
46
- import { Funnel, FunnelEngine } from '@simpli/funnels';
54
+ import { FunnelEngine, type Funnel } from '@startsimpli/funnels';
47
55
 
48
56
  interface Investor {
49
- name: string;
50
- firm: {
51
- stage: string;
52
- check_size_min: number;
53
- check_size_max: number;
54
- };
57
+ contact: { name: string };
58
+ metric: { financial: { check_size_min: number; check_size_max: number } };
59
+ tag: { stage_focus: string[] };
55
60
  }
56
61
 
57
62
  const funnel: Funnel<Investor> = {
58
63
  id: 'series-a-funnel',
59
64
  name: 'Series A Investor Qualification',
60
65
  status: 'active',
61
- input_type: 'contacts',
66
+ entityType: 'contact',
62
67
  stages: [
63
68
  {
64
69
  id: 'stage-1',
65
70
  order: 0,
66
71
  name: 'Stage Filter',
67
- filter_logic: 'OR',
72
+ filterLogic: 'OR',
68
73
  rules: [
69
- { field_path: 'firm.stage', operator: 'eq', value: 'Series A' },
70
- { field_path: 'firm.stage', operator: 'eq', value: 'Multi-Stage' }
74
+ { field: 'tag.stage_focus', operator: 'eq', value: 'series_a' },
71
75
  ],
72
- match_action: 'tag_continue',
73
- no_match_action: 'exclude',
74
- match_tags: ['qualified_stage']
76
+ matchAction: 'tag_continue',
77
+ noMatchAction: 'exclude',
78
+ matchTags: ['qualified_stage'],
75
79
  },
76
80
  {
77
81
  id: 'stage-2',
78
82
  order: 1,
79
83
  name: 'Check Size',
80
- filter_logic: 'AND',
84
+ filterLogic: 'AND',
81
85
  rules: [
82
- { field_path: 'firm.check_size_min', operator: 'lte', value: 5000000 },
83
- { field_path: 'firm.check_size_max', operator: 'gte', value: 3000000 }
86
+ { field: 'metric.financial.check_size_min', operator: 'lte', value: 5_000_000 },
87
+ { field: 'metric.financial.check_size_max', operator: 'gte', value: 3_000_000 },
84
88
  ],
85
- match_action: 'output',
86
- no_match_action: 'exclude',
87
- match_tags: ['qualified']
88
- }
89
+ matchAction: 'output',
90
+ noMatchAction: 'exclude',
91
+ matchTags: ['qualified'],
92
+ },
89
93
  ],
90
- created_at: new Date().toISOString(),
91
- updated_at: new Date().toISOString()
94
+ createdAt: new Date().toISOString(),
95
+ updatedAt: new Date().toISOString(),
92
96
  };
93
97
 
94
- // Execute funnel
95
- const engine = new FunnelEngine();
96
- const investors = [/* your data */];
97
- const results = engine.executeSync(funnel, investors);
98
+ const engine = new FunnelEngine<Investor>();
99
+ const result = engine.execute(funnel, investors);
98
100
 
99
- console.log(`Matched: ${results.matched.length}`);
100
- console.log(`Excluded: ${results.excluded.length}`);
101
+ console.log(`Matched: ${result.results.filter((r) => r.matched).length}`);
101
102
  ```
102
103
 
103
104
  ### Example 2: Recipe Funnel
@@ -105,7 +106,7 @@ console.log(`Excluded: ${results.excluded.length}`);
105
106
  Find quick, easy, vegetarian recipes:
106
107
 
107
108
  ```typescript
108
- import { Funnel } from '@simpli/funnels';
109
+ import type { Funnel } from '@startsimpli/funnels';
109
110
 
110
111
  interface Recipe {
111
112
  name: string;
@@ -118,48 +119,48 @@ const funnel: Funnel<Recipe> = {
118
119
  id: 'quick-dinner',
119
120
  name: 'Quick Weeknight Dinner',
120
121
  status: 'active',
121
- input_type: 'any',
122
+ inputType: 'any',
122
123
  stages: [
123
124
  {
124
125
  id: 'dietary',
125
126
  order: 0,
126
127
  name: 'Dietary Restrictions',
127
- filter_logic: 'AND',
128
+ filterLogic: 'AND',
128
129
  rules: [
129
- { field_path: 'dietary_restrictions', operator: 'has_all', value: ['vegetarian'] }
130
+ { field: 'dietary_restrictions', operator: 'has_all', value: ['vegetarian'] },
130
131
  ],
131
- match_action: 'tag_continue',
132
- no_match_action: 'exclude',
133
- match_tags: ['vegetarian']
132
+ matchAction: 'tag_continue',
133
+ noMatchAction: 'exclude',
134
+ matchTags: ['vegetarian'],
134
135
  },
135
136
  {
136
137
  id: 'time',
137
138
  order: 1,
138
139
  name: 'Quick Prep',
139
- filter_logic: 'AND',
140
+ filterLogic: 'AND',
140
141
  rules: [
141
- { field_path: 'prep_time_minutes', operator: 'lte', value: 30 }
142
+ { field: 'prep_time_minutes', operator: 'lte', value: 30 },
142
143
  ],
143
- match_action: 'tag_continue',
144
- no_match_action: 'exclude',
145
- match_tags: ['quick']
144
+ matchAction: 'tag_continue',
145
+ noMatchAction: 'exclude',
146
+ matchTags: ['quick'],
146
147
  },
147
148
  {
148
149
  id: 'difficulty',
149
150
  order: 2,
150
151
  name: 'Easy to Make',
151
- filter_logic: 'OR',
152
+ filterLogic: 'OR',
152
153
  rules: [
153
- { field_path: 'difficulty', operator: 'eq', value: 'easy' },
154
- { field_path: 'difficulty', operator: 'eq', value: 'medium' }
154
+ { field: 'difficulty', operator: 'eq', value: 'easy' },
155
+ { field: 'difficulty', operator: 'eq', value: 'medium' },
155
156
  ],
156
- match_action: 'output',
157
- no_match_action: 'exclude',
158
- match_tags: ['beginner_friendly']
159
- }
157
+ matchAction: 'output',
158
+ noMatchAction: 'exclude',
159
+ matchTags: ['beginner_friendly'],
160
+ },
160
161
  ],
161
- created_at: new Date().toISOString(),
162
- updated_at: new Date().toISOString()
162
+ createdAt: new Date().toISOString(),
163
+ updatedAt: new Date().toISOString(),
163
164
  };
164
165
  ```
165
166
 
@@ -168,14 +169,11 @@ const funnel: Funnel<Recipe> = {
168
169
  Score sales leads based on company size and engagement:
169
170
 
170
171
  ```typescript
171
- import { Funnel } from '@simpli/funnels';
172
+ import type { Funnel } from '@startsimpli/funnels';
172
173
 
173
174
  interface Lead {
174
175
  company: { size: number };
175
- engagement: {
176
- email_opens: number;
177
- demo_requested: boolean;
178
- };
176
+ engagement: { email_opens: number; demo_requested: boolean };
179
177
  tags: string[];
180
178
  }
181
179
 
@@ -183,38 +181,38 @@ const funnel: Funnel<Lead> = {
183
181
  id: 'lead-scoring',
184
182
  name: 'Enterprise Lead Scoring',
185
183
  status: 'active',
186
- input_type: 'any',
184
+ inputType: 'any',
187
185
  stages: [
188
186
  {
189
187
  id: 'company-size',
190
188
  order: 0,
191
189
  name: 'Enterprise Size',
192
- filter_logic: 'AND',
190
+ filterLogic: 'AND',
193
191
  rules: [
194
- { field_path: 'company.size', operator: 'gte', value: 100 }
192
+ { field: 'company.size', operator: 'gte', value: 100 },
195
193
  ],
196
- match_action: 'tag_continue',
197
- no_match_action: 'tag_continue',
198
- match_tags: ['enterprise'],
199
- no_match_tags: ['smb']
194
+ matchAction: 'tag_continue',
195
+ noMatchAction: 'tag_continue',
196
+ matchTags: ['enterprise'],
197
+ noMatchTags: ['smb'],
200
198
  },
201
199
  {
202
200
  id: 'engagement',
203
201
  order: 1,
204
202
  name: 'High Engagement',
205
- filter_logic: 'OR',
203
+ filterLogic: 'OR',
206
204
  rules: [
207
- { field_path: 'engagement.email_opens', operator: 'gte', value: 5 },
208
- { field_path: 'engagement.demo_requested', operator: 'is_true', value: null }
205
+ { field: 'engagement.email_opens', operator: 'gte', value: 5 },
206
+ { field: 'engagement.demo_requested', operator: 'is_true', value: null },
209
207
  ],
210
- match_action: 'output',
211
- no_match_action: 'output',
212
- match_tags: ['hot_lead'],
213
- match_context: { tier: 'A', score: 100 }
214
- }
208
+ matchAction: 'output',
209
+ noMatchAction: 'output',
210
+ matchTags: ['hot_lead'],
211
+ matchContext: { tier: 'A', score: 100 },
212
+ },
215
213
  ],
216
- created_at: new Date().toISOString(),
217
- updated_at: new Date().toISOString()
214
+ createdAt: new Date().toISOString(),
215
+ updatedAt: new Date().toISOString(),
218
216
  };
219
217
  ```
220
218
 
@@ -223,20 +221,30 @@ const funnel: Funnel<Lead> = {
223
221
  ### Funnel
224
222
 
225
223
  A sequential pipeline with multiple filtering stages. Each funnel has:
224
+
226
225
  - **Stages**: Ordered sequence of filter conditions
227
- - **Status**: draft | active | paused | archived
228
- - **Metadata**: Tags, context, ownership info
226
+ - **Status**: `draft | active | paused | archived`
227
+ - **InputType**: `contacts | organizations | both | any`
228
+ - **Metadata**: Tags, owner, team, completion tags
229
229
 
230
230
  ```typescript
231
231
  interface Funnel<TEntity = any> {
232
232
  id: string;
233
233
  name: string;
234
234
  status: 'draft' | 'active' | 'paused' | 'archived';
235
+ inputType: 'contacts' | 'organizations' | 'both' | 'any';
235
236
  stages: FunnelStage<TEntity>[];
236
- // ... more fields
237
+ createdAt: Date | string;
238
+ updatedAt: Date | string;
239
+ ownerId?: string;
240
+ teamId?: string;
241
+ completionTags?: string[];
242
+ metadata?: Record<string, any>;
237
243
  }
238
244
  ```
239
245
 
246
+ > **Backend rule:** the StartSimpli Django API only accepts `entity_type='contact'` or `'organization'` on `Entity`. Use **tags** for domain classification (investor, lead, employee). See `start-simpli-api/CLAUDE.md` rule 3 and `start-simpli-api/.claude/docs/funnels.md`.
247
+
240
248
  ### Stage
241
249
 
242
250
  A single filtering step with rules, actions, and tags:
@@ -246,127 +254,238 @@ interface FunnelStage<TEntity = any> {
246
254
  id: string;
247
255
  order: number;
248
256
  name: string;
249
- filter_logic: 'AND' | 'OR';
257
+ filterLogic: 'AND' | 'OR';
250
258
  rules: FilterRule[];
251
- match_action: 'continue' | 'tag' | 'tag_continue' | 'output';
252
- no_match_action: 'continue' | 'exclude' | 'tag_exclude';
253
- match_tags?: string[];
254
- no_match_tags?: string[];
255
- custom_evaluator?: (entity: TEntity) => boolean;
259
+ matchAction: 'continue' | 'tag' | 'tag_continue' | 'output';
260
+ noMatchAction: 'continue' | 'exclude' | 'tag_exclude';
261
+ matchTags?: string[];
262
+ noMatchTags?: string[];
263
+ matchContext?: Record<string, any>;
264
+ customEvaluator?: (entity: TEntity) => boolean;
256
265
  }
257
266
  ```
258
267
 
259
268
  ### Filter Rule
260
269
 
261
- A single condition with field path, operator, and value:
270
+ A single condition: a field key, an operator, a value.
262
271
 
263
272
  ```typescript
264
273
  interface FilterRule {
265
- field_path: string; // 'firm.stage', 'recipe.cuisine', 'tags'
266
- operator: Operator; // 'eq', 'gt', 'contains', 'has_any', etc.
267
- value: any; // Value to compare against
268
- negate?: boolean; // Optional negation
274
+ field: string; // 'contact.name', 'tag.stage_focus', 'metric.financial.check_size_min'
275
+ operator: Operator; // 'eq', 'gte', 'between', 'exists', 'contains', …
276
+ value?: any; // omitted for exists / not_exists
277
+ negate?: boolean;
278
+ /** @deprecated pre-registry alias for `field` */
279
+ fieldPath?: string;
269
280
  }
270
281
  ```
271
282
 
283
+ `field` is a **registry key**, the same key the API resolves
284
+ (`backend/apps/funnels/resolvers/registry.py`) and the same key
285
+ `GET /api/v1/funnels/fields/` describes:
286
+
287
+ | Key shape | Example | Resolves to |
288
+ |---|---|---|
289
+ | `contact.<attr>` | `contact.email` | a column on the contact |
290
+ | `entity.<attr>` | `entity.name` | a column on the entity |
291
+ | `tag.<category>` | `tag.stage_focus` | a `core.Tag` in that category |
292
+ | `metric.<type>.<subtype>` | `metric.financial.check_size_min` | a `core.Metric` |
293
+ | `profile.<type>` | `profile.vc` | a `core.Profile` |
294
+ | `icp.<signal>` | `icp.has_frontend_stack` | an ICP signal |
295
+
296
+ Read it with `ruleField(rule)` rather than reaching for a property: a row
297
+ persisted before this contract carries only the deprecated `fieldPath`, and the
298
+ accessor resolves either. A row the API auto-migrated carries **both** — the
299
+ serializer's legacy branch fills `field` in and leaves `field_path` in place —
300
+ so a component matching a rule against a registry should try the canonical key,
301
+ then the deprecated one, rather than assuming `field` is the one the registry
302
+ is keyed by (`RuleRow` does exactly this). For purely client-side evaluation the key doubles as a
303
+ dot-notation path into your objects (`firm.stage`), so a browser preview and a
304
+ server run take the same rule.
305
+
306
+ Nested groups are carried by `RuleGroup { groupLogic: 'all' | 'any' | 'not',
307
+ children }`, which is what the API emits and accepts; `ruleLeaves(nodes)`
308
+ flattens a tree to its leaves.
309
+
272
310
  **Supported operators:**
311
+
273
312
  - **Equality**: `eq`, `ne`
274
- - **Comparison**: `gt`, `lt`, `gte`, `lte`
313
+ - **Comparison**: `gt`, `lt`, `gte`, `lte`, `between`
314
+ - **Presence**: `exists`, `not_exists`
275
315
  - **String**: `contains`, `not_contains`, `startswith`, `endswith`, `matches`
276
316
  - **Array**: `in`, `not_in`, `has_any`, `has_all`
277
317
  - **Null**: `isnull`, `isnotnull`
278
318
  - **Tags**: `has_tag`, `not_has_tag`
279
319
  - **Boolean**: `is_true`, `is_false`
280
320
 
321
+ Everything from `between` upward in the API's own list — `eq ne in not_in gt lt
322
+ gte lte between contains startswith endswith exists not_exists` — is accepted by
323
+ the server. The rest (`matches`, `has_any`, `is_true`, …) is client-side
324
+ evaluation sugar with no server resolver: the local engine understands it, a
325
+ POST does not.
326
+
281
327
  ### Field Registry
282
328
 
283
- Defines what fields are available for filtering in your domain:
329
+ Which fields a rule may name. **Ask the server** — `GET /api/v1/funnels/fields/`
330
+ serves the resolver registry, and `FieldDefinition` is that payload:
284
331
 
285
332
  ```typescript
286
- const investorRegistry: FieldRegistry = {
287
- entity_type: 'investor',
288
- fields: [
289
- {
290
- name: 'firm.stage',
291
- label: 'Investment Stage',
292
- type: 'string',
293
- operators: ['eq', 'ne', 'in', 'not_in'],
294
- category: 'Firm Details',
295
- constraints: {
296
- choices: ['Seed', 'Series A', 'Series B', 'Growth']
297
- }
298
- },
299
- {
300
- name: 'firm.check_size_min',
301
- label: 'Min Check Size',
302
- type: 'number',
303
- operators: ['eq', 'ne', 'gt', 'lt', 'gte', 'lte'],
304
- category: 'Firm Details'
305
- }
306
- ]
307
- };
333
+ interface FieldDefinition {
334
+ key: string; // 'tag.stage_focus'
335
+ label: string; // 'stage_focus'
336
+ category?: string; // 'Tags' — for grouping in the picker
337
+ valueType: FieldType; // 'string' | 'number' | 'boolean' | 'enum' | 'date' | …
338
+ allowedOperators: Operator[];// what the server will accept for this key
339
+ enumValues?: string[]; // the known values, for an enum field
340
+ }
341
+ ```
342
+
343
+ ```typescript
344
+ const client = new FunnelApiClient(adapter, baseUrl);
345
+ const { fields } = await client.getFields('contact');
346
+ <FilterRuleEditor rules={rules} onChange={setRules} fieldRegistry={fields} />
308
347
  ```
309
348
 
349
+ A hand-written registry (`{ name, label, type, operators }`) still renders —
350
+ every component reads through `fieldKey()`, `fieldValueType()` and
351
+ `fieldOperators()`, and `normalizeFieldDefinition()` collapses either shape,
352
+ plus the raw snake_case body from an adapter that does not camelize. Prefer the
353
+ endpoint: a hand-written list drifts from the resolvers, and a rule naming a key
354
+ the registry does not know is rejected at POST time.
355
+
356
+ See `start-simpli-api/.claude/docs/funnels.md` for the ORM contract behind each
357
+ key shape. This stays consistent with the "brutally generic" rule: namespace via
358
+ type/subtype on the core primitives, do not mint new Django models per domain.
359
+
310
360
  ## Modular Exports
311
361
 
312
- Import only what you need for optimal bundle size:
362
+ Import only what you need for optimal bundle size — four subpath entry points are defined in `package.json#exports`:
313
363
 
314
364
  ```typescript
315
365
  // Full package (everything)
316
- import { FunnelEngine, FunnelPreview, createFunnelStore } from '@simpli/funnels';
366
+ import { FunnelEngine, FunnelPreview, createFunnelStore } from '@startsimpli/funnels';
317
367
 
318
- // Core only (NO React dependencies - perfect for workers, CLI, Node.js)
319
- import { FunnelEngine, evaluateRule, applyOperator } from '@simpli/funnels/core';
368
+ // Core only (NO React dependencies — workers, CLI, Node)
369
+ import { FunnelEngine, evaluateRule, applyOperator } from '@startsimpli/funnels/core';
320
370
 
321
371
  // Components only (React UI)
322
- import { FunnelCard, FunnelPreview, FunnelStageBuilder } from '@simpli/funnels/components';
372
+ import { FunnelCard, FunnelPreview, FunnelStageBuilder } from '@startsimpli/funnels/components';
323
373
 
324
- // Hooks only (React hooks)
325
- import { useDebouncedValue } from '@simpli/funnels/hooks';
374
+ // Hooks only
375
+ import { useDebouncedValue } from '@startsimpli/funnels/hooks';
326
376
 
327
- // State only (Zustand store)
328
- import { createFunnelStore } from '@simpli/funnels/store';
377
+ // Zustand store only
378
+ import { createFunnelStore } from '@startsimpli/funnels/store';
329
379
  ```
330
380
 
331
- **Use cases:**
332
- - **Server-side worker**: `@simpli/funnels/core` (no React, no DOM)
333
- - **Next.js app**: `@simpli/funnels` (full package)
334
- - **Component library**: `@simpli/funnels/components` (UI only)
335
- - **State management**: `@simpli/funnels/store` (Zustand store)
381
+ ## API Reference — Root Exports
382
+
383
+ These are the 21 named exports from `src/index.ts`. Plus `export * from './types'` (`Funnel`, `FunnelStage`, `FilterRule`, `FieldDefinition`, `FieldRegistry`, `Operator`, `FunnelRun`, `StageStats`, `FunnelResult`, `CreateFunnelInput`, `UpdateFunnelInput`, etc.) and `export * from './api'` / `./store`.
384
+
385
+ ### Field & Rule Utilities (core)
386
+
387
+ | Export | Purpose |
388
+ |---|---|
389
+ | `resolveField` | Look up a value by dot/colon-namespaced field path |
390
+ | `setField` | Write a value at a field path |
391
+ | `hasField` | Check field-path existence |
392
+ | `getFields` | Enumerate field paths on an entity |
393
+ | `applyOperator` | Apply a single `Operator` to a value pair |
394
+ | `evaluateRule` | Evaluate one `FilterRule` against an entity |
395
+ | `evaluateRuleWithResult` | Same, returning detailed match info |
396
+ | `evaluateRules` | Evaluate a `FilterRule[]` against an entity |
397
+ | `evaluateRulesAND` / `evaluateRulesOR` | Combine rules by logic |
398
+ | `evaluateRulesWithResults` | Detailed per-rule results |
399
+ | `filterEntities` | Apply rules to an entity array |
400
+
401
+ ### Engine
402
+
403
+ | Export | Purpose |
404
+ |---|---|
405
+ | `FunnelEngine` | Sequential stage executor — `engine.execute(funnel, entities)` returns `ExecutionResult<T>` |
406
+ | `ExecutionResult` (type) | Matched/excluded/stats payload |
407
+
408
+ ### Hooks
409
+
410
+ | Export | Purpose |
411
+ |---|---|
412
+ | `useDebouncedValue` | Debounce a value for search/filter inputs |
413
+
414
+ ### Components
415
+
416
+ | Group | Exports |
417
+ |---|---|
418
+ | `FunnelPreview` | `FunnelPreview`, `PreviewStats`, `StageBreakdown`, `EntityCard`, `LoadingPreview` + props/result types |
419
+ | `FunnelCard` | `FunnelCard`, `StatusBadge`, `StageIndicator`, `MatchBar`, `FunnelStats` |
420
+ | `FunnelVisualFlow` | `FunnelVisualFlow`, `StageNode`, `FlowLegend`, `getCircledNumber` (powered by `@xyflow/react`) |
421
+ | `FilterRuleEditor` | `FilterRuleEditor`, `LogicToggle`, `FieldSelector`, `OperatorSelector`, `RuleRow`, `TextValueInput`, `NumberValueInput`, `DateValueInput`, `BooleanValueInput`, `ChoiceValueInput`, `MultiChoiceValueInput`, plus `OPERATOR_LABELS`, `NULL_VALUE_OPERATORS`, `MULTI_VALUE_OPERATORS` constants |
422
+ | `FunnelStageBuilder` | `FunnelStageBuilder`, `StageCard`, `StageForm`, `StageActions`, `TagInput`, `AddStageButton` |
423
+ | `FunnelRunHistory` | `FunnelRunHistory`, `RunStatusBadge`, `RunFilters`, `RunRow`, `RunActions`, `RunDetailsModal`, `StageBreakdownList`, plus formatters `formatDuration`, `formatRelativeTime`, `calculateMatchRate`, `formatNumber`, `formatFullTimestamp` |
424
+
425
+ ### Store
426
+
427
+ | Export | Purpose |
428
+ |---|---|
429
+ | `createFunnelStore` | Zustand store factory for stage editing |
430
+ | `FunnelStore` (type) | Store shape |
431
+ | `createInitialState` | Helper for initial state |
432
+
433
+ ### API Client
434
+
435
+ | Export | Purpose |
436
+ |---|---|
437
+ | `FunnelApiClient` | Generic API client for funnel CRUD / runs / results / preview / fields |
438
+ | `FetchAdapter` | Default fetch-based `ApiAdapter` |
439
+ | `ApiAdapter` (type) | Adapter interface for plugging in your own HTTP layer |
440
+ | `createFunnelPaths` | Build the URL family the client talks to |
441
+ | `centralFunnelPaths` | `/api/v1/funnels/…` — the default |
442
+ | `foundryTenantFunnelPaths(slug)` | `/api/v1/foundry/tenants/<slug>/funnels/…`, runs nested |
443
+ | `proxiedCentralFunnelPaths(prefix)` | the central family behind a fork's same-origin proxy |
444
+ | `createApiError` / `isApiError` | Error helpers |
445
+ | `FunnelListFilters`, `PaginatedResponse`, `PreviewResult`, `FunnelPaths` (types) | Response and path shapes |
446
+
447
+ The path family is injected, so one client reaches every surface the same
448
+ endpoints are mounted on:
449
+
450
+ ```typescript
451
+ import { FunnelApiClient, foundryTenantFunnelPaths } from '@startsimpli/funnels';
336
452
 
337
- ## API Reference
453
+ const central = new FunnelApiClient(adapter, 'https://api.startsimpli.com');
454
+ const tenant = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
455
+ ```
338
456
 
339
- See [API_REFERENCE.md](./API_REFERENCE.md) for complete API documentation.
457
+ A family that nests runs under their funnel (the control plane) needs the funnel
458
+ id when addressing a run: `getFunnelRun(runId, funnelId)`. It throws a named
459
+ error rather than building a wrong URL if you omit it.
340
460
 
341
- ### Core Engine
461
+ ### Core Engine — usage
342
462
 
343
463
  ```typescript
344
- import { FunnelEngine } from '@simpli/funnels/core';
345
-
346
- const engine = new FunnelEngine();
464
+ import { FunnelEngine } from '@startsimpli/funnels/core';
347
465
 
348
- // Synchronous execution
349
- const results = engine.executeSync(funnel, entities);
466
+ const engine = new FunnelEngine<Investor>();
467
+ const result = engine.execute(funnel, entities);
350
468
 
351
- // Get matched entities
352
- const matched = results.matched.map(r => r.entity);
469
+ // Matched entities
470
+ const matched = result.results.filter((r) => r.matched).map((r) => r.entity);
353
471
 
354
- // Get excluded entities
355
- const excluded = results.excluded.map(r => r.entity);
472
+ // Excluded entities + which stage rejected them
473
+ const excluded = result.results
474
+ .filter((r) => !r.matched)
475
+ .map((r) => ({ entity: r.entity, excludedAtStage: r.excludedAtStage }));
356
476
  ```
357
477
 
358
- ### React Components
478
+ > The previous README referenced `engine.executeSync()` — the actual method is `engine.execute()`. There is no separate sync method.
359
479
 
360
- ```typescript
361
- import { FunnelCard, FunnelPreview, FunnelStageBuilder } from '@simpli/funnels/components';
480
+ ### React Components — usage
481
+
482
+ ```tsx
483
+ import { FunnelCard, FunnelPreview, FunnelStageBuilder } from '@startsimpli/funnels/components';
362
484
 
363
- // Display funnel card
364
485
  <FunnelCard funnel={myFunnel} onEdit={handleEdit} onDelete={handleDelete} />
365
486
 
366
- // Preview funnel results
367
487
  <FunnelPreview funnel={myFunnel} entities={myData} />
368
488
 
369
- // Build funnel stages
370
489
  <FunnelStageBuilder
371
490
  funnel={myFunnel}
372
491
  fieldRegistry={registry}
@@ -374,71 +493,55 @@ import { FunnelCard, FunnelPreview, FunnelStageBuilder } from '@simpli/funnels/c
374
493
  />
375
494
  ```
376
495
 
377
- ### State Management
496
+ ### Zustand Store — usage
378
497
 
379
498
  ```typescript
380
- import { createFunnelStore } from '@simpli/funnels/store';
499
+ import { createFunnelStore } from '@startsimpli/funnels/store';
381
500
 
382
501
  const useFunnelStore = createFunnelStore();
383
502
 
384
503
  function MyComponent() {
385
504
  const { funnel, updateStage, addStage } = useFunnelStore();
386
-
387
- // Use store methods
388
505
  addStage(newStage);
389
506
  updateStage(stage.id, { name: 'New Name' });
390
507
  }
391
508
  ```
392
509
 
393
- ## Architecture
510
+ ## Real-world reference
394
511
 
395
- ### Why Brutally Generic?
512
+ The two production consumers in this monorepo are the source of truth for how this package is meant to be wired up:
396
513
 
397
- Traditional filtering systems hardcode domain models:
514
+ | App | Field registry | Funnel list page | Funnel detail / stage builder |
515
+ |---|---|---|---|
516
+ | `raise-simpli/web-app/` | `src/config/funnel-fields.ts` | `src/app/(dashboard)/fundraises/[id]/funnels/page.tsx` | `src/components/funnel-flow/FunnelFlowCanvas.tsx`, `FilterCupEditPanel.tsx` |
517
+ | `market-simpli/` | `src/config/funnel-fields.ts` | `src/app/(dashboard)/funnels/page.tsx`, `src/app/(dashboard)/campaigns/[id]/funnels/page.tsx` | `src/app/(dashboard)/funnels/[id]/page.tsx` |
398
518
 
399
- ```typescript
400
- // ❌ Domain-specific (not reusable)
401
- interface InvestorFilter {
402
- firm_stage: string[];
403
- check_size_min: number;
404
- geography: string[];
405
- }
519
+ API integrations:
406
520
 
407
- interface RecipeFilter {
408
- cuisine: string[];
409
- prep_time_max: number;
410
- dietary: string[];
411
- }
412
- ```
521
+ - `raise-simpli/web-app/src/lib/api/funnels.ts` and `investor-funnels.ts`
522
+ - `market-simpli/src/shared/lib/api/lead-funnels.ts`
413
523
 
414
- With @simpli/funnels, one system handles everything:
524
+ These wrap `FunnelApiClient` with the project's auth/fetch layer and surface a domain-flavored shape to UI code, while keeping the funnel-modeling primitives generic.
415
525
 
416
- ```typescript
417
- // ✅ Brutally generic (reusable everywhere)
418
- interface FilterRule {
419
- field_path: string; // Works for ANY field
420
- operator: Operator; // Works for ANY comparison
421
- value: any; // Works for ANY value
422
- }
423
- ```
526
+ ## Architecture
424
527
 
425
- **Benefits:**
426
- - Write filtering logic once, use everywhere
427
- - Add new domains without code changes
428
- - Type-safe with TypeScript generics
429
- - Test once, trust everywhere
528
+ ### Why Brutally Generic?
529
+
530
+ Traditional filtering systems hardcode domain models. With `@startsimpli/funnels`, one engine handles everything via `FilterRule` with `field`, `operator`, `value`. New domains require a new field registry — served by the API, not written by hand — not new code.
531
+
532
+ This is the same principle as the backend's `core.Attribute / core.Profile / core.Tag / core.Metric` primitives: namespace via `type/subtype` instead of minting new tables. The `tag.stage_focus` and `metric.financial.check_size_min` keys line up 1:1 with `core.Tag` and `core.Metric` rows.
430
533
 
431
534
  ### Sequential Stage Processing
432
535
 
433
- Stages execute in order (0, 1, 2, ...). Each stage can:
536
+ Stages execute in `order` (0, 1, 2, …). Each stage routes the entity by `matchAction` / `noMatchAction`:
434
537
 
435
- 1. **Continue** - Pass entity to next stage
436
- 2. **Exclude** - Remove entity from output (stop processing)
437
- 3. **Tag** - Add tags and stop processing
438
- 4. **Tag + Continue** - Add tags and pass to next stage
439
- 5. **Output** - Mark as matched (final stage)
538
+ 1. **continue** — Pass entity to next stage unchanged
539
+ 2. **exclude** — Remove entity from output, stop processing
540
+ 3. **tag** — Add tags and stop processing
541
+ 4. **tag_continue** — Add tags and pass to next stage
542
+ 5. **output** — Mark as matched (terminal)
440
543
 
441
- ```typescript
544
+ ```
442
545
  Stage 0: Filter by investment stage
443
546
  ├─ Match → tag 'qualified_stage', continue
444
547
  └─ No match → exclude
@@ -460,105 +563,47 @@ Tags and context accumulate across stages:
460
563
  {
461
564
  entity: { /* investor data */ },
462
565
  matched: true,
463
- accumulated_tags: ['qualified_stage', 'qualified_check_size', 'qualified_geography'],
464
- context: {
465
- stage: 'qualified',
466
- tier: 'A',
467
- score: 100
468
- }
566
+ accumulatedTags: ['qualified_stage', 'qualified_check_size', 'qualified_geography'],
567
+ context: { stage: 'qualified', tier: 'A', score: 100 },
568
+ stageResults: [ /* per-stage trace */ ],
469
569
  }
470
570
  ```
471
571
 
472
- ## Examples
473
-
474
- See [EXAMPLES.md](./EXAMPLES.md) for 6+ real-world examples:
475
-
476
- - Investor qualification funnel
477
- - Recipe recommendation funnel
478
- - Lead scoring funnel
479
- - GitHub issue triage funnel
480
- - E-commerce product filtering
481
- - Task prioritization funnel
482
-
483
- ## Integration
484
-
485
- See [INTEGRATION_GUIDE.md](./INTEGRATION_GUIDE.md) for step-by-step integration instructions.
486
-
487
- Quick summary:
488
-
489
- 1. Install package: `npm install @simpli/funnels`
490
- 2. Create field registry for your domain
491
- 3. Use components in your UI
492
- 4. Connect to your API/backend
493
- 5. Configure Tailwind CSS (if using components)
494
-
495
572
  ## Storybook
496
573
 
497
- Explore 50+ interactive examples:
498
-
499
574
  ```bash
500
575
  cd packages/funnels
501
- npm run storybook
576
+ pnpm storybook
502
577
  ```
503
578
 
504
- Or view the built Storybook in `./storybook-static/index.html`
505
-
506
- See [STORYBOOK.md](./STORYBOOK.md) for details.
579
+ Stories live under `src/stories/` and use the demo data in `src/stories/demo-data/`.
507
580
 
508
- ## Testing
509
-
510
- The package includes comprehensive test coverage:
581
+ ## Testing & Verification
511
582
 
512
583
  ```bash
513
- npm run test # Run all tests
514
- npm run test:watch # Watch mode
515
- npm run test:coverage # Coverage report
584
+ pnpm test # vitest run
585
+ pnpm test:watch # watch mode
586
+ pnpm test:coverage # coverage report
587
+ pnpm type-check # tsc --noEmit
516
588
  ```
517
589
 
518
- **Test stats:**
519
- - **487 tests** passing
520
- - **Core engine**: 262 tests
521
- - **Components**: 185 tests
522
- - **Store**: 29 tests
523
- - **API client**: 24 tests
524
-
525
- ## Contributing
590
+ **Verified test counts (vitest, package version 0.4.13):**
526
591
 
527
- See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidelines.
592
+ - **494 tests** across **16 test files** (all passing)
593
+ - Core: **268 tests** — `operators.test.ts` (105), `evaluator.test.ts` (76), `field-resolver.test.ts` (63), `engine.test.ts` (24)
594
+ - Components: **173 tests** — `FilterRuleEditor` (33), `FunnelCard.test.ts` (19) + `FunnelCard.test.tsx` (24), `FunnelRunHistory.test.tsx` (20) + `utils.test.ts` (18), `TagInput` (15), `FunnelPreview` (14), `FunnelStageBuilder` (11), `StageActions` (10), `FunnelVisualFlow` (9)
595
+ - Store: **29 tests** — `create-funnel-store.test.ts`
596
+ - API client: **24 tests** — `client.test.ts`
528
597
 
529
- Quick summary:
598
+ If these numbers drift, re-run `pnpm --filter @startsimpli/funnels test` and update this section — do not let the README rot.
530
599
 
531
- 1. Fork the repo
532
- 2. Create a feature branch
533
- 3. Make your changes
534
- 4. Add tests
535
- 5. Run `npm test` and `npm run type-check`
536
- 6. Submit a PR
600
+ ## Cross-references
537
601
 
538
- ## Changelog
539
-
540
- See [CHANGELOG.md](./CHANGELOG.md) for version history.
602
+ - **Monorepo root** `/CLAUDE.md` rule 9 — shared-packages policy (this package is the canonical home for filtering UI/logic; do not duplicate in app `src/`)
603
+ - **Backend field-path contract** `start-simpli-api/.claude/docs/funnels.md` — required reading before writing rules that hit the Django executor
604
+ - **Backend entity-type rule** `start-simpli-api/CLAUDE.md` rule 3 — `entity_type` is always `'contact'` (or `'organization'`); domain classification uses tags
605
+ - **Brutally-generic data model** Namespace via `type/subtype` on `core.Attribute / core.Profile / core.Tag / core.Metric` instead of new Django models or new apps
541
606
 
542
607
  ## License
543
608
 
544
- MIT - See [LICENSE](./LICENSE) for details.
545
-
546
- ---
547
-
548
- ## Why "Brutally Generic"?
549
-
550
- Because it works for **literally anything**:
551
-
552
- - ✅ Investors (VC fundraising)
553
- - ✅ Recipes (cooking app)
554
- - ✅ Leads (sales CRM)
555
- - ✅ GitHub issues (project management)
556
- - ✅ Products (e-commerce)
557
- - ✅ Tasks (task manager)
558
- - ✅ Your domain (whatever it is)
559
-
560
- **Zero domain-specific types. Just generic entity processing with rich filtering capabilities.**
561
-
562
- ---
563
-
564
- Built with ❤️ by the Simpli team
609
+ MIT — see [LICENSE](./LICENSE).