Appendix B — Sample Size Calculator

This online sample size calculator lets users adjust assumptions about project structure to estimate the sample size their study requires.

#| '!! shinylive warning !!': |
#|   shinylive does not work in self-contained HTML documents.
#|   Please set `embed-resources: false` in your metadata.
#| standalone: true
#| viewerHeight: 600

library(shiny)
library(ggplot2)
library(tibble)
library(scales)
library(ggthemes)
library(dplyr)

# Helps the Shinylive bundler include ggplot2 ecosystem deps that sometimes get missed
if (FALSE) {
  library(munsell)
  library(farver)
  library(RColorBrewer)
  library(viridisLite)
  library(gtable)
}

# Fixed axis limits covering the full slider ranges
N_XMIN <- 20
N_XMAX <- 40000
MOE_YMIN <- 0
MOE_YMAX <- 15
COST_YMIN <- 0
COST_YMAX <- 900000

info_icon <- function(title, body) {
  tags$details(
    class = "moe-info",
    tags$summary("i"),
    tags$div(
      class = "moe-info-box",
      tags$div(class = "moe-info-title", title),
      body
    )
  )
}

ui <- fluidPage(
  tags$style(HTML("
    .moe-wrap {
      display: flex;
      gap: 10px;
      align-items: flex-start;
      padding: 12px;
    }
    .moe-left { flex: 0 0 240px; }
    .moe-right { flex: 1 1 auto; min-width: 0; }

    .moe-card {
      border: 1px solid #ddd;
      border-radius: 8px;
      padding: 10px 12px;
    }
    .moe-n {
      font-size: 28px;
      font-weight: 700;
      margin-top: 6px;
    }
    .moe-cost {
      font-size: 24px;
      font-weight: 700;
      margin-top: 6px;
      color: #2ca02c;
    }
    .moe-note {
      color: #666;
      font-size: 12px;
      margin-top: 6px;
    }
    .form-group { margin-bottom: 8px; }

    /* Tab styling */
    .nav-tabs {
      border-bottom: 1px solid #ddd;
      margin-bottom: 12px;
    }
    .nav-tabs > li > a {
      padding: 6px 12px;
      font-size: 13px;
    }

    /* Plain HTML info disclosure (details/summary) */
    details.moe-info {
      display: inline-block;
      position: relative;
      margin-left: 6px;
      vertical-align: middle;
    }
    details.moe-info > summary {
      list-style: none;
      cursor: pointer;
      user-select: none;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 18px;
      height: 18px;
      border: 1px solid #bbb;
      border-radius: 999px;
      font-size: 12px;
      font-weight: 700;
      color: #444;
      background: #fff;
      line-height: 1;
    }
    details.moe-info > summary::-webkit-details-marker { display: none; }
    details.moe-info[open] > summary {
      background: #f3f3f3;
      border-color: #aaa;
    }
    .moe-info-box {
      position: absolute;
      left: 0;
      top: calc(100% + 6px);
      width: 230px;
      padding: 8px 10px;
      border: 1px solid #ddd;
      border-radius: 8px;
      background: #fff;
      color: #222;
      font-size: 12px;
      line-height: 1.25;
      box-shadow: 0 6px 18px rgba(0,0,0,0.12);
      z-index: 9999;
    }
    .moe-info-title {
      font-weight: 700;
      margin-bottom: 4px;
    }

    /* Equation block */
    .moe-eq {
      color: #444;
      margin-top: 6px;
    }
    .moe-eq .MathJax {
      font-size: 110% !important;
    }

    /* Cost breakdown text */
    .cost-breakdown {
      color: #555;
      font-size: 12px;
      margin-top: 10px;
      line-height: 1.4;
    }

    @media (max-width: 700px) {
      .moe-wrap { flex-direction: column; }
      .moe-left { flex: 1 1 auto; width: 100%; }
      .moe-info-box { width: 100%; }
    }
  ")),

  # Load MathJax inside the app context (important for Shinylive)
  shiny::withMathJax(),

  div(
    class = "moe-wrap",
    div(
      class = "moe-left",

      tabsetPanel(
        id = "main_tabs",

        # Sample Size Tab
        tabPanel(
          "Sample Size",

          div(
            class = "moe-card",
            tags$strong("Required sample size"),
            div(class = "moe-n", textOutput("n_needed", inline = TRUE)),
            div(class = "moe-note", textOutput("assumptions", inline = TRUE))
          ),

          sliderInput(
            "moe",
            label = tagList(
              "Margin of error (%)",
              info_icon(
                "Margin of error",
                tagList(
                  "Half-width of an approximate confidence interval for a proportion. ",
                  "MOE = 3% means ±3 percentage points at the chosen confidence level."
                )
              )
            ),
            min = 1, max = 15, value = 3, step = 0.5, post = "%"
          ),

          sliderInput(
            "deff",
            label = tagList(
              "Design effect (DEFF)",
              info_icon(
                "Design effect (DEFF)",
                tagList(
                  "Inflates variance due to clustering and/or unequal weights. ",
                  "DEFF = 2.5 is consistent with a clustered and/or unequally weighted design that is less statistically efficient than SRS, with variance 2.5x larger."
                )
              )
            ),
            min = 1, max = 4, value = 2.5, step = 0.1
          ),

          sliderInput(
            "prop",
            label = tagList(
              "Proportion (p)",
              info_icon(
                "Proportion (p)",
                tagList(
                  "Anticipated proportion. Required sample size is proportional to p(1-p). ",
                  "Worst-case is near p = 50%."
                )
              )
            ),
            min = 1, max = 99, value = 50, step = 1, post = "%"
          ),

          radioButtons(
            "conf",
            label = tagList(
              "Confidence level",
              info_icon(
                "Confidence level",
                tagList(
                  "Controls the z value (normal quantile) used in the MOE formula. ",
                  "Higher confidence means larger z and a larger required sample size."
                )
              )
            ),
            choices = c("90%" = 0.90, "95%" = 0.95, "99%" = 0.99),
            selected = 0.95,
            inline = TRUE
          )
        ),

        # Cost Tab
        tabPanel(
          "Cost",

          div(
            class = "moe-card",
            tags$strong("Total study cost"),
            div(class = "moe-cost", textOutput("total_cost", inline = TRUE)),
            div(class = "moe-note", textOutput("cost_note", inline = TRUE))
          ),

          sliderInput(
            "cost_planning",
            label = "Planning & Pilot ($)",
            min = 0, max = 500000, value = 246069, step = 1000, pre = "$"
          ),

          sliderInput(
            "cost_dissemination",
            label = "Dissemination ($)",
            min = 0, max = 200000, value = 102742, step = 500, pre = "$"
          ),

          sliderInput(
            "cost_analysis",
            label = "Analysis & Reporting ($)",
            min = 0, max = 400000, value = 241755, step = 1000, pre = "$"
          ),

          sliderInput(
            "cost_qa",
            label = "Monitoring & QA ($)",
            min = 0, max = 150000, value = 59768, step = 500, pre = "$"
          ),

          sliderInput(
            "cost_per_child",
            label = "Variable cost per child ($)",
            min = 1, max = 50, value = 20, step = 0.5, pre = "$"
          )
        )
      )
    ),

    div(
      class = "moe-right",

      # Sample Size plot and formula
      conditionalPanel(
        condition = "input.main_tabs == 'Sample Size'",
        plotOutput("curve", height = "360px", width = "100%"),
        div(class = "moe-note", "Formula:"),
        div(class = "moe-note", textOutput("formula_note", inline = TRUE)),
        div(
          class = "moe-eq",
          shiny::withMathJax(HTML(
            "$$\\mathrm{MOE}(n) = z\\,\\sqrt{\\frac{\\mathrm{D}_{eff}\\times\\,p(1-p)}{n}}$$"
          ))
        )
      ),

      # Cost plot and breakdown
      conditionalPanel(
        condition = "input.main_tabs == 'Cost'",
        plotOutput("cost_curve", height = "360px", width = "100%"),
        div(class = "cost-breakdown", textOutput("cost_breakdown", inline = TRUE))
      )
    )
  )
)

server <- function(input, output, session) {

  p_target <- reactive(input$prop / 100)
  moe_target <- reactive(input$moe / 100)

  z_value <- reactive({
    conf <- as.numeric(input$conf)
    stats::qnorm(0.5 + conf / 2)
  })

  n_required <- reactive({
    p <- p_target()
    z <- z_value()
    ceiling(input$deff * (z^2 * p * (1 - p)) / (moe_target()^2))
  })

  # Cost calculations
  fixed_cost <- reactive({
    input$cost_planning + input$cost_dissemination + input$cost_analysis + input$cost_qa
  })

  total_cost_at_n <- reactive({
    n <- n_required()
    fixed_cost() + (input$cost_per_child * n)
  })

  # MOE helper function
  moe_for_n <- function(n, deff, p, z) {
    z * sqrt(deff * p * (1 - p) / n) * 100
  }

  # Cost helper function
  cost_for_n <- function(n, fixed, per_child) {
    fixed + (per_child * n)
  }

  # Sample Size outputs
  output$n_needed <- renderText({
    format(n_required(), big.mark = ",")
  })

  output$assumptions <- renderText({
    paste0(
      "Uses p = ", input$prop, "%, DEFF = ", format(input$deff, trim = TRUE),
      ", and z = ", format(round(z_value(), 3), nsmall = 3), "."
    )
  })

  output$formula_note <- renderText({
    paste0("Dot marks your chosen MOE (", input$moe, "%).")
  })

  # Sample Size plot
  output$curve <- renderPlot({
    n0 <- n_required()
    p0 <- p_target()
    z0 <- z_value()

    n_seq <- unique(round(exp(seq(log(N_XMIN), log(N_XMAX), length.out = 250))))
    moe_seq <- moe_for_n(n_seq, input$deff, p0, z0)

    tibble(n = n_seq, moe = moe_seq) |>
      ggplot(aes(n, moe)) +
      geom_line() +
      labs(
        title = "Sample Size Required",
        x = "Sample size (n)",
        y = "Margin of error (%)"
      ) +
      geom_point(
        data = tibble(n = n0, moe = input$moe),
        size = 3,
        color = "red"
      ) +
      scale_x_log10(
        limits = c(N_XMIN, N_XMAX),
        breaks = c(30, 100, 500, 1000, 3000, 10000)
      ) +
      scale_y_continuous(limits = c(MOE_YMIN, MOE_YMAX)) +
      ggthemes::theme_fivethirtyeight(base_family = "sans") +
      theme(axis.title = element_text())
  }, res = 96)

  # Cost outputs
  output$total_cost <- renderText({
    scales::dollar(total_cost_at_n())
  })

  output$cost_note <- renderText({
    n <- n_required()
    var_total <- input$cost_per_child * n
    paste0(
      "Fixed: ", scales::dollar(fixed_cost()),
      " | Variable: ", scales::dollar(var_total)
    )
  })

  output$cost_breakdown <- renderText({
    n <- n_required()
    fixed <- fixed_cost()
    var_total <- input$cost_per_child * n
    total <- fixed + var_total

    paste0(
      "Fixed: ", scales::dollar(fixed),
      " | Variable (", scales::dollar(input$cost_per_child), " × ", format(n, big.mark = ","), "): ",
      scales::dollar(var_total),
      " | Total: ", scales::dollar(total)
    )
  })

  # Cost plot
  output$cost_curve <- renderPlot({
    n0 <- n_required()
    fixed <- fixed_cost()
    per_child <- input$cost_per_child

    n_seq <- unique(round(exp(seq(log(N_XMIN), log(N_XMAX), length.out = 250))))
    fixed_seq <- rep(fixed, length(n_seq))
    total_seq <- cost_for_n(n_seq, fixed, per_child)

    tibble(
      n = n_seq,
      cost = total_seq,
      type = "Total"
    ) |>
      bind_rows(
        tibble(n = n_seq, cost = fixed_seq, type = "Fixed")
      ) |>
      ggplot(aes(n, cost, color = type, linetype = type)) +
      geom_line() +
      labs(
        title = "Study Cost Estimation",
        x = "Sample size (n)",
        y = "Cost ($)",
        color = NULL,
        linetype = NULL
      ) +
      geom_point(
        data = tibble(
          n = n0,
          cost = fixed + (per_child * n0),
          type = "Total"
        ),
        size = 3,
        color = I("red"),
        show.legend = FALSE
      ) +
      scale_x_log10(
        limits = c(N_XMIN, N_XMAX),
        breaks = c(30, 100, 500, 1000, 3000, 10000)
      ) +
      scale_y_continuous(
        limits = c(COST_YMIN, COST_YMAX),
        labels = scales::dollar
      ) +
      scale_color_manual(values = c("Total" = "#1f77b4", "Fixed" = "#aec7e8")) +
      scale_linetype_manual(values = c("Total" = "solid", "Fixed" = "dashed")) +
      ggthemes::theme_fivethirtyeight(base_family = "sans") +
      theme(
        axis.title = element_text(),
        legend.position = "bottom"
      )
  }, res = 96)
}

shinyApp(ui, server)