npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@startsimpli/funnels

v0.4.23

Published

Brutally generic filtering pipeline package for any Simpli product

Downloads

1,090

Readme

@startsimpli/funnels

Brutally generic filtering pipeline package for any Simpli product

Tests Version TypeScript React Zustand

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, 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.

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:

  1. Start with entities (any type)
  2. Apply sequential filter stages
  3. Each stage: keep / exclude / tag based on rules
  4. 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 /core subpath has zero React dependencies (workers, Node, CLIs).
  • Battle-tested — 494 tests across 16 files.
  • Production — Used by raise-simpli/web-app/ and market-simpli/.

Installation

Inside the monorepo it's already wired via the workspace alias @startsimpli/funnels. Add it to a new app:

pnpm add @startsimpli/funnels

Peer 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' 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.

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 is engine.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.ts and investor-funnels.ts
  • market-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:

  1. continue — Pass entity to next stage unchanged
  2. exclude — Remove entity from output, stop processing
  3. tag — Add tags and stop processing
  4. tag_continue — Add tags and pass to next stage
  5. 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 → exclude

Accumulated 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 storybook

Stories 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 --noEmit

Verified 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.md rule 9 — shared-packages policy (this package is the canonical home for filtering UI/logic; do not duplicate in app src/)
  • 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.md rule 3 — entity_type is always 'contact' (or 'organization'); domain classification uses tags
  • 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

License

MIT — see LICENSE.