Standard Nps
Executive Summary

Executive Summary

Net Promoter Score from Likelihood to Recommend

NPS
+15
95% CI
+9 to +21
Promoters %
40
Passives %
35
Detractors %
25
Responses
600
The Net Promoter Score across 600 responses to Likelihood to Recommend is +15 (95% CI +9 to +21) — a positive but modest score — more promoters than detractors. The mix: 40% promoters, 35% passives, 25% detractors. Pro plan (NPS +30, 95% CI +21 to +39) is genuinely ahead of Free plan (NPS +0, 95% CI -9 to +9): a gap of +30 points that is statistically significant (z = 4.7, p < 0.0001), with non-overlapping confidence intervals.
Suggested Interpretation

The short answer

Overall NPS is +15 (95% CI +9 to +21). By segment, Pro plan scores +30 (95% CI +21 to +39) while Free plan scores +0 (95% CI -9 to +9)—a gap of +30 points that is statistically significant and real, not attributable to chance.

The detail

Across 600 responses, the promoter/passive/detractor split is 40% / 35% / 25%. The Pro plan NPS of +30 versus Free plan NPS of +0 yields a z-statistic of 4.7 with p < 0.0001, well below the conventional 0.05 threshold. The confidence intervals do not overlap (Pro: +21 to +39; Free: -9 to +9), confirming the difference is genuine. Both segments exceed the 30-response minimum for valid comparison.

What this can't tell you

The analysis does not explain why Pro plan respondents are more likely to recommend; it measures the difference but not its source. Segment size and composition details are not provided here.

Overview

Analysis Overview

Net Promoter Score from 600 responses to Likelihood to Recommend.

N Observations600
N Segments2
Nps Points15
Ci Width Points12.7
Suggested Interpretation

The short answer

The Net Promoter Score across 600 responses is +15 on the standard -100 to +100 scale, with a 95% confidence interval of +9 to +21. This reflects the standard NPS method: promoters (9–10 rating) minus detractors (0–6), each expressed as a percentage. The confidence interval reflects sampling variability and anchors the precision of the estimate.

The detail

NPS is calculated as the percentage of promoters (40%) minus the percentage of detractors (25%), yielding +15 points. The 95% confidence interval (+9 to +21) is derived from the classical multinomial variance formula and accounts for the sample size of 600 responses. The interval width of 12.7 points indicates moderate precision; the true population NPS is reasonably likely to fall within this range. Two customer segments are present, each with its own NPS and interval, enabling direct comparison.

What this can't tell you

The overall NPS does not reveal which specific product features or service elements drive the score, nor does it distinguish satisfaction drivers within the detractor or promoter groups.

Data Preparation

Data Quality

Row cleaning and scale validation applied before scoring.

Initial Rows616
Final Rows600
Rows Removed16
Out Of Range Dropped4
Non Integer Rounded0
Suggested Interpretation

The short answer

Of 616 rows loaded, 600 valid 0-10 responses were retained. Twelve responses with blank or non-numeric scores and 4 responses with scores outside the 0-10 range were dropped, leaving no data quality issues in the final dataset.

The detail

Initial dataset: 616 rows. Dropped: 12 rows with blank or non-numeric Likelihood to Recommend values; 4 rows with values outside 0-10. Final usable responses: 600. Non-integer scores: 0 rows required rounding. The 0-10 scale validation step confirmed no responses fell outside the valid range after cleaning.

What this can't tell you

Dropping out-of-range responses is appropriate for NPS, but the analysis does not assess whether those 4 invalid responses came from a particular segment or time period, which could reveal data collection issues.

Visualization

Score Distribution

How the 600 responses spread across the 0-10 scale.

Suggested Interpretation

The short answer

Responses cluster at opposite ends of the scale: the most common score is 9, with 40% of all responses in the promoter zone (9–10), while 25% fall in the detractor zone (0–6). The distribution is polarized rather than centered, indicating two distinct customer experiences coexist.

The detail

Score 9 is the single most frequent response. The 9–10 band contains 240 responses (40%), the 7–8 band (passives) contains 210 responses (35%), and the 0–6 band (detractors) contains 150 responses (25%). Within detractors, 10.3% score 3 or below, representing a clearly dissatisfied subgroup. Scores 7 and 8 each account for over 100 responses (107 and 103, respectively), anchoring the passive segment.

What this can't tell you

The bimodal pattern shows satisfaction is split, but the data does not identify which customer cohorts or use cases fall into each mode, nor does it explain the drivers of the two distinct clusters.

Visualization

Promoters, Passives & Detractors

The NPS composition of each Customer Segment segment.

Suggested Interpretation

The short answer

Pro plan has a healthier composition (50% promoters, 20% detractors) than Free plan (30% promoters, 30% detractors). The Free plan's equal split between promoters and detractors is its drag on NPS; the Pro plan's promoter advantage is its strength.

The detail

Overall: 40% promoters, 35% passives, 25% detractors. Pro plan: 50% promoters, 30% passives, 20% detractors (NPS +30). Free plan: 30% promoters, 40% passives, 30% detractors (NPS +0). Free plan's detractor share equals its promoter share, yielding a neutral NPS. Pro plan's 50% promoter rate exceeds its 20% detractor rate by 30 percentage points, directly driving the +30 NPS advantage. Protecting the Pro plan's promoter base and reducing Free plan detractors are both critical.

What this can't tell you

The analysis does not show which specific features or experiences separate promoters from detractors within each plan.

Data Table

NPS by Segment

NPS, sample size, and 95% CI per Customer Segment segment.

SegmentNNpsCIFlag
Pro plan30030+21 to +39
Free plan3000-9 to +9
Suggested Interpretation

The short answer

Pro plan's NPS of +30 is statistically significantly higher than Free plan's NPS of +0. The 30-point gap is real, not random noise: the confidence intervals do not overlap, and a formal z-test confirms the difference (z = 4.7, p < 0.0001).

The detail

Pro plan: NPS +30, 95% CI +21 to +39, n=300. Free plan: NPS +0, 95% CI −9 to +9, n=300. The gap of 30 points is statistically significant (z = 4.7, p < 0.0001). Non-overlapping confidence intervals confirm the difference is genuine: Pro plan's lower bound (+21) exceeds Free plan's upper bound (+9). Both segments exceed the 30-response minimum for the z-test.

What this can't tell you

The analysis does not determine whether the difference reflects product quality, user expectations, or selection bias (different customer types choosing each plan).

Data Table

Method & Fine Print

Formulas, validation rules, and this dataset's actual margin of error.

ItemDetail
Score categoriesPromoters score 9-10, passives 7-8, detractors 0-6 on the 0-10 likelihood-to-recommend scale.
NPS formulaNPS = % promoters minus % detractors, stated as a point score from -100 to +100.
Confidence intervalClassical multinomial variance formula: Var(NPS) = (p_promoter + p_detractor - (p_promoter - p_detractor)^2) / n on the proportion scale; SE in points = 100 x sqrt(Var); 95% CI = NPS plus or minus 1.96 x SE.
This datasetn = 600 valid responses; SE = 3.2 points; the 95% interval spans 13 points (+9 to +21).
Segment comparisonTwo-proportion-style z-test on the NPS difference between the best and worst segment, run only when both have at least 30 responses; otherwise the comparison is reported as underpowered. Gaps with overlapping 95% confidence intervals are never called differences.
Scale validationValues outside 0-10 are dropped and reported. A column whose values sit entirely within 1-5 stops the analysis — a 1-5 rating scale cannot be honestly rescaled to NPS.
Small-sample policyThe confidence interval width in points is always stated alongside the score, so a small-sample NPS reads as the rough estimate it is.
Suggested Interpretation

The short answer

NPS is % promoters minus % detractors, in points. The 95% confidence interval uses the classical multinomial variance formula to account for sampling error. Segment comparisons use a two-proportion z-test only when both segments have at least 30 responses; overlapping intervals mean no difference is claimed.

The detail

NPS formula: % promoters (9-10) − % detractors (0-6), stated as a point score from −100 to +100. Confidence interval: Var(NPS) = (p_promoter + p_detractor − (p_promoter − p_detractor)²) / n; on this dataset, SE = 3.2 points; 95% CI = NPS ± 1.96 × SE. Segment comparison: two-proportion z-test applied only when both segments have ≥30 responses; gaps with overlapping 95% confidence intervals are not called differences. Scale validation: values outside 0-10 are dropped and reported. This dataset: n=600, SE=3.2 points, interval width=13 points (+9 to +21).

What this can't tell you

The multinomial variance formula assumes independent responses and does not account for clustering (e.g., multiple responses per customer or organization).

Methodology

Methodology

Statistical methodology and diagnostics for NPS Analysis

Statistical Method

NPS Analysis

Standard-library analysis: Net Promoter Score from a 0-10 likelihood-to-recommend column. Promoter / passive / detractor shares, the NPS point score with a proper 95% confidence interval, the full 0-10 score distribution, and per-segment NPS with a statistically honest best-vs-worst comparison. Works on any survey export: map the 0-10 score column, optionally a segment column.

Data
N = 600 observations · 16 excluded
Assumptions
  • The score column holds responses on the standard 0-10 likelihood-to-recommend scale
  • Responses are independent (one row per respondent)
  • The sample is representative of the population you want the NPS to describe
Limitations
  • NPS on small samples is noisy — the confidence interval, stated in points, is part of the answer
  • Segment gaps with overlapping confidence intervals are not called differences
  • A 1-5 rating scale cannot be converted to NPS; the analysis stops rather than silently rescaling
  • NPS says how likely people claim they are to recommend — not why; pair it with verbatim feedback
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

Analysis Code

Complete R source code for this analysis

NPS Analysis — Net Promoter Score with Honest Uncertainty

Computes the Net Promoter Score from a 0-10 likelihood-to-recommend column: promoter / passive / detractor shares, the NPS point score with a 95% confidence interval from the classical multinomial variance formula, the full 0-10 score distribution, and per-segment NPS with a statistically honest best-vs-worst comparison.

Why This Method?

NPS is usually reported as a bare number, which hides how noisy it is on small samples. Stating the confidence interval in points, and refusing to call overlapping-interval segment gaps "differences", turns the same arithmetic into a trustworthy answer.

What This Analysis Covers

  • NPS = % promoters (9-10) minus % detractors (0-6), in points
  • 95% CI via Var(NPS) = (p9 + p0_6 - (p9 - p0_6)^2) / n
  • The raw 0-10 distribution and the promoter/passive/detractor mix
  • Per-segment NPS with CIs and a gated best-vs-worst z-test

Standard Library

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

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))

Core Analysis Pipeline

compute_shared <- function(df, params, col_map = list()) {
  # === SHARED EXPORTS ===
  #   initial_rows/final_rows/rows_removed  $ row accounting
  #   score_h / segment_h   $ humanized user names for the mapped columns
  #   has_segment           $ TRUE when a usable segment column is mapped
  #   n_na_score / n_out_of_range / n_non_integer  $ cleaning counts
  #   lumped_levels / segment_constant             $ segment cleaning notes
  #   overall               $ list(n, prom_pct, pass_pct, det_pct, nps, se_pts,
  #                                ci_low, ci_high, ci_half) — whole-sample NPS
  #   seg_stats             $ list of the same per segment (NULL if no segment)
  #   segment_nps_df        $ segment, n, nps, ci, flag (falls back to Overall)
  #   score_distribution_df $ score ("0".."10" in numeric order), count
  #   nps_mix_df            $ segment_label, share_pct, nps_category
  #   methods_df            $ item, detail — formulas + this dataset's numbers
  #   gap / gap_verdict / gap_text  $ best-vs-worst segment comparison
  #   wide_ci / wide_note   $ small-sample honesty (CI width in points)
  #   fmt_pts / fmt_ci / fmt_p      $ formatters (signed points, no e-notation)
  #   metrics / json_output
  # === /SHARED EXPORTS ===

Step 0: Formatters — signed point scores, no bare asterisks, no e-notation

fmt_pts <- function(x) sprintf("%+d", as.integer(round(x)))
  fmt_p <- function(p) {
    if (is.na(p)) "p not computed"
    else if (p < 0.0001) "p < 0.0001"
    else sprintf("p = %.4f", p)
  }

Step 1: Resolve mapped columns (humanized for all prose)

initial_rows <- nrow(df)
  score_h <- humanize_semantic("score", col_map)
  has_segment <- "segment" %in% names(df)
  segment_h <- if (has_segment) humanize_semantic("segment", col_map) else "segment"
  if (!("score" %in% names(df))) {
    stop(sprintf("NPS analysis needs the score column &#x27;%s' mapped — the 0-10 likelihood-to-recommend response.", score_h))
  }

Step 2: Coerce the score to numeric (95% rule)

v <- df$score
  if (!is.numeric(v)) {
    ch <- as.character(v)
    non_blank <- !is.na(ch) & trimws(ch) != ""
    conv <- suppressWarnings(as.numeric(ch))
    if (sum(non_blank) == 0 ||
        sum(!is.na(conv[non_blank])) < 0.95 * sum(non_blank)) {
      stop(sprintf("The score column &#x27;%s' does not look numeric — fewer than 95%% of its values parse as numbers. NPS needs the raw 0-10 responses.", score_h))
    }
    v <- conv
  }
  seg_raw <- if (has_segment) as.character(df$segment) else NULL

  keep <- !is.na(v)
  n_na_score <- sum(!keep)
  v <- v[keep]
  if (has_segment) seg_raw <- seg_raw[keep]
  if (length(v) == 0) {
    stop(sprintf("No rows with a usable numeric value in &#x27;%s' remained after cleaning.", score_h))
  }

Step 3: Scale validation — HARD. 1-5 scales stop; outside 0-10 drops.

A column whose values sit entirely in 1..5 is a 1-5 rating scale, not the 0-10 NPS question. Rescaling would fabricate promoter/detractor categories that were never asked, so the analysis stops instead.

if (min(v) >= 1 && max(v) <= 5) {
    stop(sprintf(
      paste0("The score column &#x27;%s' only contains values from %s to %s — it looks like a 1-5 rating scale. ",
             "NPS is defined on the 0-10 likelihood-to-recommend scale(detractors 0-6, passives 7-8, promoters 9-10), ",
             "and a 1-5 scale cannot be honestly rescaled onto it — the categories would be invented, not measured. ",
             "This analysis stops rather than silently rescale your data. ",
             "For 1-5 ratings, use a distribution or group-comparison analysis instead, or field the question on a 0-10 scale."),
      score_h,
      format(round(min(v), 2), scientific = FALSE),
      format(round(max(v), 2), scientific = FALSE)
    ))
  }

  in_range <- v >= 0 & v <= 10
  n_out_of_range <- sum(!in_range)
  if (n_out_of_range > 0.5 * length(v)) {
    stop(sprintf("More than half of the values in &#x27;%s' fall outside 0-10 — this does not look like the 0-10 NPS question. Map the raw 0-10 likelihood-to-recommend column.", score_h))
  }
  v <- v[in_range]
  if (has_segment) seg_raw <- seg_raw[in_range]

  n_non_integer <- sum(v != round(v))
  v <- round(v)

  final_rows <- length(v)
  rows_removed <- initial_rows - final_rows
  if (final_rows < 10) {
    stop(sprintf("Only %d usable 0-10 responses remained in &#x27;%s' — at least 10 are needed to compute an NPS.", final_rows, score_h))
  }

Step 4: Segment cleaning — blanks to Missing, lump beyond 8 levels

lumped_levels <- character(0)
  segment_constant <- FALSE
  if (has_segment) {
    seg_raw[is.na(seg_raw) | trimws(seg_raw) == ""] <- "Missing"
    tab <- sort(table(seg_raw), decreasing = TRUE)
    if (length(tab) > 8) {
      keep_lv <- names(tab)[1:8]
      lumped_levels <- setdiff(names(tab), keep_lv)
      seg_raw[seg_raw %in% lumped_levels] <- "Other"
    }
    if (length(unique(seg_raw)) < 2) {
      segment_constant <- TRUE
      has_segment <- FALSE
    }
  }

Step 5: NPS with the classical multinomial variance formula

Var(NPS proportion) = (p9 + p0_6 - (p9 - p0_6)^2) / n, where p9 is the promoter share and p0_6 the detractor share. SE in points = 100 x sqrt(Var).

nps_stats <- function(x) {
    n <- length(x)
    p_prom <- mean(x >= 9)
    p_det  <- mean(x <= 6)
    p_pass <- 1 - p_prom - p_det
    nps <- 100 * (p_prom - p_det)
    var_prop <- max(0, (p_prom + p_det - (p_prom - p_det)^2) / n)
    se_pts <- 100 * sqrt(var_prop)
    list(n = n,
         prom_pct = 100 * p_prom, pass_pct = 100 * p_pass, det_pct = 100 * p_det,
         nps = nps, se_pts = se_pts,
         ci_low = nps - 1.96 * se_pts, ci_high = nps + 1.96 * se_pts,
         ci_half = 1.96 * se_pts)
  }
  fmt_ci <- function(s) paste0(fmt_pts(s$ci_low), " to ", fmt_pts(s$ci_high))

  overall <- nps_stats(v)

Step 6: Per-segment NPS + the gated best-vs-worst comparison

seg_stats <- NULL
  gap <- NULL
  gap_verdict <- "none"     # none | underpowered | overlap | different | not_significant
  gap_text <- ""
  if (has_segment) {
    levs <- names(sort(table(seg_raw), decreasing = TRUE))
    seg_stats <- lapply(levs, function(l) c(list(segment = l), nps_stats(v[seg_raw == l])))

NA-safe best/worst pick (LAT-1445 class: never which.max over possible NAs)

nps_vals <- sapply(seg_stats, function(s) s$nps)
    ok <- which(!is.na(nps_vals))
    best  <- seg_stats[[ok[which.max(nps_vals[ok])]]]
    worst <- seg_stats[[ok[which.min(nps_vals[ok])]]]
    gap <- list(best = best, worst = worst, diff = best$nps - worst$nps,
                z = NA_real_, p = NA_real_,
                cis_overlap = best$ci_low <= worst$ci_high)

    if (best$segment != worst$segment) {
      if (best$n >= 30 && worst$n >= 30) {

Two-proportion-style z-test on the NPS difference (both n >= 30)

se_diff <- sqrt(best$se_pts^2 + worst$se_pts^2)
        if (se_diff > 0) {
          gap$z <- gap$diff / se_diff
          gap$p <- 2 * stats::pnorm(-abs(gap$z))
        }
        if (gap$cis_overlap) {
          gap_verdict <- "overlap"
          gap_text <- paste0(
            "The widest gap is ", fmt_pts(gap$diff), " points, between ", best$segment,
            " (NPS ", fmt_pts(best$nps), ", 95% CI ", fmt_ci(best), ") and ", worst$segment,
            " (NPS ", fmt_pts(worst$nps), ", 95% CI ", fmt_ci(worst),
            "), but their 95% confidence intervals overlap — this gap must not be called a real difference",
            if (!is.na(gap$p) && gap$p < 0.05) " despite the nominal test result" else "", ". ",
            "Treat the segments as statistically indistinguishable on this sample."
          )
        } else if (!is.na(gap$p) && gap$p < 0.05) {
          gap_verdict <- "different"
          gap_text <- paste0(
            best$segment, " (NPS ", fmt_pts(best$nps), ", 95% CI ", fmt_ci(best),
            ") is genuinely ahead of ", worst$segment, " (NPS ", fmt_pts(worst$nps),
            ", 95% CI ", fmt_ci(worst), "): a gap of ", fmt_pts(gap$diff),
            " points that is statistically significant(z = ",
            format(round(gap$z, 1), scientific = FALSE), ", ", fmt_p(gap$p),
            "), with non-overlapping confidence intervals."
          )
        } else {
          gap_verdict <- "not_significant"
          gap_text <- paste0(
            "The gap of ", fmt_pts(gap$diff), " points between ", best$segment,
            " and ", worst$segment, " is not statistically reliable(",
            fmt_p(gap$p), ") — treat it as noise on this sample."
          )
        }
      } else {
        gap_verdict <- "underpowered"
        small <- if (best$n < 30 && worst$n < 30) paste0(best$segment, " (n = ", best$n, ") and ", worst$segment, " (n = ", worst$n, ") both have")
                 else if (best$n < 30) paste0(best$segment, " (n = ", best$n, ") has")
                 else paste0(worst$segment, " (n = ", worst$n, ") has")
        gap_text <- paste0(
          "The observed gap between ", best$segment, " (NPS ", fmt_pts(best$nps),
          ") and ", worst$segment, " (NPS ", fmt_pts(worst$nps), ") is ",
          fmt_pts(gap$diff), " points, but the comparison is underpowered: ",
          small, " fewer than 30 responses, so no test was run and this gap should not be read as a real difference."
        )
      }
    }
  }

Step 7: Chart + table datasets (semantic column names)

cnt <- as.integer(table(factor(v, levels = 0:10)))
  score_distribution_df <- data.frame(
    score = as.character(0:10),
    count = cnt,
    stringsAsFactors = FALSE
  )

  cat_labels <- c("Promoters(9-10)", "Passives(7-8)", "Detractors(0-6)")
  mix_source <- if (has_segment) seg_stats else list(c(list(segment = "Overall"), overall))
  nps_mix_df <- do.call(rbind, lapply(mix_source, function(s) data.frame(
    segment_label = s$segment,
    nps_category = cat_labels,
    share_pct = round(c(s$prom_pct, s$pass_pct, s$det_pct), 1),
    stringsAsFactors = FALSE
  )))
  rownames(nps_mix_df) <- NULL

  table_source <- if (has_segment) seg_stats[order(-sapply(seg_stats, function(s) s$nps))]
                  else list(c(list(segment = "Overall"), overall))
  segment_nps_df <- do.call(rbind, lapply(table_source, function(s) data.frame(
    segment = s$segment,
    n = s$n,
    nps = round(s$nps, 1),
    ci = paste0(fmt_pts(s$ci_low), " to ", fmt_pts(s$ci_high)),
    flag = if (s$n < 30) "low sample(fewer than 30)" else "",
    stringsAsFactors = FALSE
  )))
  rownames(segment_nps_df) <- NULL

Step 8: Honesty notes computed from the data

wide_ci <- overall$ci_half >= 15
  wide_note <- if (wide_ci) paste0(
    "Caution: with only ", format(overall$n, big.mark = ","), " responses the NPS point score is noisy — ",
    "the 95% confidence interval spans ", as.integer(round(overall$ci_high - overall$ci_low)),
    " points(", fmt_ci(overall), "), so ", fmt_pts(overall$nps),
    " is a wide, rough estimate rather than a precise score."
  ) else ""

  methods_df <- data.frame(
    item = c(
      "Score categories",
      "NPS formula",
      "Confidence interval",
      "This dataset",
      "Segment comparison",
      "Scale validation",
      "Small-sample policy"
    ),
    detail = c(
      "Promoters score 9-10, passives 7-8, detractors 0-6 on the 0-10 likelihood-to-recommend scale.",
      "NPS = % promoters minus % detractors, stated as a point score from -100 to +100.",
      "Classical multinomial variance formula: Var(NPS) = (p_promoter + p_detractor - (p_promoter - p_detractor)^2) / n on the proportion scale; SE in points = 100 x sqrt(Var); 95% CI = NPS plus or minus 1.96 x SE.",
      paste0("n = ", format(overall$n, big.mark = ","), " valid responses; SE = ",
             format(round(overall$se_pts, 1), scientific = FALSE), " points; the 95% interval spans ",
             as.integer(round(overall$ci_high - overall$ci_low)), " points(", fmt_ci(overall), ")."),
      "Two-proportion-style z-test on the NPS difference between the best and worst segment, run only when both have at least 30 responses; otherwise the comparison is reported as underpowered. Gaps with overlapping 95% confidence intervals are never called differences.",
      "Values outside 0-10 are dropped and reported. A column whose values sit entirely within 1-5 stops the analysis — a 1-5 rating scale cannot be honestly rescaled to NPS.",
      "The confidence interval width in points is always stated alongside the score, so a small-sample NPS reads as the rough estimate it is."
    ),
    stringsAsFactors = FALSE
  )

  metrics <- list(
    `NPS` = fmt_pts(overall$nps),
    `95% CI` = fmt_ci(overall),
    `Promoters %` = round(overall$prom_pct, 1),
    `Passives %` = round(overall$pass_pct, 1),
    `Detractors %` = round(overall$det_pct, 1),
    `Responses` = overall$n
  )

  json_output <- list(
    answer = paste0(
      "NPS from ", format(overall$n, big.mark = ","), " responses to ", score_h, ": ",
      fmt_pts(overall$nps), " (95% CI ", fmt_ci(overall), "), from ",
      round(overall$prom_pct, 1), "% promoters, ", round(overall$pass_pct, 1),
      "% passives, and ", round(overall$det_pct, 1), "% detractors. ",
      if (nzchar(gap_text)) gap_text
      else if (segment_constant) paste0("The mapped ", segment_h, " column has a single value, so no segment comparison was possible.")
      else "No segment column was mapped, so the score is reported overall only.",
      if (nzchar(wide_note)) paste0(" ", wide_note) else ""
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "score_distribution",
        "nps_mix", "segment_nps", "methods"),
      function(cid) list(id = cid, metrics = metrics)
    )
  )

  list(
    initial_rows = initial_rows, final_rows = final_rows,
    rows_removed = rows_removed,
    score_h = score_h, segment_h = segment_h, has_segment = has_segment,
    n_na_score = n_na_score, n_out_of_range = n_out_of_range,
    n_non_integer = n_non_integer,
    lumped_levels = lumped_levels, segment_constant = segment_constant,
    overall = overall, seg_stats = seg_stats,
    segment_nps_df = segment_nps_df,
    score_distribution_df = score_distribution_df,
    nps_mix_df = nps_mix_df, methods_df = methods_df,
    gap = gap, gap_verdict = gap_verdict, gap_text = gap_text,
    wide_ci = wide_ci, wide_note = wide_note,
    fmt_pts = fmt_pts, fmt_ci = fmt_ci, fmt_p = fmt_p,
    metrics = metrics, json_output = json_output
  )
}
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