@startsimpli/funnels
v0.4.23
Published
Brutally generic filtering pipeline package for any Simpli product
Downloads
1,090
Maintainers
Readme
@startsimpli/funnels
Brutally generic filtering pipeline package for any Simpli product
What is this?
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.
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, orapi/funnel-*-clientinraise-simpli/ormarket-simpli/, that logic almost certainly belongs here instead. See/CLAUDE.mdrule 9 and.claude/docs/conventions.md#shared-packages-policy.
The Philosophy
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.
The TypeScript flow:
- Start with entities (any type)
- Apply sequential filter stages
- Each stage: keep / exclude / tag based on rules
- End with filtered subset + accumulated tags + per-stage context
Why use this?
- Zero domain coupling — Investors, recipes, leads, products, tasks: same engine.
- Type-safe — Full TypeScript generics on
Funnel<TEntity>,FunnelStage<TEntity>,FunnelResult<TEntity>. - Modular — Tree-shakeable subpath exports (
/core,/components,/hooks,/store). - Server-compatible — The
/coresubpath has zero React dependencies (workers, Node, CLIs). - Battle-tested — 494 tests across 16 files.
- Production — Used by
raise-simpli/web-app/andmarket-simpli/.
Installation
Inside the monorepo it's already wired via the workspace alias @startsimpli/funnels. Add it to a new app:
pnpm add @startsimpli/funnelsPeer deps: react ^18 || ^19, react-dom ^18 || ^19, zustand ^4 || ^5.
Quick Start
Example 1: Investor Funnel
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.
import { FunnelEngine, type Funnel } from '@startsimpli/funnels';
interface Investor {
contact: { name: string };
metric: { financial: { check_size_min: number; check_size_max: number } };
tag: { stage_focus: string[] };
}
const funnel: Funnel<Investor> = {
id: 'series-a-funnel',
name: 'Series A Investor Qualification',
status: 'active',
entityType: 'contact',
stages: [
{
id: 'stage-1',
order: 0,
name: 'Stage Filter',
filterLogic: 'OR',
rules: [
{ field: 'tag.stage_focus', operator: 'eq', value: 'series_a' },
],
matchAction: 'tag_continue',
noMatchAction: 'exclude',
matchTags: ['qualified_stage'],
},
{
id: 'stage-2',
order: 1,
name: 'Check Size',
filterLogic: 'AND',
rules: [
{ field: 'metric.financial.check_size_min', operator: 'lte', value: 5_000_000 },
{ field: 'metric.financial.check_size_max', operator: 'gte', value: 3_000_000 },
],
matchAction: 'output',
noMatchAction: 'exclude',
matchTags: ['qualified'],
},
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const engine = new FunnelEngine<Investor>();
const result = engine.execute(funnel, investors);
console.log(`Matched: ${result.results.filter((r) => r.matched).length}`);Example 2: Recipe Funnel
Find quick, easy, vegetarian recipes:
import type { Funnel } from '@startsimpli/funnels';
interface Recipe {
name: string;
prep_time_minutes: number;
difficulty: 'easy' | 'medium' | 'hard';
dietary_restrictions: string[];
}
const funnel: Funnel<Recipe> = {
id: 'quick-dinner',
name: 'Quick Weeknight Dinner',
status: 'active',
inputType: 'any',
stages: [
{
id: 'dietary',
order: 0,
name: 'Dietary Restrictions',
filterLogic: 'AND',
rules: [
{ field: 'dietary_restrictions', operator: 'has_all', value: ['vegetarian'] },
],
matchAction: 'tag_continue',
noMatchAction: 'exclude',
matchTags: ['vegetarian'],
},
{
id: 'time',
order: 1,
name: 'Quick Prep',
filterLogic: 'AND',
rules: [
{ field: 'prep_time_minutes', operator: 'lte', value: 30 },
],
matchAction: 'tag_continue',
noMatchAction: 'exclude',
matchTags: ['quick'],
},
{
id: 'difficulty',
order: 2,
name: 'Easy to Make',
filterLogic: 'OR',
rules: [
{ field: 'difficulty', operator: 'eq', value: 'easy' },
{ field: 'difficulty', operator: 'eq', value: 'medium' },
],
matchAction: 'output',
noMatchAction: 'exclude',
matchTags: ['beginner_friendly'],
},
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};Example 3: Lead Scoring
Score sales leads based on company size and engagement:
import type { Funnel } from '@startsimpli/funnels';
interface Lead {
company: { size: number };
engagement: { email_opens: number; demo_requested: boolean };
tags: string[];
}
const funnel: Funnel<Lead> = {
id: 'lead-scoring',
name: 'Enterprise Lead Scoring',
status: 'active',
inputType: 'any',
stages: [
{
id: 'company-size',
order: 0,
name: 'Enterprise Size',
filterLogic: 'AND',
rules: [
{ field: 'company.size', operator: 'gte', value: 100 },
],
matchAction: 'tag_continue',
noMatchAction: 'tag_continue',
matchTags: ['enterprise'],
noMatchTags: ['smb'],
},
{
id: 'engagement',
order: 1,
name: 'High Engagement',
filterLogic: 'OR',
rules: [
{ field: 'engagement.email_opens', operator: 'gte', value: 5 },
{ field: 'engagement.demo_requested', operator: 'is_true', value: null },
],
matchAction: 'output',
noMatchAction: 'output',
matchTags: ['hot_lead'],
matchContext: { tier: 'A', score: 100 },
},
],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};Core Concepts
Funnel
A sequential pipeline with multiple filtering stages. Each funnel has:
- Stages: Ordered sequence of filter conditions
- Status:
draft | active | paused | archived - InputType:
contacts | organizations | both | any - Metadata: Tags, owner, team, completion tags
interface Funnel<TEntity = any> {
id: string;
name: string;
status: 'draft' | 'active' | 'paused' | 'archived';
inputType: 'contacts' | 'organizations' | 'both' | 'any';
stages: FunnelStage<TEntity>[];
createdAt: Date | string;
updatedAt: Date | string;
ownerId?: string;
teamId?: string;
completionTags?: string[];
metadata?: Record<string, any>;
}Backend rule: the StartSimpli Django API only accepts
entity_type='contact'or'organization'onEntity. Use tags for domain classification (investor, lead, employee). Seestart-simpli-api/CLAUDE.mdrule 3 andstart-simpli-api/.claude/docs/funnels.md.
Stage
A single filtering step with rules, actions, and tags:
interface FunnelStage<TEntity = any> {
id: string;
order: number;
name: string;
filterLogic: 'AND' | 'OR';
rules: FilterRule[];
matchAction: 'continue' | 'tag' | 'tag_continue' | 'output';
noMatchAction: 'continue' | 'exclude' | 'tag_exclude';
matchTags?: string[];
noMatchTags?: string[];
matchContext?: Record<string, any>;
customEvaluator?: (entity: TEntity) => boolean;
}Filter Rule
A single condition: a field key, an operator, a value.
interface FilterRule {
field: string; // 'contact.name', 'tag.stage_focus', 'metric.financial.check_size_min'
operator: Operator; // 'eq', 'gte', 'between', 'exists', 'contains', …
value?: any; // omitted for exists / not_exists
negate?: boolean;
/** @deprecated pre-registry alias for `field` */
fieldPath?: string;
}field is a registry key, the same key the API resolves
(backend/apps/funnels/resolvers/registry.py) and the same key
GET /api/v1/funnels/fields/ describes:
| Key shape | Example | Resolves to |
|---|---|---|
| contact.<attr> | contact.email | a column on the contact |
| entity.<attr> | entity.name | a column on the entity |
| tag.<category> | tag.stage_focus | a core.Tag in that category |
| metric.<type>.<subtype> | metric.financial.check_size_min | a core.Metric |
| profile.<type> | profile.vc | a core.Profile |
| icp.<signal> | icp.has_frontend_stack | an ICP signal |
Read it with ruleField(rule) rather than reaching for a property: a row
persisted before this contract carries only the deprecated fieldPath, and the
accessor resolves either. A row the API auto-migrated carries both — the
serializer's legacy branch fills field in and leaves field_path in place —
so a component matching a rule against a registry should try the canonical key,
then the deprecated one, rather than assuming field is the one the registry
is keyed by (RuleRow does exactly this). For purely client-side evaluation the key doubles as a
dot-notation path into your objects (firm.stage), so a browser preview and a
server run take the same rule.
Nested groups are carried by RuleGroup { groupLogic: 'all' | 'any' | 'not',
children }, which is what the API emits and accepts; ruleLeaves(nodes)
flattens a tree to its leaves.
Supported operators:
- Equality:
eq,ne - Comparison:
gt,lt,gte,lte,between - Presence:
exists,not_exists - String:
contains,not_contains,startswith,endswith,matches - Array:
in,not_in,has_any,has_all - Null:
isnull,isnotnull - Tags:
has_tag,not_has_tag - Boolean:
is_true,is_false
Everything from between upward in the API's own list — eq ne in not_in gt lt
gte lte between contains startswith endswith exists not_exists — is accepted by
the server. The rest (matches, has_any, is_true, …) is client-side
evaluation sugar with no server resolver: the local engine understands it, a
POST does not.
Field Registry
Which fields a rule may name. Ask the server — GET /api/v1/funnels/fields/
serves the resolver registry, and FieldDefinition is that payload:
interface FieldDefinition {
key: string; // 'tag.stage_focus'
label: string; // 'stage_focus'
category?: string; // 'Tags' — for grouping in the picker
valueType: FieldType; // 'string' | 'number' | 'boolean' | 'enum' | 'date' | …
allowedOperators: Operator[];// what the server will accept for this key
enumValues?: string[]; // the known values, for an enum field
}const client = new FunnelApiClient(adapter, baseUrl);
const { fields } = await client.getFields('contact');
<FilterRuleEditor rules={rules} onChange={setRules} fieldRegistry={fields} />A hand-written registry ({ name, label, type, operators }) still renders —
every component reads through fieldKey(), fieldValueType() and
fieldOperators(), and normalizeFieldDefinition() collapses either shape,
plus the raw snake_case body from an adapter that does not camelize. Prefer the
endpoint: a hand-written list drifts from the resolvers, and a rule naming a key
the registry does not know is rejected at POST time.
See start-simpli-api/.claude/docs/funnels.md for the ORM contract behind each
key shape. This stays consistent with the "brutally generic" rule: namespace via
type/subtype on the core primitives, do not mint new Django models per domain.
Modular Exports
Import only what you need for optimal bundle size — four subpath entry points are defined in package.json#exports:
// Full package (everything)
import { FunnelEngine, FunnelPreview, createFunnelStore } from '@startsimpli/funnels';
// Core only (NO React dependencies — workers, CLI, Node)
import { FunnelEngine, evaluateRule, applyOperator } from '@startsimpli/funnels/core';
// Components only (React UI)
import { FunnelCard, FunnelPreview, FunnelStageBuilder } from '@startsimpli/funnels/components';
// Hooks only
import { useDebouncedValue } from '@startsimpli/funnels/hooks';
// Zustand store only
import { createFunnelStore } from '@startsimpli/funnels/store';API Reference — Root Exports
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.
Field & Rule Utilities (core)
| Export | Purpose |
|---|---|
| resolveField | Look up a value by dot/colon-namespaced field path |
| setField | Write a value at a field path |
| hasField | Check field-path existence |
| getFields | Enumerate field paths on an entity |
| applyOperator | Apply a single Operator to a value pair |
| evaluateRule | Evaluate one FilterRule against an entity |
| evaluateRuleWithResult | Same, returning detailed match info |
| evaluateRules | Evaluate a FilterRule[] against an entity |
| evaluateRulesAND / evaluateRulesOR | Combine rules by logic |
| evaluateRulesWithResults | Detailed per-rule results |
| filterEntities | Apply rules to an entity array |
Engine
| Export | Purpose |
|---|---|
| FunnelEngine | Sequential stage executor — engine.execute(funnel, entities) returns ExecutionResult<T> |
| ExecutionResult (type) | Matched/excluded/stats payload |
Hooks
| Export | Purpose |
|---|---|
| useDebouncedValue | Debounce a value for search/filter inputs |
Components
| Group | Exports |
|---|---|
| FunnelPreview | FunnelPreview, PreviewStats, StageBreakdown, EntityCard, LoadingPreview + props/result types |
| FunnelCard | FunnelCard, StatusBadge, StageIndicator, MatchBar, FunnelStats |
| FunnelVisualFlow | FunnelVisualFlow, StageNode, FlowLegend, getCircledNumber (powered by @xyflow/react) |
| FilterRuleEditor | FilterRuleEditor, LogicToggle, FieldSelector, OperatorSelector, RuleRow, TextValueInput, NumberValueInput, DateValueInput, BooleanValueInput, ChoiceValueInput, MultiChoiceValueInput, plus OPERATOR_LABELS, NULL_VALUE_OPERATORS, MULTI_VALUE_OPERATORS constants |
| FunnelStageBuilder | FunnelStageBuilder, StageCard, StageForm, StageActions, TagInput, AddStageButton |
| FunnelRunHistory | FunnelRunHistory, RunStatusBadge, RunFilters, RunRow, RunActions, RunDetailsModal, StageBreakdownList, plus formatters formatDuration, formatRelativeTime, calculateMatchRate, formatNumber, formatFullTimestamp |
Store
| Export | Purpose |
|---|---|
| createFunnelStore | Zustand store factory for stage editing |
| FunnelStore (type) | Store shape |
| createInitialState | Helper for initial state |
API Client
| Export | Purpose |
|---|---|
| FunnelApiClient | Generic API client for funnel CRUD / runs / results / preview / fields |
| FetchAdapter | Default fetch-based ApiAdapter |
| ApiAdapter (type) | Adapter interface for plugging in your own HTTP layer |
| createFunnelPaths | Build the URL family the client talks to |
| centralFunnelPaths | /api/v1/funnels/… — the default |
| foundryTenantFunnelPaths(slug) | /api/v1/foundry/tenants/<slug>/funnels/…, runs nested |
| proxiedCentralFunnelPaths(prefix) | the central family behind a fork's same-origin proxy |
| createApiError / isApiError | Error helpers |
| FunnelListFilters, PaginatedResponse, PreviewResult, FunnelPaths (types) | Response and path shapes |
The path family is injected, so one client reaches every surface the same endpoints are mounted on:
import { FunnelApiClient, foundryTenantFunnelPaths } from '@startsimpli/funnels';
const central = new FunnelApiClient(adapter, 'https://api.startsimpli.com');
const tenant = new FunnelApiClient(adapter, '', foundryTenantFunnelPaths('acme'));A family that nests runs under their funnel (the control plane) needs the funnel
id when addressing a run: getFunnelRun(runId, funnelId). It throws a named
error rather than building a wrong URL if you omit it.
Core Engine — usage
import { FunnelEngine } from '@startsimpli/funnels/core';
const engine = new FunnelEngine<Investor>();
const result = engine.execute(funnel, entities);
// Matched entities
const matched = result.results.filter((r) => r.matched).map((r) => r.entity);
// Excluded entities + which stage rejected them
const excluded = result.results
.filter((r) => !r.matched)
.map((r) => ({ entity: r.entity, excludedAtStage: r.excludedAtStage }));The previous README referenced
engine.executeSync()— the actual method isengine.execute(). There is no separate sync method.
React Components — usage
import { FunnelCard, FunnelPreview, FunnelStageBuilder } from '@startsimpli/funnels/components';
<FunnelCard funnel={myFunnel} onEdit={handleEdit} onDelete={handleDelete} />
<FunnelPreview funnel={myFunnel} entities={myData} />
<FunnelStageBuilder
funnel={myFunnel}
fieldRegistry={registry}
onChange={handleChange}
/>Zustand Store — usage
import { createFunnelStore } from '@startsimpli/funnels/store';
const useFunnelStore = createFunnelStore();
function MyComponent() {
const { funnel, updateStage, addStage } = useFunnelStore();
addStage(newStage);
updateStage(stage.id, { name: 'New Name' });
}Real-world reference
The two production consumers in this monorepo are the source of truth for how this package is meant to be wired up:
| App | Field registry | Funnel list page | Funnel detail / stage builder |
|---|---|---|---|
| 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 |
| 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 |
API integrations:
raise-simpli/web-app/src/lib/api/funnels.tsandinvestor-funnels.tsmarket-simpli/src/shared/lib/api/lead-funnels.ts
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.
Architecture
Why Brutally Generic?
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.
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.
Sequential Stage Processing
Stages execute in order (0, 1, 2, …). Each stage routes the entity by matchAction / noMatchAction:
- continue — Pass entity to next stage unchanged
- exclude — Remove entity from output, stop processing
- tag — Add tags and stop processing
- tag_continue — Add tags and pass to next stage
- output — Mark as matched (terminal)
Stage 0: Filter by investment stage
├─ Match → tag 'qualified_stage', continue
└─ No match → exclude
Stage 1: Filter by check size
├─ Match → tag 'qualified_check_size', continue
└─ No match → tag 'excluded_check_size', exclude
Stage 2: Filter by geography
├─ Match → output (final)
└─ No match → excludeAccumulated State
Tags and context accumulate across stages:
{
entity: { /* investor data */ },
matched: true,
accumulatedTags: ['qualified_stage', 'qualified_check_size', 'qualified_geography'],
context: { stage: 'qualified', tier: 'A', score: 100 },
stageResults: [ /* per-stage trace */ ],
}Storybook
cd packages/funnels
pnpm storybookStories live under src/stories/ and use the demo data in src/stories/demo-data/.
Testing & Verification
pnpm test # vitest run
pnpm test:watch # watch mode
pnpm test:coverage # coverage report
pnpm type-check # tsc --noEmitVerified test counts (vitest, package version 0.4.13):
- 494 tests across 16 test files (all passing)
- Core: 268 tests —
operators.test.ts(105),evaluator.test.ts(76),field-resolver.test.ts(63),engine.test.ts(24) - 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) - Store: 29 tests —
create-funnel-store.test.ts - API client: 24 tests —
client.test.ts
If these numbers drift, re-run pnpm --filter @startsimpli/funnels test and update this section — do not let the README rot.
Cross-references
- Monorepo root
/CLAUDE.mdrule 9 — shared-packages policy (this package is the canonical home for filtering UI/logic; do not duplicate in appsrc/) - Backend field-path contract
start-simpli-api/.claude/docs/funnels.md— required reading before writing rules that hit the Django executor - Backend entity-type rule
start-simpli-api/CLAUDE.mdrule 3 —entity_typeis always'contact'(or'organization'); domain classification uses tags - Brutally-generic data model Namespace via
type/subtypeoncore.Attribute / core.Profile / core.Tag / core.Metricinstead of new Django models or new apps
License
MIT — see LICENSE.
