FunnelAnalyzer
Step counts to drop-off, conversion and the biggest leak, with a what-if on any step carried through to the end, and a warning when a step grows.
Demo
Store checkout
| Step | People | Kept | Lost | Of the first | Remove |
|---|---|---|---|---|---|
| 100.0% | |||||
| 60.0% | 4,000 | 60.0% | |||
| 30.0% | 4,200 | 18.0% | |||
| 50.0% | 900 | 9.0% | |||
| 70.0% | 270 | 6.3% |
The biggest leak is Viewed a product to Added to cart: 4,200 people lost, 70.0% of those who got that far. 6.3% of Visited reach Paid.
Sign-up
| Step | People | Kept | Lost | Of the first | Remove |
|---|---|---|---|---|---|
| 100.0% | |||||
| 37.5% | 500 | 37.5% | |||
| 80.0% | 60 | 30.0% |
The biggest leak is Landed to Started: 500 people lost, 62.5% of those who got that far. 30.0% of Landed reach Confirmed.
Change a figure to see on_change.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/funnel_analyzer.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/funnel_analyzer.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/funnel_analyzer.qwe
let funnel_analyzer_demo_steps = [{ name: "Visited", count: 10000 }, { name: "Viewed a product", count: 6000 }, { name: "Added to cart", count: 1800 }, { name: "Checkout", count: 900 }, { name: "Paid", count: 630 }]component FunnelAnalyzerDemoLive() { state seen = "Change a figure to see on_change." <div.demo-stack> <FunnelAnalyzer id="funnel-analyzer-demo-live" title="Sign-up" name="signup" steps={[{ name: "Landed", count: 800 }, { name: "Started", count: 300 }, { name: "Confirmed", count: 240 }]} on_change={(f) => { seen = f == null ? "on_change: a count is wrong." : `on_change: ${f.end} at the end, ${round(f.overall, 1, "half_up")}%, leak ${join(f.leak, " and ")}, what-if ${f.what_if ?? "none"}.` }}/> <p.demo-note>{seen}</p> </div>}view FunnelAnalyzerDemo() = <div.demo-stack> <FunnelAnalyzer id="funnel-analyzer-demo" title="Store checkout" steps={funnel_analyzer_demo_steps}/> <FunnelAnalyzerDemoLive/> </div>Source
ui/funnel_analyzer.qwe
// FunnelAnalyzer: step counts to a funnel that explains itself: what each step keeps// and loses, the biggest leak, and a what-if on any step that carries a better rate// through to the end, the steps after it keeping the rates they have. A step that// grows is warned about, since a funnel can only lose people.//// <FunnelAnalyzer steps={[{ name: "Visited", count: 10000 }, { name: "Cart", count: 1800 }, { name: "Paid", count: 630 }]}/>// <FunnelAnalyzer title="Sign-up" steps={steps} on_change={(f) => { end = f.end }}/>//// Rates are decimals and each what-if count is multiplied before it is divided, so a// rate of 40% on 4,500 is 1,800 and nothing drifts; people are rounded half up once,// at the end. Counts go up to 999,999,999,999.//// steps: the steps in order, each { name, count }; the reader can edit, add and remove// them (up to 30). title: the heading ("Funnel"). id: a prefix for its fields' ids// ("funnel-analyzer"), unique on the page. Each step's fields are named step_name and// step_count, once for each step in order, so a form around it gets them as lists;// the what-if's are what_if_step and what_if_rate; name: a prefix for those names.// on_change(result): inside one of your components, given { counts, end, overall,// leak, what_if } after each change, or null while a count is wrong: overall is a// decimal percentage, leak the leaking steps' names, what_if the end with the what-if// applied, or null.//// funnel_analyzer_rows(counts), funnel_analyzer_leak(rows) and// funnel_analyzer_what_if(counts, at, rate) are the arithmetic, for a program's own// use: rows are { count, lost, rate, overall, grew }, rate and overall being decimal// percentages (null where the step before held nobody), and what_if gives// { counts, end, gain, gain_rate } or { error }.import ui/theme.qwe use ui_clslet funnel_analyzer_css = css`.ui-funnel-analyzer { display: grid; gap: 14px; min-width: 0; max-width: 760px; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-funnel-analyzer .ui-funnel-analyzer-title { display: block; margin: 0; font-size: 16px; font-weight: 650; }.ui-funnel-analyzer-scroll { position: relative; min-width: 0; overflow-x: auto; }.ui-funnel-analyzer-scroll:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-funnel-analyzer-table { width: 100%; min-width: 560px; border-collapse: collapse; font-variant-numeric: tabular-nums; }.ui-funnel-analyzer-table th { padding: 6px 8px; border-bottom: 1px solid var(--ui-border); color: var(--ui-muted); font-size: 12px; font-weight: 600; text-align: right; white-space: nowrap; }.ui-funnel-analyzer-table th:first-child { text-align: left; }.ui-funnel-analyzer-table td { padding: 6px 8px; border-bottom: 1px solid var(--ui-border); text-align: right; white-space: nowrap; }.ui-funnel-analyzer-table td:first-child { text-align: left; }.ui-funnel-analyzer-input { box-sizing: border-box; width: 100%; min-width: 0; height: 32px; padding: 0 8px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); font-variant-numeric: tabular-nums; }.ui-funnel-analyzer-input:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-funnel-analyzer-input[aria-invalid] { border-color: var(--ui-danger); }.ui-funnel-analyzer-count { width: 110px; text-align: right; }.ui-funnel-analyzer-track { display: block; height: 4px; margin-top: 5px; border-radius: 2px; background: var(--ui-subtle); overflow: hidden; }.ui-funnel-analyzer-fill { display: block; height: 100%; background: var(--ui-accent); }.ui-funnel-analyzer-leaky td { background: var(--ui-danger-soft); }.ui-funnel-analyzer-leaky .ui-funnel-analyzer-fill { background: var(--ui-danger); }.ui-funnel-analyzer-button { min-height: 32px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 13px var(--ui-font); cursor: pointer; }.ui-funnel-analyzer-button:hover:not(:disabled) { border-color: var(--ui-strong); }.ui-funnel-analyzer-button:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-funnel-analyzer-button:disabled { opacity: .55; cursor: not-allowed; }.ui-funnel-analyzer-live { display: grid; gap: 8px; min-width: 0; }.ui-funnel-analyzer-leak { margin: 0; padding: 10px 14px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); font-weight: 600; }.ui-funnel-analyzer-warn { margin: 0; padding: 8px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-warning-soft); color: var(--ui-warning); font-size: 13px; }.ui-funnel-analyzer-error { margin: 0; color: var(--ui-danger); font-size: 13px; }.ui-funnel-analyzer-whatif { display: grid; gap: 10px; margin: 0; padding: 12px 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); }.ui-funnel-analyzer-whatif legend { padding: 0 6px; font-weight: 600; }.ui-funnel-analyzer-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }.ui-funnel-analyzer-field { display: grid; gap: 4px; min-width: 0; font-size: 13px; }.ui-funnel-analyzer-field .ui-funnel-analyzer-input { height: 36px; }.ui-funnel-analyzer-result { margin: 0; font-weight: 600; }.ui-funnel-analyzer-hint { margin: 0; color: var(--ui-muted); font-size: 13px; }@media (max-width: 480px) { .ui-funnel-analyzer-pair { grid-template-columns: minmax(0, 1fr); } }`let funnel_analyzer_limit = 30// A count as typed: a whole number of 0 or more, commas allowed, else null.fn funnel_analyzer_read(text) { let t = replace(trim(str(text ?? "")), ",", "") re.test(t, "^\\d{1,12}$") ? int(t) : null}// A percentage as typed, as a decimal: 0 to 100, a trailing % allowed, else null.fn funnel_analyzer_rate(text) { let t = replace(trim(str(text ?? "")), "%", "") if !re.test(t, "^\\d{1,3}(\\.\\d{1,6})?$") { return null } let d = dec(t) d > 100 ? null : d}fn funnel_analyzer_group(n: int) -> str { if n < 1000 { return str(n) } let rest = str(n % 1000) `${funnel_analyzer_group(floor(n / 1000))},${repeat("0", 3 - len(rest))}${rest}`}// Whole people, the sign written when asked.fn funnel_analyzer_people(n: int, signed: bool = false) -> str = `${n < 0 ? "-" : (signed && n > 0 ? "+" : "")}${funnel_analyzer_group(abs(n))}`fn funnel_analyzer_pct(d) -> str = d == null ? "None" : `${str(round(d, 1, "half_up"))}%`fn funnel_analyzer_rows(counts: list) -> list = for i in range(len(counts)) { let c = counts[i] let before = i == 0 ? null : counts[i - 1] { count: c, lost: before == null ? 0 : before - c, rate: before == null || before == 0 ? null : dec(c) * 100 / dec(before), overall: counts[0] == 0 ? null : dec(c) * 100 / dec(counts[0]), grew: before != null && c > before, } }// The steps losing the most people, as indexes (several when they tie), and the most lost;// { at: [], lost: 0 } when nobody is lost anywhere.fn funnel_analyzer_leak(rows: list) -> map { let most = max(rows | map((r) => r.lost)) ?? 0 if most <= 0 { return { at: [], lost: 0 } } { at: range(len(rows)) | filter((i) => rows[i].lost == most), lost: most }}// Counts with step `at` converting at `rate` percent, what comes after keeping its rates.fn funnel_analyzer_what_if(counts: list, at: int, rate) -> map { let before = counts[at - 1] let today = counts[at] let last = len(counts) - 1 if today == 0 && at < last { return { error: "Nobody reaches this step today, so the steps after it have no rate to carry the change through." } } let reached = dec(before) * rate / 100 let scaled = for j in range(len(counts)) { j < at ? counts[j] : int(round(j == at ? reached : reached * dec(counts[j]) / dec(today), 0, "half_up")) } let end = scaled[last] let gain = end - counts[last] { counts: scaled, end, gain, gain_rate: counts[last] == 0 ? null : dec(gain) * 100 / dec(counts[last]), error: null }}fn funnel_analyzer_names(steps: list, at: list) -> str { let names = at | map((i) => steps[i]) len(names) == 1 ? names[0] : `${join(slice(names, 0, len(names) - 1), ", ")} and ${names[len(names) - 1]}`}fn funnel_analyzer_start(steps: list) -> list = for i in range(min([len(steps), funnel_analyzer_limit])) { let s = steps[i] { id: i, name: str(s.name ?? ""), text: s.count == null ? "" : str(s.count) } }fn funnel_analyzer_label(s: map, i: int) -> str = filled(trim(s.name)) ?? `Step ${i + 1}`component FunnelAnalyzer(steps: list = [], title: str = "Funnel", id: str = "funnel-analyzer", name: str? = null, on_change = null, class: str? = null) { style global funnel_analyzer_css state rows_in = funnel_analyzer_start(steps) state serial = len(steps) state picked = null state rate_text = "" let named = (n: str) => name != null ? `${name}_${n}` : n let labels = for i in range(len(rows_in)) => funnel_analyzer_label(rows_in[i], i) let reads = rows_in | map((s) => funnel_analyzer_read(s.text)) let wrong = range(len(rows_in)) | filter((i) => reads[i] == null) let enough = len(rows_in) >= 2 let ready = enough && len(wrong) == 0 && reads[0] != 0 let rows = ready ? funnel_analyzer_rows(reads) : [] let leak = ready ? funnel_analyzer_leak(rows) : { at: [], lost: 0 } let grows = range(len(rows)) | filter((i) => rows[i].grew) // What the what-if is on: the step picked, else the leak, else the second step. let at = () => { let i = picked == null ? null : index_of(rows_in | map((s) => s.id), picked) (i != null && i >= 1 ? i : null) ?? first(leak.at) ?? 1 } let at_step = ready ? at() : 1 let rate = trim(rate_text) == "" ? null : funnel_analyzer_rate(rate_text) let rate_bad = trim(rate_text) != "" && rate == null let result = ready && rate != null ? funnel_analyzer_what_if(reads, at_step, rate) : null let applied = result != null && result.error == null let leak_text = () => { let share = funnel_analyzer_pct(dec(leak.lost) * 100 / dec(reads[leak.at[0] - 1])) let from = funnel_analyzer_names(labels, leak.at | map((i) => i - 1)) let into = funnel_analyzer_names(labels, leak.at) len(leak.at) == 1 ? `The biggest leak is ${from} to ${into}: ${funnel_analyzer_people(leak.lost)} people lost, ${share} of those who got that far.` : `The biggest leaks tie: ${into}, each losing ${funnel_analyzer_people(leak.lost)} people.` } let tell = () => { if on_change != null { let r = rows_in | map((s) => funnel_analyzer_read(s.text)) if len(r) < 2 || any(r, (x) => x == null) || r[0] == 0 { on_change(null) } else { let rs = funnel_analyzer_rows(r) let lk = funnel_analyzer_leak(rs) let names = for i in range(len(rows_in)) => funnel_analyzer_label(rows_in[i], i) let p = trim(rate_text) == "" ? null : funnel_analyzer_rate(rate_text) let w = p == null ? null : funnel_analyzer_what_if(r, at(), p) on_change({ counts: r, end: r[len(r) - 1], overall: rs[len(r) - 1].overall, leak: lk.at | map((i) => names[i]), what_if: w == null || w.error != null ? null : w.end }) } } } let edit = (sid: int, field: str, text: str) => { rows_in = rows_in | map((s) => s.id == sid ? set(s, field, text) : s) tell() } let add = () => { rows_in = push(rows_in, { id: serial, name: "", text: "" }) serial += 1 tell() } let drop = (e, sid: int) => { let box = e.target.closest(".ui-funnel-analyzer") rows_in = rows_in | filter((s) => s.id != sid) tell() box.querySelector(".ui-funnel-analyzer-add").focus() } <section class={ui_cls(["ui-funnel-analyzer", class])} aria-labelledby={`${id}-title`}> <h3.ui-funnel-analyzer-title id={`${id}-title`}>{title}</h3> <div.ui-funnel-analyzer-scroll tabindex="0" role="group" aria-label="Funnel steps"> <table.ui-funnel-analyzer-table> <thead><tr><th>Step</th><th>People</th><th>Kept</th><th>Lost</th><th>Of the first</th>{applied && <th>What-if</th>}<th><span.ui-sr>Remove</span></th></tr></thead> <tbody> {for i in range(len(rows_in)) { let s = rows_in[i] let r = ready ? rows[i] : null <tr key={s.id} data-step={i} class={ready && contains(leak.at, i) ? "ui-funnel-analyzer-leaky" : ""}> <td> <label.ui-sr for={`${id}-name-${s.id}`}>Name of step {i + 1}</label> <input.ui-funnel-analyzer-input type="text" id={`${id}-name-${s.id}`} name={named("step_name")} value={s.name} placeholder={`Step ${i + 1}`} autocomplete="off" @input={(e) => { edit(s.id, "name", e.value) }}/> {ready && <span.ui-funnel-analyzer-track aria-hidden="true"><span.ui-funnel-analyzer-fill style={`width: ${str(round(min([100m, r.overall ?? 0m]), 1, "half_up"))}%`}></span></span>} </td> <td> <label.ui-sr for={`${id}-count-${s.id}`}>People at {labels[i]}</label> <input.ui-funnel-analyzer-input.ui-funnel-analyzer-count type="text" inputmode="numeric" id={`${id}-count-${s.id}`} name={named("step_count")} value={s.text} autocomplete="off" aria-invalid={reads[i] == null ? "true" : null} @input={(e) => { edit(s.id, "text", e.value) }}/> </td> <td data-k="rate">{ready && i > 0 ? funnel_analyzer_pct(r.rate) : ""}</td> <td data-k="lost">{ready && i > 0 ? funnel_analyzer_people(r.lost) : ""}</td> <td data-k="overall">{ready ? funnel_analyzer_pct(r.overall) : ""}</td> {applied && <td data-k="whatif">{funnel_analyzer_people(result.counts[i])}</td>} <td><button.ui-funnel-analyzer-button type="button" aria-label={`Remove ${labels[i]}`} @click={(e) => { drop(e, s.id) }}>Remove</button></td> </tr> }} </tbody> </table> </div> <div> <button.ui-funnel-analyzer-button.ui-funnel-analyzer-add type="button" disabled={len(rows_in) >= funnel_analyzer_limit} @click={add}>Add a step</button> </div> <div.ui-funnel-analyzer-live aria-live="polite"> {!enough && <p.ui-funnel-analyzer-hint>A funnel needs at least two steps.</p>} {enough && len(wrong) > 0 && <p.ui-funnel-analyzer-error role="alert">{wrong | map((i) => labels[i]) | join(", ")}: {len(wrong) == 1 ? "the count must be" : "the counts must be"} a whole number of 0 or more.</p>} {enough && len(wrong) == 0 && reads[0] == 0 && <p.ui-funnel-analyzer-error role="alert">{labels[0]} has nobody in it, so there is nothing to follow.</p>} {ready && <p.ui-funnel-analyzer-leak>{len(leak.at) > 0 ? leak_text() : "Nobody is lost between these steps."} {funnel_analyzer_pct(rows[len(rows) - 1].overall)} of {labels[0]} reach {labels[len(labels) - 1]}.</p>} {ready && len(grows) > 0 && <p.ui-funnel-analyzer-warn>{funnel_analyzer_names(labels, grows)} {len(grows) == 1 ? "has" : "have"} more people than the step before. A funnel can only lose people: check the counts and the order of the steps.</p>} </div> {ready && <fieldset.ui-funnel-analyzer-whatif> <legend>What if</legend> <div.ui-funnel-analyzer-pair> <label.ui-funnel-analyzer-field for={`${id}-at`}>Step to change <select.ui-funnel-analyzer-input id={`${id}-at`} name={named("what_if_step")} @change={(e) => { picked = int(e.value); tell() }}> {for i in range(1, len(rows_in)) => <option key={rows_in[i].id} value={rows_in[i].id} selected={i == at_step}>{labels[i - 1]} to {labels[i]}</option>} </select></label> <label.ui-funnel-analyzer-field for={`${id}-rate`}>Rate instead of {funnel_analyzer_pct(rows[at_step].rate)} (%) <input.ui-funnel-analyzer-input type="text" inputmode="decimal" id={`${id}-rate`} name={named("what_if_rate")} value={rate_text} autocomplete="off" aria-invalid={rate_bad ? "true" : null} @input={(e) => { rate_text = e.value; tell() }}/></label> </div> <div aria-live="polite"> {rate_bad && <p.ui-funnel-analyzer-error role="alert">The rate must be a percentage from 0 to 100.</p>} {result != null && result.error != null && <p.ui-funnel-analyzer-error role="alert">{result.error}</p>} {applied && <p.ui-funnel-analyzer-result>{labels[len(labels) - 1]}: {funnel_analyzer_people(result.end)} instead of {funnel_analyzer_people(reads[len(reads) - 1])}, {funnel_analyzer_people(result.gain, true)} people{result.gain_rate == null ? "" : ` (${result.gain_rate > 0 ? "+" : ""}${funnel_analyzer_pct(result.gain_rate)})`}.</p>} {ready && rate == null && !rate_bad && <p.ui-funnel-analyzer-hint>Type a rate to see what it does to the end of the funnel.</p>} </div> </fieldset>} </section>}Related
UtmBuilderBuilds a campaign link with consistent case, warns on spaces and mixed conventions, keeps the query and fragment, and remembers recent values.3 views · 8 downloadsAbTestCalcReads an A/B test from visitors and conversions: lift, z-test, p-value, interval and a plain verdict, refusing to call too small a sample.2 views · 7 downloadsMailMergeFills a template with {{name}} and fallbacks from CSV or JSON rows: a preview for each row, the missing and unused variables, and the rows that would send an empty value.3 views · 8 downloadsSampleSizeCalcVisitors per variant and days to run for an A/B test, with the formula shown, refusing inputs that have no answer.3 views · 6 downloadsAnnouncementBarA slim bar at the top with one message, a badge and a link, closed for good once dismissed.6 views · 5 downloadsBlogListPosts as cards in a grid or rows down the page: picture, tags, title, excerpt, author and date.8 views · 7 downloads
Thread
Thread 0 posts
On FunnelAnalyzer: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.