Repository: insightsengineering/crane
Pull request #50: Adding`tbl_survfit_times()`
Author: login: ddsjoberg
Created: 2025-05-27T04:23:55Z | Merged: 2025-05-28T16:40:04Z

**What changes are proposed in this pull request?**
* Adding `tbl_survfit_times()` and `add_overall.tbl_survfit_times()`

**Reference GitHub issue associated with pull request.** _e.g., 'closes #<issue number>'_


--------------------------------------------------------------------------------

Pre-review Checklist (if item does not apply, mark is as complete)
- [x] **All** GitHub Action workflows pass with a :white_check_mark:
- [x] PR branch has pulled the most recent updates from master branch: `usethis::pr_merge_main()`
- [x] If a bug was fixed, a unit test was added.
- [x] Code coverage is suitable for any new functions/features (generally, 100% coverage for new code): `devtools::test_coverage()`
- [x] Request a reviewer

Reviewer Checklist (if item does not apply, mark is as complete)

- [x] If a bug was fixed, a unit test was added.
- [x] Run `pkgdown::build_site()`. Check the R console for errors, and review the rendered website.
- [x] Code coverage is suitable for any new functions/features: `devtools::test_coverage()`

When the branch is ready to be merged:
- [x] Update `NEWS.md` with the changes from this pull request under the heading "`# cards (development version)`". If there is an issue associated with the pull request, reference it in parentheses at the end update (see `NEWS.md` for examples).
- [x] **All** GitHub Action workflows pass with a :white_check_mark:
- [x] Approve Pull Request
- [x] Merge the PR. Please use "Squash and merge" or "Rebase and merge".



Diff:
diff --git a/DESCRIPTION b/DESCRIPTION
index 8062af9e..4add67ff 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -23,7 +23,7 @@ Depends:
 Imports: 
     broom (>= 1.0.8),
     cards (>= 0.5.0),
-    cardx (>= 0.2.3.9010),
+    cardx (>= 0.2.4.9004),
     cli (>= 3.6.4),
     dplyr (>= 1.1.4),
     flextable (>= 0.9.7),
diff --git a/NAMESPACE b/NAMESPACE
index a4c33a68..5ba7a09a 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -1,6 +1,7 @@
 # Generated by roxygen2: do not edit by hand
 
 S3method(add_overall,tbl_survfit_quantiles)
+S3method(add_overall,tbl_survfit_times)
 S3method(print,crane_conflicts)
 export("%>%")
 export(add_blank_row)
@@ -14,6 +15,7 @@ export(label_style_number)
 export(roche_pvalue)
 export(tbl_demographics)
 export(tbl_survfit_quantiles)
+export(tbl_survfit_times)
 export(theme_gtsummary_roche)
 import(glue)
 import(rlang)
diff --git a/NEWS.md b/NEWS.md
index a90765fb..20262203 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,7 @@
 # crane 0.1.0.9015
 
+* Adding `tbl_survfit_times()` and `add_overall.tbl_survfit_times()`
+
 * Added an article giving an overview of the {crane} package. (#37)
 
 * Updated `tbl_demographics()` to convert cells with `"0 (0.0%)"` to `"0"`.
diff --git a/R/tbl_survfit_quantiles.R b/R/tbl_survfit_quantiles.R
index f65ec111..3561dbb9 100644
--- a/R/tbl_survfit_quantiles.R
+++ b/R/tbl_survfit_quantiles.R
@@ -22,13 +22,13 @@
 #'   Default is `label_style_number(digits = 1)`.
 #' @param method.args (named `list`)\cr
 #'   Named list of arguments that will be passed to `survival::survfit()`.
-#' @param x (`tbl_survfit_quantiles`)\cr
-#'   A stratified 'tbl_survfit_quantiles' object
 #'
 #'   Note that this list may contain non-standard evaluation components, and
 #'   must be handled similarly to tidyselect inputs by using
 #'   rlang's embrace operator `{{ . }}` or `!!enquo()` when programming with this
 #'   function.
+#' @param x (`tbl_survfit_quantiles`)\cr
+#'   A stratified 'tbl_survfit_quantiles' object.
 #'
 #' @returns a gtsummary table
 #' @name tbl_survfit_quantiles
@@ -166,12 +166,6 @@ tbl_survfit_quantiles <- function(data,
       stat_names = c("estimate", "conf.low", "conf.high"),
       fmt_fn = estimate_fun
     )
-  # get the confidence level
-  conf.level <-
-    ard_surv_quantiles |>
-    dplyr::filter(.data$stat_name == "conf.level") |>
-    getElement("stat") |>
-    getElement(1L)
 
   # calculate range of followup times ------------------------------------------
   df_time <-
@@ -200,6 +194,13 @@ tbl_survfit_quantiles <- function(data,
   }
   ard_n <- cards::ard_total_n(data)
 
+  # get the confidence level
+  conf.level <-
+    ard_surv_quantiles |>
+    dplyr::filter(.data$stat_name == "conf.level") |>
+    dplyr::pull("stat") |>
+    unlist()
+
   # build gtsummary table ------------------------------------------------------
   res <-
     dplyr::bind_rows(
diff --git a/R/tbl_survfit_times.R b/R/tbl_survfit_times.R
new file mode 100644
index 00000000..cfb47afc
--- /dev/null
+++ b/R/tbl_survfit_times.R
@@ -0,0 +1,164 @@
+#' Survival Times
+#'
+#' Create a gtsummary table with Kaplan-Meier estimated survival estimates
+#' and specified times.
+#'
+#' @inheritParams tbl_survfit_quantiles
+#' @inheritParams cardx::ard_survival_survfit
+#' @inheritParams gtsummary::add_overall.tbl_summary
+#' @param label (`string`)\cr
+#'   Label to appear in the header row. Default is `"Time {time}"`, where
+#'   the glue syntax injects the time estimate into the label.
+#' @param statistic (`character`)\cr
+#'   Character vector of the statistics to report.
+#'   May use any of the following statistics:
+#'   `c(n.risk, estimate, std.error, conf.low, conf.high)`,
+#'   Default is `c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%")`
+#' @param estimate_fun (`function`) \cr
+#'   Function used to style/round the `c(estimate, conf.low, conf.high)` statistics.
+#' @param x (`tbl_survfit_times`)\cr
+#'   A stratified 'tbl_survfit_times' object
+#'
+#' @details
+#' When the `statistic` argument is modified, the statistic labels will likely
+#' also need to be updated. To change the label, call the `modify_table_body()`
+#' function to directly update the underlying `x$table_body` data frame.
+#'
+#' @returns a gtsummary table
+#' @name tbl_survfit_times
+#'
+#' @examples
+#' # Example 1 ----------------------------------
+#' tbl_survfit_times(
+#'   data = cards::ADTTE,
+#'   by = "TRTA",
+#'   times = c(30, 60),
+#'   label = "Day {time}"
+#' ) |>
+#'   add_overall()
+NULL
+
+#' @rdname tbl_survfit_times
+#' @export
+tbl_survfit_times <- function(data,
+                              times,
+                              y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)",
+                              by = NULL,
+                              label = "Time {time}",
+                              statistic = c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%"),
+                              estimate_fun = label_style_number(digits = 1, scale = 100),
+                              method.args = list(conf.int = 0.95)) {
+  # check inputs ---------------------------------------------------------------
+  method.args <- enquo(method.args)
+  set_cli_abort_call()
+  check_not_missing(data)
+  check_not_missing(times)
+  check_data_frame(data)
+  check_numeric(times)
+  check_range(times, range = c(0, Inf))
+  check_string(label)
+  check_class(statistic, "character")
+  check_class(estimate_fun, "function")
+  cards::process_selectors(data, by = {{ by }})
+  check_scalar(by, allow_empty = TRUE)
+  if ("time" %in% by) {
+    cli::cli_abort(
+      "The {.arg by} column cannot be named {.val time}.",
+      call = get_cli_abort_call()
+    )
+  }
+
+  y <- .expr_as_string({{ y }}) # convert y to string (if not already)
+  func_inputs <- as.list(environment())
+
+  # subset data on complete row ------------------------------------------------
+  form <- glue("{y} ~ {ifelse(is_empty(by), 1, cardx::bt(by))}") |> stats::as.formula()
+  data <- data[stats::complete.cases(data[all.vars(form)]), ]
+
+  # get survival quantiles -----------------------------------------------------
+  ard_surv_times <-
+    cardx::ard_survival_survfit(
+      x = data,
+      y = y,
+      variables = any_of(by),
+      times = times,
+      method.args = !!method.args
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("estimate", "conf.low", "conf.high"),
+      fmt_fn = estimate_fun
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("n.risk", "n.censor", "cum.risk", "cum.censor"),
+      fmt_fn = gtsummary::label_style_number()
+    )
+
+  # calculate ARD for by vars
+  if (!is_empty(by)) {
+    ard_by <- cards::ard_categorical(data, variables = all_of(by))
+  }
+  ard_n <- cards::ard_total_n(data)
+
+  # get the confidence level
+  conf.level <-
+    ard_surv_times |>
+    dplyr::filter(.data$stat_name == "conf.level") |>
+    dplyr::pull("stat") |>
+    unlist()
+
+  # build gtsummary table ------------------------------------------------------
+  tbl <-
+    dplyr::bind_rows(
+      ard_surv_times |>
+        # remove model-wide stats
+        dplyr::filter(.data$variable == "time") |>
+        dplyr::mutate(
+          variable = paste0(.data$variable, unlist(.data$variable_level)),
+          variable_level = NULL
+        ),
+      case_switch(!is_empty(by) ~ ard_by),
+      ard_n
+    ) |>
+    gtsummary::tbl_ard_summary(
+      by = any_of(by),
+      type = starts_with("time") ~ "continuous2",
+      statistic = starts_with("time") ~ statistic,
+      label =
+        map(times, ~ glue::glue_data(list(time = .x), label)) |>
+          set_names(paste0("time", times))
+    ) |>
+    gtsummary::modify_header(
+      all_stat_cols() ~ "**{level}**  \nN = {n}",
+      label = ""
+    ) |>
+    gtsummary::modify_table_body(
+      ~ .x |>
+        mutate(
+          label = dplyr::case_when(
+            .data$label == "Number of Subjects at Risk" ~ "Patients remaining at risk",
+            .data$label == "Survival Probability%" ~ "Event Free Rate (%)",
+            .data$label == "CI Lower Bound%, CI Upper Bound%" ~ glue("{gtsummary::style_number(conf.level, scale = 100)}% CI"),
+            .default = .data$label
+          )
+        )
+    )
+
+  # return tbl -----------------------------------------------------------------
+  tbl$cards <-
+    list(
+      tbl_survfit_times =
+        dplyr::bind_rows(
+          ard_surv_times,
+          if (!is_empty(by)) ard_by,  # styler: off
+          ard_n
+        )
+    )
+  tbl[["call_list"]] <- list(tbl_survfit_times = match.call())
+  tbl$inputs <- func_inputs
+  tbl |>
+    structure(class = c("tbl_survfit_times", "gtsummary"))
+}
+
+#' @export
+#' @rdname tbl_survfit_times
+add_overall.tbl_survfit_times <- add_overall.tbl_survfit_quantiles
diff --git a/man/tbl_survfit_quantiles.Rd b/man/tbl_survfit_quantiles.Rd
index 82093bca..503527d1 100644
--- a/man/tbl_survfit_quantiles.Rd
+++ b/man/tbl_survfit_quantiles.Rd
@@ -43,16 +43,16 @@ Function used to round and format the estimates in the table.
 Default is \code{label_style_number(digits = 1)}.}
 
 \item{method.args}{(named \code{list})\cr
-Named list of arguments that will be passed to \code{survival::survfit()}.}
-
-\item{x}{(\code{tbl_survfit_quantiles})\cr
-A stratified 'tbl_survfit_quantiles' object
+Named list of arguments that will be passed to \code{survival::survfit()}.
 
 Note that this list may contain non-standard evaluation components, and
 must be handled similarly to tidyselect inputs by using
 rlang's embrace operator \code{{{ . }}} or \code{!!enquo()} when programming with this
 function.}
 
+\item{x}{(\code{tbl_survfit_quantiles})\cr
+A stratified 'tbl_survfit_quantiles' object.}
+
 \item{last}{(scalar \code{logical})\cr
 Logical indicator to display overall column last in table.
 Default is \code{FALSE}, which will display overall column first.}
diff --git a/man/tbl_survfit_times.Rd b/man/tbl_survfit_times.Rd
new file mode 100644
index 00000000..7006ac15
--- /dev/null
+++ b/man/tbl_survfit_times.Rd
@@ -0,0 +1,96 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/tbl_survfit_times.R
+\name{tbl_survfit_times}
+\alias{tbl_survfit_times}
+\alias{add_overall.tbl_survfit_times}
+\title{Survival Times}
+\usage{
+tbl_survfit_times(
+  data,
+  times,
+  y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)",
+  by = NULL,
+  label = "Time {time}",
+  statistic = c("{n.risk}", "{estimate}\%", "{conf.low}\%, {conf.high}\%"),
+  estimate_fun = label_style_number(digits = 1, scale = 100),
+  method.args = list(conf.int = 0.95)
+)
+
+\method{add_overall}{tbl_survfit_times}(
+  x,
+  last = FALSE,
+  col_label = "**Overall**  \\nN = {gtsummary::style_number(N)}",
+  ...
+)
+}
+\arguments{
+\item{data}{(\code{data.frame})\cr
+A data frame}
+
+\item{times}{(\code{numeric})\cr
+a vector of times for which to return survival probabilities.}
+
+\item{y}{(\code{string} or \code{expression})\cr
+A string or expression with the survival outcome, e.g. \code{survival::Surv(time, status)}.
+The default value is
+\code{survival::Surv(time = AVAL, event = 1 - CNSR, type = "right", origin = 0)}.}
+
+\item{by}{(\code{\link[dplyr:dplyr_tidy_select]{tidy-select}})\cr
+A single column from \code{data}. Summary statistics will be stratified by this variable.
+Default is \code{NULL}, which returns results for the unstratified model.}
+
+\item{label}{(\code{string})\cr
+Label to appear in the header row. Default is \code{"Time {time}"}, where
+the glue syntax injects the time estimate into the label.}
+
+\item{statistic}{(\code{character})\cr
+Character vector of the statistics to report.
+May use any of the following statistics:
+\code{c(n.risk, estimate, std.error, conf.low, conf.high)},
+Default is \code{c("{n.risk}", "{estimate}\%", "{conf.low}\%, {conf.high}\%")}}
+
+\item{estimate_fun}{(\code{function}) \cr
+Function used to style/round the \code{c(estimate, conf.low, conf.high)} statistics.}
+
+\item{method.args}{(named \code{list})\cr
+Named list of arguments that will be passed to \code{survival::survfit()}.
+
+Note that this list may contain non-standard evaluation components, and
+must be handled similarly to tidyselect inputs by using
+rlang's embrace operator \code{{{ . }}} or \code{!!enquo()} when programming with this
+function.}
+
+\item{x}{(\code{tbl_survfit_times})\cr
+A stratified 'tbl_survfit_times' object}
+
+\item{last}{(scalar \code{logical})\cr
+Logical indicator to display overall column last in table.
+Default is \code{FALSE}, which will display overall column first.}
+
+\item{col_label}{(\code{string})\cr
+String indicating the column label. Default is \code{"**Overall**  \nN = {style_number(N)}"}}
+
+\item{...}{These dots are for future extensions and must be empty.}
+}
+\value{
+a gtsummary table
+}
+\description{
+Create a gtsummary table with Kaplan-Meier estimated survival estimates
+and specified times.
+}
+\details{
+When the \code{statistic} argument is modified, the statistic labels will likely
+also need to be updated. To change the label, call the \code{modify_table_body()}
+function to directly update the underlying \code{x$table_body} data frame.
+}
+\examples{
+# Example 1 ----------------------------------
+tbl_survfit_times(
+  data = cards::ADTTE,
+  by = "TRTA",
+  times = c(30, 60),
+  label = "Day {time}"
+) |>
+  add_overall()
+}
diff --git a/tests/testthat/_snaps/tbl_survfit_times.md b/tests/testthat/_snaps/tbl_survfit_times.md
new file mode 100644
index 00000000..1f9e1242
--- /dev/null
+++ b/tests/testthat/_snaps/tbl_survfit_times.md
@@ -0,0 +1,73 @@
+# tbl_survfit_times() works
+
+    Code
+      as.data.frame(tbl)
+    Output
+                                   **Placebo**  \nN = 86 **Xanomeline High Dose**  \nN = 84 **Xanomeline Low Dose**  \nN = 84
+      1                    Time 30                  <NA>                               <NA>                              <NA>
+      2 Patients remaining at risk                    69                                 38                                42
+      3        Event Free Rate (%)                 84.4%                              53.0%                             53.4%
+      4                     95% CI          77.0%, 92.6%                       42.8%, 65.7%                      43.4%, 65.6%
+      5                    Time 60                  <NA>                               <NA>                              <NA>
+      6 Patients remaining at risk                    59                                 14                                20
+      7        Event Free Rate (%)                 76.8%                              24.3%                             31.1%
+      8                     95% CI          68.2%, 86.6%                       15.8%, 37.3%                      21.9%, 44.1%
+
+---
+
+    Code
+      as.data.frame(tbl)
+    Output
+                                   **Drug A**  \nN = 98 **Drug B**  \nN = 102
+      1                    Time 12                 <NA>                  <NA>
+      2 Patients remaining at risk                   89                    88
+      3        Event Free Rate (%)                90.8%                 86.3%
+      4                     95% CI         85.3%, 96.7%          79.8%, 93.2%
+      5                    Time 15                 <NA>                  <NA>
+      6 Patients remaining at risk                   83                    75
+      7        Event Free Rate (%)                84.7%                 73.5%
+      8                     95% CI         77.9%, 92.1%          65.4%, 82.6%
+
+---
+
+    Code
+      as.data.frame(tbl)
+    Output
+                                   **Overall**  \nN = 254
+      1                    Time 30                   <NA>
+      2 Patients remaining at risk                    149
+      3        Event Free Rate (%)                  64.1%
+      4                     90% CI           59.1%, 69.4%
+      5                    Time 60                   <NA>
+      6 Patients remaining at risk                     93
+      7        Event Free Rate (%)                  45.7%
+      8                     90% CI           40.5%, 51.5%
+
+# tbl_survfit_times(by) messaging
+
+    Code
+      tbl_survfit_times(data = cards::ADTTE, by = everything(), times = 30)
+    Condition
+      Error in `tbl_survfit_times()`:
+      ! The `by` argument must be length 1 or empty.
+
+---
+
+    Code
+      tbl_survfit_times(data = dplyr::rename(gtsummary::trial, time = trt), by = "time", y = "survival::Surv(ttdeath, death)",
+      times = 30)
+    Condition
+      Error in `tbl_survfit_times()`:
+      ! The `by` column cannot be named "time".
+
+# add_overall.tbl_survfit_times() works
+
+    Code
+      as.data.frame(add_overall(tbl_survfit_times(data = cards::ADTTE, by = "TRTA", times = 30, label = "Day {time}"), last = TRUE, col_label = "**All Participants**  \nN = {n}"))
+    Output
+                                   **Placebo**  \nN = 86 **Xanomeline High Dose**  \nN = 84 **Xanomeline Low Dose**  \nN = 84 **All Participants**  \nN = 254
+      1                     Day 30                  <NA>                               <NA>                              <NA>                            <NA>
+      2 Patients remaining at risk                    69                                 38                                42                             149
+      3        Event Free Rate (%)                 84.4%                              53.0%                             53.4%                           64.1%
+      4                     95% CI          77.0%, 92.6%                       42.8%, 65.7%                      43.4%, 65.6%                    58.2%, 70.5%
+
diff --git a/tests/testthat/test-tbl_survfit_times.R b/tests/testthat/test-tbl_survfit_times.R
new file mode 100644
index 00000000..5928129f
--- /dev/null
+++ b/tests/testthat/test-tbl_survfit_times.R
@@ -0,0 +1,91 @@
+skip_if_not(is_pkg_installed(c("survival", "withr")))
+
+test_that("tbl_survfit_times() works", {
+  withr::local_options(list(width = 120))
+  # Using the default value of the `y` argument
+  expect_silent(
+    tbl <-
+      tbl_survfit_times(
+        data = cards::ADTTE,
+        by = "TRTA",
+        times = c(30, 60)
+      )
+  )
+  expect_snapshot(as.data.frame(tbl))
+
+  # Specifying the `y` argument
+  expect_silent(
+    tbl <-
+      tbl_survfit_times(
+        data = gtsummary::trial,
+        by = "trt",
+        y = "survival::Surv(ttdeath, death)",
+        times = c(12, 15)
+      )
+  )
+  expect_snapshot(as.data.frame(tbl))
+
+  # works for unstratified models
+  expect_silent(
+    tbl <-
+      tbl_survfit_times(
+        data = cards::ADTTE,
+        times = c(30, 60),
+        method.args = list(conf.int = 0.90)
+      )
+  )
+  expect_snapshot(as.data.frame(tbl))
+
+  # works with NSE inputs in `method.args()`
+  expect_equal(
+    tbl_survfit_times(
+      data = cards::ADTTE,
+      times = c(30, 60),
+      method.args = list(id = SEX)
+    ) |>
+      gtsummary::gather_ard() |>
+      getElement("tbl_survfit_times") |>
+      dplyr::filter(variable == "time") |>
+      dplyr::select(-fmt_fn),
+    survival::survfit(survival::Surv(time = AVAL, event = 1 - CNSR) ~ 1, data = cards::ADTTE, id = SEX) |>
+      cardx::ard_survival_survfit(times = c(30, 60)) |>
+      dplyr::filter(variable == "time") |>
+      dplyr::select(-fmt_fn)
+  )
+})
+
+test_that("tbl_survfit_times(by) messaging", {
+  withr::local_options(list(width = 120))
+  expect_snapshot(
+    error = TRUE,
+    tbl_survfit_times(
+      data = cards::ADTTE,
+      by = everything(),
+      times = 30
+    )
+  )
+
+  expect_snapshot(
+    error = TRUE,
+    tbl_survfit_times(
+      data = gtsummary::trial |> dplyr::rename(time = trt),
+      by = "time",
+      y = "survival::Surv(ttdeath, death)",
+      times = 30
+    )
+  )
+})
+
+test_that("add_overall.tbl_survfit_times() works", {
+  withr::local_options(list(width = 180))
+  expect_snapshot(
+    tbl_survfit_times(
+      data = cards::ADTTE,
+      by = "TRTA",
+      times = 30,
+      label = "Day {time}"
+    ) |>
+      add_overall(last = TRUE, col_label = "**All Participants**  \nN = {n}") |>
+      as.data.frame()
+  )
+})


Reviews:
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T08:56:41Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T08:56:48Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T08:59:02Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T09:00:17Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T09:00:41Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T09:01:47Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T09:02:25Z
user: login: Melkiades
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T09:02:43Z
user: login: Melkiades
body: Thanks for this PR! For me it is good to go, just few small comments ;) 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-28T09:03:43Z
user: login: ddsjoberg
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-28T16:09:38Z

Review comments:
diff_hunk: @@ -22,13 +22,13 @@
 #'   Default is `label_style_number(digits = 1)`.
 #' @param method.args (named `list`)\cr
 #'   Named list of arguments that will be passed to `survival::survfit()`.
-#' @param x (`tbl_survfit_quantiles`)\cr
-#'   A stratified 'tbl_survfit_quantiles' object
 #'
 #'   Note that this list may contain non-standard evaluation components, and
 #'   must be handled similarly to tidyselect inputs by using
 #'   rlang's embrace operator `{{ . }}` or `!!enquo()` when programming with this
 #'   function.
path: R/tbl_survfit_quantiles.R
user: login: Melkiades
body: Is this referring to method.args or x?
created_at: 2025-05-28T08:56:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,13 +22,13 @@
 #'   Default is `label_style_number(digits = 1)`.
 #' @param method.args (named `list`)\cr
 #'   Named list of arguments that will be passed to `survival::survfit()`.
-#' @param x (`tbl_survfit_quantiles`)\cr
-#'   A stratified 'tbl_survfit_quantiles' object
 #'
 #'   Note that this list may contain non-standard evaluation components, and
 #'   must be handled similarly to tidyselect inputs by using
 #'   rlang's embrace operator `{{ . }}` or `!!enquo()` when programming with this
 #'   function.
+#' @param x (`tbl_survfit_quantiles`)\cr
+#'   A stratified 'tbl_survfit_quantiles' object
path: R/tbl_survfit_quantiles.R
user: login: Melkiades
body: ```suggestion
#'   A stratified 'tbl_survfit_quantiles' object.
```
created_at: 2025-05-28T08:56:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,13 +22,13 @@
 #'   Default is `label_style_number(digits = 1)`.
 #' @param method.args (named `list`)\cr
 #'   Named list of arguments that will be passed to `survival::survfit()`.
-#' @param x (`tbl_survfit_quantiles`)\cr
-#'   A stratified 'tbl_survfit_quantiles' object
 #'
 #'   Note that this list may contain non-standard evaluation components, and
 #'   must be handled similarly to tidyselect inputs by using
 #'   rlang's embrace operator `{{ . }}` or `!!enquo()` when programming with this
 #'   function.
path: R/tbl_survfit_quantiles.R
user: login: Melkiades
body: ok I see the enquo later
created_at: 2025-05-28T08:59:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+#' Survival Times
+#'
+#' Create a gtsummary table with Kaplan-Meier estimated survival estimates
+#' and specified times.
+#'
+#' @inheritParams tbl_survfit_quantiles
+#' @inheritParams cardx::ard_survival_survfit
+#' @inheritParams gtsummary::add_overall.tbl_summary
+#' @param label (`string`)\cr
+#'   Label to appear in the header row. Default is `"Time {time}"`, where
+#'   the glue syntax injects the time estimate into the label.
+#' @param statistic (`character`)\cr
+#'   Character vector of the statistics to report.
+#'   May use any of the following statistics:
+#'   `c(n.risk, estimate, std.error, conf.low, conf.high)`,
+#'   Default is `c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%")`
+#' @param estimate_fun (`function`) \cr
+#'   Function used to style/round the `c(estimate, conf.low, conf.high)` statistics.
+#' @param x (`tbl_survfit_times`)\cr
+#'   A stratified 'tbl_survfit_times' object
+#'
+#' @returns a gtsummary table
+#' @name tbl_survfit_times
+#'
+#' @examples
+#' # Example 1 ----------------------------------
+#' tbl_survfit_times(
+#'   data = cards::ADTTE,
+#'   by = "TRTA",
+#'   times = c(30, 60),
+#'   label = "Day {time}"
+#' ) |>
+#'   add_overall()
+NULL
+
+#' @rdname tbl_survfit_times
+#' @export
+tbl_survfit_times <- function(data,
+                              times,
+                              y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)",
+                              by = NULL,
+                              label = "Time {time}",
+                              statistic = c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%"),
+                              estimate_fun = label_style_number(digits = 1, scale = 100),
+                              method.args = list(conf.int = 0.95)) {
+  # check inputs ---------------------------------------------------------------
+  method.args <- enquo(method.args)
+  set_cli_abort_call()
+  check_not_missing(data)
+  check_not_missing(times)
+  check_data_frame(data)
+  check_numeric(times)
+  check_range(times, range = c(0, Inf))
+  check_string(label)
+  check_class(statistic, "character")
+  check_class(estimate_fun, "function")
+  cards::process_selectors(data, by = {{ by }})
+  check_scalar(by, allow_empty = TRUE)
+  if ("time" %in% by) {
+    cli::cli_abort(
+      "The {.arg by} column cannot be named {.val time}.",
+      call = get_cli_abort_call()
+    )
+  }
+
+  y <- .expr_as_string({{ y }}) # convert y to string (if not already)
+  func_inputs <- as.list(environment())
+
+  # subset data on complete row ------------------------------------------------
+  form <- glue("{y} ~ {ifelse(is_empty(by), 1, cardx::bt(by))}") |> stats::as.formula()
+  data <- data[stats::complete.cases(data[all.vars(form)]), ]
+
+  # get survival quantiles -----------------------------------------------------
+  ard_surv_times <-
+    cardx::ard_survival_survfit(
+      x = data,
+      y = y,
+      variables = any_of(by),
path: R/tbl_survfit_times.R
user: login: Melkiades
body: what if by is empty?
created_at: 2025-05-28T09:00:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+#' Survival Times
+#'
+#' Create a gtsummary table with Kaplan-Meier estimated survival estimates
+#' and specified times.
+#'
+#' @inheritParams tbl_survfit_quantiles
+#' @inheritParams cardx::ard_survival_survfit
+#' @inheritParams gtsummary::add_overall.tbl_summary
+#' @param label (`string`)\cr
+#'   Label to appear in the header row. Default is `"Time {time}"`, where
+#'   the glue syntax injects the time estimate into the label.
+#' @param statistic (`character`)\cr
+#'   Character vector of the statistics to report.
+#'   May use any of the following statistics:
+#'   `c(n.risk, estimate, std.error, conf.low, conf.high)`,
+#'   Default is `c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%")`
+#' @param estimate_fun (`function`) \cr
+#'   Function used to style/round the `c(estimate, conf.low, conf.high)` statistics.
+#' @param x (`tbl_survfit_times`)\cr
+#'   A stratified 'tbl_survfit_times' object
+#'
+#' @returns a gtsummary table
+#' @name tbl_survfit_times
+#'
+#' @examples
+#' # Example 1 ----------------------------------
+#' tbl_survfit_times(
+#'   data = cards::ADTTE,
+#'   by = "TRTA",
+#'   times = c(30, 60),
+#'   label = "Day {time}"
+#' ) |>
+#'   add_overall()
+NULL
+
+#' @rdname tbl_survfit_times
+#' @export
+tbl_survfit_times <- function(data,
+                              times,
+                              y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)",
+                              by = NULL,
+                              label = "Time {time}",
+                              statistic = c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%"),
+                              estimate_fun = label_style_number(digits = 1, scale = 100),
+                              method.args = list(conf.int = 0.95)) {
+  # check inputs ---------------------------------------------------------------
+  method.args <- enquo(method.args)
+  set_cli_abort_call()
+  check_not_missing(data)
+  check_not_missing(times)
+  check_data_frame(data)
+  check_numeric(times)
+  check_range(times, range = c(0, Inf))
+  check_string(label)
+  check_class(statistic, "character")
+  check_class(estimate_fun, "function")
+  cards::process_selectors(data, by = {{ by }})
+  check_scalar(by, allow_empty = TRUE)
+  if ("time" %in% by) {
+    cli::cli_abort(
+      "The {.arg by} column cannot be named {.val time}.",
+      call = get_cli_abort_call()
+    )
+  }
+
+  y <- .expr_as_string({{ y }}) # convert y to string (if not already)
+  func_inputs <- as.list(environment())
+
+  # subset data on complete row ------------------------------------------------
+  form <- glue("{y} ~ {ifelse(is_empty(by), 1, cardx::bt(by))}") |> stats::as.formula()
+  data <- data[stats::complete.cases(data[all.vars(form)]), ]
+
+  # get survival quantiles -----------------------------------------------------
+  ard_surv_times <-
+    cardx::ard_survival_survfit(
+      x = data,
+      y = y,
+      variables = any_of(by),
+      times = times,
+      method.args = !!method.args
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("estimate", "conf.low", "conf.high"),
+      fmt_fn = estimate_fun
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("n.risk", "n.censor", "cum.risk", "cum.censor"),
+      fmt_fn = gtsummary::label_style_number()
+    )
+
+  # calculate ARD for by vars
+  if (!is_empty(by)) {
path: R/tbl_survfit_times.R
user: login: Melkiades
body: can it be empty?
created_at: 2025-05-28T09:00:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+#' Survival Times
+#'
+#' Create a gtsummary table with Kaplan-Meier estimated survival estimates
+#' and specified times.
+#'
+#' @inheritParams tbl_survfit_quantiles
+#' @inheritParams cardx::ard_survival_survfit
+#' @inheritParams gtsummary::add_overall.tbl_summary
+#' @param label (`string`)\cr
+#'   Label to appear in the header row. Default is `"Time {time}"`, where
+#'   the glue syntax injects the time estimate into the label.
+#' @param statistic (`character`)\cr
+#'   Character vector of the statistics to report.
+#'   May use any of the following statistics:
+#'   `c(n.risk, estimate, std.error, conf.low, conf.high)`,
+#'   Default is `c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%")`
+#' @param estimate_fun (`function`) \cr
+#'   Function used to style/round the `c(estimate, conf.low, conf.high)` statistics.
+#' @param x (`tbl_survfit_times`)\cr
+#'   A stratified 'tbl_survfit_times' object
+#'
+#' @returns a gtsummary table
+#' @name tbl_survfit_times
+#'
+#' @examples
+#' # Example 1 ----------------------------------
+#' tbl_survfit_times(
+#'   data = cards::ADTTE,
+#'   by = "TRTA",
+#'   times = c(30, 60),
+#'   label = "Day {time}"
+#' ) |>
+#'   add_overall()
+NULL
+
+#' @rdname tbl_survfit_times
+#' @export
+tbl_survfit_times <- function(data,
+                              times,
+                              y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)",
+                              by = NULL,
+                              label = "Time {time}",
+                              statistic = c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%"),
+                              estimate_fun = label_style_number(digits = 1, scale = 100),
+                              method.args = list(conf.int = 0.95)) {
+  # check inputs ---------------------------------------------------------------
+  method.args <- enquo(method.args)
+  set_cli_abort_call()
+  check_not_missing(data)
+  check_not_missing(times)
+  check_data_frame(data)
+  check_numeric(times)
+  check_range(times, range = c(0, Inf))
+  check_string(label)
+  check_class(statistic, "character")
+  check_class(estimate_fun, "function")
+  cards::process_selectors(data, by = {{ by }})
+  check_scalar(by, allow_empty = TRUE)
+  if ("time" %in% by) {
+    cli::cli_abort(
+      "The {.arg by} column cannot be named {.val time}.",
+      call = get_cli_abort_call()
+    )
+  }
+
+  y <- .expr_as_string({{ y }}) # convert y to string (if not already)
+  func_inputs <- as.list(environment())
+
+  # subset data on complete row ------------------------------------------------
+  form <- glue("{y} ~ {ifelse(is_empty(by), 1, cardx::bt(by))}") |> stats::as.formula()
+  data <- data[stats::complete.cases(data[all.vars(form)]), ]
+
+  # get survival quantiles -----------------------------------------------------
+  ard_surv_times <-
+    cardx::ard_survival_survfit(
+      x = data,
+      y = y,
+      variables = any_of(by),
path: R/tbl_survfit_times.R
user: login: Melkiades
body: > any_of() doesn't check for missing variables.

ok ahah
created_at: 2025-05-28T09:01:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+#' Survival Times
+#'
+#' Create a gtsummary table with Kaplan-Meier estimated survival estimates
+#' and specified times.
+#'
+#' @inheritParams tbl_survfit_quantiles
+#' @inheritParams cardx::ard_survival_survfit
+#' @inheritParams gtsummary::add_overall.tbl_summary
+#' @param label (`string`)\cr
+#'   Label to appear in the header row. Default is `"Time {time}"`, where
+#'   the glue syntax injects the time estimate into the label.
+#' @param statistic (`character`)\cr
+#'   Character vector of the statistics to report.
+#'   May use any of the following statistics:
+#'   `c(n.risk, estimate, std.error, conf.low, conf.high)`,
+#'   Default is `c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%")`
+#' @param estimate_fun (`function`) \cr
+#'   Function used to style/round the `c(estimate, conf.low, conf.high)` statistics.
+#' @param x (`tbl_survfit_times`)\cr
+#'   A stratified 'tbl_survfit_times' object
+#'
+#' @returns a gtsummary table
+#' @name tbl_survfit_times
+#'
+#' @examples
+#' # Example 1 ----------------------------------
+#' tbl_survfit_times(
+#'   data = cards::ADTTE,
+#'   by = "TRTA",
+#'   times = c(30, 60),
+#'   label = "Day {time}"
+#' ) |>
+#'   add_overall()
+NULL
+
+#' @rdname tbl_survfit_times
+#' @export
+tbl_survfit_times <- function(data,
+                              times,
+                              y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)",
+                              by = NULL,
+                              label = "Time {time}",
+                              statistic = c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%"),
+                              estimate_fun = label_style_number(digits = 1, scale = 100),
+                              method.args = list(conf.int = 0.95)) {
+  # check inputs ---------------------------------------------------------------
+  method.args <- enquo(method.args)
+  set_cli_abort_call()
+  check_not_missing(data)
+  check_not_missing(times)
+  check_data_frame(data)
+  check_numeric(times)
+  check_range(times, range = c(0, Inf))
+  check_string(label)
+  check_class(statistic, "character")
+  check_class(estimate_fun, "function")
+  cards::process_selectors(data, by = {{ by }})
+  check_scalar(by, allow_empty = TRUE)
+  if ("time" %in% by) {
+    cli::cli_abort(
+      "The {.arg by} column cannot be named {.val time}.",
+      call = get_cli_abort_call()
+    )
+  }
+
+  y <- .expr_as_string({{ y }}) # convert y to string (if not already)
+  func_inputs <- as.list(environment())
+
+  # subset data on complete row ------------------------------------------------
+  form <- glue("{y} ~ {ifelse(is_empty(by), 1, cardx::bt(by))}") |> stats::as.formula()
+  data <- data[stats::complete.cases(data[all.vars(form)]), ]
+
+  # get survival quantiles -----------------------------------------------------
+  ard_surv_times <-
+    cardx::ard_survival_survfit(
+      x = data,
+      y = y,
+      variables = any_of(by),
+      times = times,
+      method.args = !!method.args
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("estimate", "conf.low", "conf.high"),
+      fmt_fn = estimate_fun
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("n.risk", "n.censor", "cum.risk", "cum.censor"),
+      fmt_fn = gtsummary::label_style_number()
+    )
+
+  # calculate ARD for by vars
+  if (!is_empty(by)) {
+    ard_by <- cards::ard_categorical(data, variables = all_of(by))
+  }
+  ard_n <- cards::ard_total_n(data)
+
+  # get the confidence level
+  conf.level <-
+    ard_surv_times |>
+    dplyr::filter(.data$stat_name == "conf.level") |>
+    dplyr::pull("stat") |>
+    unlist()
+
+  # build gtsummary table ------------------------------------------------------
+  tbl <-
+    dplyr::bind_rows(
+      ard_surv_times |>
+        # remove model-wide stats
+        dplyr::filter(.data$variable == "time") |>
+        dplyr::mutate(
+          variable = paste0(.data$variable, unlist(.data$variable_level)),
+          variable_level = NULL
+        ),
+      case_switch(!is_empty(by) ~ ard_by),
+      ard_n
+    ) |>
+    gtsummary::tbl_ard_summary(
+      by = any_of(by),
+      type = starts_with("time") ~ "continuous2",
+      statistic = starts_with("time") ~ statistic,
+      label =
+        map(times, ~ glue::glue_data(list(time = .x), label)) |>
+          set_names(paste0("time", times))
+    ) |>
+    gtsummary::modify_header(
+      all_stat_cols() ~ "**{level}**  \nN = {n}",
+      label = ""
+    ) |>
+    gtsummary::modify_table_body(
+      ~ .x |>
+        mutate(
+          label = dplyr::case_when(
+            .data$label == "Number of Subjects at Risk" ~ "Patients remaining at risk",
+            .data$label == "Survival Probability%" ~ "Event Free Rate (%)",
+            .data$label == "CI Lower Bound%, CI Upper Bound%" ~ glue("{gtsummary::style_number(conf.level, scale = 100)}% CI"),
+            .default = .data$label
+          )
+        )
+    )
+
+  # update statistic labels with defaults --------------------------------------
path: R/tbl_survfit_times.R
user: login: Melkiades
body: why is this section empty?
created_at: 2025-05-28T09:02:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+#' Survival Times
+#'
+#' Create a gtsummary table with Kaplan-Meier estimated survival estimates
+#' and specified times.
+#'
+#' @inheritParams tbl_survfit_quantiles
+#' @inheritParams cardx::ard_survival_survfit
+#' @inheritParams gtsummary::add_overall.tbl_summary
+#' @param label (`string`)\cr
+#'   Label to appear in the header row. Default is `"Time {time}"`, where
+#'   the glue syntax injects the time estimate into the label.
+#' @param statistic (`character`)\cr
+#'   Character vector of the statistics to report.
+#'   May use any of the following statistics:
+#'   `c(n.risk, estimate, std.error, conf.low, conf.high)`,
+#'   Default is `c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%")`
+#' @param estimate_fun (`function`) \cr
+#'   Function used to style/round the `c(estimate, conf.low, conf.high)` statistics.
+#' @param x (`tbl_survfit_times`)\cr
+#'   A stratified 'tbl_survfit_times' object
+#'
+#' @returns a gtsummary table
+#' @name tbl_survfit_times
+#'
+#' @examples
+#' # Example 1 ----------------------------------
+#' tbl_survfit_times(
+#'   data = cards::ADTTE,
+#'   by = "TRTA",
+#'   times = c(30, 60),
+#'   label = "Day {time}"
+#' ) |>
+#'   add_overall()
+NULL
+
+#' @rdname tbl_survfit_times
+#' @export
+tbl_survfit_times <- function(data,
+                              times,
+                              y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)",
+                              by = NULL,
+                              label = "Time {time}",
+                              statistic = c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%"),
+                              estimate_fun = label_style_number(digits = 1, scale = 100),
+                              method.args = list(conf.int = 0.95)) {
+  # check inputs ---------------------------------------------------------------
+  method.args <- enquo(method.args)
+  set_cli_abort_call()
+  check_not_missing(data)
+  check_not_missing(times)
+  check_data_frame(data)
+  check_numeric(times)
+  check_range(times, range = c(0, Inf))
+  check_string(label)
+  check_class(statistic, "character")
+  check_class(estimate_fun, "function")
+  cards::process_selectors(data, by = {{ by }})
+  check_scalar(by, allow_empty = TRUE)
+  if ("time" %in% by) {
+    cli::cli_abort(
+      "The {.arg by} column cannot be named {.val time}.",
+      call = get_cli_abort_call()
+    )
+  }
+
+  y <- .expr_as_string({{ y }}) # convert y to string (if not already)
+  func_inputs <- as.list(environment())
+
+  # subset data on complete row ------------------------------------------------
+  form <- glue("{y} ~ {ifelse(is_empty(by), 1, cardx::bt(by))}") |> stats::as.formula()
+  data <- data[stats::complete.cases(data[all.vars(form)]), ]
+
+  # get survival quantiles -----------------------------------------------------
+  ard_surv_times <-
+    cardx::ard_survival_survfit(
+      x = data,
+      y = y,
+      variables = any_of(by),
+      times = times,
+      method.args = !!method.args
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("estimate", "conf.low", "conf.high"),
+      fmt_fn = estimate_fun
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("n.risk", "n.censor", "cum.risk", "cum.censor"),
+      fmt_fn = gtsummary::label_style_number()
+    )
+
+  # calculate ARD for by vars
+  if (!is_empty(by)) {
+    ard_by <- cards::ard_categorical(data, variables = all_of(by))
+  }
+  ard_n <- cards::ard_total_n(data)
+
+  # get the confidence level
+  conf.level <-
+    ard_surv_times |>
+    dplyr::filter(.data$stat_name == "conf.level") |>
+    dplyr::pull("stat") |>
+    unlist()
+
+  # build gtsummary table ------------------------------------------------------
+  tbl <-
+    dplyr::bind_rows(
+      ard_surv_times |>
+        # remove model-wide stats
+        dplyr::filter(.data$variable == "time") |>
+        dplyr::mutate(
+          variable = paste0(.data$variable, unlist(.data$variable_level)),
+          variable_level = NULL
+        ),
+      case_switch(!is_empty(by) ~ ard_by),
+      ard_n
+    ) |>
+    gtsummary::tbl_ard_summary(
+      by = any_of(by),
+      type = starts_with("time") ~ "continuous2",
+      statistic = starts_with("time") ~ statistic,
+      label =
+        map(times, ~ glue::glue_data(list(time = .x), label)) |>
+          set_names(paste0("time", times))
+    ) |>
+    gtsummary::modify_header(
+      all_stat_cols() ~ "**{level}**  \nN = {n}",
+      label = ""
+    ) |>
+    gtsummary::modify_table_body(
+      ~ .x |>
+        mutate(
+          label = dplyr::case_when(
+            .data$label == "Number of Subjects at Risk" ~ "Patients remaining at risk",
+            .data$label == "Survival Probability%" ~ "Event Free Rate (%)",
+            .data$label == "CI Lower Bound%, CI Upper Bound%" ~ glue("{gtsummary::style_number(conf.level, scale = 100)}% CI"),
+            .default = .data$label
+          )
+        )
+    )
+
+  # update statistic labels with defaults --------------------------------------
path: R/tbl_survfit_times.R
user: login: Melkiades
body: I think it should be moved above right?
created_at: 2025-05-28T09:02:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,162 @@
+#' Survival Times
+#'
+#' Create a gtsummary table with Kaplan-Meier estimated survival estimates
+#' and specified times.
+#'
+#' @inheritParams tbl_survfit_quantiles
+#' @inheritParams cardx::ard_survival_survfit
+#' @inheritParams gtsummary::add_overall.tbl_summary
+#' @param label (`string`)\cr
+#'   Label to appear in the header row. Default is `"Time {time}"`, where
+#'   the glue syntax injects the time estimate into the label.
+#' @param statistic (`character`)\cr
+#'   Character vector of the statistics to report.
+#'   May use any of the following statistics:
+#'   `c(n.risk, estimate, std.error, conf.low, conf.high)`,
+#'   Default is `c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%")`
+#' @param estimate_fun (`function`) \cr
+#'   Function used to style/round the `c(estimate, conf.low, conf.high)` statistics.
+#' @param x (`tbl_survfit_times`)\cr
+#'   A stratified 'tbl_survfit_times' object
+#'
+#' @returns a gtsummary table
+#' @name tbl_survfit_times
+#'
+#' @examples
+#' # Example 1 ----------------------------------
+#' tbl_survfit_times(
+#'   data = cards::ADTTE,
+#'   by = "TRTA",
+#'   times = c(30, 60),
+#'   label = "Day {time}"
+#' ) |>
+#'   add_overall()
+NULL
+
+#' @rdname tbl_survfit_times
+#' @export
+tbl_survfit_times <- function(data,
+                              times,
+                              y = "survival::Surv(time = AVAL, event = 1 - CNSR, type = 'right', origin = 0)",
+                              by = NULL,
+                              label = "Time {time}",
+                              statistic = c("{n.risk}", "{estimate}%", "{conf.low}%, {conf.high}%"),
+                              estimate_fun = label_style_number(digits = 1, scale = 100),
+                              method.args = list(conf.int = 0.95)) {
+  # check inputs ---------------------------------------------------------------
+  method.args <- enquo(method.args)
+  set_cli_abort_call()
+  check_not_missing(data)
+  check_not_missing(times)
+  check_data_frame(data)
+  check_numeric(times)
+  check_range(times, range = c(0, Inf))
+  check_string(label)
+  check_class(statistic, "character")
+  check_class(estimate_fun, "function")
+  cards::process_selectors(data, by = {{ by }})
+  check_scalar(by, allow_empty = TRUE)
+  if ("time" %in% by) {
+    cli::cli_abort(
+      "The {.arg by} column cannot be named {.val time}.",
+      call = get_cli_abort_call()
+    )
+  }
+
+  y <- .expr_as_string({{ y }}) # convert y to string (if not already)
+  func_inputs <- as.list(environment())
+
+  # subset data on complete row ------------------------------------------------
+  form <- glue("{y} ~ {ifelse(is_empty(by), 1, cardx::bt(by))}") |> stats::as.formula()
+  data <- data[stats::complete.cases(data[all.vars(form)]), ]
+
+  # get survival quantiles -----------------------------------------------------
+  ard_surv_times <-
+    cardx::ard_survival_survfit(
+      x = data,
+      y = y,
+      variables = any_of(by),
+      times = times,
+      method.args = !!method.args
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("estimate", "conf.low", "conf.high"),
+      fmt_fn = estimate_fun
+    ) |>
+    cards::update_ard_fmt_fn(
+      stat_names = c("n.risk", "n.censor", "cum.risk", "cum.censor"),
+      fmt_fn = gtsummary::label_style_number()
+    )
+
+  # calculate ARD for by vars
+  if (!is_empty(by)) {
+    ard_by <- cards::ard_categorical(data, variables = all_of(by))
+  }
+  ard_n <- cards::ard_total_n(data)
+
+  # get the confidence level
+  conf.level <-
+    ard_surv_times |>
+    dplyr::filter(.data$stat_name == "conf.level") |>
+    dplyr::pull("stat") |>
+    unlist()
+
+  # build gtsummary table ------------------------------------------------------
+  tbl <-
+    dplyr::bind_rows(
+      ard_surv_times |>
+        # remove model-wide stats
+        dplyr::filter(.data$variable == "time") |>
+        dplyr::mutate(
+          variable = paste0(.data$variable, unlist(.data$variable_level)),
+          variable_level = NULL
+        ),
+      case_switch(!is_empty(by) ~ ard_by),
+      ard_n
+    ) |>
+    gtsummary::tbl_ard_summary(
+      by = any_of(by),
+      type = starts_with("time") ~ "continuous2",
+      statistic = starts_with("time") ~ statistic,
+      label =
+        map(times, ~ glue::glue_data(list(time = .x), label)) |>
+          set_names(paste0("time", times))
+    ) |>
+    gtsummary::modify_header(
+      all_stat_cols() ~ "**{level}**  \nN = {n}",
+      label = ""
+    ) |>
+    gtsummary::modify_table_body(
+      ~ .x |>
+        mutate(
+          label = dplyr::case_when(
+            .data$label == "Number of Subjects at Risk" ~ "Patients remaining at risk",
+            .data$label == "Survival Probability%" ~ "Event Free Rate (%)",
+            .data$label == "CI Lower Bound%, CI Upper Bound%" ~ glue("{gtsummary::style_number(conf.level, scale = 100)}% CI"),
+            .default = .data$label
+          )
+        )
+    )
+
+  # update statistic labels with defaults --------------------------------------
path: R/tbl_survfit_times.R
user: login: ddsjoberg
body: oops! it is deleted now
created_at: 2025-05-28T16:09:38Z
author_association: COLLABORATOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-27T23:25:31Z
body: ![badge](https://raw.githubusercontent.com/insightsengineering/crane/_xml_coverage_reports/data/adding-tbl_survfit_times/./badge.svg)


## Code Coverage Summary

```
Filename                     Stmts    Miss  Cover    Missing
-------------------------  -------  ------  -------  ----------------------------
R/add_blank_row.R               26       0  100.00%
R/attach.R                      50       5  90.00%   28, 43, 72, 75, 83
R/conflicts.R                   71      12  83.10%   6-7, 61-65, 88, 106, 110-113
R/crane-package.R                2       2  0.00%    15-16
R/label_roche.R                  8       0  100.00%
R/tbl_demographics.R            59       0  100.00%
R/tbl_survfit_quantiles.R      132       1  99.24%   295
R/tbl_survfit_times.R           92       0  100.00%
R/theme_gtsummary_roche.R       74       0  100.00%
R/utils.R                        5       0  100.00%
R/zzz.R                         11       0  100.00%
TOTAL                          530      20  96.23%
```

### Diff against main

```
Filename                 Stmts    Miss  Cover
---------------------  -------  ------  --------
R/tbl_survfit_times.R      +92       0  +100.00%
TOTAL                      +92       0  +0.79%
```


Results for commit: 1f982863a47922cf24c4f709b8414c14f584cb0d


_Minimum allowed coverage is `80%`_


:recycle: This comment has been updated with latest results


<!-- Sticky Pull Request Commentcrane//coverage.xml -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-27T23:25:47Z
body: ## Unit Tests Summary
 1 files  16 suites   8s ⏱️
16 tests  8 ✅  8 💤 0 ❌
19 runs   8 ✅ 11 💤 0 ❌

Results for commit 1f982863.

[test-results]:data:application/gzip;base64,H4sIACI3N2gC/1WMyw6DIBBFf8Ww7gLoVKE/Y3B0ElKVhsfK9N9LwBq6u+fc5ByM7LoE9uzErWMh2VihzzQnb6J1e2aVMT/x+gqMISG27xhe9v0nyNg1C36JxXvnT+PTXoL63G2vcs0J8RNNrnBbQ7dtNmZggrSSqr8bGLSUSBIQaOB6UiAABdBDAU58Zp8vd5MJev8AAAA=

:recycle: This comment has been updated with latest results.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-27T23:25:50Z
body: 
## Unit Test Performance Difference

| Test Suite | $Status$ | Time on `main` | $±Time$ | $±Tests$ | $±Skipped$ | $±Failures$ | $±Errors$ |
|:-----|:----:|:----:|:-----:|:-------:|:--------:|:------:|:------:|
| tbl_survfit_times | 👶 |  | $+0.01$ | $+1$ | $+1$ | $0$ | $0$ |

<details>
  <summary><b>Additional test case details</b></summary>

| Test Suite | $Status$ | Time on `main` | $±Time$ | Test Case |
|:-----|:----:|:----:|:----:|:-----|
| tbl_demographics | 💚 | $5.47$ | $-1.35$ | tbl_demographics_works |
| tbl_survfit_times | 👶 |  | $+0.01$ | add_overall.tbl_survfit_times_works |
| tbl_survfit_times | 👶 |  | $+0.01$ | tbl_survfit_times_by_messaging |
| tbl_survfit_times | 👶 |  | $+0.91$ | tbl_survfit_times_works |
</details>

Results for commit 43ffca32312064dae7b37c5a4c70db90f2733df6

♻️ This comment has been updated with latest results.

<!-- Sticky Pull Request Commentmarkdown-table -->
author_association: CONTRIBUTOR
user: login: ddsjoberg
created_at: 2025-05-28T16:40:01Z
body: > Thanks for this PR! For me it is good to go, just few small comments ;)

Thank you @Melkiades !!
author_association: COLLABORATOR