Executive Summary
End-to-end conversion and the biggest leak across 5 steps
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.
Analysis Overview
Conversion funnel across 5 steps and 2,507 User ID values.
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 Quality
Rows used, the inferred step order, and how missing events were handled.
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.
Users Remaining at Each Step
How many users are still in the flow at each step, in inferred funnel order.
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.
Step-by-Step Conversion
Users, step conversion, cumulative conversion, users lost, and median time to advance.
| Stage Name | Users | Step Conversion PCT | Cumulative Conversion PCT | Dropped | Median Time To Advance |
|---|---|---|---|---|---|
| Landing Page | 2507 | — | 100 | — | n/a |
| Signup Form | 1504 | 60 | 60 | 1003 | 0 sec |
| Email Verified | 1353 | 90 | 54 | 151 | 0 sec |
| Payment Info | 537 | 39.7 | 21.4 | 816 | 0 sec |
| Purchase Complete | 457 | 85.1 | 18.2 | 80 | 0 sec |
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.
Conversion by Segment
The same funnel computed separately for each segment, small segments excluded.
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
Statistical methodology and diagnostics for Conversion Funnel Drop-Off Analysis
Statistical Method
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.
- 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
- 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
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'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_iSlowest 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) <- NULLKeep 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: 'users' is how many reached the step, 'step conversion' is the ",
"share of the previous step's users who continued, 'cumulative conversion' is the share of ",
shared$stages[1], "'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'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(
"'", 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(
"'", 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, ", '", 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)
)
}