@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.
- package/README.md +326 -281
- package/package.json +24 -16
- package/src/api/client.paths.test.ts +175 -0
- package/src/api/client.ts +78 -19
- package/src/api/http-adapter.test.ts +68 -0
- package/src/api/http-adapter.ts +48 -0
- package/src/api/index.ts +16 -0
- package/src/api/paths.test.ts +138 -0
- package/src/api/paths.ts +192 -0
- package/src/components/FilterRuleEditor/FieldSelector.tsx +7 -5
- package/src/components/FilterRuleEditor/FilterRuleEditor.stories.tsx +3 -3
- package/src/components/FilterRuleEditor/FilterRuleEditor.test.tsx +13 -8
- package/src/components/FilterRuleEditor/FilterRuleEditor.tsx +7 -2
- package/src/components/FilterRuleEditor/OperatorSelector.tsx +1 -1
- package/src/components/FilterRuleEditor/RuleRow.test.tsx +166 -0
- package/src/components/FilterRuleEditor/RuleRow.tsx +73 -10
- package/src/components/FilterRuleEditor/constants.ts +12 -0
- package/src/components/FunnelPreview/example.tsx +15 -15
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.stories.tsx +2 -2
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.test.tsx +2 -2
- package/src/components/FunnelStageBuilder/FunnelStageBuilder.tsx +2 -2
- package/src/components/FunnelStageBuilder/StageCard.tsx +2 -2
- package/src/components/FunnelStageBuilder/StageForm.tsx +2 -2
- package/src/core/evaluator.example.ts +23 -23
- package/src/core/evaluator.test.ts +4 -1
- package/src/core/evaluator.ts +11 -5
- package/src/core/operators.ts +35 -0
- package/src/page/FunnelsPage.test.tsx +383 -0
- package/src/page/FunnelsPage.tsx +739 -0
- package/src/page/index.ts +24 -0
- package/src/page/types.ts +102 -0
- package/src/store/create-funnel-store.ts +1 -0
- package/src/stories/demo-data/investors.ts +2 -2
- package/src/stories/demo-data/leads.ts +2 -2
- package/src/stories/demo-data/recipes.ts +2 -2
- package/src/types/contract.test.ts +284 -0
- package/src/types/index.ts +339 -49
package/README.md
CHANGED
|
@@ -1,103 +1,104 @@
|
|
|
1
|
-
# @
|
|
1
|
+
# @startsimpli/funnels
|
|
2
2
|
|
|
3
3
|
> Brutally generic filtering pipeline package for any Simpli product
|
|
4
4
|
|
|
5
|
-
[]()
|
|
6
|
+
[]()
|
|
7
|
+
[]()
|
|
7
8
|
[]()
|
|
8
9
|
[]()
|
|
9
10
|
|
|
10
11
|
## What is this?
|
|
11
12
|
|
|
12
|
-
|
|
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**
|
|
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
|
|
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
|
|
28
|
+
### Why use this?
|
|
25
29
|
|
|
26
|
-
- **Zero domain coupling**
|
|
27
|
-
- **Type-safe**
|
|
28
|
-
- **Modular** -
|
|
29
|
-
- **Server-compatible**
|
|
30
|
-
- **Battle-tested**
|
|
31
|
-
- **Production
|
|
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
|
-
|
|
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 {
|
|
54
|
+
import { FunnelEngine, type Funnel } from '@startsimpli/funnels';
|
|
47
55
|
|
|
48
56
|
interface Investor {
|
|
49
|
-
name: string;
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
66
|
+
entityType: 'contact',
|
|
62
67
|
stages: [
|
|
63
68
|
{
|
|
64
69
|
id: 'stage-1',
|
|
65
70
|
order: 0,
|
|
66
71
|
name: 'Stage Filter',
|
|
67
|
-
|
|
72
|
+
filterLogic: 'OR',
|
|
68
73
|
rules: [
|
|
69
|
-
{
|
|
70
|
-
{ field_path: 'firm.stage', operator: 'eq', value: 'Multi-Stage' }
|
|
74
|
+
{ field: 'tag.stage_focus', operator: 'eq', value: 'series_a' },
|
|
71
75
|
],
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
84
|
+
filterLogic: 'AND',
|
|
81
85
|
rules: [
|
|
82
|
-
{
|
|
83
|
-
{
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
+
matchAction: 'output',
|
|
90
|
+
noMatchAction: 'exclude',
|
|
91
|
+
matchTags: ['qualified'],
|
|
92
|
+
},
|
|
89
93
|
],
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
createdAt: new Date().toISOString(),
|
|
95
|
+
updatedAt: new Date().toISOString(),
|
|
92
96
|
};
|
|
93
97
|
|
|
94
|
-
|
|
95
|
-
const
|
|
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 '@
|
|
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
|
-
|
|
122
|
+
inputType: 'any',
|
|
122
123
|
stages: [
|
|
123
124
|
{
|
|
124
125
|
id: 'dietary',
|
|
125
126
|
order: 0,
|
|
126
127
|
name: 'Dietary Restrictions',
|
|
127
|
-
|
|
128
|
+
filterLogic: 'AND',
|
|
128
129
|
rules: [
|
|
129
|
-
{
|
|
130
|
+
{ field: 'dietary_restrictions', operator: 'has_all', value: ['vegetarian'] },
|
|
130
131
|
],
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
140
|
+
filterLogic: 'AND',
|
|
140
141
|
rules: [
|
|
141
|
-
{
|
|
142
|
+
{ field: 'prep_time_minutes', operator: 'lte', value: 30 },
|
|
142
143
|
],
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
152
|
+
filterLogic: 'OR',
|
|
152
153
|
rules: [
|
|
153
|
-
{
|
|
154
|
-
{
|
|
154
|
+
{ field: 'difficulty', operator: 'eq', value: 'easy' },
|
|
155
|
+
{ field: 'difficulty', operator: 'eq', value: 'medium' },
|
|
155
156
|
],
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
157
|
+
matchAction: 'output',
|
|
158
|
+
noMatchAction: 'exclude',
|
|
159
|
+
matchTags: ['beginner_friendly'],
|
|
160
|
+
},
|
|
160
161
|
],
|
|
161
|
-
|
|
162
|
-
|
|
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 '@
|
|
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
|
-
|
|
184
|
+
inputType: 'any',
|
|
187
185
|
stages: [
|
|
188
186
|
{
|
|
189
187
|
id: 'company-size',
|
|
190
188
|
order: 0,
|
|
191
189
|
name: 'Enterprise Size',
|
|
192
|
-
|
|
190
|
+
filterLogic: 'AND',
|
|
193
191
|
rules: [
|
|
194
|
-
{
|
|
192
|
+
{ field: 'company.size', operator: 'gte', value: 100 },
|
|
195
193
|
],
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
+
filterLogic: 'OR',
|
|
206
204
|
rules: [
|
|
207
|
-
{
|
|
208
|
-
{
|
|
205
|
+
{ field: 'engagement.email_opens', operator: 'gte', value: 5 },
|
|
206
|
+
{ field: 'engagement.demo_requested', operator: 'is_true', value: null },
|
|
209
207
|
],
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
208
|
+
matchAction: 'output',
|
|
209
|
+
noMatchAction: 'output',
|
|
210
|
+
matchTags: ['hot_lead'],
|
|
211
|
+
matchContext: { tier: 'A', score: 100 },
|
|
212
|
+
},
|
|
215
213
|
],
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
-
|
|
257
|
+
filterLogic: 'AND' | 'OR';
|
|
250
258
|
rules: FilterRule[];
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
270
|
+
A single condition: a field key, an operator, a value.
|
|
262
271
|
|
|
263
272
|
```typescript
|
|
264
273
|
interface FilterRule {
|
|
265
|
-
|
|
266
|
-
operator: Operator;
|
|
267
|
-
value
|
|
268
|
-
negate?: boolean;
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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 '@
|
|
366
|
+
import { FunnelEngine, FunnelPreview, createFunnelStore } from '@startsimpli/funnels';
|
|
317
367
|
|
|
318
|
-
// Core only (NO React dependencies
|
|
319
|
-
import { FunnelEngine, evaluateRule, applyOperator } from '@
|
|
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 '@
|
|
372
|
+
import { FunnelCard, FunnelPreview, FunnelStageBuilder } from '@startsimpli/funnels/components';
|
|
323
373
|
|
|
324
|
-
// Hooks only
|
|
325
|
-
import { useDebouncedValue } from '@
|
|
374
|
+
// Hooks only
|
|
375
|
+
import { useDebouncedValue } from '@startsimpli/funnels/hooks';
|
|
326
376
|
|
|
327
|
-
//
|
|
328
|
-
import { createFunnelStore } from '@
|
|
377
|
+
// Zustand store only
|
|
378
|
+
import { createFunnelStore } from '@startsimpli/funnels/store';
|
|
329
379
|
```
|
|
330
380
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
453
|
+
const central = new FunnelApiClient(adapter, 'https://api.startsimpli.com');
|
|
454
|
+
const tenant = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));
|
|
455
|
+
```
|
|
338
456
|
|
|
339
|
-
|
|
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 '@
|
|
345
|
-
|
|
346
|
-
const engine = new FunnelEngine();
|
|
464
|
+
import { FunnelEngine } from '@startsimpli/funnels/core';
|
|
347
465
|
|
|
348
|
-
|
|
349
|
-
const
|
|
466
|
+
const engine = new FunnelEngine<Investor>();
|
|
467
|
+
const result = engine.execute(funnel, entities);
|
|
350
468
|
|
|
351
|
-
//
|
|
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
|
-
//
|
|
355
|
-
const excluded = results
|
|
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
|
-
|
|
478
|
+
> The previous README referenced `engine.executeSync()` — the actual method is `engine.execute()`. There is no separate sync method.
|
|
359
479
|
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
###
|
|
496
|
+
### Zustand Store — usage
|
|
378
497
|
|
|
379
498
|
```typescript
|
|
380
|
-
import { createFunnelStore } from '@
|
|
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
|
-
##
|
|
510
|
+
## Real-world reference
|
|
394
511
|
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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,
|
|
536
|
+
Stages execute in `order` (0, 1, 2, …). Each stage routes the entity by `matchAction` / `noMatchAction`:
|
|
434
537
|
|
|
435
|
-
1. **
|
|
436
|
-
2. **
|
|
437
|
-
3. **
|
|
438
|
-
4. **
|
|
439
|
-
5. **
|
|
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
|
-
```
|
|
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
|
-
|
|
464
|
-
context: {
|
|
465
|
-
|
|
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
|
-
|
|
576
|
+
pnpm storybook
|
|
502
577
|
```
|
|
503
578
|
|
|
504
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
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).
|