DEV Community

Isaiah Kim
Isaiah Kim

Posted on

Ranking a dashboard by funnel depth, not counts

I had a metrics page that told me nothing. Page views per host, signups, a couple of Stripe lines, all rendered as cards in a grid. Every morning I'd look at it, feel vaguely informed, and then go build whatever I'd already decided to build the night before. That's the tell: if the dashboard never changes what you do next, it isn't a dashboard, it's decoration.

So I rewrote it around one question — how far do people actually get — and let that ordering decide what I see first.

The ordering is the product

Kynth Studios ships a lot of separate products on separate hosts: FetchDue chasing overdue invoices for agencies, BenchFile doing NYC Local Law 84 filings, CertScope determining CPSC scope per SKU, ParseRail for developers integrating against the API. They don't share a funnel shape in any interesting way, but they do share a funnel depth: someone lands, someone starts the demo, someone finishes the guided run, someone starts signup, someone finishes it, someone activates.

Depth is the only axis where those products are comparable. A traffic number tells me a host got attention. A depth number tells me where attention stopped.

So the model is a stage list plus a rollup per product:

// stages are ordered; index IS the depth
const STAGES = [
  'demo_start',
  'demo_complete',
  'signup_start',
  'signup_complete',
  'activated',
] as const

type Stage = (typeof STAGES)[number]

type ProductFunnel = {
  slug: string
  counts: Record<Stage, number>
}

// deepest stage a product has ANY reach into
function depth(f: ProductFunnel): number {
  for (let i = STAGES.length - 1; i >= 0; i--) {
    if (f.counts[STAGES[i]] > 0) return i
  }
  return -1
}

// the first stage that eats everything
function stall(f: ProductFunnel): Stage | null {
  for (let i = 1; i < STAGES.length; i++) {
    const prev = f.counts[STAGES[i - 1]]
    if (prev > 0 && f.counts[STAGES[i]] === 0) return STAGES[i]
  }
  return null
}
Enter fullscreen mode Exit fullscreen mode

depth sorts the list. stall is the label on each row. Everything else on the page is subordinate to those two functions.

Ranking a dashboard by funnel depth, not counts — code

Vanity metrics are the ones with no stage below them

The rule I ended up with for what stays on the page: a metric earns its spot if there's a next stage it's supposed to feed. Page views feed demo starts. Demo starts feed completions. Completions feed signup starts.

Page views on their own feed nothing I control, so they came off. Same with a top-level "total events" number, which mostly moved when I deployed something. Anything that only ever goes up and never implies an action is a metric you can watch forever without learning anything.

The one that hurt to cut was a combined cross-product total. It looked healthy. It was healthy — it just averaged a product where people finish the demo together with one where nobody gets past the landing page, and the average was never the thing I needed to know. Now every row is per-product and the sort does the summarizing.

Keep the data engine away from the rendering

The part I'd do again on any dashboard: the thing that computes stages, predicates, sorts and rollups is framework-agnostic and knows nothing about React. It takes events, returns ProductFunnel[] sorted by depth. The components take that array and draw it.

That split has paid off more than once. When a number looks wrong, the first question is which half is wrong — and because the contract between them is a plain typed object, I can answer it by calling the rollup in a test and reading the output. A rendering bug costs nothing on the engine side. A rollup bug is visible without a browser.

It also means the same rollup feeds a morning summary that isn't a web page at all. Same functions, different renderer.

What the page actually looks like now

One list. Products sorted deepest-first, each row showing its stall stage and the counts on either side of it. FetchDue near the top because there's a one-click live demo with no signup form in front of it, so people reach the guided run. The compliance products — BenchFile, GoodStanding, DoseTrace — sit lower, because a done-for-you filing service has a different entry shape and the interesting stage is further along.

That ordering is doing real work. Last week it put a product at the bottom of the list whose landing page I thought was fine, with a stall at demo_start — plenty of people arriving, nobody starting. That's a specific bug in a specific place, which is more than the old grid ever gave me.

FetchDue has no customers yet and the dashboard says so; the honest zero is on the page next to a link to the live product and a recorded run. I'd rather look at a zero I can locate than a total I can't.

The bit I'd tell someone rebuilding theirs

Pick the axis before you pick the charts. I spent longer than I should have arranging cards, and none of that time mattered, because the layout question and the ranking question are different questions and only one of them changes what you build tomorrow.

Sorting by depth also has a property I didn't expect: the top of the list is the least urgent thing. The products getting people deepest need the least attention. Reading the page bottom-up is the actual workflow, which means the sort could arguably be reversed — I've left it deepest-first because I want the ceiling visible, and I go to the bottom on purpose.

Top comments (0)