Standard Funnel
Executive Summary

Executive Summary

End-to-end conversion and the biggest leak across 5 steps

Users Entering
2507
Steps
5
End-to-End Conversion %
18.2
Biggest Leak
Landing Page -> Signup Form (1,003 users lost)
Worst Step Conversion
Email Verified -> Payment Info (39.7%)
Of the 2,507 User ID values that reach Landing Page, 457 (18.2%) make it all the way to Purchase Complete. The biggest single loss is Landing Page -> Signup Form, where 1,003 User ID values stop — 40% of everyone who entered. The worst-converting step is a different one — Email Verified -> Payment Info, which passes only 39.7% of the people who get that far. It loses a smaller number of User ID values because fewer arrive there, but it is the leakiest step by rate. The slowest transition is Landing Page -> Signup Form, taking a median of 0 sec, and it converts at 60% — a long wait with good conversion is a stall to shorten, not a leak to plug. By Device, 'mobile' converts worst end-to-end (13.7% of 1,375) versus 'desktop' at 25.3%. Caution: the order of Landing Page -> Signup Form, Signup Form -> Email Verified, Email Verified -> Payment Info and Payment Info -> Purchase Complete could not be determined confidently from the timestamps, so the conversion between them may be mis-assigned.
Suggested Interpretation

The short answer

Of 2,507 users entering at Landing Page, 457 (18.2%) reach Purchase Complete. The single biggest dropout is Landing Page → Signup Form, where 1,003 users stop. However, the worst-converting step by rate is Email Verified → Payment Info at 39.7%—a different step. Mobile devices convert worst end-to-end at 13.7% versus desktop at 25.3%.

The detail

Landing Page → Signup Form loses 1,003 users (40% of all entrants) but converts at 60%. Email Verified → Payment Info passes only 39.7% of arrivals, losing 816 users in absolute terms. The median time to advance at Landing Page → Signup Form is 0 sec. By Device, mobile converts at 13.7% of 1,375 users; desktop at 25.3% of 875 users—an 11.6 percentage point gap. Mobile's weakest step is Email Verified → Payment Info at 29.9%. The relative order of the four early transitions (within 30 sec of each other) is not reliably determined by timestamps.

What this can't tell you

The analysis cannot explain why users drop out or why mobile converts worse. Device segments are not randomised, so groups differ in unmeasured ways too.

Overview

Analysis Overview

Conversion funnel across 5 steps and 2,507 User ID values.

N Users2507
N Steps5
End To End Conversion Pct18.2
Suggested Interpretation

The short answer

The funnel tracks 2,507 users across 5 steps inferred from event timing: Landing Page → Signup Form → Email Verified → Payment Info → Purchase Complete. Step order is determined by median time reached; progression is treated as monotonic so missing intermediate events don't create false dropouts. End-to-end conversion is 18.2%.

The detail

The analysis reconstructed step order from Event Time, calculating the median time each step was reached relative to a user's first event. All 2,507 users had logged events at each step up to their furthest point; no back-fill was required. Step conversion measures each step against the prior one; cumulative conversion measures against Landing Page. The four early transitions (Landing Page → Signup Form, Signup Form → Email Verified, Email Verified → Payment Info, Payment Info → Purchase Complete) are reached within 30 sec of each other, making their relative order not reliably determined by timestamps alone—a critical caveat for interpreting the conversion assignments between them.

What this can't tell you

The analysis cannot identify users who never reached Landing Page. It shows where dropouts occur but not why. The ambiguous timestamp ordering means conversion rates assigned to the four early transitions may be mis-assigned if the true user flow differs from the inferred order.

Data Preparation

Data Quality

Rows used, the inferred step order, and how missing events were handled.

Initial Rows6998
Final Rows6998
Rows Removed0
N Users2507
N Steps5
Suggested Interpretation

The short answer

All 6,998 rows were usable; 2,507 distinct users and 5 steps were tracked between 1 March and 30 April 2026. No rows were removed and no back-fill of missing events was needed because every user had a logged event at each step up to their furthest point.

The detail

Every row had a readable Event Time, non-blank User ID, and non-blank Funnel Step. The inferred step order is: Landing Page (2,507 users, median 0 sec after first event), Signup Form (1,504 users, median 0 sec), Email Verified (1,353 users, median 0 sec), Payment Info (537 users, median 0 sec), and Purchase Complete (457 users, median 0 sec). The four transitions from Landing Page through Payment Info are reached within 30 sec, so their relative order cannot be reliably determined from timestamps. Repeat events at the same step were collapsed to the first occurrence.

What this can't tell you

If the inferred step order is wrong, every conversion number is wrong—that order must be verified against the actual user flow. The timestamp ambiguity means conversion rates for the four early transitions may be mis-assigned.

Visualization

Users Remaining at Each Step

How many users are still in the flow at each step, in inferred funnel order.

Suggested Interpretation

The short answer

The funnel shows a sharp cliff at Landing Page → Signup Form (2,507 down to 1,504 users), then a gentler taper through later steps. The visual profile is uneven: one step accounts for most of the absolute loss.

The detail

Landing Page retains 2,507 users; Signup Form drops to 1,504 (a loss of 1,003); Email Verified is 1,353; Payment Info is 537; Purchase Complete is 457. The cliff between Landing Page and Signup Form represents 1,003 users lost in a single transition. Later steps show smaller absolute losses: Email Verified → Payment Info loses 816, Payment Info → Purchase Complete loses 80. Bar length is a count, not a conversion rate—the short bar at Purchase Complete (457) still represents 85.1% step conversion because fewer users arrive there.

What this can't tell you

The chart shows volume lost, not the reason for the loss or whether the dropout is due to friction, abandonment, or user choice. A step with few users and high conversion is not a leak even if its bar is short.

Data Table

Step-by-Step Conversion

Users, step conversion, cumulative conversion, users lost, and median time to advance.

Stage NameUsersStep Conversion PCTCumulative Conversion PCTDroppedMedian Time To Advance
Landing Page2507100n/a
Signup Form1504606010030 sec
Email Verified135390541510 sec
Payment Info53739.721.48160 sec
Purchase Complete45785.118.2800 sec
Suggested Interpretation

The short answer

Landing Page → Signup Form loses the most users in absolute terms (1,003), while Email Verified → Payment Info has the worst conversion rate (39.7%). The median time to advance at Landing Page → Signup Form is 0 sec, and it converts at 60%—a fast step with good conversion is a stall to shorten, not a leak to plug.

The detail

Landing Page: 2,507 users, 100% cumulative conversion. Signup Form: 1,504 users, 60% step conversion, 60% cumulative, 1,003 dropped, 0 sec median advance. Email Verified: 1,353 users, 90% step conversion, 54% cumulative, 151 dropped, 0 sec median advance. Payment Info: 537 users, 39.7% step conversion, 21.4% cumulative, 816 dropped, 0 sec median advance. Purchase Complete: 457 users, 85.1% step conversion, 18.2% cumulative, 80 dropped, 0 sec median advance. All four transitions have 0 sec median time to advance and sufficient users to report.

What this can't tell you

Median time does not measure friction or user experience—it is the clock time between first event at one step and first event at the next. A 0 sec median does not mean the transition is instant for all users.

Visualization

Conversion by Segment

The same funnel computed separately for each segment, small segments excluded.

Suggested Interpretation

The short answer

Mobile users (1,375 entrants) convert worst end-to-end at 13.7%, compared to desktop (875 users) at 25.3%—an 11.6 percentage point gap. Mobile's weakest step is Email Verified → Payment Info at 29.9%. Tablet (242 users) falls between at 18.2%.

The detail

At Landing Page, all three segments start at 100%: desktop 875 users, mobile 1,375 users, tablet 242 users. At Signup Form, all three remain near 60% (desktop 60%, mobile 60%, tablet 59.9%). At Email Verified, all three are near 54% (desktop 53.9%, mobile 54%, tablet 54.1%). At Payment Info, segments diverge: desktop 29.7%, mobile 16.1%, tablet 21.5%. At Purchase Complete: desktop 25.3%, mobile 13.7%, tablet 18.2%. Mobile's steepest drop is Email Verified → Payment Info (from 54% to 16.1%). Smart TV (15 users) is suppressed as too small to report.

What this can't tell you

Segment differences do not explain why mobile converts worse or what drives the gap. Device segments are not randomised, so groups differ in other unmeasured characteristics that may account for the performance difference.

Methodology

Methodology

Statistical methodology and diagnostics for Conversion Funnel Drop-Off Analysis

Statistical Method

Conversion Funnel Drop-Off Analysis

Standard-library analysis: where in your signup, checkout, or onboarding flow you lose people. Upload an event log (one row per user per step) and get the funnel shape, step-by-step conversion and drop-off counts, the biggest leak in absolute users AND the worst-converting step (they are usually different), how long people take to move between steps, and the same funnel broken down by segment. This is the conversion/marketing funnel — user progression through a flow — not the meta-analysis publication-bias chart of the same name.

Data
N = 6998 observations
Assumptions
  • One row per user per step reached (extra repeat events are fine — the first occurrence is used)
  • Every user in the log is attempting the same flow
  • The step order is the same for everyone, and the median time each step is reached reflects that order
  • A user who reached a later step also passed the earlier ones, even if an earlier event is missing from the log
Limitations
  • A funnel shows WHERE users leave, not WHY — it is descriptive, not causal, and fixing a step does not necessarily recover the users lost there
  • The step order is inferred from timing; if two steps happen at nearly the same time the order may be wrong, and the analysis says so when that is the case
  • Users still mid-flow at the end of the log look like drop-offs (right-censoring)
  • Segments with few users produce noisy rates and are suppressed rather than reported
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

Conversion Funnel Drop-Off Analysis — Where Are You Losing People?

Reconstructs a conversion flow (signup, checkout, onboarding) from a raw event log, infers the canonical step order from the data, and quantifies how many users survive each step, where the biggest leak is, and how long people take to advance.

Why This Method?

A conversion funnel is the cheapest way to turn a pile of events into a single actionable question: which ONE step is costing the most customers. Because the step order is inferred from observed timing rather than assumed, the same analysis works on any flow without configuration — and because progression is treated as monotonic, logs with missing intermediate events still produce a coherent funnel.

What This Analysis Covers

  • The funnel shape: users remaining at each step, in inferred order
  • Step-over-step conversion, cumulative conversion, and users lost
  • The biggest ABSOLUTE leak and the WORST-CONVERTING step (rarely the same)
  • Median time to advance between consecutive steps (stalls vs leaks)
  • The same funnel per segment, with small segments suppressed

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {user, stage, timestamp, segment_1..N}. All narrative is derived from the user's own column names and computed values.

Note: this is the CONVERSION funnel (user progression through a flow). It is unrelated to the meta-analysis publication-bias chart that shares the word.

suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))

Epoch seconds (plausible range 1973..2096) — only if essentially all parse

num <- suppressWarnings(as.numeric(s))
  if (n_nonblank > 0 && sum(!is.na(num)) >= 0.95 * n_nonblank &&
      all(is.na(num) | (num > 1e8 & num < 4e9))) {
    return(as.POSIXct(num, origin = "1970-01-01", tz = "UTC"))
  }

Date-time formats first: strptime tolerates trailing text, so a bare "%Y-%m-%d" would silently swallow date-times and zero out the clock.

formats <- c(
    "%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M",
    "%Y/%m/%d %H:%M:%S", "%m/%d/%Y %H:%M:%S", "%m/%d/%Y %H:%M",
    "%d/%m/%Y %H:%M", "%b %d, %Y %H:%M",
    "%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y",
    "%m-%d-%Y", "%d-%m-%Y", "%d.%m.%Y", "%b %d, %Y", "%d %b %Y", "%Y%m%d"
  )
  best <- as.POSIXct(rep(NA_real_, length(s)), origin = "1970-01-01", tz = "UTC")
  best_n <- 0L
  for (f in formats) {
    d <- suppressWarnings(as.POSIXct(s, format = f, tz = "UTC"))
    n <- sum(!is.na(d))
    if (n > best_n) { best_n <- n; best <- d }
  }
  best
}

Core Analysis Pipeline

compute_shared <- function(df, params, col_map = list()) {
  # === SHARED EXPORTS ===
  #   initial_rows/final_rows/rows_removed  $ row accounting
  #   user_h / stage_h / time_h  $ humanized user column names
  #   n_users                    $ distinct users in the funnel
  #   stages                     $ character — step names in INFERRED funnel order
  #   users_at                   $ integer — users reaching each step (monotonic)
  #   raw_at                     $ integer — users with an actual event at each step
  #   funnel_df                  $ data.frame(stage_name, users) — chart, funnel order
  #   step_df                    $ data.frame(stage_name, users, step_conversion_pct,
  #                                cumulative_conversion_pct, dropped, median_time_to_advance)
  #   end_to_end_pct             $ numeric — last step / first step
  #   worst_abs_*                $ biggest ABSOLUTE leak (step label, users lost)
  #   worst_rel_*                $ worst RELATIVE step (step label, conversion %)
  #   ambiguous_pairs            $ character — adjacent steps too close in timing to order confidently
  #   backfilled_users           $ integer — users counted at a step with no logged event there
  #   seg_cols / seg_summ / segment_df / seg_primary_h / seg_suppressed
  #   metrics / json_output
  # === /SHARED EXPORTS ===

  MAX_STAGES     <- 12L   # keep at most this many steps (largest by reach)
  MIN_USERS      <- 30L   # refuse below this — percentages would be noise
  MIN_SEG_USERS  <- 30L   # suppress segment levels smaller than this
  MAX_SEG_LEVELS <- 6L    # chart at most this many segment levels
  MIN_PAIRS_TIME <- 5L    # min users needed to report a median time-to-advance

  initial_rows <- nrow(df)
  user_h  <- humanize_semantic("user", col_map)
  stage_h <- humanize_semantic("stage", col_map)
  time_h  <- humanize_semantic("timestamp", col_map)

Step 1: Validate the mapping

need <- c("user", "stage", "timestamp")
  miss <- setdiff(need, names(df))
  if (length(miss) > 0) {
    stop(sprintf(paste0("Conversion funnel analysis needs three columns mapped: the person or session(%s), ",
                        "the step they reached(%s), and when they reached it(%s). Missing: %s."),
                 user_h, stage_h, time_h,
                 and_list(humanize_semantic(miss, col_map))))
  }

  u  <- trimws(as.character(df$user))
  st <- trimws(as.character(df$stage))
  raw_t <- df$timestamp
  ts <- parse_event_times(raw_t)
  n_nonblank_t <- sum(!is.na(raw_t) & trimws(as.character(raw_t)) != "")
  if (n_nonblank_t == 0 || sum(!is.na(ts)) < 0.95 * n_nonblank_t) {
    stop(sprintf(paste0("The %s column could not be read as dates or times — expected values like ",
                        "2026-03-04 09:12:00, 2026-03-04, or 3/4/2026. The step order of the funnel ",
                        "is inferred from it, so it cannot be skipped."), time_h))
  }

  keep <- !is.na(ts) & !is.na(u) & u != "" & !is.na(st) & st != ""
  rows_bad <- initial_rows - sum(keep)
  seg_cols <- grep("^segment_[0-9]+$", names(df), value = TRUE)
  seg_cols <- seg_cols[order(as.integer(sub("^segment_", "", seg_cols)))]
  seg_raw <- if (length(seg_cols) > 0) df[keep, seg_cols, drop = FALSE] else NULL

  ev <- data.frame(user = u[keep], stage = st[keep],
                   t = as.numeric(ts[keep]), stringsAsFactors = FALSE)
  if (nrow(ev) < 2) {
    stop(sprintf("Too few usable rows after cleaning — check %s, %s and %s for blanks.",
                 user_h, stage_h, time_h))
  }
  date_min <- min(ev$t); date_max <- max(ev$t)

Step 2: Cap the number of steps (keep the most-reached ones)

raw_reach_all <- sapply(split(ev$user, ev$stage), function(x) length(unique(x)))
  dropped_stages <- character(0)
  if (length(raw_reach_all) > MAX_STAGES) {
    keep_st <- names(sort(raw_reach_all, decreasing = TRUE))[seq_len(MAX_STAGES)]
    dropped_stages <- setdiff(names(raw_reach_all), keep_st)
    in_scope <- ev$stage %in% keep_st
    ev <- ev[in_scope, , drop = FALSE]
    if (!is.null(seg_raw)) seg_raw <- seg_raw[in_scope, , drop = FALSE]
  }

  n_stages <- length(unique(ev$stage))
  if (n_stages < 2) {
    stop(sprintf(paste0("A conversion funnel needs at least two distinct steps, but %s contains only ",
                        "%d(%s). Map the column that names each step of the flow."),
                 stage_h, n_stages, and_list(unique(ev$stage))))
  }

Step 3: First arrival per (user, step); per-user offsets

ord <- order(ev$t)                       # chronological; the SAME order is
  ev <- ev[ord, , drop = FALSE]            # applied to the segment columns so
  if (!is.null(seg_raw)) {                 # they stay aligned row-for-row
    seg_raw <- seg_raw[ord, , drop = FALSE]
  }
  ukey <- paste0(ev$user, "\r", ev$stage)
  fo <- ev[!duplicated(ukey), , drop = FALSE]           # first arrival per user+step
  user_first <- tapply(fo$t, fo$user, min)              # named: user -> first event time
  fo$off <- fo$t - as.numeric(user_first[fo$user])

  n_users <- length(user_first)
  if (n_users < MIN_USERS) {
    stop(sprintf(paste0("Only %d distinct %s values were found; a funnel needs at least %d for the ",
                        "conversion percentages to mean anything. Supply a longer event log."),
                 n_users, user_h, MIN_USERS))
  }

Step 4: Infer the step order

Primary key: the median time after a user's FIRST event at which each step is reached. This is robust to users arriving on different calendar days. Steps whose medians are within a tolerance are treated as tied and ordered by median absolute clock time, then by how many users reached them.

med_off <- tapply(fo$off, fo$stage, median)
  med_abs <- tapply(fo$t,   fo$stage, median)
  raw_cnt <- table(fo$stage)
  stats_df <- data.frame(
    stage = names(med_off),
    med_off = as.numeric(med_off),
    med_abs = as.numeric(med_abs[names(med_off)]),
    cnt = as.integer(raw_cnt[names(med_off)]),
    stringsAsFactors = FALSE
  )
  span <- max(stats_df$med_off) - min(stats_df$med_off)
  tol <- max(30, 0.01 * span)   # seconds

  stats_df <- stats_df[order(stats_df$med_off), , drop = FALSE]
  grp <- integer(nrow(stats_df)); grp[1] <- 1L; anchor <- stats_df$med_off[1]
  if (nrow(stats_df) > 1) {
    for (i in 2:nrow(stats_df)) {
      if (stats_df$med_off[i] - anchor > tol) {
        grp[i] <- grp[i - 1] + 1L; anchor <- stats_df$med_off[i]
      } else grp[i] <- grp[i - 1]
    }
  }
  stats_df$grp <- grp
  stats_df <- stats_df[order(stats_df$grp, stats_df$med_abs, -stats_df$cnt), , drop = FALSE]
  stages <- stats_df$stage
  k <- length(stages)

Ambiguity: consecutive steps whose median timing is inside the tolerance

ambiguous_pairs <- character(0)
  if (k > 1) {
    d <- diff(stats_df$med_off)
    amb <- which(abs(d) <= tol)
    if (length(amb) > 0) {
      ambiguous_pairs <- paste0(stages[amb], " -> ", stages[amb + 1L])
    }
  }

Step 5: Refuse an incoherent funnel rather than draw a nonsense chart

raw_at <- as.integer(raw_cnt[stages])
  if (k > 1 && raw_at[1] < max(raw_at[-1])) {
    worst_i <- which.max(raw_at[-1]) + 1L
    stop(sprintf(paste0("The step inferred as first(\"%s\") was reached by only %s distinct %s values, ",
                        "but the later step \"%s\" was reached by %s. Either the log is missing early ",
                        "events or the step order could not be inferred from %s, so a funnel would be ",
                        "misleading. Check that every user&#x27;s entry step is logged."),
                 stages[1], format(raw_at[1], big.mark = ","), user_h,
                 stages[worst_i], format(raw_at[worst_i], big.mark = ","), time_h))
  }

Step 6: Monotonic progression — a user at step j counts at steps 1..j

wt <- tapply(fo$t, list(fo$user, fo$stage), min)      # users x steps, NA = never reached
  wt <- wt[, stages, drop = FALSE]
  reached <- !is.na(wt)
  furthest <- apply(reached, 1, function(r) max(which(r)))
  users_at <- sapply(seq_len(k), function(i) sum(furthest >= i))
  backfilled_users <- sum(users_at - raw_at)

Step 7: Step-by-step conversion, drop-off and time to advance

step_conv <- c(NA_real_, round(100 * users_at[-1] / users_at[-k], 1))
  cum_conv  <- round(100 * users_at / users_at[1], 1)
  dropped   <- c(NA_integer_, as.integer(users_at[-k] - users_at[-1]))

  med_advance <- rep(NA_real_, k)
  n_out_of_order <- 0L
  if (k > 1) {
    for (i in 2:k) {
      d <- wt[, i] - wt[, i - 1]
      d <- d[!is.na(d)]
      n_out_of_order <- n_out_of_order + sum(d < 0)
      d <- d[d >= 0]
      if (length(d) >= MIN_PAIRS_TIME) med_advance[i] <- as.numeric(median(d))
    }
  }

  funnel_df <- data.frame(stage_name = stages, users = as.integer(users_at),
                          stringsAsFactors = FALSE)
  step_df <- data.frame(
    stage_name = stages,
    users = as.integer(users_at),
    step_conversion_pct = step_conv,
    cumulative_conversion_pct = cum_conv,
    dropped = dropped,
    median_time_to_advance = sapply(med_advance, fmt_duration),
    stringsAsFactors = FALSE
  )

  end_to_end_pct <- cum_conv[k]

Biggest ABSOLUTE leak and WORST RELATIVE step — NA-safe index selection

worst_abs_label <- worst_rel_label <- NA_character_
  worst_abs_lost <- NA_integer_; worst_rel_conv <- NA_real_
  worst_abs_i <- worst_rel_i <- NA_integer_
  cand <- which(!is.na(dropped))
  if (length(cand) > 0) {
    worst_abs_i <- cand[which.max(dropped[cand])]
    worst_abs_label <- paste0(stages[worst_abs_i - 1L], " -> ", stages[worst_abs_i])
    worst_abs_lost <- as.integer(dropped[worst_abs_i])
  }
  candr <- which(!is.na(step_conv))
  if (length(candr) > 0) {
    worst_rel_i <- candr[which.min(step_conv[candr])]
    worst_rel_label <- paste0(stages[worst_rel_i - 1L], " -> ", stages[worst_rel_i])
    worst_rel_conv <- step_conv[worst_rel_i]
  }
  same_step <- !is.na(worst_abs_i) && !is.na(worst_rel_i) && worst_abs_i == worst_rel_i

Slowest step to advance (a stall, not necessarily a leak)

slow_label <- NA_character_; slow_secs <- NA_real_; slow_i <- NA_integer_
  cands <- which(!is.na(med_advance))
  if (length(cands) > 0) {
    slow_i <- cands[which.max(med_advance[cands])]
    slow_label <- paste0(stages[slow_i - 1L], " -> ", stages[slow_i])
    slow_secs <- med_advance[slow_i]
  }

Step 8: Segment breakdown (optional mapping)

seg_summ <- list()          # per mapped segment column: findings
  segment_df <- NULL          # chart dataset (primary segment column only)
  seg_primary_h <- NA_character_
  seg_suppressed <- character(0)
  if (length(seg_cols) > 0 && !is.null(seg_raw)) {
    users_vec <- rownames(wt)

Per-user segment value = the value on the user's earliest event row

first_row_of_user <- !duplicated(ev$user)
    for (sc in seg_cols) {
      sc_h <- humanize_semantic(sc, col_map)
      vals_all <- trimws(as.character(seg_raw[[sc]]))
      vals_all[is.na(vals_all) | vals_all == ""] <- "Missing"
      per_user <- setNames(vals_all[first_row_of_user], ev$user[first_row_of_user])
      lv <- per_user[users_vec]
      lv[is.na(lv)] <- "Missing"

      tab <- table(lv)
      big <- names(tab)[tab >= MIN_SEG_USERS]
      small <- names(tab)[tab < MIN_SEG_USERS]

Cap the number of charted levels — keep the largest

if (length(big) > MAX_SEG_LEVELS) {
        big <- names(sort(tab[big], decreasing = TRUE))[seq_len(MAX_SEG_LEVELS)]
      }
      if (length(small) > 0) {
        seg_suppressed <- c(seg_suppressed, sprintf("%s: %s", sc_h,
          and_list(sprintf("%s(%d users)", small, as.integer(tab[small])))))
      }
      if (length(big) == 0) {
        seg_summ[[sc]] <- list(col_h = sc_h, ok = FALSE, levels = character(0))
        next
      }

      rows <- list(); lev_worst <- list()
      for (L in big) {
        idx <- which(lv == L)
        f_l <- furthest[idx]
        ua <- sapply(seq_len(k), function(i) sum(f_l >= i))
        cum_l <- round(100 * ua / ua[1], 1)
        sc_l <- c(NA_real_, round(100 * ua[-1] / ua[-k], 1))
        rows[[L]] <- data.frame(stage_name = stages, segment_value = L,
                                users = as.integer(ua), cumulative_pct = cum_l,
                                stringsAsFactors = FALSE)
        ci <- which(!is.na(sc_l))
        if (length(ci) > 0) {
          wi <- ci[which.min(sc_l[ci])]
          lev_worst[[L]] <- list(level = L, end_pct = cum_l[k],
                                 worst_step = paste0(stages[wi - 1L], " -> ", stages[wi]),
                                 worst_conv = sc_l[wi], n = as.integer(ua[1]))
        }
      }
      dfl <- do.call(rbind, rows); rownames(dfl) <- NULL

Keep the chart in funnel order, then by segment

dfl <- dfl[order(match(dfl$stage_name, stages), dfl$segment_value), , drop = FALSE]
      end_v <- sapply(lev_worst, function(z) z$end_pct)
      worst_lv <- best_lv <- NULL
      if (length(end_v) > 0) {
        okv <- which(!is.na(end_v))
        if (length(okv) > 0) {
          worst_lv <- lev_worst[[okv[which.min(end_v[okv])]]]
          best_lv  <- lev_worst[[okv[which.max(end_v[okv])]]]
        }
      }
      seg_summ[[sc]] <- list(col_h = sc_h, ok = TRUE, levels = big,
                             df = dfl, worst = worst_lv, best = best_lv,
                             n_levels = length(big))
      if (is.null(segment_df)) {
        segment_df <- dfl[, c("stage_name", "segment_value", "cumulative_pct", "users")]
        seg_primary_h <- sc_h
      }
    }
  }

  final_rows <- nrow(ev)
  rows_removed <- initial_rows - final_rows

  metrics <- list(
    `Users Entering`          = as.integer(users_at[1]),
    `Steps`                   = k,
    `End-to-End Conversion %` = end_to_end_pct,
    `Biggest Leak`            = if (!is.na(worst_abs_label))
      sprintf("%s(%s users lost)", worst_abs_label, format(worst_abs_lost, big.mark = ",")) else "n/a",
    `Worst Step Conversion`   = if (!is.na(worst_rel_label))
      sprintf("%s(%.1f%%)", worst_rel_label, worst_rel_conv) else "n/a"
  )

  json_output <- list(
    answer = paste0(
      "Conversion funnel over ", format(n_users, big.mark = ","), " ", user_h,
      " values and ", k, " steps(", and_list(stages), "): ",
      format(users_at[1], big.mark = ","), " enter and ",
      format(users_at[k], big.mark = ","), " reach ", stages[k],
      ", an end-to-end conversion of ", end_to_end_pct, "%. ",
      if (!is.na(worst_abs_label)) paste0(
        "The largest single loss is ", worst_abs_label, " (",
        format(worst_abs_lost, big.mark = ","), " users). ") else "",
      if (!is.na(worst_rel_label)) paste0(
        "The worst-converting step is ", worst_rel_label, " at ", worst_rel_conv, "%",
        if (same_step) " — the same step" else " — a different step", ". ") else "",
      "A funnel shows where users leave, not why."
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "funnel_shape",
        "step_breakdown", "segment_funnel"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows = initial_rows, final_rows = final_rows,
    rows_removed = rows_removed, rows_bad = rows_bad,
    user_h = user_h, stage_h = stage_h, time_h = time_h,
    n_users = n_users, stages = stages, k = k,
    users_at = as.integer(users_at), raw_at = raw_at,
    backfilled_users = as.integer(backfilled_users),
    dropped_stages = dropped_stages,
    funnel_df = funnel_df, step_df = step_df,
    step_conv = step_conv, cum_conv = cum_conv, dropped = dropped,
    med_advance = med_advance, n_out_of_order = as.integer(n_out_of_order),
    end_to_end_pct = end_to_end_pct,
    worst_abs_label = worst_abs_label, worst_abs_lost = worst_abs_lost,
    worst_rel_label = worst_rel_label, worst_rel_conv = worst_rel_conv,
    same_step = same_step,
    slow_label = slow_label, slow_secs = slow_secs, slow_i = slow_i,
    ambiguous_pairs = ambiguous_pairs, tol = tol,
    med_off = setNames(stats_df$med_off, stats_df$stage),
    date_min = as.POSIXct(date_min, origin = "1970-01-01", tz = "UTC"),
    date_max = as.POSIXct(date_max, origin = "1970-01-01", tz = "UTC"),
    seg_cols = seg_cols, seg_summ = seg_summ, segment_df = segment_df,
    seg_primary_h = seg_primary_h, seg_suppressed = seg_suppressed,
    min_seg_users = MIN_SEG_USERS,
    metrics = metrics, json_output = json_output
  )
}

Describe the shape from the data rather than asserting a canned pattern

shape_word <- {
    dd <- drops[!is.na(drops)]
    if (length(dd) == 0) "flat"
    else if (max(dd) >= 0.5 * sum(dd)) "dominated by one step — most of the loss happens in a single place"
    else if (max(dd) <= 1.6 * (sum(dd) / length(dd))) "an even taper — losses are spread across the steps"
    else "uneven — a few steps carry most of the loss"
  }
  list(
    title = "Users Remaining at Each Step",
    description = "How many users are still in the flow at each step, in inferred funnel order.",
    text = paste0(
      "Each bar is the number of ", shared$user_h, " values that reached that step, from ",
      format(shared$users_at[1], big.mark = ","), " at ", shared$stages[1], " down to ",
      format(shared$users_at[shared$k], big.mark = ","), " at ", shared$stages[shared$k], ". ",
      "The profile is ", shape_word, ". ", biggest_txt,
      "Bar length is a count, not a rate — a short bar late in the funnel can still represent ",
      "good conversion among the people who got there. Read this chart together with the ",
      "step-by-step table."
    ),
    chart_labels = list(
      stage_name = paste0("Step(", shared$stage_h, ")"),
      users = paste0("Distinct ", shared$user_h, " values reaching the step")
    ),
    data = list(funnel_stages = shared$funnel_df)
  )
}

# Card: step_breakdown (table)
card_step_breakdown <- function(shared, df, params) {
  n_timed <- sum(!is.na(shared$med_advance))
  stall_txt <- if (!is.na(shared$slow_label)) {
    paste0("The longest wait is ", shared$slow_label, " at a median of ",
           fmt_duration(shared$slow_secs),
           "; if that step also converts well, the delay is a stall to shorten rather than a leak to plug. ")
  } else ""
  compare_txt <- if (!is.na(shared$worst_abs_label) && !is.na(shared$worst_rel_label)) {
    if (shared$same_step) {
      paste0("The biggest loss by count and the worst conversion rate are the same step, ",
             shared$worst_abs_label, " (", format(shared$worst_abs_lost, big.mark = ","),
             " lost, ", shared$worst_rel_conv, "% pass). ")
    } else {
      paste0("The two views disagree, which is the useful part: ", shared$worst_abs_label,
             " loses the most people(", format(shared$worst_abs_lost, big.mark = ","),
             "), while ", shared$worst_rel_label, " has the worst pass rate(",
             shared$worst_rel_conv, "%). The first is the bigger prize in volume; the second is ",
             "the more broken experience. ")
    }
  } else ""
  list(
    title = "Step-by-Step Conversion",
    description = "Users, step conversion, cumulative conversion, users lost, and median time to advance.",
    text = paste0(
      "Reading down the table: &#x27;users' is how many reached the step, 'step conversion' is the ",
      "share of the previous step&#x27;s users who continued, 'cumulative conversion' is the share of ",
      shared$stages[1], "&#x27;s users still present, and 'dropped' is the absolute number lost at that ",
      "step. ", compare_txt,
      "Median time to advance is shown for ", n_timed, " of ", max(0, shared$k - 1),
      " transitions(a transition needs enough users with a logged event at both steps). ", stall_txt,
      "Blank cells in the first row are expected: there is no previous step to convert from."
    ),
    data = list(step_breakdown = shared$step_df)
  )
}

# Card: segment_funnel (grouped_bar)
card_segment_funnel <- function(shared, df, params) {
  if (length(shared$seg_cols) == 0 || is.null(shared$segment_df)) {
    reason <- if (length(shared$seg_cols) == 0) {
      paste0("No segment column was mapped, so there is nothing to compare. Map a column such as ",
             "device, channel or plan to see which audience falls out at which step.")
    } else {
      paste0("A segment column was mapped, but no level of it had at least ", shared$min_seg_users,
             " ", shared$user_h, " values, so every breakdown would have been noise. ",
             "Reporting a confident percentage from a handful of users would be misleading, so it is omitted.")
    }
    return(list(
      title = "Conversion by Segment",
      description = "Funnel conversion broken down by segment.",
      text = reason,
      data = list()
    ))
  }
  s1 <- shared$seg_summ[[1]]
  parts <- character()
  parts <- c(parts, paste0(
    "Each group of bars is one step of the funnel, with one bar per ", s1$col_h,
    " value, showing the share of that segment&#x27;s entrants still present. ",
    s1$n_levels, " level(s) of ", s1$col_h, " had at least ", shared$min_seg_users,
    " ", shared$user_h, " values and are shown."))
  if (!is.null(s1$worst)) {
    parts <- c(parts, paste0(
      "&#x27;", s1$worst$level, "' converts worst end-to-end (", s1$worst$end_pct, "% of ",
      format(s1$worst$n, big.mark = ","), " entrants), and its weakest step is ",
      s1$worst$worst_step, " at ", s1$worst$worst_conv, "%."))
  }
  if (!is.null(s1$best) && !is.null(s1$worst) && !identical(s1$best$level, s1$worst$level)) {
    parts <- c(parts, paste0(
      "&#x27;", s1$best$level, "' is the strongest at ", s1$best$end_pct,
      "% end-to-end, a gap of ", round(s1$best$end_pct - s1$worst$end_pct, 1),
      " percentage points."))
  }

Additional mapped segment columns: report their findings even though the chart shows only the first.

if (length(shared$seg_summ) > 1) {
    extra <- character()
    for (nm in names(shared$seg_summ)[-1]) {
      z <- shared$seg_summ[[nm]]
      if (isTRUE(z$ok) && !is.null(z$worst)) {
        extra <- c(extra, paste0("by ", z$col_h, ", &#x27;", z$worst$level, "' is weakest (",
                                 z$worst$end_pct, "% end-to-end, worst step ",
                                 z$worst$worst_step, " at ", z$worst$worst_conv, "%)"))
      }
    }
    if (length(extra) > 0) {
      parts <- c(parts, paste0("The chart shows ", s1$col_h,
                               "; the other mapped breakdowns give: ", and_list(extra), "."))
    }
  }
  if (length(shared$seg_suppressed) > 0) {
    parts <- c(parts, paste0("Suppressed as too small to report(under ", shared$min_seg_users,
                             " users) — ", and_list(shared$seg_suppressed), "."))
  }
  parts <- c(parts, paste0("Segment differences show where audiences diverge; they do not explain it, ",
                           "and segments are not randomised, so the groups differ in other ways too."))
  list(
    title = "Conversion by Segment",
    description = "The same funnel computed separately for each segment, small segments excluded.",
    text = paste(parts, collapse = " "),
    chart_labels = list(
      stage_name = paste0("Step(", shared$stage_h, ")"),
      cumulative_pct = "Share of that segment still present(%)",
      segment_value = shared$seg_primary_h
    ),
    data = list(segment_funnel = shared$segment_df)
  )
}
Your data has more stories to tell. Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No Signup Sign Up Free

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing